Repository navigation
fix(kv-unified): take the exact scorer's floors and the object prune's appends in loops (#255) - #257
Conversation
…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>
|
Required label not found on this PR. |
Anarchid
left a comment
There was a problem hiding this comment.
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 whatMath.min(...xs)does. NaN propagates,-0sorts below+0either 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.cacheChurnandcontinuityLossare 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 sparseunscoredarray, wherefor…ofthrows on the hole instead of giving a NaN floor. No caller can produce one, since all of them build arrays withmaporpush, andwithBestScoreUnder's existing loop at:295already assumes a dense array. - Prune loop equivalence.
coveredisrepresentatives.map(...)(kv-unified-pareto.ts:1024), so it is dense. The loop appends in the same order, andlabelsCreated,maxLabelsPerStateand the ceiling check read the sameresult.length. - No other ceiling-bounded call spreads in
src/adaptive/. We scanned the TypeScript AST for every spread argument in a call ornew. The certificate'sout.push(...selected | ...expanded)(kv-unified-certificate.ts:582,586) takes arrays thatcombineChildrencaps atEXTENSION_CAP= 256.packed.ts:112andpareto.ts:382,678spread one leaf's levels.kv-unified.ts:768spreads one L1's leaves, andkv-unified.ts:1812one 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), andstrategies/kv-unified.ts:201's...argsis 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'andstorage: 'objects'all complete with 205,320 terminal labels, in about 4 s each. With the scorer reverted,fullthrows inscorePreparedCandidates ← PackedDagSolver.solveandobjectsthrows inscorePreparedCandidates ← solveDag. With the scorer fixed and only the prune reverted,objectscompletes. 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
storagethe solve takes the packed engine (kv-unified-pareto.ts:165-170). WithoutterminalEvaluationit scores throughscoreBoundedCandidates(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-448on #231). The strategy built from a recipe spreadskvUnifiedand 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).bucketRepresentativeskeeps at most 4 labels, or 3 when the cache isn't relevant (kv-unified-pareto.ts:1118-1136). The unbucketed prune is the quadraticpool.filterat: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 tocontinuityLossand its fourth tocacheChurn, 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 readingfidelityLoss, the floors swapped, a continuity floor that keeps the first value,maxformin, 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.
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>
…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>
|
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
The two nits stay as they are:
Written by Esther-2584. — Lottie-2661 |
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 exceededbelow its label ceiling.Problem
Two sites spread an array into a call where only the label ceiling bounds its length.
ExactKvUnifiedPolicySolver.scorePreparedCandidatestakesMath.min(...unscored.map(...))twice, and a full terminal evaluation passes it every terminal label. The prune inParetoKvUnifiedPolicySolver.solveDagrunsresult.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.terminalEvaluation: 'full', it throws theRangeErrorinscorePreparedCandidates, called fromPackedDagSolver.solve.storage: 'objects', it throws the sameRangeErrorinscorePreparedCandidates, called fromsolveDag.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
scoreBoundedCandidatesand 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 akvUnifiedrecipe sets neitherstoragenorterminalEvaluation. A caller that passesterminalEvaluation: 'full'orstorage: 'objects'can pass them one, as #231's forest above shows. The packed engine's tests passstorage: 'objects'.Changes
src/adaptive/kv-unified-policy.ts: one loop takes both floors withMath.min, as the spread did. An empty pool still throws before the loop, as it did before the spread.src/adaptive/kv-unified-pareto.ts: the prune appends a group's labels one at a time, as feat(kv-unified): subtree-memoized engine, the default on every forest (#45) #231 does for its own engine in e6b9090.changelog.d/255-spread-loops.fixed.md.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
exact scoring takes more candidates than a call takes as spread arguments, intest/adaptive/kv-unified-selective.test.ts. It first asserts that the runtime can't spread 200,000 arguments intoMath.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 onmain's scorer with theRangeErrorand passes with this change. Each of four changes to the loop also fails it: either floor starting at 0, the cache floor read fromfidelityLoss, and a continuity floor that keeps only the first value.npm teston this branch's tree (60692350; Linux, Node 24.22.0): 2,188 pass, 0 fail.main's CI atc0efbfce: 2,187 pass, 0 fail. Typecheck and build are clean.terminalEvaluation: 'full'andstorage: 'objects'both complete, with 205,320 terminal labels.Not verified
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.Out of scope
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).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:7185takesMath.max(...mints)over one value per summary, the spread thatgetMaxSummaryLevel's comment (:3342) says it avoids, andrepair/topology.ts:341takesMath.minover one level per crossed summary. Three leaf collectors spread a child summary's leaves:strategies/autobiographical.ts:3971and:5857, andrepair/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-seruaccount.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.