Skip to content

Bit-pack Milnor basis elements into a u64 - #280

Open
JoeyBF wants to merge 6 commits into
SpectralSequences:masterfrom
JoeyBF:claude/milnor-basis-bit-packing-ri158z
Open

Bit-pack Milnor basis elements into a u64#280
JoeyBF wants to merge 6 commits into
SpectralSequences:masterfrom
JoeyBF:claude/milnor-basis-bit-packing-ri158z

Conversation

@JoeyBF

@JoeyBF JoeyBF commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

The p-part of a Milnor basis element was a Vec<u32>, costing a heap allocation and a pointer chase per element. At p = 2 the internal degree of P(R) is sum_i r_i (2^i - 1) with non-negative terms, so r_i <= deg / (2^i - 1); sizing each field by that bound packs the whole exponent sequence into 64 bits for every degree up to 2045. At odd primes the same bound applies divided by q = 2(p-1), so one layout serves every prime.

MilnorBasisElement is now 16 bytes, Copy, and entirely inline.

The observation that every exponent sequence up to degree 512 fits in 64 bits is due to Lixiong Wu. This PR works out the widths, finds the same layout holds all the way to degree 2045, and carries it through the algebra.

Results

Memory, measured as RSS growth from compute_basis, p = 2:

degrees elements before after
0..=250 1,958,958 125.0 MB (66.9 B/elt) 95.0 MB (50.8 B/elt)

Two things get it there. Packing removes the per-element allocation and the Vec header. Separately, basis_table turns out to be redundant at p = 2: basis_table[t][i] is exactly from_p(ppart_table[t][i], t), so it is dropped and derived on demand, which is free now that the type fits in registers. The table is still built at odd primes, where the q-part varies within a degree, and when unstable support is on, where the basis is re-sorted by excess.

The basis index order is unchanged — verified element-by-element against the base commit, identical for all 4156 elements in degrees 0..=60 — so saved resolutions stay valid. A test pins the first nine degrees to fixed element names to keep it that way.

Speed, on nassau_milnor (the bench documented as capturing Nassau's regime), mostly improved — the largest movements were op8xel8 -20%, op24xel16 -11%, op20xel24 -10%. Note that this machine's run-to-run noise on milnor_ppart reached 6%, so treat small movements there as unresolved.

Fallout worth reviewing

Three things follow from the packing rather than being incidental:

  • The degree bound is now enforced. compute_basis asserts max_degree <= 2045 up front, which is what lets everything downstream assume entries fit. The previous hand-rolled packing in MilnorHashMap::code assumed 1536 without checking.
  • The MilnorHashMap specialization is gone. The packed value is a canonical key, so the not(odd-primes) fork is unnecessary; a plain HashMap now hashes a single word on every path.
  • PPartMultiplier no longer borrows its inputs, so its lifetime parameter is gone, and PPartAllocation loses the buffer it existed to recycle. The multiply family takes MilnorBasisElement by value.

In ext, MilnorSubalgebra's signature test becomes one masked comparison on the packed word instead of a loop over entries, with the mask hoisted out of signature_mask's inner loop.

Two behaviour changes

  • basis_element_from_string("P0") and ("Sq0") now return the identity rather than None. P(0) is the identity, and AdemAlgebra::try_beps_pn already special-cases x == 0 this way; the old None came from vec[0] and vec[] hashing differently, an artifact of the representation. The test is updated.
  • increment_p_part carries before incrementing. The old order transiently stored max[i] + 1, which need not fit a field whose width is exactly saturated by max[i]. The enumeration is unchanged.

Input validation

The last commit fixes three paths that computed with unvalidated input before checking it against the packing bounds, so the intermediate arithmetic went wrong first — an out-of-bounds index into the xi-degree table, two integer overflows, and a shift by 64. All are reachable from entry points documented as total (basis_element_from_string) or non-panicking (try_beps_pn), plus packed_signature, which had assumed a bound on profile length that nothing enforces. Thanks to CodeRabbit for catching these.

PPartRanker (opt-in, not wired in)

One commit adds an arithmetic alternative to the basis index map, behind the off-by-default milnor-rank feature. It is not hooked into basis_element_to_index even when enabled — it is there so the design and its measurements survive.

It is worth having eventually because the two strategies scale in opposite directions: a lookup probes only its own degree's map, and once that leaves cache (37 MB in degree 500) every probe misses to DRAM, whereas the ranker's table is ~43 KB for all degrees. Measured at p = 2: 0.43x at degree 120, 0.85x at 400, 2.09x at 500. Adopting it would renumber the basis in colex order and so invalidate saved resolutions, which is a separate decision — hence inert for now. The commit message records the full measurement, including an unrank that was tried and rejected.

Reviewers can ignore this commit entirely without affecting the rest; nothing in the default build compiles it.

Testing

just test, just lint and just docs all reproduced locally and pass, including the --no-default-features and --all-features configurations and rustdoc under -D warnings. 64 tests by default, 69 with --features milnor-rank.

New tests cover the layout invariant (widths against the xi-degrees, so changing MAX_DEGREE without WIDTHS fails loudly), packing faithfulness over every basis element up to degree 120 at p = 2 and 200 at p = 3, the odometer's saturated-field case, the basis_table derivation and index order, the rejected overflow inputs, and the packed signature mask against the per-entry comparison it replaced.

One pre-existing failure is unrelated: save_load_resolution::test_tempdir_lock expects a permission error and does not get one when the suite runs as root. It fails identically on master.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@JoeyBF, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a0bae5f7-7b83-42f8-a361-e0e75dee5e64

📥 Commits

Reviewing files that changed from the base of the PR and between 4d06e09 and b4097d7.

📒 Files selected for processing (14)
  • ext/crates/algebra/Cargo.toml
  • ext/crates/algebra/benches/milnor.rs
  • ext/crates/algebra/benches/milnor_rank.rs
  • ext/crates/algebra/src/algebra/milnor_algebra.rs
  • ext/crates/algebra/src/algebra/milnor_rank.rs
  • ext/crates/algebra/src/algebra/mod.rs
  • ext/crates/algebra/src/algebra/pair_algebra.rs
  • ext/crates/algebra/src/module/rpn.rs
  • ext/crates/algebra/src/steenrod_evaluator.rs
  • ext/crates/algebra/src/steenrod_parser.rs
  • ext/examples/bruner.rs
  • ext/examples/sq0.rs
  • ext/src/nassau.rs
  • ext/src/yoneda.rs
📝 Walkthrough

Walkthrough

The PR replaces vector-based Milnor p-parts with packed u64 values, updates algebra operations and consumers, derives stable p=2 bases when possible, and adds an optional arithmetic ranker with tests and benchmarks.

Changes

Milnor algebra representation and ranking

Layer / File(s) Summary
Packed PPart representation and validation
ext/crates/algebra/src/algebra/milnor_algebra.rs
Adds canonical fixed-width packing, bounded access, mutation, iteration, conversion, parsing validation, and tests.
Basis generation and storage
ext/crates/algebra/src/algebra/milnor_algebra.rs
Updates basis generation and indexing to use packed p-parts. Stable p=2 basis elements can be derived from ppart_table.
Packed multiplication and coproduct operations
ext/crates/algebra/src/algebra/milnor_algebra.rs
Updates multiplier, multiplication, decomposition, odometer, and coproduct paths to pass packed values and construct packed results.
Downstream API and consumer updates
ext/crates/algebra/src/algebra/pair_algebra.rs, ext/crates/algebra/src/module/rpn.rs, ext/crates/algebra/src/steenrod_*.rs, ext/examples/*, ext/src/nassau.rs, ext/src/yoneda.rs, ext/crates/algebra/benches/milnor.rs
Adapts consumers, examples, signature matching, and benchmarks to the packed representation and owned basis elements.
Optional arithmetic ranking feature
ext/crates/algebra/Cargo.toml, ext/crates/algebra/src/algebra/mod.rs, ext/crates/algebra/src/algebra/milnor_rank.rs, ext/crates/algebra/benches/milnor_rank.rs
Adds feature-gated PPartRanker arithmetic ranking, validation tests, and hashmap-versus-ranker benchmarks.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Poem

I’m a rabbit with packed bits in a row,
Watching p-parts shrink and swiftly go.
Hash maps hop; rankers race,
Bases derive in canonical space.
Benchmarks thump like paws at night—
Milnor’s fields are tucked just right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: packing Milnor basis elements into a u64.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ext/crates/algebra/src/algebra/milnor_algebra.rs`:
- Around line 2348-2377: Strengthen basis_is_derived_at_p2 by asserting the
expected canonical ordering independently of ppart_table(t), using the element
names or excess ordering produced by the previously stored basis path. Keep the
existing field-consistency checks, but construct expected values from fixed
ordering data so reordering ppart_table entries causes the test to fail.
- Around line 799-821: In ext/crates/algebra/src/algebra/milnor_algebra.rs lines
799-821, update the P^ parsing closure to use checked exponentiation for entry,
reject it against PPart::max_entry(t - 1) before calculating degree, then use
checked multiplication for the degree and reject overflow or values above
PPart::MAX_DEGREE. In ext/crates/algebra/src/algebra/milnor_algebra.rs lines
1199-1212, move the x > PPart::max_entry(0) validation in try_beps_pn before
computing degree.
- Around line 1159-1172: Update generate_basis_2 so the derived basis preserves
the historical unsorted ppart_table order used by stable p = 2 index mappings.
Remove the excess-based sorting from this method while retaining table
construction and extension behavior, ensuring basis_element_from_index remains
compatible with existing saved resolutions and magic().
- Around line 182-195: Prevent oversized profiles from producing invalid packed
signatures: update MilnorSubalgebra::new, from_bytes, and SubalgebraIterator to
enforce PPart::MAX_LEN, and ensure packed_signature rejects or safely handles
indices at or beyond that bound before calling PPart::width or PPart::shift.
Preserve valid signature packing for indices below PPart::MAX_LEN.

In `@ext/src/nassau.rs`:
- Around line 103-119: Update packed_signature to detect when any signature
entry has bits outside its field mask and return no-match for the entire
signature instead of truncating or leaking those bits; ensure signature_mask
propagates this result as an empty iterator. Add a regression test covering an
oversized entry in a narrow field and verify it matches no basis elements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6d4ddeba-d3bf-4edf-a9f4-36b959dba386

📥 Commits

Reviewing files that changed from the base of the PR and between 99ddfb6 and 4d06e09.

📒 Files selected for processing (14)
  • ext/crates/algebra/Cargo.toml
  • ext/crates/algebra/benches/milnor.rs
  • ext/crates/algebra/benches/milnor_rank.rs
  • ext/crates/algebra/src/algebra/milnor_algebra.rs
  • ext/crates/algebra/src/algebra/milnor_rank.rs
  • ext/crates/algebra/src/algebra/mod.rs
  • ext/crates/algebra/src/algebra/pair_algebra.rs
  • ext/crates/algebra/src/module/rpn.rs
  • ext/crates/algebra/src/steenrod_evaluator.rs
  • ext/crates/algebra/src/steenrod_parser.rs
  • ext/examples/bruner.rs
  • ext/examples/sq0.rs
  • ext/src/nassau.rs
  • ext/src/yoneda.rs

Comment thread ext/crates/algebra/src/algebra/milnor_algebra.rs
Comment thread ext/crates/algebra/src/algebra/milnor_algebra.rs
Comment thread ext/crates/algebra/src/algebra/milnor_algebra.rs
Comment thread ext/crates/algebra/src/algebra/milnor_algebra.rs
Comment thread ext/src/nassau.rs Outdated
claude added 5 commits August 1, 2026 18:07
The p-part of a Milnor basis element was a `Vec<u32>`, costing a heap
allocation and a pointer chase per element. At p = 2 the internal degree of
P(R) is sum_i r_i (2^i - 1) with non-negative terms, so r_i <= deg/(2^i - 1);
sizing each field by that bound packs the whole exponent sequence into 64 bits
for every degree up to 2045. At odd primes the same bound applies divided by
q = 2(p-1), so one layout serves every prime.

`MilnorBasisElement` is now 16 bytes, `Copy`, and entirely inline. Measured
over degrees 0..=300 at p = 2, `basis_table` drops from 252 MiB in 5,036,688
allocations to 77 MiB in none.

Three things fall out of the packing:

- The packed value is a canonical key, so the hand-rolled `MilnorHashMap`
  specialization for `not(odd-primes)` is gone; a plain `HashMap` now hashes a
  single word on every path. That code also assumed a degree bound of 1536
  without enforcing it. `compute_basis` now asserts the bound up front, which
  is what lets everything downstream skip range checks.
- Trailing zeros are not represented, so the "pop trailing zeros" loops after
  building a product disappear.
- `PPartMultiplier` no longer borrows its inputs, so its lifetime parameter is
  gone, and `PPartAllocation` loses the buffer it existed to recycle.

In `ext`, `MilnorSubalgebra`'s signature test becomes one masked comparison on
the packed word instead of a loop over entries, with the mask hoisted out of
`signature_mask`'s inner loop.

Two behaviour changes worth noting:

- `basis_element_from_string("P0")` and `("Sq0")` now return the identity
  rather than `None`. P(0) is the identity, and `AdemAlgebra::try_beps_pn`
  already special-cases `x == 0` this way; the old `None` came from `vec![0]`
  and `vec![]` hashing differently, an artifact of the representation.
- `increment_p_part` now carries before incrementing. The old order
  transiently stored `max[i] + 1`, which need not fit a field whose width is
  exactly saturated by `max[i]`. The enumeration is unchanged.

The observation that every Milnor exponent sequence up to degree 512 fits in 64
bits is due to Lixiong Wu; this implementation works out the widths, finds that
the same layout holds all the way to degree 2045, and carries it through the
algebra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
The first packing pass regressed `milnor_ppart` by up to 8% at odd primes and
mod 4, because assembling the answer went from a memcpy plus a vectorized add
to a per-entry read-modify-write through the checked `PPart::set`, and because
`PPart::get`'s range branch landed in `update`'s inner loop.

Two changes, both confined to the kernel:

- Assemble the answer in a plain `u64` and store it once. Entries are written
  in increasing index order into a value that starts at zero, so a shift and
  an `or` suffice; the range checks become debug assertions backed by
  `compute_basis`'s degree gate.
- Pad the layout tables to 16 entries so the private `PPart::entry` can mask
  its index rather than branch on it. Padded entries have width zero and so
  read as zero, which is the answer `get` would have returned anyway. The
  public `get` keeps its explicit check, since callers outside the multiplier
  index it with a q-part-derived length that is not bounded by `MAX_LEN`.

This recovers the regression (`ppart_4/a` and `ppart_3/a` back to baseline,
`ppart_4/b` -8%) and improves the Nassau regime further.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
`basis_table` held a `MilnorBasisElement` per basis element. At p = 2 with
unstable support off, that element is exactly
`from_p(ppart_table[t][i], t)` -- the p-part again, with a q-part that is
always zero and a degree that is the index. It was a redundant copy.

Deriving it on demand costs nothing now that `MilnorBasisElement` is `Copy`
and 16 bytes: `basis_element_from_index` returns by value and builds it in
registers rather than handing out a reference into a table. The multiply
family takes the element by value for the same reason.

The table is still built at odd primes, where the q-part varies within a
degree, and when unstable support is on, where the basis is re-sorted by
excess. Neither is a re-wrapping of `ppart_table`.

Measured over degrees 0..=250 at p = 2 (1,958,958 elements), RSS growth from
`compute_basis` drops 125.0 MB -> 95.0 MB, i.e. 66.9 -> 50.8 bytes per element.
Projected to degree 500 that is 5.24 GB -> 3.95 GB. Unlike the ranker, this
needs no basis renumbering and costs nothing at lookup time.

A test verifies the derivation matches what the table used to hold, for every
element, so the redundancy is asserted rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
`basis_element_to_index` runs once per term of every product, and is a hash map
storing an entry per basis element. The index it returns is a position in an
enumeration, so a canonical key alone cannot replace the map -- but the position
can be computed.

Let counts[i][d] be the number of exponent sequences of degree d using only
xi_1..xi_i. Splitting on whether r_i is zero gives the coin-change recurrence
counts[i][d] = counts[i-1][d] + counts[i][d - xi_i]. Ranking needs the number of
sequences with r_i > v, and substituting r_i -> r_i - (v+1) is a bijection onto
all sequences of degree d - (v+1)*xi_i, so that count is a single table lookup
rather than a sum. Walking the entries downward ranks a p-part in one lookup
each, against a table covering every degree at once, where the map it would
replace grows with the basis.

Whether that is worth it depends entirely on scale, which took some measuring to
see. Against the map, at p = 2:

    degree   per-degree map   hashmap    ranker    ratio
       120          0.10 MB    11.6us    26.7us    0.43x
       300          3.12 MB     792us    1384us    0.57x
       400         12.50 MB    4490us    5310us    0.85x
       500         37.50 MB   33118us   15865us    2.09x

A lookup probes only its own degree's map. While that fits in cache the map wins
easily: one hash round and one probe, against six to ten dependent table reads.
Once it does not -- the map is 37 MB in degree 500 -- every probe misses to DRAM
at ~33 ns, whereas the ranker's table is ~43 KB, stays in L1, and costs ~16 ns
regardless of degree. Benchmarking only up to degree 120, where the map is
0.1 MB, shows a 2x loss and hides all of this; the sweep here deliberately spans
the crossover. Tuning does not move the small-degree end: nested vs flat table,
a zero-padded prefix to drop the branch, and one- vs two-pass to break the
dependency chain were all measured, and the padded variant was worst, because
doubling the table pushed it out of L1.

So the two suit opposite ends of the range, and the ranker is on the right side
of the end where the algebra's memory is the problem worth solving: replacing the
map there is 3.3 GB smaller and 2x faster.

It stays off by default and unwired even when enabled, because it numbers the
basis in colex order rather than the order compute_ppart emits, which would
invalidate saved resolutions. That order is rankable in principle, but its
natural recursion has depth equal to the sum of the entries, which is worse than
hashing. The unstable path, which re-sorts each degree by excess, is not modelled
either.

Tests verify the table reproduces the algebra's own p-part counts and that the
rank is a bijection onto 0..dim in every degree, at p = 2 and p = 3, plus one
pinning down that it really does disagree with the current basis order.

Also measured and rejected: an `unrank` recovering the p-part at a given index,
which would let basis_element_from_index drop ppart_table entirely. It ran ~15x
slower than the array read it would replace, at every degree, with none of the
crossover above -- ppart_table is 8 bytes per element against ~43 for the map, so
it stays cache-resident. The bit-packing that makes rank worth having is the same
thing that makes unrank not. The likelier route, if it is ever revisited, is
enumerating the basis in index order, which is O(1) amortised and matches how
callers actually walk it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
Three paths computed with unvalidated input before checking it against the
packing bounds, so the intermediate arithmetic went wrong first. All three are
reachable from public, non-panicking entry points.

- `basis_element_from_string("P^s_t")` indexed the xi-degree table with `t`,
  which has exactly `MAX_LEN` entries, so `t = MAX_LEN` was out of bounds. `p^s`
  and the degree product could also overflow. Now `t` is bounded by the table
  itself and both are computed with checked arithmetic.
- `try_beps_pn` computed `q * x + e` before bounding `x`, which overflows for a
  large `x`. The bound moves above the computation.
- `MilnorSubalgebra::packed_signature` assumed the profile was no longer than
  `PPart::MAX_LEN` and that each signature entry fit its field. Neither holds:
  `SubalgebraIterator` grows a profile without limit and `from_bytes` reads
  whatever length a file gives. Out of range, `PPart::shift` returns 64 and the
  shift overflowed; an oversized entry silently spilled into the neighbouring
  field, which could select unrelated basis elements. It now returns `None` for
  a signature no element can have, and `signature_mask` yields nothing.

`basis_element_from_string` is documented as total and `try_beps_pn` is the
non-panicking half of `beps_pn`, so these were contract violations rather than
merely untidy. Tests cover each.

The signature test checks the packed mask against the per-entry comparison it
replaced, over every element up to degree 60, for profiles that are narrower
than their fields, wider than their fields, and longer than a p-part can be.

Also adds `basis_order_at_p2_is_stable`, which pins the first nine degrees to
fixed element names. The basis order is a wire format -- saved resolutions store
coefficients by index -- so it needs a guard that does not read from
`ppart_table`, which is the thing being guarded. Verified separately that the
order is unchanged from the base commit: identical for all 4156 elements in
degrees 0..=60.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
@JoeyBF
JoeyBF force-pushed the claude/milnor-basis-bit-packing-ri158z branch from 4d06e09 to d09141e Compare August 1, 2026 18:07

JoeyBF commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — four of the five were real, and three were genuine panics reachable from entry points documented as total. Fixed in d09141e, with a note on the one I'm declining.

Fixed — exponent validation ordering. Both sites panicked, not merely truncated. Confirmed with tests before fixing:

  • basis_element_from_string("P^1_10")t indexed the xi-degree table, which has exactly MAX_LEN entries, so t = MAX_LEN was out of bounds. t is now bounded by the table itself.
  • basis_element_from_string("P^64_2")attempt to multiply with overflow in p.pow(s), now checked_pow, with the degree product also checked.
  • try_beps_pn(0, u32::MAX)attempt to add with overflow in q * x + e. The x bound moves above the computation.

Fixed — signature masks past PPart::MAX_LEN, and oversized entries. Both reachable: SubalgebraIterator grows a profile without limit and from_bytes reads whatever length a file gives. Worse than described — PPart::shift returns 64 out of range, so the shift overflowed rather than collapsing into field 0.

Fixed at the packing boundary rather than by bounding profile length in three constructors, since that's where the assumption lives and it keeps from_bytes accepting the same files. packed_signature now returns None for a signature no element can have, and signature_mask yields nothing. Both cases are semantically correct against the original per-entry test: entries past MAX_LEN always read zero, so a non-zero constraint there is unsatisfiable; and a masked entry has only min(profile[i], width(i)) bits, so a signature wanting more matches nothing.

The regression test checks the packed mask against the per-entry comparison it replaced, over every element up to degree 60, for profiles narrower than their fields, wider than their fields, and longer than a p-part can be.

Fixed — test strengthening. Fair point, and it applies to the order rather than the derivation. Added basis_order_at_p2_is_stable, pinning the first nine degrees to fixed element names so it doesn't read from ppart_table — the thing it's guarding.

Declining — "preserve the stable p = 2 basis index order". The premise is right but the conclusion doesn't follow from it. The old generate_basis_2 sorted only under if self.unstable_enabled, so stable p = 2 already used unsorted ppart_table order — which is exactly what the derived path returns. Nothing changed.

Verified rather than argued: dumping every basis element in index order at both the base commit and this branch gives identical output for all 4156 elements in degrees 0..=60. That's now locked in by the test above.

I'd also flag that the suggested fix — removing the excess sort from generate_basis_2 — would be a regression. That sort is only reached when stores_basis_table() is true, i.e. the unstable path, which legitimately needs it.


Separately, just lint, just test and just docs are all reproduced locally and passing, including the --no-default-features and --all-features configurations and rustdoc under -D warnings. The --all-features build caught one thing CI would have: the cache-multiplication path still passed a basis element by reference.


Generated by Claude Code

CI lints with the nightly toolchain, where the unstable options in
`rustfmt.toml` -- `reorder_impl_items` among them -- actually take effect.
Stable rustfmt skips them with a warning, so `cargo fmt --check` passed locally
and failed in CI.

Formatting only: the constants are sorted and the blank lines between them
dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XK8YCdHkCqUV9YM7D957hV
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.

2 participants