Skip to content

perf(pep440set): probe containment with a stack-held position, not a bound - #39

Merged
jonyoder merged 7 commits into
mainfrom
spike/span-size
Aug 14, 2026
Merged

perf(pep440set): probe containment with a stack-held position, not a bound#39
jonyoder merged 7 commits into
mainfrom
spike/span-size

Conversation

@jonyoder

@jonyoder jonyoder commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

What

Set.Contains no longer materializes atBound(v) to test membership. It probes the spans with a stack-held verPos: the group key is derived up front (every comparison needs it first), and the public spelling — a render plus, for a version carrying a local label, a re-parse — is derived lazily, only when a comparison descends into a release group. Cross-group probes, the common case, never pay it. cmpVerBound is cmpBound with the left side specialized to edgeAt; containsBound remains as the reference path.

Base: 00e608e (origin/main, rebased). The branch was first cut from a stale local main four commits behind baa7af1, then rebased onto baa7af1, then rebased again onto 00e608e after #38 merged (the CHANGELOG conflict from that merge is what silently suppressed every CI run — see the thread). The warm end-to-end A/B below was re-measured on the rebased base, including #38's corrected backtracking entry; the micro and correctness numbers from the baa7af1 session are labeled where they appear.

Why

The resolver tests membership once per candidate version per Candidates call (provider.go's in-range filter). On the warm profile that made Set.Contains 2.0% of samples, essentially all of it in newPosKey. With the candidate.Rank fast path in progress elsewhere expected to remove the current dominant cost, this was on track to be roughly a third of the residual; after this change Contains is 0.54% of samples.

Review follow-up: Contains now also stops at the first span whose floor is above the probe — spans are canonical (sorted, disjoint), so nothing later can match. The pre-PR containsBound had the same gap, so this is an added improvement, not a regression fix. Reviewer-measured micro on a 15-span set: below-range probes 570 → 224 ns (−61%), !=-hole probes 888 → 665 ns (−25%) — the shapes a backtracking solve's != holes produce. Its end-to-end contribution is below this machine's noise floor (see below); it is kept for the micro win.

Measured (Apple M4 Max, go1.26.4, prod snapshot 932,861 pkgs)

Microbenchmark (BenchmarkContains, same-session before/after pairs, medians of 5). The two cases bound the lazy derivation from both ends:

case before after
cross-group probe (common; never renders) 588 ns, 800 B, 16 allocs 329 ns, 232 B, 8 allocs
same-group probe (worst; pays render + full ladder) 1612 ns, 1582 B, 42 allocs 1559 ns (flat within noise), 1169 B, 41 allocs
every other pep440set benchmark unchanged (construction untouched)

Warm end-to-end, re-measured on the rebased base 00e608e (BenchmarkResolveWarm, interleaved A/B ×3 rounds, medians), including #38's corrected genuinely-backtracking entry:

entry before (00e608e) after Δ time Δ B/op
single-no-deps 1.759 ms 1.753 ms −0.3% (noise) −4.8%
small-tree 4.356 ms 4.148 ms −4.8% −9.2%
extras 10.139 ms 9.630 ms −5.0% −8.2%
app-set 70.43 ms 68.18 ms −3.2% −4.9%
wide-versions 80.67 ms 80.79 ms +0.1% (noise) −4.9%
backtracking (new entry) 25.00 ms 25.08 ms +0.3% (noise) −5.3%
unsatisfiable 760 µs 770 µs +1.3% (noise) −5.4%

The allocation cut is deterministic on all seven entries; the wall-clock benefit is 3–5% on the allocation-heavy entries and flat within noise on the rest. (An earlier session on baa7af1 read −2.3% to −6.5% across all seven; the rebased session is the one this PR claims. Noise floor on this shared machine is roughly ±1.5%, and was visibly worse in the final session.) Retained heap per Set unchanged (2829.7 → 2829.6 B).

A third interleaved A/B (×3 rounds) after adding the early exit: the branch won all 21 of 21 within-round entry comparisons against 00e608e (sign test alone puts the direction beyond doubt), with per-entry medians in the same −2.5% to −9% band — but that session's baseline rounds ran hot relative to the identical commit the day before, so the magnitudes are not upgraded on its evidence and the 3–5% claim above stands. The early exit itself moved nothing end-to-end that survives the noise floor.

Correctness evidence

  • TestBoundOrdering, TestBoundOrderingGrid, TestBoundOrderingTransitive, TestBoundEqualPositions, TestEqualSpellingsCanonicalizeAlike: pass — the strict total order and the 1.0/1.0.0/1.0.0.0/1.00 single-position invariant are untouched (releaseKey is shared, not reimplemented).
  • New agreement tests hold the fast path to the reference path: TestCmpVerBoundAgreesWithCmpBound (full ordering grid × 34 version spellings, one verPos reused per row so the lazy derivation runs in every order) and TestContainsAgreesWithContainsBound (every span shape construct.go produces, each also complemented).
  • Production differential at PEP440SET_CORPUS_PACKAGES=20000: 33,918,235 (specifier, version) pairs over 20,000 packages, zero mismatches. The differential asserts Contains — this path — against version.Specifiers.Check.
  • FuzzFromSpecifiers 120 s: 8.63M execs, 0 failures (the target also asserts Contains vs Check).
  • go test ./... and go test -race ./... green; pypa/packaging 26.2 spot-checks agree.

Concurrency

No new sharing: verPos is call-local and mutated only by its owning goroutine; the shared object in every comparison remains the bound-side posKey, exactly as before. New TestContainsConcurrent (8 goroutines × 500 iterations against one shared Set, probes ending in .0 so their releases carry the spare capacity the go-version padding hazard needs, sharing the bounds' release group so the ladder reaches pub.Compare) is green under -race on both paths. Structural note: same-group comparisons have equal normalized release lengths, so Parts.Padding never appends in place on this path today — that is why it is clean, not evidence that go-version v0.0.2 is safe to share. go-version PR #5 remains the real fix and is not merged.

Recorded negative: shrinking span does not pay (complete-index regime)

This branch began as the span-size spike, and the negative belongs on the record:

  • Struct sizes confirmed exact with unsafe.Sizeof (committed size_test.go): version.Version 352 B, posKey 416 B, bound 376 B, span 752 B.
  • On baa7af1, the entire set algebra that copies those spans — Intersect, Complement, newSet, appendSpan, versionset.Difference — totals ~1% of warm resolve time against the complete production index. A pointer-bound shrink (752 → ~32 B per span) has no room to produce a meaningful end-to-end win there, and the prototype was deliberately not built.
  • ⚠️ Caveat: the incomplete-index regime — curated-shaped indexes where resolution fails and backtracks pathologically (rstudio/package-manager#19713 measured 25 GB allocated per resolve there) — is unmeasured by this spike and is the only place the span-shrink argument could legitimately be revived. Anyone re-proposing it should measure that regime first, not this one.

Scaffolding carried on the branch

size_test.go (struct-size logger), heap_test.go (retained-heap harness), verpos_race_test.go (concurrent probe). All assert nothing beyond their purpose and run in normal go test.

🤖 Generated with Claude Code

@jonyoder jonyoder closed this Aug 13, 2026
@jonyoder jonyoder reopened this Aug 13, 2026
jonyoder and others added 5 commits August 14, 2026 07:48
…bound

Set.Contains materialized atBound(v) -- a public-spelling render, a
possible re-parse, and a heap-allocated posKey -- purely to test the
membership of a version the caller already holds. The resolver calls
Contains once per candidate version per Candidates call, which made that
derivation a measurable slice of warm resolution time.

Contains now probes the spans with a verPos: the group key is derived up
front (every comparison needs it first), and the public spelling waits
until a comparison actually descends into a release group, which
cross-group probes -- the common case -- never do. cmpVerBound is
cmpBound with the left side specialized to edgeAt, and two agreement
tests hold the fast path to the reference path: cmpVerBound against
cmpBound over the full ordering grid, and Contains against containsBound
over every span shape construct.go produces.

Also carries the spike's measurement scaffolding: a struct-size logger
and a retained-heap harness, from the span-size investigation this
branch started as (752-byte spans are ~1% of warm resolve time on a
complete index; the measured negative is recorded in the spike report).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verPos path is single-owner by construction, but the bound side of
every comparison is a SHARED posKey, and rstudio/go-version v0.0.2's
Parts.Padding appends into spare capacity in place -- the reason a
version.Version cannot be shared across goroutines for reads (go-version
PR #5, unmerged). Every probe here ends in .0 so its release carries
that spare capacity, and shares the bounds' release group so the ladder
descends to pub.Compare rather than stopping at the group key.

Both Contains and the containsBound reference path run, so a race in
either is attributed correctly. Green today because two same-group
versions have equal normalized release lengths, which never pads; the
test exists so a change that lets cross-group probes reach pub.Compare
cannot land silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unbriefed review seat demonstrated the latent hazard: init set v,
epoch and release but left pubDone/public/pub/pubOK from the previous
version, so re-initializing a used verPos kept the stale public spelling
-- init(1.0.post1), compare (fills pub), init(1.0.dev0), compare returned
+1 where the reference returns -1. No caller re-inits today, but the
name promises it works, and hoisting one verPos out of a per-candidate
loop is exactly the micro-optimization someone will try. init now clears
the whole struct first, and TestVerPosReinit reuses one verPos across
every (version, bound) pair -- verified to FAIL against the old body.

Also from the same review: the retained-heap harness logs its delta in
signed arithmetic (a shrinking live heap read as ~1.8e19), and the
concurrent test now asserts that at least one probe actually reaches the
pub.Compare arm instead of trusting the comment that says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2-6% by-entry claim was measured against baa7af1's corpus, whose
backtracking entry did not backtrack (#38 replaced it). Re-measured
interleaved against 00e608e: the allocation cut is deterministic on all
seven entries (5-9% fewer bytes per resolve); wall clock is 3-5% on the
allocation-heavy entries and flat within noise on the rest, including
the corrected backtracking entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonyoder and others added 2 commits August 14, 2026 08:15
Spans are canonical -- sorted by lo, disjoint -- so a probe below one
span's floor is below every later span too, and the scan can stop. The
pre-verPos containsBound had the same gap, so this is an added
improvement, not a regression fix: below-range probes on a 15-span set
drop 570 -> 224 ns and !=-hole probes 888 -> 665 ns (reviewer-measured),
and the shape is exactly what a backtracking solve's != holes produce.

Also from review: the concurrent test's comment now states in the file
that the go-version padding race is UNREACHABLE from this path (green
here must not be read as covering it); BenchmarkContains gains a
same-group case so the lazy derivation is quoted from both ends
(cross-group never renders, same-group pays the full ladder);
containsBound is marked as the test-only reference path; the retained-
heap harness warns its global MemStats reading is only meaningful solo;
two test comments now say what the tests actually do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The micro figures were medians of 5 from an earlier session; the entry
described them with the warm A/B's methodology. They are now re-measured
same-session pairs, quoted from both ends of the lazy derivation
(cross-group never renders; same-group pays the full ladder), and the
early exit's end-to-end contribution is stated as below the noise floor
rather than folded into the headline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonyoder
jonyoder merged commit 73d820a into main Aug 14, 2026
2 checks passed
@jonyoder
jonyoder deleted the spike/span-size branch August 14, 2026 12:20
jonyoder added a commit that referenced this pull request Aug 14, 2026
… and re-measure on 73d820a

Review findings, and a re-measurement forced by the base moving under the branch.

⚠️ THE MEMO DIFFERENTIAL DID NOT OBSERVE THE MEMO. It reported "N calls served
from a warm memo" as calls-minus-packages, which is arithmetic over its own loop
shape and nothing else. Neuter the memo lookup so every call misses and the test
still passed, still printing the identical figure -- and that figure had been
quoted in CHANGELOG.md and the PR description as a measurement. It is now read
through a counting index and asserts one Versions() call per DISTINCT package,
which is not derivable from the loop: 14,379 calls for 14,379 packages, so 68,255
of 82,634 (82.6%) came from the memo. Verified by the mutation the old one slept
through -- always-miss now fails it at 940 reads for 136 packages.

Also adds the test for the property the memo's whole argument is phrased in, and
did not have. Candidates rests on candidate.Rank being a STABLE sort, and
stability says nothing unless elements TIE -- but the default Newest policy is
total on real index output (Versions collapses each PEP 440 equality class) and
the only other Policy in these tests, oldestFirst, is total too. An UNSTABLE sort
passed every existing provider test. TestCandidatesUnderATiedPolicy... drives a
deliberately non-total policy (rank by major version only) through Candidates
against ExactCandidates over 17 ranges, 13 of them choosing a version inside a
tie group of more than one.

⚠️ My first draft of the companion stability test hardcoded the expected order
from an assumption about MockIndex's ordering. The assumption was wrong and it
failed against correct code -- a hardcoded expectation had turned a stability
test into an index-ordering test. It now asserts the property (tied versions keep
the index's relative order, whatever that order is) and is stable over -count=8.

⚠️ The transitivity test's equivalence half was ~99.997% degenerate while its
guard certified it. Classes held at most 2 spellings, so a "mutually equivalent
triple" had to repeat an element: either a == c, where the conclusion is
reflexive and cannot fail, or the antecedent restated. 749,465 satisfied
antecedents contained 2 genuine witnesses. Injecting a second alternate spelling
takes that to 96,989, and the guard now counts WITNESSES rather than antecedents.

Stale and wrong numbers in production doc comments, all introduced by the first
commit and never updated when later commits re-measured:

  * provider.go said the corpus fell "1.3x to 9.6x"; it is 1.4x to 11.8x.
  * policy.go said the pass took wide-versions "from 80.0 ms to 8.2 ms". 8.2 ms
    matches nothing for wide-versions -- 8.14 ms was APP-SET's number. Replaced
    with the comparison counts it is actually about.
  * policy.go claimed "at most 2(n-1) Less calls" and "two Less calls on the pair
    that settles it". The loop makes exactly ONE call per pair: at most n-1.
  * Candidates still said the change keeps cost "proportional to the packages
    actually decided rather than to every version in range". True of METADATA
    reads only; the walk is O(all versions) by construction, which is why
    Contains is 28.6% of the call.
  * BenchmarkSelectFirst's "O(1) versus O(n)" is a claim about ranking, not about
    a Candidates call, which does not break out of its walk.

Documents three things the memo narrows that nothing recorded: retained memory
traded for churn (and NOT measured at peak), the loss of a cancellation
checkpoint on a call with nothing in range, and a transient Versions() failure
becoming unobservable after a first success.

Re-measured against 73d820a, since #39 changed Contains, which sits inside
Candidates. Medians of three INTERLEAVED runs -- one round was contended and put
the baseline's small-tree at 8.10 ms against 4.31 and 4.22, which is what medians
are for.

    entry            base    after
    single-no-deps   1.77 ->  0.28    6.3x
    small-tree       4.31 ->  1.39    3.1x
    extras           9.68 ->  1.90    5.1x
    app-set         67.10 ->  5.67   11.8x
    wide-versions   80.33 -> 15.02    5.3x
    backtracking    24.51 ->  3.29    7.5x
    unsatisfiable    0.73 ->  0.51    1.4x

⚠️ And a 2x2, because the two changes are NOT separable by subtraction. All four
cells interleaved in one session: #39 alone is 1.02x/1.12x/1.02x on the three
large entries, this change alone is 9.19x/5.43x/6.90x, and #39 ON TOP of this
change is 1.29x/1.12x/1.11x. #39 got more valuable, mechanically: Contains was
4.4% of a Candidates call before this work and 28.6% after. Neither change may be
credited with the other's contribution.

Discloses that every corpus figure comes from a local full-snapshot run; CI falls
back to the 139-package excerpt, and its anti-skip guard covers ./index/ only.
Records the transcript harness's exact invocation (the committed default deadline
is 10s; the published runs used 8s) and that its excluded set is BIASED toward
the hardest resolutions, since the faster side times out less often.
jonyoder added a commit that referenced this pull request Aug 14, 2026
…tead of sorting (#40)

* spike(candidate,provider): rank once per package, and detect order instead of sorting

A spike, not a proposal: measure where a Candidates call actually spends its
time now that the found/rank change cut metadata reads by up to 190x, and
prototype whatever the profile names.

The profile names the sort, unambiguously. Warm against the production
snapshot, candidate.Rank is 83% of Candidates on the benchmark's app-set entry
and 85% on wide-versions; the usability walk that the found/rank change had
just optimized is 1.7% and 0.6%, index.Versions is ~10%, and pep440set.Contains
-- the whole "intersect the version list" step -- is 4-5%. Nothing outside the
sort is worth attacking.

Two independent causes, both fixed here:

  * The same lists are re-ranked constantly. The solver re-asks about a package
    on every round it reconsiders it, and each call sorted from scratch.
    Provider now memoizes candidate.Rank over the package's FULL version list
    for the life of the resolution, and each call walks that order filtering by
    allowed. Ranking the superset rather than the in-range list is what makes it
    memoizable: the in-range list is a function of caller-supplied input.

    The memo lives on the Provider and not on the index deliberately. A
    version.Version cannot be shared across goroutines even for reads --
    index/rsfindex.go documents that upstream defect at length and declines to
    memoize parsed versions because of it -- and a Provider serves exactly one
    resolution and is documented as unsafe for concurrent use, so nothing here
    is read from two goroutines. On a shared index this would reintroduce the
    race in full.

  * index.RSFIndex returns versions ASCENDING and the default Newest policy
    wants them descending, so sort.SliceStable was being handed its worst case
    on essentially every call. candidate.Rank now classifies the input in one
    linear pass and reverses it, or returns it untouched, when it can; otherwise
    it sorts as before. Detection, not assumption -- MetadataIndex promises no
    ordering, and the branch a real list takes is measured rather than assumed.

Warm resolution, production snapshot, 10 iterations, before -> after:

    single-no-deps    1.63 ms ->  0.33 ms   5.0x
    small-tree        4.28 ms ->  1.59 ms   2.7x
    extras            9.76 ms ->  2.32 ms   4.2x
    app-set          68.98 ms ->  7.22 ms   9.6x
    wide-versions    81.17 ms -> 16.54 ms   4.9x
    backtracking      6.36 ms ->  1.36 ms   4.7x
    unsatisfiable     0.70 ms ->  0.52 ms   1.3x

candvers, the metric the found/rank change did not move at all, finally moves:
app-set 6040 -> 943, wide-versions 7206 -> 4647.

Equivalence, checked rather than argued:

  * candidate: the fast path against a sort-only reference, on 13 hand-built
    shapes (including the tie cases the reversed branch claims are impossible),
    20,000 random lists, and every version list of 200,000 production packages
    (1,623,115 versions; 103,358 lists take the reversed branch, 42,609 the
    ordered one, 0 fall through to the sort).
  * candidate: Newest is a genuine strict weak ordering on production data --
    124,918 ordered triples and 749,465 equivalence triples, no violation.
    ⚠️ The equivalence half needs both injected equal spellings and biased
    sampling to be non-vacuous, because Versions() dedupes PEP 440 equality
    classes: on real index output the order is TOTAL and stability is vacuous.
  * provider: the existing differential against ExactCandidates over 60,000
    production packages, 42,355 with something available, 16 of them
    discriminating. ExactCandidates no longer calls candidate.Rank, since Rank
    is now part of what is under test.
  * provider: a NEW differential for the memo, which the existing one cannot
    reach -- it asks each package once, always with All(), and the memo only
    does anything on the second call with a DIFFERENT allowed set. 82,634 calls
    over 14,540 production packages, 68,094 of them served from a warm memo.

Not pursued, with the reason: a cheaper intersection (binary search over a
sorted list rather than a walk) is 4-5% of the call at most, and pre-release
admission breaks the contiguity it would need.

* spike(provider): measure contiguity, and make the harness lint-clean

Two additions to the spike, both measurement rather than mechanism.

TestInRangeIsNotContiguous decides the "intersect with two binary searches
instead of a walk" question with data instead of an argument. Over 72,976
production packages and 807,795 versions: 12.38% of versions are pre-releases,
10.37% of packages publish at least one, and 4.18% of packages have their
admitted set SPLIT by one -- so for those, no pair of binary searches over a
version-ordered list can delimit the admitted set, because a pre-release sits
between two finals in version order and removing it breaks the run. 95.8% are
contiguous, so the idea is viable WITH a fallback, not without one.

⚠️ It is also aimed at the wrong half of the cost. Set.Contains is 28.6% of a
Candidates call after this spike, and 83% of THAT is pep440set.atBound --
building the probe bound for the version, not walking the spans. Memoizing the
probe alongside the ranked list would take the same cost out with no contiguity
assumption at all; it needs a pep440set API that does not exist yet.

TestRSFIndexVersionsAreAscending pins the property the fast path was built for,
across 51,521 multi-version packages: 0 adjacent inversions, 0 adjacent
PEP 440-equal pairs. ⚠️ It is a check on the IMPLEMENTATION, not the contract --
MetadataIndex promises no ordering, which is why Rank detects the shape rather
than assuming it. It exists so that if RSFIndex ever stops being sorted, the
reason the fast path went quiet is discoverable rather than mysterious.

Mutation-tested, because a differential that cannot fail is decoration:

  * Delete the reverse from Rank's reversed branch and the synthetic shapes, the
    20,000 random lists, the real-index comparison, the memo differential and
    four pre-existing policy tests all fail.
  * Poison the memo with the RANGE-FILTERED list -- the "keyed by caller-supplied
    input" bug -- and exactly ONE test in the whole module fails: the new memo
    differential. The pre-existing exact-count differential passes, because it
    asks each package once with All(); so does the entire resolver suite.

Also silences errcheck on the transcript harness. The bufio.Writer defers every
error to Flush, so the per-call returns carried nothing; Flush is now checked and
fails the run, which the deferred one could not do.

* spike(candidate,resolver): measure the alternatives, and record the result

BenchmarkSelectFirst measures the three ways of answering the question
Candidates actually asks -- which version to try first -- rather than assuming
which is best. sort (what every call used to do), scan (one linear pass, the
partial-selection idea), and memo (the list is already ranked, take [0]).

⚠️ scan answers a STRICTLY EASIER question than the other two: it finds the
single best element and cannot produce the second-best, which Candidates needs
whenever the best version is unusable. Its number is a LOWER bound on a real
partial-selection implementation, not an estimate of one -- and it is still
O(n) comparisons per call where the memo is O(1), against a solver that asks
about the same package 2.4 to 4.8 times per resolution. Measured anyway,
because "the memo wins" is a claim about a ratio and the ratio is what decides
whether the memo's retained memory is worth paying for.

TestFirstByScanAgreesWithTheSort keeps that benchmark honest over 20,000 random
lists: an alternative that returned a different element would be timing the
wrong algorithm.

resolver/bench_test.go records the result and, more importantly, corrects
itself. The previous section closed with "the remaining cost is in walking and
intersecting those lists -- set algebra and version comparison". It named two
suspects and the profile convicted exactly one: version comparison inside the
sort, 83-86% of a Candidates call. The set algebra it also blamed --
pep440set.Set.Contains, the entire intersection step -- was 4.4% on app-set and
5.2% on wide-versions.

Also corrects a figure I had wrong in three places: app-set's 87 Versions()
calls span 18 distinct packages, not 27. It is measurable rather than
estimatable now, because after the memo `versions/op` IS the distinct-name
count -- each name triggers exactly one call.

* spike(resolver): the final numbers, and the equivalence transcript result

Re-measured both sides back to back on an idle machine, median of three runs of
ten iterations each, and replaced the single-run figures with those. Warm:

    single-no-deps    1.64 ms ->  0.30 ms   5.5x
    small-tree        4.29 ms ->  1.42 ms   3.0x
    extras            9.74 ms ->  2.31 ms   4.2x
    app-set          68.49 ms ->  7.19 ms   9.5x
    wide-versions    82.50 ms -> 16.38 ms   5.0x
    backtracking      6.67 ms ->  1.29 ms   5.2x
    unsatisfiable     0.77 ms ->  0.51 ms   1.5x

Equivalence is now measured end to end rather than at the Candidates seam.
4,007 resolutions against the production snapshot -- the seven corpus entries
plus 4,000 sampled package names -- produce BYTE-IDENTICAL transcripts before
and after: same pins, same decision ORDER, same activated extras, and the same
failure report text on the 1,605 cases that fail. 48 cases where either side hit
an 8-second deadline are excluded and counted, because a wall-clock deadline is
not a property two builds share.

⚠️ That closes a gap provider/unusable.go names explicitly: "the differential
compares found, best and rank, not Unusable() and not rendered reports". The
transcript compares rendered reports, on 1,605 of them.

The mechanism numbers are isolated too, from candidate/'s comparison-counting
benchmarks:

  * On ascending input -- what RSFIndex returns -- detection replaces ~9.8
    comparisons per element with one: 13,999 against 137,387 at n=14,000.
  * ⚠️ On shuffled input, where detection fails and the sort runs anyway, it
    costs 2-3 extra comparisons TOTAL (243,629 against 243,626), because the
    pass breaks at the first pair that rules both shapes out. The regression
    case is free, not a tradeoff.
  * Partial selection, measured rather than dismissed: a linear max-scan is
    9.8-10.0x faster than sorting and allocates nothing. Real, and strictly
    dominated -- the memo answers in 2.7-3.8 ns with no comparisons, against a
    solver that asks 2.4 to 4.8 times per package.

Also corrects the previous section in place. It closed by blaming "set algebra
and version comparison"; the profile convicted version comparison inside the
sort and cleared the set algebra, which was 4.4% and 5.2%.

* perf(provider,candidate): rank once per package, and detect order instead of sorting

Re-measured against the CORRECTED backtracking entry from #38 and added the
changelog entry. The numbers in the previous commit were taken against the
retired `pandas, numpy<2` entry and are not comparable; these replace them.

⚠️ The backtracking row is the one to read. It is the only corpus entry whose
purpose is that the solver reconsiders the same package repeatedly, which is
exactly what the memo targets, and it improves MORE than the retired entry did:
6.7x against 5.2x, with the largest candvers drop of the corpus (2,495 -> 351,
7.1x) and the largest Versions() drop (30 calls -> 6).

    entry            warm ms          candvers     Metadata
                     before  after    before after
    single-no-deps    1.68    0.32     130    65     3     3   5.2x
    small-tree        4.38    1.58     984   254    30    30   2.8x
    extras            9.77    2.55    1658   321    43    43   3.8x
    app-set          72.44    8.14    6040   943   105   105   8.9x
    wide-versions    83.16   17.13    7206  4647    24    24   4.9x
    backtracking     25.27    3.78    2495   351    42    42   6.7x
    unsatisfiable     0.75    0.54     124   124     3     3   1.4x

Medians of three runs per side, both sides back to back in one session on an
idle machine. ⚠️ Medians rather than means because one app-set baseline run came
in at 118.79 ms against 72.44 and 69.22 -- a transient, and a mean would have
carried it.

#38's TestEveryReasonIsRecordedWhenNOTHINGIsUsable passes unchanged, which is
the check that matters for the rebase: the not-found path still walks every
in-range version, so every reason a failure report needs still survives.

* fix(provider,candidate): make the memo differential observe the memo, and re-measure on 73d820a

Review findings, and a re-measurement forced by the base moving under the branch.

⚠️ THE MEMO DIFFERENTIAL DID NOT OBSERVE THE MEMO. It reported "N calls served
from a warm memo" as calls-minus-packages, which is arithmetic over its own loop
shape and nothing else. Neuter the memo lookup so every call misses and the test
still passed, still printing the identical figure -- and that figure had been
quoted in CHANGELOG.md and the PR description as a measurement. It is now read
through a counting index and asserts one Versions() call per DISTINCT package,
which is not derivable from the loop: 14,379 calls for 14,379 packages, so 68,255
of 82,634 (82.6%) came from the memo. Verified by the mutation the old one slept
through -- always-miss now fails it at 940 reads for 136 packages.

Also adds the test for the property the memo's whole argument is phrased in, and
did not have. Candidates rests on candidate.Rank being a STABLE sort, and
stability says nothing unless elements TIE -- but the default Newest policy is
total on real index output (Versions collapses each PEP 440 equality class) and
the only other Policy in these tests, oldestFirst, is total too. An UNSTABLE sort
passed every existing provider test. TestCandidatesUnderATiedPolicy... drives a
deliberately non-total policy (rank by major version only) through Candidates
against ExactCandidates over 17 ranges, 13 of them choosing a version inside a
tie group of more than one.

⚠️ My first draft of the companion stability test hardcoded the expected order
from an assumption about MockIndex's ordering. The assumption was wrong and it
failed against correct code -- a hardcoded expectation had turned a stability
test into an index-ordering test. It now asserts the property (tied versions keep
the index's relative order, whatever that order is) and is stable over -count=8.

⚠️ The transitivity test's equivalence half was ~99.997% degenerate while its
guard certified it. Classes held at most 2 spellings, so a "mutually equivalent
triple" had to repeat an element: either a == c, where the conclusion is
reflexive and cannot fail, or the antecedent restated. 749,465 satisfied
antecedents contained 2 genuine witnesses. Injecting a second alternate spelling
takes that to 96,989, and the guard now counts WITNESSES rather than antecedents.

Stale and wrong numbers in production doc comments, all introduced by the first
commit and never updated when later commits re-measured:

  * provider.go said the corpus fell "1.3x to 9.6x"; it is 1.4x to 11.8x.
  * policy.go said the pass took wide-versions "from 80.0 ms to 8.2 ms". 8.2 ms
    matches nothing for wide-versions -- 8.14 ms was APP-SET's number. Replaced
    with the comparison counts it is actually about.
  * policy.go claimed "at most 2(n-1) Less calls" and "two Less calls on the pair
    that settles it". The loop makes exactly ONE call per pair: at most n-1.
  * Candidates still said the change keeps cost "proportional to the packages
    actually decided rather than to every version in range". True of METADATA
    reads only; the walk is O(all versions) by construction, which is why
    Contains is 28.6% of the call.
  * BenchmarkSelectFirst's "O(1) versus O(n)" is a claim about ranking, not about
    a Candidates call, which does not break out of its walk.

Documents three things the memo narrows that nothing recorded: retained memory
traded for churn (and NOT measured at peak), the loss of a cancellation
checkpoint on a call with nothing in range, and a transient Versions() failure
becoming unobservable after a first success.

Re-measured against 73d820a, since #39 changed Contains, which sits inside
Candidates. Medians of three INTERLEAVED runs -- one round was contended and put
the baseline's small-tree at 8.10 ms against 4.31 and 4.22, which is what medians
are for.

    entry            base    after
    single-no-deps   1.77 ->  0.28    6.3x
    small-tree       4.31 ->  1.39    3.1x
    extras           9.68 ->  1.90    5.1x
    app-set         67.10 ->  5.67   11.8x
    wide-versions   80.33 -> 15.02    5.3x
    backtracking    24.51 ->  3.29    7.5x
    unsatisfiable    0.73 ->  0.51    1.4x

⚠️ And a 2x2, because the two changes are NOT separable by subtraction. All four
cells interleaved in one session: #39 alone is 1.02x/1.12x/1.02x on the three
large entries, this change alone is 9.19x/5.43x/6.90x, and #39 ON TOP of this
change is 1.29x/1.12x/1.11x. #39 got more valuable, mechanically: Contains was
4.4% of a Candidates call before this work and 28.6% after. Neither change may be
credited with the other's contribution.

Discloses that every corpus figure comes from a local full-snapshot run; CI falls
back to the 139-package excerpt, and its anti-skip guard covers ./index/ only.
Records the transcript harness's exact invocation (the committed default deadline
is 10s; the published runs used 8s) and that its excluded set is BIASED toward
the hardest resolutions, since the faster side times out less often.

* fix(provider): stop the memo differential wedging in its own fixture builder

⚠️ THE TEST COULD NOT BE RUN THE WAY ITS OWN DOC INVITED. Pointed at a full
snapshot with no sample cap -- the obvious way to reproduce the figures quoted
for it -- it swept 680,711 packages and ran for 34 minutes without reaching an
assertion, failing the whole provider package on the 40-minute limit.

The hang was in the TEST'S OWN FIXTURE BUILDER, not in anything under test. A
profile of it put the stack in rangesOver -> Union -> newSet -> cmpBound ->
version.Compare -> version.String on a package with 905 versions. rangesOver
built its allowed sets by unioning per-version singletons, which re-canonicalizes
a span list that has grown by one each time: quadratic in cmpBound, and cmpBound
reaches the go-python-packaging v0.5.0 hot spot that resolver/bench_test.go
already points at. So scaling the corpus knob up bought cmpBound cost rather than
coverage.

Ranges are now built through specifiers -- >=, <, and != for a two-span set -- so
each is one or two spans regardless of how many versions a package has. The
union-built gappy shape is kept, because it is the one no pair of binary searches
can express and therefore the one worth having, but only for lists of 64 or
fewer. And the default sample is capped at 20,000 rather than "all", with
GPR_SAMPLE=0 still meaning all for anyone who wants it.

A bare full-snapshot run now takes 57 seconds and covers MORE: 109,564 calls
against the previous 82,634, because the specifier-built ranges are more varied
than the windows they replace.

Also corrects corpus figures that were quoted as though they were full-corpus and
came from a ~10% sample, understating the corpus about 9x. Re-run with
GPR_SAMPLE=0:

    contiguity   72,976 packages / 807,795 versions  ->  680,711 / 7,666,753
    split by a pre-release              4.18%        ->  4.23%
    ascending    51,521 multi-version packages       ->  481,998

The percentages moved by ~0.05pp, so the conclusion they support is unchanged --
but the absolute counts were wrong and read as authoritative.

And records the configuration behind the transitivity figures (GPR_SAMPLE=3000
GPR_TRIPLES=3000000), which the default run does not reproduce. Same
reproducibility gap as the transcript harness's, and the same fix: write the
invocation next to the number or the number is not checkable.

* test(resolver): measure peak heap, which falls rather than rising

Closes the review findings, and replaces a documented prediction with a
measurement that contradicts it.

⚠️ THE RETAINED-MEMORY CAVEAT WAS WRONG, in the safe direction. The memo holds
the parsed version list of every package in the closure for the whole resolve,
where those were previously garbage, so the obvious reading is that it trades
allocation churn for a higher high-water mark -- and B/op cannot settle it,
because B/op is cumulative rather than peak. The doc asserted that trade-off as
though it were established. It was a prediction, and peak heap goes the other
way:

    entry            peak over baseline
                     base    after
    single-no-deps    1.3 ->  0.3 MB
    small-tree        4.6 ->  1.3 MB
    extras            9.5 ->  2.0 MB
    app-set          53.9 ->  7.4 MB    7.3x lower
    wide-versions    56.2 -> 18.6 MB    3.0x
    backtracking     23.4 ->  5.7 MB    4.1x
    unsatisfiable     0.4 ->  0.3 MB

Reproducible across three runs a side, within 4%. The reason is that the churn
removed was never short-lived: the old path allocated a fresh in-range slice AND
a fresh Rank copy on each of app-set's 87 calls, which pile up inside one GC
cycle, against 18 retained lists now and no in-range slice at all. Fewer live
bytes, not more.

TestPeakHeapDuringOneResolve samples runtime.ReadMemStats during one resolution
and keeps the maximum. ⚠️ It is a SAMPLED maximum -- ReadMemStats stops the
world, so the interval bounds how sharp a spike it can see -- which makes it a
floor on the true peak, not the true peak. It also perturbs GC timing, so only
the before/after comparison means anything and both sides are sampled the same
way. Skipped unless GPR_PEAK is set.

Remaining review findings:

  * A stale ratio in bench_test.go: prose said the backtracking entry's "6.7x is
    above the 5.2x the retired entry showed" while the table 15 lines up already
    read 7.5x -- left over from the pre-re-measurement draft, in the paragraph
    that tells the reader this is the row to read.
  * Three different runtimes for one run recorded in three places (30s, 57s,
    85s; the 85s predates the specifier-built ranges). Replaced with "well under
    a minute" and a note on why a precise figure invites this.
  * realVersions fataled on a version it merely cannot use. Appending ".0" is
    PEP 440-equal for a release segment and NOT for a base carrying a local
    label -- "1.0+abc" gives "1.0+abc.0", which parses and compares unequal.
    PyPI rejects local versions on upload so it cannot fire today, but a fixture
    that aborts on a data property is a red test waiting to happen. It now skips
    the class; the vacuity guard catches "too few classes".

And the equivalence transcript is re-run at the CURRENT base, which the earlier
ones were not -- they were taken at baa7af1 and 00e608e, both before 73d820a
changed Set.Contains inside Candidates. 1,199 of 1,199 compared cases identical,
500 of them full failure reports, 8 excluded for deadline.
jonyoder added a commit that referenced this pull request Aug 14, 2026
The bump entry asserted the packed key and #40 do not compose
multiplicatively. That was reasoning, not a measurement. This records the
2x2 that establishes it, in one interleaved session, warm, medians of 3.

The packed key is worth 1.99-5.93x BEFORE #40 and 1.23-1.88x after; #40 is
worth 1.43-12.34x at gpp v0.5.0 and 1.07-2.95x at v0.6.0. Both directions
shrink, so the two are substitutes: on app-set, multiplying the isolated
headlines predicts 65.8x against a measured 15.7x end to end.

That is the opposite sign to the #39/#40 interaction, where Contains became
MORE valuable after the rank memo, so the interaction cannot be guessed.

It also resolves an apparent conflict with go-python-packaging's own release
notes, which claim 2.1-5.2x warm on this benchmark: those were measured at
73d820a, before #40 landed 46 minutes later. The pre-#40 column reproduces
them (1.99-5.93x).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonyoder added a commit that referenced this pull request Aug 14, 2026
#41)

Bumps go-python-packaging v0.5.0 -> v0.6.0. No code in this repository
changes; the diff is go.mod, go.sum, a CHANGELOG entry and two doc
corrections.

v0.6.0 compares versions through a packed 4xuint64 order-preserving key
rather than a field-by-field walk, for 97.3% of real versions. Against the
production snapshot (932,861 packages), medians of five interleaved rounds:
cold resolution 2.0x to 13.4x faster, warm 1.2x to 1.9x.

Cold gains far exceed warm, and that asymmetry is the finding: building a
package's sorted version order is comparison-bound and happens once per
package per index, so it lands in cold (wide-versions 167.35 -> 12.46 ms).
Warm reuses that order through 0.5.0's memo, so the packed key only reaches
the residual comparisons inside pep440set containment and candidate filtering.

The interaction with #40 is measured, not asserted: the two CONTEND. The
packed key is worth 1.99-5.93x before #40 and 1.23-1.88x after; #40 is worth
1.43-12.34x at v0.5.0 and 1.07-2.95x at v0.6.0. On app-set, multiplying the
isolated headlines predicts 65.8x against a measured 15.7x end to end. That
is the opposite sign to the #39/#40 interaction, so these cannot be guessed.

Equivalence: 4,007 resolutions against the production snapshot (7 corpus
entries plus 4,000 sampled names, seed 1) produce byte-identical transcripts
before and after -- identical pins, decision order, activated extras, and
failure report text on the 1,607 that fail -- with 36 wall-clock-deadline
cases excluded. candvers, metadata and the pin set are identical everywhere.

Also inherits an upstream data-race fix: a parsed Version is now safe to
share across goroutines. That restriction was documented on the exported
PackageMetadata.SupportsPython, which told callers to give each goroutine
its own version.Parse; that doc and the canonical account in
RSFIndex.Versions are corrected. Taking the now-unblocked parsed-version
memo is left to #42.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonyoder added a commit that referenced this pull request Aug 14, 2026
…er (#46)

* perf(pep440set): take the release group key from gpp, not from a render

Contains needs each candidate version's (epoch, release) position, and it
got it by calling BaseVersion() -- which renders the version through a
bytes.Buffer with one math/big decimal conversion PER SEGMENT -- then
split the text back into digit runs. Once per candidate version per
Contains call.

version.ReleaseKey, new in go-python-packaging v0.7.0, derives the same
key from the parsed Version's own epoch and release fields: 16 ns and no
allocation against 220 ns and 10 allocations. releaseKey, canonDigits,
cmpDigits and cmpSegments go with it. isDigits stays; construct.go uses
it.

Warm resolution against the production snapshot is 1.19x to 1.39x faster
and allocates 1.25x to 2.27x less on the six corpus entries that resolve
anything; cold is 1.11x to 1.28x. The allocation column is the one to
read -- what the render was buying was garbage. Contains/cross-group, the
common case, drops from 304 ns and 8 allocations to 104 ns and none, and
Set.Contains falls from 41% of resolver.Resolve to 24% on app-set. Full
tables in resolver/bench_test.go.

⚠️ That share is against Resolve, not against total samples: removing
this allocation shrinks the profile's GC share too, so "% of samples"
would credit this change with that as well. And strings.Split leaves the
profile while writeRelease and math/big.nat.itoa do NOT -- ensurePub
still renders the public spelling, which this change does not touch.
Both were stated wrongly in an earlier draft of these comments.

TestBoundOrderingGrid gains a pair differing only past the SIXTH release
segment, where gpp's packed fast path gives way to arbitrary precision.
Truncating gpp's packer there instead of refusing makes that row fail;
without it, nothing in this module would notice.

⚠️ unsatisfiable is NOT measurably faster. Its 1.07x is a median of three
and a fourth confirmation round put it at 0.96x, slower; read it as no
change. That is the prediction met rather than a disappointment -- it
fails before enumerating many candidates and so makes the fewest Contains
calls in the corpus. Its allocations do fall by 1.25x, which is the effect
appearing where the mechanism says it should.

⚠️ This is the THIRD change to this cost and the one that removes it. #33
derived a bound's key once instead of per comparison and #39 replaced the
probe bound with a stack-held verPos, but both left the derivation itself
a string render, in newPosKey and in verPos.init. Fixing releaseKey fixes
both and leaves no fourth site. A memo over the old function was measured
and rejected: it would have made memory larger where this makes it
smaller.

TestCanonDigits is replaced rather than deleted. It existed because the
key was built from DIGIT RUNS compared length-first, where "007" would
sort above "7". The key now comes from math/big integers, where "007" is
7 before a key exists and there is no run to strip -- the concern is gone
rather than moved. But the behaviour it protected is not optional, so
TestLeadingZeroSpellings asserts it through cmpBound at widths either
side of int64 and uint64, and also asserts the spelling has not flattened
a real difference: 1.007 is 1.7, not 1.70 and not 1.0.7.

Held by the production differential at PEP440SET_CORPUS_PACKAGES=20000 --
33,918,235 (specifier, version) pairs, 0 mismatches against Check -- the
rendering differential over the same 20,000 packages, FuzzFromSpecifiers
for 120 s with no new input, the ordering suites under -race, and on the
gpp side a migration differential against this very algorithm across
348,752 corpus versions.

* docs(changelog): the nanosecond figures are load-sensitive, the allocation counts are not

An unbriefed review seat measured 23 ns / 520 ns for gpp's ReleaseKey
benchmark where this entry quotes 16 ns / 220 ns. Both are real: mine was
an idle machine, theirs was running mutation tests. The ratio survives
either way and the allocation counts -- 0 against 10 -- are identical on
both, so those are what the entry now leads with. A reader who needs a
time should run the benchmark.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant