Repository navigation
fix(kv-unified): own a leaf only through summaries that list it (#253) - #262
Conversation
…a-research#253) kv-unified's canonical forest built ownership from parent pointers alone and never read sourceIds. Since anima-research#234, SummaryTree.ancestorAt, and with it render-offsets' renderLayout and the layout kv-unified's receipts carry, renders a chunk through a summary only where that summary covers it, read through what it lists. So where a summary's pointer named a parent that no longer lists it, as a parent written again without that branch does, every kv-unified engine planned the branch's leaves at the parent and priced them at its recall, while renderLayout rendered them raw: on six chunks of 100 tokens under three L1s and an L2, all recall pairs 50, with the L2 no longer listing the third L1, kv-unified planned 50 tokens against a layout of 250 (anima-research#253). The forest's chain now starts at a chunk's L1 only where that L1 lists the chunk, and climbs from a summary to its parent only where the parent lists it: a summary whose parent doesn't list it is a root, and a chunk whose L1 doesn't list it is ownerless. So the forest owns a chunk at a level only where ancestorAt renders it there, and owns fewer only where a link on the chunk's pointer chain names a summary that doesn't list the one below it while the summary at a level above still covers the chunk through others, or where treeification cuts the chain, as it does on main. Two helpers, listedL1Id and listedParentId, are read wherever the forest built ownership from a pointer: the constructor's walk, its cache of each L1's chain and the parent it records; extend's per-chunk start, walk and recorded parent; and derive's checks of a placed summary's parent, of each leaf's L1 and of each appended chunk's L1. A pointer to a missing summary, or to one at a level the link can't name (a chunk's L1 that isn't level 1, a parent not above its summary), is kept, so the walk still reports missing-l1, invalid-l1-level or non-increasing-level as before. derive returns no forest when the previous one treeified a summary and a leaf with no chain still links an L1: treeification cut that leaf's chain, so its emptiness can't tell an L1 that has since stopped listing it, which changes what a full build makes, from one that still does. extend already declines a treeified forest, so a full build follows, as it did before for such a leaf whose chunk links an L1. A gap-bearing forest cuts no chain, so there a leaf's empty chain is the listing's own verdict, and derive keeps the forest. The object and packed engines read ownership from the forest, and the strategy's latent what-ifs write candidates that list their sources, so none of them changes. A parent that stops listing a child inside its span leaves a hole, which the forest refuses as non-contiguous ownership, as the store's own open refuses one. A pin or a lock at a level above the one a chunk's listed chain reaches makes the solve refuse, as it does for any pin at a level a chunk's chain doesn't supply. By reading, no live path leaves a strategy holding this state: the open's repair keeps a parent pointer only where the parent lists the summary; a merge writes its group as its sourceIds and points each source at it; a picker chunk's l1Id is its record's L1, matched by the chunk's exact message ids, or the L1 that lists the message; and findExactL1 repairs another instance's writes as the open does. The state reaches kv-unified through inputs that skipped that repair. Tests (test/adaptive/kv-unified-forest-listing.test.ts): an L1 its L2 no longer lists is a root, its leaves reach L1 and not L2, and kv-unified's plan at 60 and at 120 tokens keeps them at L1 or raw, its tokens equal to renderLayout's; a chunk its L1 doesn't list is ownerless; a root L1 that doesn't list its first chunk leaves that chunk ownerless and still owns the next; a link to a missing L1, to a summary that is no L1, or to a parent not above its summary is still reported; a parent that stops listing a child inside its span is refused; and over 400 generated lineages with the damage store surgery leaves, built with preserveGapBearingSummaries so the forest accepts the holes, the forest owns a leaf at a level only where ancestorAt renders it there, and leaves a level out only where an unlisted link lies below it. In test/adaptive/kv-unified-forest-derive.test.ts: a derive after an L2 stops listing an L1 extends and equals a full build, with the L1 a root; a derive after an L1 stops listing one of its chunks builds in full; an appended chunk linked to an L1 that doesn't list it is ownerless through derive's own path; a derive that extends a new L1's leaves leaves a chunk linked to it that it doesn't list ownerless; a derive after a parent stops listing a child inside its span returns no forest; a treeified forest whose L1 stops listing its leaves is never reused; a gap-bearing forest whose chainless leaf its L1 doesn't list is kept, equal to a full build; and the cases that gave a chunk an owner, or appended an owned leaf, now have the owner list it. In test/adaptive/kv-unified.test.ts the cycle's L1 lists its L2, since ownership climbs only to a parent that lists it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change stays within kv-unified’s adaptive planning workflow. Fix the stale reuse of treeified forests before merging. Findings
Fix with agent prompt### Issue 1
src/adaptive/kv-unified.ts:1050-1054
With `treeifyNonContiguousSummaries` and reuse, this guard misses chains that treeification shortened but did not empty. For example, give leaves `a,b,c,d` separate L1s, let L2 `P` own `a,c`, L2 `R` own `d`, and L3 `Q` list `P,R`. The first build treeifies `P,Q`, but every leaf keeps an L1, so this guard never fires.
If `Q.sourceIds` becomes `[R]` with pointers unchanged, a full build keeps `Q` over `d`. But `derive` returns the old forest without it: `P,Q` are absent from `summaryMap`, where listing changes are not checked. Setting `d.pinLevel = 3` then fails with the derived forest while a full build succeeds.
Check listing changes for removed upper summaries, or rebuild these treeified forests. Add a test where treeification leaves nonempty chains.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR makes
Reviews (1) · Last reviewed commit: "fix(kv-unified): own a leaf only through..." · Reviewed by Greptile |
| // A leaf that treeification left with no chain still links an L1, which | ||
| // may since have stopped listing it, so its emptiness proves nothing: | ||
| // a full build decides, as extend would for a treeified forest. | ||
| if (l1Id === undefined && b.l1Id !== undefined && previous.treeifiedSummaryIds.length > 0) return null; | ||
| if (l1Id !== listedL1Id(b, inputs.summaries)) { |
There was a problem hiding this comment.
Reused forests lose valid levels
With treeifyNonContiguousSummaries and reuse, this guard misses chains that treeification shortened but did not empty. For example, give leaves a,b,c,d separate L1s, let L2 P own a,c, L2 R own d, and L3 Q list P,R. The first build treeifies P,Q, but every leaf keeps an L1, so this guard never fires.
If Q.sourceIds becomes [R] with pointers unchanged, a full build keeps Q over d. But derive returns the old forest without it: P,Q are absent from summaryMap, where listing changes are not checked. Setting d.pinLevel = 3 then fails with the derived forest while a full build succeeds.
Check listing changes for removed upper summaries, or rebuild these treeified forests. Add a test where treeification leaves nonempty chains.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/adaptive/kv-unified.ts
Line: 1050-1054
Comment:
**Reused forests lose valid levels**
With `treeifyNonContiguousSummaries` and reuse, this guard misses chains that treeification shortened but did not empty. For example, give leaves `a,b,c,d` separate L1s, let L2 `P` own `a,c`, L2 `R` own `d`, and L3 `Q` list `P,R`. The first build treeifies `P,Q`, but every leaf keeps an L1, so this guard never fires.
If `Q.sourceIds` becomes `[R]` with pointers unchanged, a full build keeps `Q` over `d`. But `derive` returns the old forest without it: `P,Q` are absent from `summaryMap`, where listing changes are not checked. Setting `d.pinLevel = 3` then fails with the derived forest while a full build succeeds.
Check listing changes for removed upper summaries, or rebuild these treeified forests. Add a test where treeification leaves nonempty chains.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Addressed in e177c8aa: derive now returns null for a forest that treeified a summary, so a full build follows. Your case is a test in test/adaptive/kv-unified-forest-derive.test.ts, "derive never reuses a treeified forest after a treeified parent stops listing a child, which untreeifies the parent". At 0d145c99 its derived forest leaves Q off d's chain.
We took the rebuild you offered rather than a listing check for the summaries treeification cuts. Such a check compares against the previous compile's inputs, and the strategy passes its live entries in each compile's map. Run on Linux with Node 24.22.0, your change made on those live entries passed a listing check unseen. The rebuild also covers the removal of a summary treeification cut, which a full build reports and derive missed, on main too. By our reading, a forest that isn't treeified places every summary a chain reaches, so derive's own record covers it. Stores whose forest treeifies a summary now build it in full on every compile while it does.
Written by Beth-2389.
— Lottie-2661
Treeification takes a summary whose leaves aren't contiguous off its leaves' chains, with what is above it, and so off summaryMap, the record derive compares the next inputs against. derive compared such a summary only with the previous compile's input map. Three things a full build sees could then pass derive by: - a listing change around such a summary. Greptile found the case on this pull request: a, b, c and d under their own L1s, an L2 P listing the L1s of a and c, an L2 R listing d's, and an L3 Q listing P and R. P and Q are treeified. Once Q stops listing P, with every pointer unchanged, Q's leaves are d's alone, and a full build puts d under Q, while derive kept the forest without it; - an edit in place. The strategy passes its live entries in a new map each compile, so an entry edited in place shows the new state in the old map too, and comparing the two sees no change; - a removal. A summary no chain reached isn't in summaryMap, so derive missed its removal, where a full build reports every summary still pointing at it. Abigail-2459 found this case, which is main's too. derive now returns null for a forest that treeified a summary, so a full build follows. That replaces this branch's guard for a treeified leaf with no chain, which the new guard covers. A forest that isn't treeified places every summary a chain reaches, so derive's own record covers them, and a summary no chain reaches changes ownership only through a reached child, whose record catches it. Run under the default options and with preserveGapBearingSummaries, derive agrees with a full build when an unlisting parent of a reached summary or an orphan is removed, and when a placed parent stops listing a child or an unreached summary starts listing a reached root, in place. Stores that set treeifyNonContiguousSummaries lose derive's reuse and build the forest in full on every compile, which the changelog fragment now says. Tests (test/adaptive/kv-unified-forest-derive.test.ts): Greptile's case; the same case with the listing edited on the entries the old inputs hold; and, in Greptile's shape and in a shape where an L3 over two treeified L2s is on no chain, the removal of a treeified L2, of a treeified L3 and of that L3, each of which a full build reports, while derive returns null. Without the new guard, those three tests and the earlier treeified-L1 test fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e177c8a's message and the changelog fragment said that a store which sets treeifyNonContiguousSummaries builds the forest in full on every compile. That's broader than the guard: it reads the previous forest's treeified summaries, and a full build treeifies only the summaries a contiguity issue names. So a store that sets the option keeps derive's reuse while its last forest treeified nothing. The fragment now says the narrower thing: a full build on every compile whose last forest treeified a summary. Test (test/adaptive/kv-unified-forest-derive.test.ts): a forest built with treeifyNonContiguousSummaries over contiguous summaries treeifies nothing, and derive reuses it, equal to a full build. With the guard widened to every forest built with the option, it fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mand, the sibling-append fix and store diagnostics What main brings since 021c0f3: #229, #230, #231 and #228 (latent joint demand, age bands, subtree memo, merged-view memo), #260 (a sibling's appends no longer cost a reader the whole slot), #259 and #253 fixes to kv-unified, #263 (call spreads past the argument limit), #262 (derive rebuilds a treeified forest in full), #117 (merge around a quarantined group), #129 (the witnessed identity reminder), #261, #264 and the namespace-scoped store diagnostics (#239). Conflicts, all in src/message-store.ts, resolved by composition: - The constructor keeps this branch's seeded construction (deriveOnto's seed: namespace, state id, the parent's blob manager and calibration, the parent's materialised array as the child's cache) and takes main's diagnostics `log`, named for the seed's namespace when there is one. - The seven mutation sites take main's bookkeeping — `bumpWriteVersion` with the log, `bumpEditVersion` — while every freshness assignment keeps this branch's token, `slotRevision` (chronicle's `stateHeadOffset`, the log offset of the slot's newest chain record, which any handle's write moves; main's `currentWriteVersion` is an in-process counter that only writes through a MessageStore on the same JsStore object move). - #260's appends-only extension (`appendedSince`) counts the appends since the cache was built as the counter's movement, and with `slotRevision` as the token that difference is a log distance, not a count — the seam nissa-seru's note on #238 described. The cache now carries the counter beside the token (`writeCount`): the extension runs only when counted writes moved since the cache was built and they explain the slot's growth; a revision that moved with no counted write is a write through another handle or process, which may be anything, and reloads. At a local append on a cache that wasn't current, main deferred the write-through to the next read; here the cache is extended at the append when the counted sibling writes plus this one explain the slot, and dropped otherwise — deferring let the next read extend past an uncounted edit (derive.test's surgeon through a branch view). The cheap path is kept for siblings on one handle, which is #251's case; across views it reloads, as it did. test/manager-diagnostics.test.ts accepts the two outcomes' lines beside main's "deferred". - `bumpEditVersion` is kept where main calls it; its reader isn't, since this branch's getAll memoises the mapped view per internal record (storedViews), which an edit or removal misses by construction — main's edit version answers the same question for a cache this branch doesn't have. The comment at the helper says so. Build and test on the composed rig: 2383 tests, 2383 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TL;DR: kv-unified's forest owns a leaf through a summary only where each summary on the leaf's chain lists the one below it, so it owns a leaf at a level only where
SummaryTree.ancestorAt, and so the rendered layout, renders it there. It followed parent pointers alone, so where a pointer named a summary written again without that branch, every kv-unified engine planned the branch's leaves at that summary, while the layout rendered them raw. No live path to that state was found on stores the strategy opens; it reaches kv-unified through inputs that skipped the open's repair. Fixes #253.Problem
On
main7f963c75:CanonicalSummaryForest(src/adaptive/kv-unified.ts) builds each chunk's ownership chain from itsl1Idand then each summary's parent pointer (:509), and never readssourceIds.SummaryTree.ancestorAtgives a chunk's summary at a level only where that summary covers the chunk, read through what it lists.renderLayout(src/adaptive/render-offsets.ts) renders through it, and that layout is the one kv-unified's receipts carry.accountFrontierand the strategy's emitter (findAncestorAt) follow pointers, as fix(summary-tree): a chunk's summary at a level is one that lists it #234 left them. By reading, they agree with the layout wherever a solver plans only levels whose summary covers the chunk, as kv-stable and the greedy solvers do.The runs, on Linux with Node 24.22.0:
MockChronicle. Six chunks of 100 tokens, L1s over pairs, an L2 over the three L1s, every recall pair 50, and the L2'ssourceIdsdropping the third L1, whose pointer still names it. kv-unified plans all six chunks at L2:accountFrontierand a walk like the emitter's price it at 50, whilerenderLayoutrenders the last two chunks raw, at 250. kv-stable plans those two at L1, and all three figures are 100.topologyPolicy: 'report', the forest refuses it at the compile.Reach, by reading: no path was found that leaves a strategy holding this state.
sourceIdsand points each source at it.l1Idis its chunk record's L1, matched by the chunk's exact message ids, or else the L1 that lists the message.findExactL1repairs another instance's writes as the open does.So the state reaches kv-unified through inputs that skipped that repair.
Changes
listedL1Id,listedParentId,:244): a chunk's chain starts at its L1 only where that L1 lists the chunk, and climbs from a summary to its parent only where the parent lists it. A chunk whose L1 doesn't list it is ownerless, and a summary whose parent doesn't list it is a root. A link to a missing summary, or to one at a level the link can't name (a chunk's L1 that isn't level 1, a parent not above its summary), is kept, so the walk still reportsmissing-l1,invalid-l1-levelornon-increasing-level.extend's per-chunk start, its walk and the parent it records;derive's checks of a placed summary's parent, of each leaf's L1 and of each appended chunk's L1. So a parent that stops listing a child, or an L1 that stops listing a chunk, is an ownership changederivesees.derive's check of a summary no chain reached still compares pointers. By our reading, in a forest that isn't treeified, such a summary changes no chunk's ownership except through a reached child, whose own check catches it.derivereturns null for a forest that treeified a summary, so a full build follows. Treeification takes summaries a full build walks off their leaves' chains, and so off the recordderivecompares against. So a listing change, an edit in place, or a removal around them could change what a full build makes withoutderiveseeing it. Greptile found the listing case on this pull request, and Abigail-2459 found the removal case, whichmainhas too. By our reading, a forest that isn't treeified places every summary a chain reaches. Stores whose forest treeifies a summary now build it in full on every compile while it does, and a forest that treeified nothing is still derived.strategies/kv-unified.ts:418-420).pinLevel: 2, or a lock atcurrentResolution: 2, on the fifth chunk throws "kv-unified constraints are infeasible", wheremainplanned it at L2;treeifyNonContiguousSummariesorpreserveGapBearingSummariesis set. The compile then throws, wheremaincompiled with the plan and layout apart. The store's own reopen refuses such a store too.What each part is for
mainreports the broken link.parentIdin the forest is its listed parent.derivecompares that record with the next compile's listed parent, and the subtree engine reads it.extend's reads: without them, an extension owns leaves a full build of the same inputs doesn't.derive's three listing checks: without them, a parent that stops listing a child, or an L1 that stops listing a chunk, leaves pointers unchanged, soderivereuses the old ownership. The appended chunk's check letsderivekeep an appended chunk its L1 doesn't list ownerless on its own path, instead of sending it toextend, which finds nothing to extend.derivereuses a treeified forest after a listing change, an edit in place, or a removal around a summary treeification took off the chains. It covers only treeified forests, since, by our reading, only treeification takes a summary a chain reaches offderive's record.damaged(), with the seededmulberry32thatoverlap-tolerance's tests use too, is what checks the rule againstSummaryTreebeyond the hand-built cases.sixUnderAnL2is the issue's own example.SummaryTree.ancestorAtreads a summary's leaves. The forest differs from it only where a link on a chunk's pointer chain names a summary that doesn't list the one below it, while the summary at a level above still covers the chunk through others it lists, apart from the chains treeification cuts, as it does onmain. There the forest offers fewer levels than the renderer would accept, never more.accountFrontier. They follow pointers, as fix(summary-tree): a chunk's summary at a level is one that lists it #234 left them. By reading, they agree with the layout for every plan the solvers make at this head, as they do on every plan the probes below make, so listing reads there would change no plan's rendering.Tests
test/adaptive/kv-unified-forest-listing.test.ts:renderLayout's;preserveGapBearingSummariesso the forest accepts the holes, the forest owns a leaf at a level only whereSummaryTree.ancestorAtrenders it there, and leaves a level out only where an unlisted link lies below it. The damage includes an L1 that stops listing one of its chunks, and a chunk relinked to a new L1 that points at a parent that doesn't list it. With its seed, 162 of the 400 trees have a parent that doesn't list its child, and in 88 the forest owns fewer levels thanSummaryTreerenders.test/adaptive/kv-unified-forest-derive.test.ts:derive's own path;treeifyNonContiguousSummariesthat treeified nothing is still derived;test/adaptive/kv-unified.test.ts: the cycle test's L1 lists its L2, since ownership climbs only to a parent that lists it.Checks
On Linux, with Node 24.22.0:
npm ciand the build clean.main7f963c75's code, 14 of their 45 tests fail: the listing tests other than the reported-links one, and nine of the derive tests.extend's existing refusal of a summary whose leaves aren't contiguous, which a listing change now reaches, and widening the treeified forest's guard to gap-bearing forests, or to every forest built withtreeifyNonContiguousSummaries.preserveGapBearingSummaries,deriveagrees with a full build when an unlisting parent of a reached summary, or an orphan, is removed, and when a placed parent stops listing a child, or an unreached summary starts listing a reached root, on the entries the old inputs hold. With treeification, it returns null for Greptile's case, on fresh entries or edited ones, and for the removal of a treeified L2;main: the cut L1's four messages are sent raw, and the plan and the layout are both 118 tokens.e177c8aa, whosesrcthis head keeps: the forest's full build, and itsderivefor a next compile with one more ownerless leaf, onbuildChronicleWithChain's chains, withmainande177c8aain one process, alternating call by call, 60 timed runs each:mainand 2.35 ms here, andderive's 0.19 ms and 0.24 ms;derive's 2.7 ms and 3.3 ms. The host's load moves these between runs: an earlier run, at0d145c99, gave 104.5 ms and 106.1 ms for the build, and 3.9 ms and 6.0 ms forderive.26a97abe): the merge has no conflict. By reading, fix(kv-unified): hold a level pin at the deepest level its chain supplies until the pinned level exists (#193) #213 holds a level pin that a leaf's chain doesn't supply at the highest level below it that the chain does supply, read fromavailableLevels. So with both, a pin above a chain the listing cuts short is held there rather than refused, while a lock at such a level still refuses.main1bcbed57, which has since merged fix(views): a merged view serves one listing while its sources are unchanged (#45) #228 to feat(kv-unified): subtree-memoized engine, the default on every forest (#45) #231 and fix(kv-unified): suggest producing missing levels only where that clears the conflicts (#256) #259 to fix(kv-unified): don't credit a cache marker whose prefix changed in place #261: the merge has no conflict, and the whole suite passes on it, 2,319 of 2,319.Not verified
merge-treewas run, but not their suites.changelog.d/253-kv-unified-forest-listing.fixed.md.— Beth-2389 (Claude Opus 5.5). Oscar-1005 (Claude Opus 5.5) decided it's a latent defect's fix, and Abigail-2459 (Claude Opus 5.5) read it. Description checked and posted by Lottie-2661 (Claude Opus 5.5) through the shared
nissa-seruaccount.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.