version: ReleaseKey, a release-group key that needs no render - #35
Merged
Conversation
Getting at a version's release from outside this package meant BaseVersion(), which renders it back to text -- a bytes.Buffer plus one math/big decimal conversion per segment -- and left the caller splitting the result apart again. go-pyresolver's PEP 440 set algebra did exactly that once per candidate version per containment test, where it cost 32% of a warm resolution. ReleaseKey derives the same (epoch, trailing-zero-stripped release) from the parsed fields instead: 16 ns and no allocations against 220 ns and 10 for the render-and-split, comparing in ~3 ns. Most keys pack into three words of 32-bit segment fields; the rest compare as arbitrary-precision integers, because PEP 440 puts no ceiling on an epoch or a segment and datestamped releases push real ones high. packVersion's release-word assembly moves into a shared packRelease, so the layout has one definition rather than two that could drift. The key is coarser than the version order on purpose -- 1.0, 1.00, 1.0a1 and 1.0+ubuntu1 are one position -- and coarser at the zero value, where it folds the uninitialized Version in with "0" rather than sorting it below. Both are asserted, the second alongside the Compare behaviour it deliberately differs from. Held to pypa/packaging 26.2 by a frozen fixture ranked on packaging's own release key, to the render-and-split derivation it replaces across 348,752 corpus versions, and to the general path pairwise so the packed fast path cannot hide a bug.
An unbriefed review seat found the doc claim misusable, and it was right.
String() renders the SHORTEST version string carrying a key, and the doc
called it the smallest one -- while the type doc sold itself for
"bracketing versions by release". Together those invite
lo, _ := Parse(k.String())
as a group's lower bound, which silently drops every pre-release and dev
release of that release: 1.0a1 and 1.0.dev0 carry the key of "1" and sort
strictly BELOW Parse("1"). The group has no greatest member either, since
.postN and +local extend it upward without limit. Both facts are now
stated on the type, on String, in the changelog, and asserted by
TestReleaseKeyStringIsNotABound.
Also from that seat:
- "group" reads as "map key", which a ReleaseKey cannot be. Say order
or partition, and say why.
- The "32% of a warm resolution" figure is not in the issue cited
beside it, and a hard percentage in a public godoc rots. Replaced
with the benchmark in this repository, which a reader can rerun.
- The ~16 ns figure is the best case. The receiver is by value and the
method does not inline, so a caller ranging over a []Version pays the
struct copy too, ~24 ns. Documented rather than papered over; the
value receiver stays, for consistency and because it cannot write
through to the caller's Version.
- The file explains why the per-segment loop uses a pointer conversion
and then uses the value conversion twice elsewhere. Scoped the rule
to the loop it is actually about.
- packRelease now takes the already-stripped release, so its own strip
finds nothing to redo.
- TestReleaseKeyRefinesVersionOrder only asserted on strictly-below
pairs, so it would have passed vacuously against a Compare that never
returned one. It now counts them and fails at zero, like its
neighbours.
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.
What
Adds
version.ReleaseKey— a version's position in PEP 440's release order: its epoch and its release segments with trailing zeros stripped, and nothing else.Version.ReleaseKey()derives one,ReleaseKey.Compareorders two,ReleaseKey.Stringrenders the smallest version string carrying a key.It is deliberately coarser than the version order:
1.0,1.0.0,1.00,1.0a1,1.0.post3.dev2and1.0+ubuntu1all occupy one position, because PEP 440 gives every one of them epoch 0 and, after stripping, the release tuple(1).Why
Until now the only way to reach a version's release from outside this package was
BaseVersion(), which renders the version back to text — abytes.Bufferplus onemath/bigdecimal conversion per segment — and left the caller splitting the result apart again.go-pyresolver's PEP 440 set algebra did exactly that, once per candidate version per containment test. Measured at go-pyresolver
v0.6.0, that derivation is 32.0% of a warm app-set resolution and 23.1% of wide-versions, withmath/big.nat.itoaalone at 13.9%.Deriving the key from the parsed fields instead:
13.6x faster, and the allocations go to zero — which is the point, since what the churn was buying was garbage collection. Comparing two keys is ~3 ns packed, ~8–11 ns on the general path.
How
Most keys pack into three words of six 32-bit segment fields, ordered by three integer comparisons. The rest — a nonzero epoch, more than six segments, or a segment at or above 2^32 — compare as arbitrary-precision integers. PEP 440 puts no ceiling on an epoch or a release segment (the grammar is
[0-9]+) and datestamped and calendar versions push real segments high, so the general path is not optional.packVersion's release-word assembly moves out into a sharedpackRelease, so the layout has one definition rather than two that could drift. That drift is the failure mode no single-encoding test can see: two copies would each order versions consistently within themselves while disagreeing with each other.No allocation anywhere: the epoch is a by-value copy of a
math/bigheader, and the release is a subslice of theVersion's own — with its capacity clipped, so a laterappendby a holder of the key cannot write through into theVersion's segments. That is the same aliasing hazard that made go-version'sParts.Paddinga data race between two copies of oneVersion.The zero value, which differs from
Compareon purposeThe zero
ReleaseKeycompares equal to the key of"0"and"0.0", because those strip to an empty release too — whereVersion.Comparesorts an uninitializedVersionstrictly below every real version. That distinction lives in the full version order; the release grouping genuinely cannot see it. Both behaviours are asserted side by side inTestReleaseKeyZeroValue, so anyone who "fixes" one to match the other reads why first.Testing
TestReleaseKeyConformanceFixtureVersion._key[0:2]), frozen intopypa-26.2-releasekey.ranked.gz. The only test holdingReleaseKeyto the reference implementation directly rather than throughVersion's parse. Covers epochs and segments beyond a machine word, eight-segment releases, and leading zeros inside a segment — none of which the existing packed grid contains.TestReleaseKeyMatchesBaseVersionSplitTestReleaseKeyPackedAgreesWithGeneralTestReleaseKeyRefinesVersionOrder(epoch, release)first, so a strictly smaller release key must rank strictly below. Independent of the render-and-split reference.TestPackReleaseIsTheOnlyReleaseLayoutpackRelease's words.TestReleaseKeyAllocatesNothingAllocsPerRun == 0for both derivation and comparison, on five shapes including the unpackable ones.TestReleaseKeyDoesNotAliasIntoTheVersionVersiondid not move.Each of the four load-bearing assertions was mutation-checked: removing the capacity clip, packing regardless of epoch, breaking the length tie-break in the general path, and under-stripping trailing zeros each turn the suite red, with the failure naming the defect.
go test ./...green,gofmt -lsilent,golangci-lint run ./...(v2.11.2, from the module root) reports 0 issues.Compatibility
Purely additive. No existing behaviour changes: the
packVersionrefactor is exact — the old strip loop'ssmallUint(seg, packedSegMax) == 0test and the newbig.Int.Sign() != 0test agree on every input the grammar admits.🤖 Generated with Claude Code