Hash make_distinct intervals on data identity - #193
Merged
Merged
Conversation
intervaltree.Interval hashes on (begin, end) alone while its equality test also compares data, and IntervalTree keeps its intervals in sets. Every bounded object make_distinct receives starts with near-identical bounds, so the intervals collapse into a handful of buckets and each set insertion and lookup degenerates into a linear scan of equality tests. Profiling a 24-key dict-versus-dict JSON diff showed 44,018,706 calls to Interval.__eq__ taking 6.38 s of a 9.96 s run, against only 1,042,667 calls to Interval.__hash__ -- about 42 equality probes per lookup. Mix id(data) into the hash with a local Interval subclass. IntervalTree constructs no intervals of its own on the add, remove, and overlap-query paths make_distinct uses, so the tree only ever holds and probes instances of the subclass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add two tests for the case where every bounded input reports the same initial bounds, which is what make_distinct sees at the start of a bipartite match. The first asserts the postcondition callers rely on: after make_distinct, sorted ranges are either definitive or non-overlapping. Verified by dropping the re-add of the second-biggest interval, which makes it fail with "Range(204, 209) was followed by Range(209, 214)". The second counts data comparisons and requires them to stay linear in the number of inputs. Verified by reverting IdentityInterval.__hash__ to the inherited (begin, end) hash, which takes a 64-element run from 0 to 800,996 comparisons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
make_distinctingraphtage/bounds.pybuilds oneintervaltree.Intervalper bounded object andputs them all in an
IntervalTree.intervaltree.Intervalhashes on(begin, end)alone, while itsequality test also compares
data, andIntervalTreekeeps its intervals insetobjects. At thestart of a bipartite match every edge reports near-identical initial bounds, so hundreds of intervals
collapse into a handful of hash buckets and each
set.addand membership test degenerates into alinear scan of equality tests.
Profile evidence
A 24-key dict-versus-dict JSON diff, profiled with
cProfilebefore the change:44.0 M equality tests against only 1.04 M hash computations is about 42 equality probes per lookup,
and accounts for 6.38 s of the 9.96 s profiled run. After the change,
Interval.__eq__no longerappears in the top 15 entries and the same profiled run takes 1.10 s.
The change
graphtage/bounds.pygains anIdentityIntervalsubclass whose__hash__ishash((self.begin, self.end, id(self.data))), andmake_distinctbuilds its intervals from it.Graphtage's edits use identity equality —
AbstractEdit.__eq__and__hash__are the ones inheritedfrom
object— so foldingid(data)into the hash agrees with the equality test thatintervaltree.Intervalalready performs. It only spreads the buckets.The subclass is the route taken here rather than replacing
intervaltreewith a sorted list andbisect. The risk with a subclass is thatIntervalTreemight build a plainIntervalinternallyfor a lookup: a plain instance and a subclass instance hash differently, so such a lookup would miss
and results would be wrong without any error. That was checked empirically rather than by reading.
Instrumenting
Interval.__new__and exercising theadd,remove,overlap,overlaps,__len__,and
__iter__paths thatmake_distinctuses showsIntervalTreeconstructs no intervals of itsown on any of them. Range queries, membership tests, and removals were also compared against a
brute-force scan and agree. The one method that does build a plain
Interval,Interval.copy, is onno path
make_distincttouches. The constraint is recorded in the class docstring.Measurements
Random dicts of
nkeys againstnkeys with all keys differing, built withgraphtage.json.build_tree, diffed witht1.diff(t2), and rendered withJSONFormatterinto aquiet, uncolored
Printer:The rendered diff is byte-for-byte identical before and after at every size. The change affects only
how the interval tree distributes its entries across hash buckets, not which intervals it stores or
which ones a query returns.
Tests
Two tests in
test/test_bounds.py, both driven by a newCollidingRangehelper whose instances allreport the same initial bounds:
test_make_distinct_with_identical_initial_boundsasserts the postcondition callers rely on: aftermake_distinct, the sorted ranges are either definitive or non-overlapping. It asserts the outcome,not the data structure, so it also covers a future rewrite of
make_distinct. Verified by droppingthe re-add of the second-biggest interval, which makes the test fail with
Range(204, 209) was followed by Range(209, 214).test_make_distinct_does_not_probe_quadraticallycountsdataequality comparisons during a64-element run and requires them to stay linear in the input size. Verified by reverting
IdentityInterval.__hash__to the inherited(begin, end)hash, which takes the run from 0comparisons to 800,996.
pytest -qpasses in full: 193 tests and 23 subtests.ruff check graphtage test docs bindistisclean.
🤖 Generated with Claude Code