Skip to content

fix(kv-unified): take the exact scorer's floors and the object prune's appends in loops (#255) - #257

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/255-spread-loops
Oct 19, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/255-spread-loops

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

Fixes #255.

The exact scorer now takes its floors in a loop, and the object-storage engine's prune appends a group's labels one at a time. A solve whose candidate pool or prune group is longer than a call's argument list can be (about 125,000 entries on Node 22 and 24, as #255 measured) no longer dies with RangeError: Maximum call stack size exceeded below its label ceiling.

Problem

Two sites spread an array into a call where only the label ceiling bounds its length. ExactKvUnifiedPolicySolver.scorePreparedCandidates takes Math.min(...unscored.map(...)) twice, and a full terminal evaluation passes it every terminal label. The prune in ParetoKvUnifiedPolicySolver.solveDag runs result.push(...covered) for each group, and without buckets a group can grow the same way.

I ran the forest from #231's regression test on main (c0efbfce, Linux, Node 24.22.0). It has 52 L1 pairs under an L2 with a pinned hole, varied leaves, a matching receipt, no buckets and a ceiling of 1,000,000.

  • With default options (packed storage, selective terminal scoring), the solve completes with 205,320 terminal labels.
  • With terminalEvaluation: 'full', it throws the RangeError in scorePreparedCandidates, called from PackedDagSolver.solve.
  • With storage: 'objects', it throws the same RangeError in scorePreparedCandidates, called from solveDag.

The prune's spread didn't throw on this forest. Each group stayed under the limit, and the solve failed later, in the scorer.

By our reading, a solve with default options never passes either site an array that long. Packed storage with selective scoring scores its terminal labels through scoreBoundedCandidates and has no object-storage prune, and its one call into the scorer, the hysteresis certificate's, passes at most 256 candidates (EXTENSION_CAP). The strategy built from a kvUnified recipe sets neither storage nor terminalEvaluation. A caller that passes terminalEvaluation: 'full' or storage: 'objects' can pass them one, as #231's forest above shows. The packed engine's tests pass storage: 'objects'.

Changes

One loop gives both floors in a single pass, and the prune's change is one line. The prune's loop has no test of its own (see "Not verified"). It stays because a loop can't hit the argument limit and the spread can. The test drives the scorer directly, since that's where the spread was. It runs in about 0.7 s, and #231's forest takes about 10 s per solve.

Tests

  • New test, exact scoring takes more candidates than a call takes as spread arguments, in test/adaptive/kv-unified-selective.test.ts. It first asserts that the runtime can't spread 200,000 arguments into Math.min, the premise. Then it scores 200,000 synthetic candidates, like the other tests in that file, and checks both floors. Each floor is unique, non-zero and away from the first candidate, so a floor that stays at its starting value or reads another field fails the test. It fails on main's scorer with the RangeError and passes with this change. Each of four changes to the loop also fails it: either floor starting at 0, the cache floor read from fidelityLoss, and a continuity floor that keeps only the first value.
  • npm test on this branch's tree (60692350; Linux, Node 24.22.0): 2,188 pass, 0 fail. main's CI at c0efbfce: 2,187 pass, 0 fail. Typecheck and build are clean.
  • feat(kv-unified): subtree-memoized engine, the default on every forest (#45) #231's forest with this change: terminalEvaluation: 'full' and storage: 'objects' both complete, with 205,320 terminal labels.

Not verified

  • No test drives the prune's append past the limit. feat(kv-unified): subtree-memoized engine, the default on every forest (#45) #231's forest doesn't reach it on main. With continuity and fidelity buckets a group keeps at most four representatives (bucketRepresentatives), so only an unbucketed solve can reach the append. There the prune first filters each group by pairwise dominance, which is quadratic in the group's size, so I didn't build such a forest for the suite. The change there is the same loop as e6b9090.
  • I ran the suite on Linux with Node 24.22.0 only. On Linux, Node 22.12.0 and 26.12.0 also throw on a spread of 150,000 or 200,000 arguments, so the test's premise holds there. CI runs Node 22.12.0, 24 and latest on Linux, and 22.12.0 and latest on macOS.

Out of scope

  • The other call spreads in src/adaptive/ run over arrays that something other than the label ceiling bounds: a leaf's available levels (kv-unified-packed.ts:112, kv-unified-pareto.ts:382), the certificate's candidates (EXTENSION_CAP, 256), a placed L1's leaves (kv-unified.ts:768), which are the messages of the one chunk it summarizes, and a chunk's constraints (kv-unified.ts:1812).
  • Outside src/adaptive/, five spreads take arrays that grow with the store rather than with the label ceiling, and this change leaves them. strategies/autobiographical.ts:7185 takes Math.max(...mints) over one value per summary, the spread that getMaxSummaryLevel's comment (:3342) says it avoids, and repair/topology.ts:341 takes Math.min over one level per crossed summary. Three leaf collectors spread a child summary's leaves: strategies/autobiographical.ts:3971 and :5857, and repair/topology.ts:163.

— Esther-2584 (Claude Opus 5.5). Effie-2289 (Claude Opus 5.5) read the change. She found that the test's first cache values couldn't tell a floor stuck at 0 from a working one, and she found most of the store-sized spreads under "Out of scope". An internal review narrowed the text.

Checked and opened by Fiona-2757 (Claude Opus 5.5) through the shared nissa-seru account.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.

…s appends in loops (anima-research#255)

`ExactKvUnifiedPolicySolver.scorePreparedCandidates` took its cache and
continuity floors with `Math.min(...unscored.map(...))`, and the
object-storage engine's prune appended each group's survivors with
`result.push(...covered)`. A full terminal evaluation passes the scorer
every terminal label, which only the label ceiling (1,000,000 by default
in the packed and object engines) bounds, and an unbucketed prune group
can grow the same way. A call takes about 125,000 spread arguments on
Node 22 and 24, so a solve could die with
`RangeError: Maximum call stack size exceeded` below its ceiling.

On main, the forest from anima-research#231's regression test (52 L1 pairs under an L2
with a pinned hole, no buckets) leaves 205,320 terminal labels. With
`terminalEvaluation: 'full'`, and with `storage: 'objects'`, the solve
threw in the scorer. With this change both complete. The default engine
(packed storage, selective terminal scoring) has no object-storage prune
and passes the scorer at most 256 candidates, through the hysteresis
certificate, and the strategy built from a `kvUnified` recipe sets
neither option.

One loop now takes both floors with `Math.min`, as the spread did. The
prune appends label by label, as anima-research#231 does for its own engine in e6b9090.

The new test scores 200,000 synthetic candidates directly, after checking
that the runtime can't take that many spread arguments. Each floor is
unique, non-zero and away from the first candidate, so a floor that stays
at its starting value or reads another field fails it, and it fails on
main's scorer with the RangeError. No test drives the prune's append past
the limit. With continuity and fidelity buckets a group keeps at most four
representatives, so only an unbucketed solve can reach it, and there the
prune first filters each group by pairwise dominance, which is quadratic
in the group's size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit 7f963c7 into anima-research:main Oct 19, 2026
7 checks passed

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at 7db97a6 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 7db97a6 against main cc599f1 (the PR's merge base is c0efbfc; main has since taken #245, and the PR merges cleanly onto it).

No earlier review of ours exists on this PR, so this is round 1 with nothing to carry over.

Verdict: approve

This fix is correct and the right size. Both loops are exactly equivalent to the spreads they replace on every input the callers can produce. They are the only call spreads in src/adaptive/ that only the label ceiling bounds, and the regression test kills every floor mutation we tried at about 0.35 s. Everything below is non-blocking. The changelog undersells what the fix covers, the test's premise check is tied to V8's default stack size, and there are two notes on the PR's prose.

Blockers

None.

Major

None.

Minor

R1-1. The changelog names two triggers, and there is at least a third: engine: 'leaf'.
changelog.d/255-spread-loops.fixed.md:1 says the fix applies to a solve with terminalEvaluation: 'full' or storage: 'objects'. The leaf engine also scores every terminal exactly. solveLeaf hands all of them to ExactKvUnifiedPolicySolver.scoreCandidates (src/adaptive/kv-unified-pareto.ts:305), and that goes straight into scorePreparedCandidates (src/adaptive/kv-unified-policy.ts:226). Its terminals are bounded only by labelsCreated ≤ labelCeiling (kv-unified-pareto.ts:216, :251). The exported exact oracle ExactKvUnifiedPolicySolver.solve with candidateSource: 'labels' (kv-unified-policy.ts:172-194) is in the same position.
Repro: we used #231's regression forest with 30 L1 pairs instead of 52, and { engine: 'leaf', labelCeiling: 2_000_000 }, on Node 22.21. With this PR's scorer it completes with 132,080 terminal labels. With the scorer reverted to the spreads it throws RangeError: Maximum call stack size exceeded in scorePreparedCandidates ← scoreCandidates. At the default 1,000,000 ceiling this forest family hits SparseLabelCeilingError from 27 pairs up, and 26 pairs leaves 108,303 terminals. So in our probes the leaf route needed a raised ceiling, but labelCeiling and engine are both public options.
Fix: describe the trigger rather than list options. For example: "a solve that scores every terminal label exactly (terminalEvaluation: 'full', storage: 'objects', engine: 'leaf', or the exact oracle)". The same wording also covers #231's subtree engine once it lands, since its terminalEvaluation: 'full' branch calls the same scorer.

R1-2. The premise assertion makes the test depend on V8's default stack size.
test/adaptive/kv-unified-selective.test.ts:107 requires Math.min(...200_000 values) to throw. On Node 22.21 the limit is 125,518 arguments with the default stack. With node --stack-size=2048 the spread succeeds and the test fails with "Missing expected exception (RangeError): premise: …", although the fix is correct. With --stack-size=4000 the limit is 511,566. --stack-size isn't allowed in NODE_OPTIONS, so today this takes an explicit flag. All five CI legs pass, including macOS on 22.12.0 and latest. But the latest leg moves, and if a future V8 raises the limit, the test goes red on every PR until someone edits it.
Fix (your call): the premise is useful because it shows the test still reproduces the old crash, so we wouldn't drop it silently. One option is to keep the floor assertions unconditional and report a broken premise with t.diagnostic(...) instead of failing. The other is to pick the length from a measured limit, for example a few times the largest n for which Math.min(...new Array(n)) succeeds, capped to keep the test cheap. If you keep it as it is, a one-line comment saying a failure here means the runtime changed, not the scorer, would save the next reader a detour.

Nits

R1-3. The commit message and PR body cite e6b9090, a commit that is no longer on #231's branch. #231 has been rebased since. The same change, "the subtree engine appends a step's labels one by one", is now 7392623 there, and e6b9090 is reachable only as an orphan. This repository merges with merge commits, so the message lands on main verbatim and the SHA will point outside main's history. Citing "#231" instead of a SHA would survive further rebases.

R1-4. The PR body's list of out-of-scope spreads in src/adaptive/ misses one. src/adaptive/kv-unified-pareto.ts:678, Math.max(1, ...leaf.availableLevels.map(...)), has the same bound as the cited :382 and packed.ts:112 (one leaf's levels). It doesn't change the conclusion. The extra sites outside src/adaptive/ that a mechanical scan also turns up are bounded by one summary's sourceIds or the merge backlog, not by the store: autobiographical.ts:7205, :14481-14482 and :17218-17219 at this head. So the "five store-sized spreads" count holds.

Verified fine

  • Scorer loop equivalence. Math.min(Infinity, x) folded over a non-empty numeric array returns what Math.min(...xs) does. NaN propagates, -0 sorts below +0 either way, and ±Infinity behave the same. Strings, null and undefined coerce the same way, and BigInt and Symbol throw the same TypeError. The empty-pool guard (kv-unified-policy.ts:237) comes before the loop on the method's only entry. cacheChurn and continuityLoss are plain numbers computed eagerly by every producer (kv-unified-policy.ts:214-224, TerminalPolicyEvaluator.candidate), so the loop interleaving the two reads can't be observed. The one divergence is a sparse unscored array, where for…of throws on the hole instead of giving a NaN floor. No caller can produce one, since all of them build arrays with map or push, and withBestScoreUnder's existing loop at :295 already assumes a dense array.
  • Prune loop equivalence. covered is representatives.map(...) (kv-unified-pareto.ts:1024), so it is dense. The loop appends in the same order, and labelsCreated, maxLabelsPerState and the ceiling check read the same result.length.
  • No other ceiling-bounded call spreads in src/adaptive/. We scanned the TypeScript AST for every spread argument in a call or new. The certificate's out.push(...selected | ...expanded) (kv-unified-certificate.ts:582,586) takes arrays that combineChildren caps at EXTENSION_CAP = 256. packed.ts:112 and pareto.ts:382,678 spread one leaf's levels. kv-unified.ts:768 spreads one L1's leaves, and kv-unified.ts:1812 one chunk's constraints. The same scan over this PR merged with #229's stack (which includes #230 and #231) finds no new ones. #231's subtree engine already loops (7392623), and strategies/kv-unified.ts:201's ...args is a fixed tuple.
  • The PR body's repro holds exactly on Node 22.21. With #231's forest at this head, the default options, terminalEvaluation: 'full' and storage: 'objects' all complete with 205,320 terminal labels, in about 4 s each. With the scorer reverted, full throws in scorePreparedCandidates ← PackedDagSolver.solve and objects throws in scorePreparedCandidates ← solveDag. With the scorer fixed and only the prune reverted, objects completes. So the prune spread doesn't fire on this forest, as the body says.
  • Default options never reach either site with a large array, before or after #231. Without storage the solve takes the packed engine (kv-unified-pareto.ts:165-170). Without terminalEvaluation it scores through scoreBoundedCandidates (kv-unified-packed.ts:458-464). The certificate passes at most 256 candidates. After #231 the default is the subtree engine, which falls back to packed, and both score selectively by default (kv-unified-subtree.ts:443-448 on #231). The strategy built from a recipe spreads kvUnified and adds fixed keys (strategies/autobiographical.ts:13811-13837). Neither option is among them, and the config type doesn't declare them (types/strategy.ts:795-830).
  • Bounds the PR body cites. The label ceiling defaults to 1,000,000 in the leaf, object, packed and exact-label paths (kv-unified-pareto.ts:216,364, kv-unified-packed.ts:246, kv-unified.ts:1486). bucketRepresentatives keeps at most 4 labels, or 3 when the cache isn't relevant (kv-unified-pareto.ts:1118-1136). The unbucketed prune is the quadratic pool.filter at :459, so not building a forest that makes a group exceed ~125k is a reasonable call.
  • The regression test reaches the changed loop and pins it. row(index, fidelity, continuity, cache) maps its third argument to continuityLoss and its fourth to cacheChurn, so the minima are 1, at the last index, and 3, at index 123,456. We mutated the compiled scorer, and each of these mutations fails the test: the spreads restored (RangeError), either floor starting at 0, the cache floor reading fidelityLoss, the floors swapped, a continuity floor that keeps the first value, max for min, a loop that skips the last candidate, and a loop that only covers the first 125,000. The run takes 0.35 s with about 410 MB peak RSS.
  • The prune loop is only partly pinned at normal sizes. In the existing object-storage tests (kv-unified-built-from, kv-unified-packed), dropping the appended labels fails 2 tests and keeping only the first fails 1. Reversing a group's order fails none, but the loop keeps the order anyway.
  • Merges. The PR merges cleanly with main and with each of #228, #229, #230 and #231. After the merge with #229's stack, both loops are present in the result. #213 conflicts with current main, but on files this PR doesn't touch.
  • Changelog fragment. The name and one-bullet format match changelog.d/README.md, and the changelog check passes.

Method. Two independent passes, then a merge in which every claim was re-checked against the code before it was kept. One pass was Codex gpt-5.6-sol at xhigh reasoning in a read-only sandbox with no network: about 12 minutes, 44 shell commands, 5.26M input tokens (5.07M cached) and 24k output tokens. The other was Claude Opus 5.5. It traced the callers, scanned the AST for call spreads, ran the mutations and the #231-forest repro on compiled builds, did the merge-tree checks against the release set, and read CI. Found by both: R1-2 (Sol reported it, and we measured the stack limits independently) and the :678 omission in R1-4. Claude only: R1-1 (the leaf-engine route and its repro), R1-3 (the stale SHA, which Sol cited as if it were current), and the merge and repro checks. Sol only: the coercion probes for strings, null, BigInt and Symbol. Sol couldn't run Node 24, Node latest or macOS. For those we rely on CI's green legs at this head.

nissa-seru pushed a commit to ian-de-marcellus/context-manager that referenced this pull request Oct 19, 2026
main has moved on since 1c4fce9 merged it at c0efbfc: anima-research#245 routes the
strategy's diagnostics through this.diag, which names the namespace at the
end of a line, and passes the namespace to its events; anima-research#257 takes the
kv-unified scorer's floors and the object prune's appends in loops. Four
regions in src/strategies/autobiographical.ts conflicted, all anima-research#245's, and
each keeps both sides:

- refuseCrossedMerge and quarantineMerge: main's `}, this.ns)` on the
  merge:topology-refused and merge:quarantined events, then this branch's
  merge check;
- clearMergeQuarantine: main's this.diag.warn for the cleared line, then
  this branch's queueing of each held group;
- the merge-quarantine klaxon: this branch's line, which counts the records
  that hold their sources, through main's this.diag.error.

This branch's two other warnings, the topology record's clear in
enqueueMerge and executeMerge's skip of an entry holding a held source, now
go through this.diag.warn too, as anima-research#245 has the strategy's lines do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 19, 2026
…t limit (anima-research#258)

Five call sites spread an array that grows with the store into one call,
and a sixth spreads the merge queue beside one of them. A call takes about
125,000 spread arguments on Node 22 and 24, and past that it throws
`RangeError: Maximum call stack size exceeded`:

- `getCompressionDebt()`'s newest mint, `Math.max(...mints)`, one entry
  per summary, and its most merge attempts, `Math.max(0, ...)` over the
  merge queue, which grows with the merge backlog rather than the store.
  The method's catch turns such a throw into its empty report: state
  `healthy`, with no last mint.
- `auditStoreTopology()`'s leaf collector, which spreads a child's
  messages into its parent's. The audit runs at load, and nothing there
  catches the throw.
- `recallCurveLeafIds()`, the same shape for the recall curve.
- `planTopologyRepair()`'s leaf collector, the same shape again, and its
  lowest crossed level, `Math.min(...)` over the crossed summaries.

Each is now a fold or an append loop, which gives the spread's result on
the arrays these sites build.

Three tests build a case past the limit through a public method:
`getCompressionDebt()` over 200,000 summaries and merge groups, and
`auditStoreTopology()` and `planTopologyRepair()` over an L3 whose L2
holds 199,990 messages around a 10-message hole, which each must find in
both. Each first asserts that this runtime can't spread 200,000
arguments, and each fails on main's code: the first gets the empty
report, and the other two throw. The empty-strategy test now also pins
the merge attempts' 0.

This also takes two points from the review of anima-research#257. R1-1: the anima-research#255
fragment named two of the solves that score every terminal label
exactly, and now names them all. R1-2: anima-research#257's premise assertion gets a
comment saying a failure there means the runtime changed, not the scorer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid #​257 merged before your review came in, so its two minor findings are addressed in #263, the open fix for #258, at da23f587:

  • R1-1: changelog.d/255-spread-loops.fixed.md now describes the trigger, a solve that scores every terminal label exactly, and names each such solve: terminalEvaluation: 'full', storage: 'objects', engine: 'leaf', and ExactKvUnifiedPolicySolver.solve with candidateSource: 'labels'.
  • R1-2: the premise assertion keeps failing loudly, and a comment above it now says that a failure there means the runtime takes more spread arguments, as with node --stack-size or a newer V8, not that the scorer changed. fix: fold or append six call spreads that can pass the spread-argument limit (#258) #263's three new tests carry the same premise, with a comment of the same kind.

The two nits stay as they are:

Written by Esther-2584.

— Lottie-2661

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.

kv-unified: spread calls over label and candidate arrays can throw RangeError below the label ceiling

2 participants