Skip to content

Hold every holds-check to an edited claim or the test that kills its drop (#344) - #345

Merged
mattlorimor merged 2 commits into
masterfrom
test/344-holds-check-audit
Nov 6, 2026
Merged

mattlorimor merged 2 commits into
masterfrom
test/344-holds-check-audit

Conversation

@mattlorimor

Copy link
Copy Markdown
Owner

Fixes #344.

The cut sweep can't see a check that holds a count to the bytes after it, when the writer keeps that count small (#332). This PR audits every such check in the library, adds edited-claim tests for the four nothing caught, and adds a roster test so a new check can't go untested. It's test-only, plus a TESTING.md note, and follows the coordinator's plan on #344, approved by Matt.

The audit

The roster comes from the code: a parse of the library with comments and strings blanked. It found 31 RequireHolds calls, plus the 7 checks inside PayloadReader that hold a claimed length to the bytes left. Each check was dropped through scripts/mutate.sh and run against the whole suite at 6e2e5ec.

  • 34 of the 38 drops were killed. The full table, with each check's file, line, what it holds and how many tests failed, is in ccd3f49's message.
  • CountSketch, IBLT, DDSketch and QuotientFilter call no RequireHolds. Their runs are held by BeginRun and Skip, and both drops are killed.

The four survivors:

Check What it holds Why the sweep missed it
HeavyKeeper:686 element count The count is at most k (500 in the sample), and the only cuts that keep it come after 80 KB of buckets, where the bound has room for a heap of k elements.
TopK:408 element count The same, with k = 1,000, after a 110 KB nested sketch.
SublimeCountMinSketch:1166 current rows' counters Each block is checked again where it begins, so every cut it refuses is refused later anyway. An edited depth isn't: at depth 64, without the check the price runs to offset 32,838 and the read allocates 118,024 bytes against 82,768.
PayloadBuffer:974 (Slice) a nested envelope's length against its run No cut can make the two disagree. Dropped, a held payload's slice throws ArgumentOutOfRangeException in place of InvalidDataException.

None of the four is unreachable, so none is proposed for removal.

The tests

All are in TestReadsAllocateOnlyWhatTheyHold.cs.

  • TestAClaimNoCutCanSeeIsRefusedBeforeItIsPricedOrAllocatedWhenEdited: three edited-claim cases, in Test DpswSketch's substream price check with an edited count (#332) #340's pattern. They are HeavyKeeper's and TopK's element counts, with k raised to the heap's limit, and Sublime's depth.
    • Each claim is edited in the sweep's own sample, checksum resealed: once to one past what the bytes hold, once to the largest the field allows.
    • The price must stop exactly where the check refuses (offsets from FORMAT.md), having priced exactly what a cut just before the field prices.
    • The read is refused for the missing bytes under its own price, the default budget and none. It allocates no more than that cut's read, plus the envelope's difference and 1 KiB.
    • Control: the most the bytes can hold gets past the check, and its read allocates 15,472 to 154,848 bytes past the bound. Test DpswSketch's substream price check with an edited count (#332) #340's envelope + Fixed bound was too loose here, since Sublime's control fell inside 16 KiB.
  • TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData: the nested envelope's length is edited to the least its run can't hold. Its roster is the structures whose price calls PriceNested, taken from IL: TopK and ScalableBloomFilter.
  • TestEveryHoldsCheckIsHeldByAnEditedClaimOrNamesTheTestThatHoldsIt: the roster is the library's IL, every RequireHolds call. Each check must be one of the edited claims, or be listed in HeldElsewhere with the test that kills its drop, verified to be a real test.
    • Stale entries fail.
    • The edited claims' own checks must be found, so a scan that finds nothing can't pass.

A sweep over FORMAT.md or StructureId wasn't feasible. FORMAT.md's layouts are prose code blocks, with version conditions and nested groups that no test parses. StructureId doesn't name checks. The IL is the roster that can be derived.

Watched failing, each drop applied by hand:

  • HeavyKeeper's price priced 116,536 bytes and stopped at 91,961, not 81,961.
  • TopK's priced 232,496 and stopped at 128,827, not 108,827.
  • Sublime's, at depth 10, priced 22,048 and stopped at 5,190, not 32.
  • Slice: ArgumentOutOfRangeException from both nesting structures.

Mutation of the new tests: 20 mutants, all killed, against the whole suite and by the three new tests alone. They are:

  • the four drops;
  • each bound a byte looser, and a byte stricter;
  • each element-count check moved after its price, or after its charge;
  • for the rosters: a check added to BloomFilter.Price, the TopK case removed, a stale entry added, and a nesting structure left out.

TESTING.md

A note under Persistence: the cut sweep can't see a check on a count the writer keeps small, so such a check needs an edited claim. It also says what this audit found.

Left for later

An envelope's claimed length is held only by the read budget. A held 128,827-byte TopK payload whose header claims 2^30 bytes allocates 1,073,743,704 bytes with no budget. Under the default budget it's refused for its budget, not as malformed. Nested envelopes behave the same way. Matt has decided that held lengths should be checked against the bytes held, and a separate round will do it.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx

mattlorimor and others added 2 commits November 6, 2026 01:45
…drop (#344)

The cut sweep in TestReadsAllocateOnlyWhatTheyHold sees a check that holds a claim
to the bytes after it only by what dropping it costs at a cut, and a cut keeps its
writer's claim. Where that claim stays small, a price or read that skipped the check
costs less than the sweep's bound at the cuts that hold it, and the sweep passes with
the check gone. #332 found this for DpswSketch and #340 tested that one. This finds
the rest.

The audit. The roster is the code's: every call of PayloadReader.RequireHolds in the
library (a parse with comments and strings blanked, each with the method enclosing
it), and the checks inside PayloadReader that hold a claimed run length to the bytes
remaining (every RequireAhead a claimed length reaches, and the two comparisons with
Remaining). Each was dropped in turn through scripts/mutate.sh (MUTATION_JOBS=2,
Release), against the whole suite at 6e2e5ec. Two of the checks are a method's whole
expression body, where deleting the statement does not compile, so those were dropped
by giving the method an empty body. Lines are at 6e2e5ec.

  check                              method            holds                     drop
  CountMinSketch:616                 CheckMatrix       counters                  killed, 6 tests
  CuckooBloomFilter:663              RequireTable      words of table and state  killed ({ }), 4
  DpswSketch:839                     ReadPayload       substreams                killed, 2
  DpswSketch:897                     Price             substreams                killed, 2
  Grafite:1221                       ReadLowWords      words of low bits         killed, 4
  Grafite:1240                       ReadHighWords     words of high bits        killed, 3
  HeavyKeeper:686                    ReadElementCount  elements                  SURVIVED
  HeavyKeeper:742                    CheckShape        buckets                   killed, 4
  HyperLogLogPlus:777                ReadPayload       stored hashes             killed, 2
  HyperLogLogPlus:858                Price             stored hashes             killed, 3
  InfiniFilter:687                   ReadPayload       words                     killed, 5
  InfiniFilter:727                   Price             words                     killed, 6
  MementoFilter:836                  ReadPayload       words                     killed, 3
  MementoFilter:920                  Price             words                     killed, 4
  MinHashSignature:169               ReadPayload       values                    killed, 2
  MinHashSignature:205               Price             values                    killed, 3
  PersistenceFormat:1460             ReadBuckets64     arrays of buckets         killed, 6
  PersistenceFormat:1510             PriceBuckets64    arrays of buckets         killed, 11
  PrivateCountMinSketch:561          CheckShape        counters                  killed, 7
  ScalableBloomFilter:677            ReadFilterCount   contained filters         killed, 3
  SetSketch:839                      ReadPayload       registers                 killed, 3
  SetSketch:941                      Price             registers                 killed, 3
  SublimeCountMinSketch:1002         RequireCounts     counters                  killed ({ }), 3
  SublimeCountMinSketch:1166         CheckShape        counters                  SURVIVED
  ThetaSketch:474                    ReadPayload       values                    killed, 1
  ThetaSketch:535                    Price             values                    killed, 2
  TopK:408                           ReadElementCount  elements                  SURVIVED
  TupleSketch:696                    ReadPayload       keys                      killed, 1
  TupleSketch:764                    Price             keys                      killed, 2
  VarOpt:905                         ReadPayload       items                     killed, 1
  VarOpt:988                         Price             items                     killed, 2
  PayloadBuffer:723                  ReadBytes         a streamed run            killed, 2
  PayloadBuffer:776                  ReadHeldRunLength a run's length            killed, 5
  PayloadBuffer:811                  BeginRun          a run                     killed, 40
  PayloadBuffer:862                  ReadBlock         a streamed piece          killed, 1
  PayloadBuffer:923                  Skip              a run skipped             killed, 1
  PayloadBuffer:974                  Slice             a nested envelope         SURVIVED
  PayloadBuffer:1038                 RequireHolds      its own comparison        killed, 75

A checked price and reader that share one helper (CheckMatrix, ReadElementCount and
the like) are one row: dropping it drops both. CountSketch, InvertibleBloomLookupTable,
DDSketch and QuotientFilter call no RequireHolds; their runs are held by BeginRun and
Skip, rows above. A second run, keeping each failing test's name, found which tests
kill each killed drop; that is where HeldElsewhere's names come from.

The four survivors:
- HeavyKeeper's and TopK's element counts. A count is at most k, a few hundred and a
  thousand in their budget samples, and the only cuts that hold it come after the
  buckets or the nested sketch, where the bound has room for a heap of k elements on
  top of them.
- Sublime's shape check holds the current rows to the bytes after the shape, and
  every block, the current rows' among them, is held again where it begins, so every
  cut it refuses is refused at the current rows at the latest, having read only blocks
  it holds. It is reachable, though: a depth edited past what the current rows can
  have is still held by the records' blocks, and unchecked, the price prices them at
  that depth. Probed on the budget sample with depth 64: priced 0 and stopped at offset
  32 with the check, 140,848 and offset 32,838 without it, the read allocating 82,768
  against 118,024.
- Slice's check is what holds a nested envelope's length to the run it is stored in,
  in its outer price, and no cut can make the two disagree. Without it a held payload's
  slice throws ArgumentOutOfRangeException where every refusal is InvalidDataException.

The tests.
- TestAClaimNoCutCanSeeIsRefusedBeforeItIsPricedOrAllocatedWhenEdited, over
  EditedClaims: HeavyKeeper's and TopK's element counts, with k raised to the heap's
  limit so the count is held only by the bytes, and Sublime's depth. Each claim is
  edited, as #340's was, in the budget sample the sweep cuts, its checksum resealed,
  to one past what the bytes after it hold and to the largest the field can make. Its
  price stops exactly where the check refuses, from FORMAT.md, having priced exactly
  what a cut just before the field does. Its read is refused for what it lacks under
  its own price, the default budget and none, tightest first, stopping at the first
  problem so that a reader charging billions before its check is not then read
  unbudgeted. It allocates no more than the cut's read plus the envelope's difference
  and 1 KiB. Measured, each allocated 56 to 696 bytes less than that cut's read. The
  most the bytes hold is the control: its price walks past the check and prices more,
  and its read allocates past that bound, by 15,472 (Sublime), 76,328 (HeavyKeeper) and
  154,848 (TopK) bytes.
- TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData: for TopK and
  ScalableBloomFilter, the structures whose price prices a nested one (from the IL),
  the nested envelope's length edited to the least its run cannot hold. The price and
  the read under no budget and the default refuse it as data. Four bytes fewer, which
  the run holds by taking the checksum's bytes, is not refused by the price.
- TestEveryHoldsCheckIsHeldByAnEditedClaimOrNamesTheTestThatHoldsIt: the roster is
  the library's IL, every call of RequireHolds with the string pushed before it, which
  is what it counts. It agrees with the parse above. Every check must be an edited
  claim or name, in HeldElsewhere, the test that kills its drop, which must be a test.
  An entry the IL no longer has fails, and the edited claims' checks must be found.
  Theta's, Tuple's and VarOpt's checks, price and reader, are killed by the cut sweep
  alone, because their budget samples' claims are large; their entries say so, since a
  sample that came to hold few would leave them unseen.

Watched failing, each drop applied by hand:
- HeavyKeeper:686: the price priced 116,536 bytes past a cut before the count and
  stopped at 91,961, not 81,961; at the largest claim, 257,698,046,896 bytes.
- TopK:408: 232,496 bytes, stopped at 128,827, not 108,827.
- SublimeCountMinSketch:1166: depth 10 priced 22,048 bytes and stopped at 5,190, not 32;
  depth 710 stopped at 66.
- PayloadBuffer:974: both nesting structures' prices threw ArgumentOutOfRangeException,
  at nested lengths of 108,798 (TopK) and 2,558 (ScalableBloomFilter).

Mutation of the new tests, through scripts/mutate.sh, against the whole suite and then
against the three new tests alone:

  mutant                                         whole suite      new tests alone
  E1  HeavyKeeper elements dropped               killed, 2        killed, 2
  E2  TopK elements dropped                      killed, 2        killed, 2
  E3  Sublime shape dropped                      killed, 2        killed, 2
  E4  Slice dropped                              killed, 1        killed, 1
  E5  HeavyKeeper elements at 11 bytes each      killed, 1        killed, 1
  E6  HeavyKeeper elements at 13 bytes each      killed, 1        killed, 1
  E7  TopK elements at 11 bytes each             killed, 1        killed, 1
  E8  TopK elements at 13 bytes each             killed, 1        killed, 1
  E9  Sublime shape at 7 bytes each              killed, 2        killed, 1
  E10 Sublime shape at 9 bytes each              killed, 47       killed, 1
  E11 Slice a byte laxer                         killed, 1        killed, 1
  E12 Slice a byte stricter                      killed, 19       killed, 2
  E13 HeavyKeeper price checks after pricing     killed, 3        killed, 2
  E14 TopK price checks after pricing            killed, 3        killed, 2
  E15 HeavyKeeper read checks after charging     killed, 4        killed, 2
  E16 TopK read checks after charging            killed, 3        killed, 2
  R1  a check added to BloomFilter.Price         killed, 1        killed, 1
  R2  the TopK edited claim removed              killed, 1        killed, 1
  R3  a stale entry in HeldElsewhere             killed, 1        killed, 1
  R4  a nesting structure left out of the test   killed, 1        killed, 1

The laxer bounds (E5, E7, E9, E11) let one claim past the bytes through, and the
stricter ones (E6, E8, E10, E12) refuse the most the bytes hold, which the controls
see. E13 to E16 move a check after its price or its charge in one of the price and
the reader, keeping it in the other; each puts a RequireHolds where there was none, so
the roster fails on it too. R1 adds a RequireHolds that refuses nothing.

Probed under the segments GC macOS uses (DOTNET_GCName=libclrgc.so,
DOTNET_PROCESSOR_COUNT=3): the new tests and #340's pass.

Test-only, and no CHANGELOG entry, as for earlier test-only fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
… claim (#344)

The note the Decision asks for, in Persistence after the bullet on what no cut can
show: why the cut sweep cannot see such a check, which checks the audit found it
could not see, how the edited-claim test holds them and to what bound, and the roster
taken from the library's IL that makes a new check say what holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
@mattlorimor
mattlorimor merged commit baaf26f into master Nov 6, 2026
9 checks passed
@mattlorimor
mattlorimor deleted the test/344-holds-check-audit branch November 6, 2026 13:48
mattlorimor added a commit that referenced this pull request Nov 6, 2026
Brings in #345 and #348 (2a4dfbc). CHANGELOG merged without conflict.
#345's TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData fails on
this merge, as expected: it pins Slice's refusal, and the price now
refuses first, in RequireHeld. The commit after this holds it to that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
mattlorimor added a commit that referenced this pull request Nov 6, 2026
TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData, from #345,
expected the price to refuse a nested envelope claiming more than its
run with Slice's message ("while reading N more at offset 14"). On the
merge of master it failed for TopK and ScalableBloomFilter, refused
instead by RequireHeld, which since #347 holds a nested envelope's
length to what its run holds before anything is priced.

RequireHeld counts the checksum as well, so the boundary the test held
moved. A claim of the run less its header was #345's control, held and
then refused by the nested read for its checksum; it is now refused by
the price, as is any claim past the writer's. So the claims are now the
coarsest pair at the new boundary: the writer's own, which the run holds
with its checksum and which has to read, and a byte more, which the
price and the read both have to refuse with RequireHeld's message, and
no other. The summary and remarks say so; they described the refusal as
coming from Slice.

Mutation through scripts/mutate.sh, against this test alone:

  N1  nested price: RequireHeld dropped            killed (1)
  N3  nested price: one byte looser                killed (1)
  N4  nested price: one byte stricter              killed (1)
  R1  read: RequireHeld dropped                    killed (1)
  H4  RequireHeld: checksum not counted            killed (1)

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

The cut sweep can't see a holds-check on a count the writer keeps small, as with DpswSketch's (#332)

1 participant