fix(compression): honor raw pins in L1 recall - #116
ian-de-marcellus wants to merge 3 commits into
Conversation
Anarchid
left a comment
There was a problem hiding this comment.
Verdict: changes requested. One pinned message can make every later L1 prompt carry an entire L3 raw
Reviewed at 08b4314 against main eca70da (0.11.0).
The goal is right: the compressor shouldn't recall a summary over a span the resident sees raw. But the fix drops the whole covering frontier summary and replays every leaf under it through the raw-middle section, and that section has no budget. The live selector doesn't do this; it keeps sibling branches folded. So the PR swaps a small mismatch for a large one that can overflow the context window.
Blocker
1. A pin inside a merged summary expands the entire ancestor raw, with no bound. The new filter (autobiographical.ts:5337-5350) removes any frontier summary whose transitive leaves include a force-raw message. Coverage is now computed from the retained frontier only (:5368-5370), so every leaf of the dropped summary is uncovered, and section 3 "Raw middle" (:5485-5492) emits all of them. compressionRecallBudgetTokens caps recall pairs only. Nothing caps the raw middle, and the canonical L1 request goes out before context admission (:5909-5915); admission applies only to the fallback variants.
Repro: 240 messages, hierarchical, mergeThreshold: 6, adaptive → frontier {L1: 4, L2: 1, L3: 1}. pinRange() on one old message, then compress the next chunk:
| L1 prompt messages | bytes | old messages raw | live view (compile(4000)): old raw |
|
|---|---|---|---|---|
| main | 19 | 3,388 | 5 | 9 |
| main + this PR | 199 | 43,129 | 187 | 9 |
The live view keeps 9 raw and folds the rest. The compressor now gets 187. This repeats on every L1 while the pin exists. At production scale an L3/L4 spans thousands of messages, so the result is a context_length rejection on every L1 and a stalled pyramid. The trigger is an ordinary operator action: connectome-host's WebUI exposes pinRange and markDocument (panel-data.ts:633-636), and a document mark is itself a pin (markDocument pushes kind: 'document' into this.pins, and pinnedPositions doesn't filter by kind).
Fix: build a pin-aware mixed frontier. Descend only the branches that contain a raw pin, keep untouched child summaries as recall pairs, and emit raw only for the pinned leaves (plus any unavoidable gap). That's what live kv-control already does: it allows protected raw leaves beside their group's recall (kv-control.ts:868-875). Separately, the canonical request needs a size bound or admission check before dispatch.
repro116.mjs (run against a built CM: node repro116.mjs <cm-root> <tmp-store-dir>)
import { rmSync } from 'node:fs';
const [root, store] = process.argv.slice(2);
const { ContextManager, AutobiographicalStrategy } = await import(`${root}/dist/src/index.js`);
rmSync(store, { recursive: true, force: true });
const requests = [];
let calls = 0;
const membrane = { complete: async (req) => { requests.push(JSON.parse(JSON.stringify(req))); calls++;
return { stopReason: 'end_turn', content: [{ type: 'text', text: `Summary #${calls}: ordinary events.` }], usage: { inputTokens: 500, outputTokens: 100 } }; } };
const strategy = new AutobiographicalStrategy({ compressionModel: 'm', targetChunkTokens: 200, headWindowTokens: 0, recentWindowTokens: 0,
autoTickOnNewMessage: false, minChunkCharsForLLM: 0, summaryParticipant: 'Claude', hierarchical: true, mergeThreshold: 6, adaptiveResolution: true });
const cm = await ContextManager.open({ path: store, strategy, membrane });
const filler = (n) => 'word '.repeat(n);
const ids = [];
for (let i = 0; i < 240; i++) ids.push(cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `OLD-${i} ${filler(30)}` }]));
for (let r = 0; r < 200; r++) { await cm.compile(); await cm.tick(); if (calls > 150) break; }
const lv = {}; for (const s of strategy.summaries) if (!s.mergedInto) lv[s.level] = (lv[s.level] ?? 0) + 1;
console.log('frontier by level', JSON.stringify(lv));
cm.pinRange(ids[5], ids[5], { name: 'one-message' }); // one message deep inside the oldest top-level summary
const live = JSON.stringify(await cm.compile(4000));
console.log('live after pin: OLD raw =', (live.match(/OLD-\d+ /g) || []).length);
requests.length = 0;
for (let i = 0; i < 20; i++) cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `NEW-${i} ${filler(30)}` }]);
await cm.compile(); await cm.tick();
const l1 = requests[0];
const texts = l1.messages.flatMap((m) => m.content).filter((b) => typeof b.text === 'string').map((b) => b.text);
console.log(`first L1 prompt after pin: ${l1.messages.length} messages, ${JSON.stringify(l1).length} bytes, ` +
`OLD raw = ${texts.filter((t) => t.startsWith('OLD-')).length}, recall pairs = ${texts.filter((t) => t.startsWith('[CM] Recall memory')).length}`);
await cm.close();Minor
- The force-raw predicate differs from the live selectors for some pin combinations. (Sol)
pinLevelBoundscan store bothlevelandmaxLevelfrom overlapping pins (:2418-2427). This PR treats{level: 2, maxLevel: 0}as raw, while kv-stable checkspinLevelfirst and fixes it at L2 (kv-stable.ts:104-117). The flat and oldest-first folders honour onlyc.pinned(greedy-fold.ts:122), sopinAtLevel(…, 0)isn't live-raw there despitepinAtLevel's doc. That's narrow, and partly pre-existing: the live selectors already disagree with each other. Fix: resolve pin precedence once and use that result in compression and in every selector. - The claim is broader than the scope. (Sol) The comment at
:5321and the PR body say "a raw pin is a promise about every model-facing view". But merge recalls (:6954-6967), L3+ merge inputs (:7152-7166), transition summaries (:9927-9943) and source-only L1 are all untouched. Either apply the same rule there or narrow the comment to the canonical L1 prompt. - Missing DAG children fail open. (Sol)
expandSummaryToLeafMessageIdssilently skips absent children (:9129-9144). Reopen can drop an empty L1 and leave its L2 parent'ssourceIdspointing at nothing (:1936-1974), so a pin under that missing child isn't detected. The resulting double representation (L2 recalled, orphan leaves raw) already happens on main for that legacy shape; the new filter just doesn't catch it. - Empty-content frontier summaries now surface raw as well. With the all-L1 expansion gone, a frontier summary skipped for empty content no longer hides its leaves, so they reach the same unbounded raw middle as #1. That's arguably more faithful, but it needs the same bound.
- The test can't see #1. It pins an entire L1's span with
mergeThreshold: 1_000(no L2+), so the excluded span always equals the pinned span. Fix: add an L2+ frontier with a single-message pin, and assert that unpinned sibling branches stay summarized. Add table cases for classic,level: 0,maxLevel: 0and overlapping pins.
Verified fine
- On a healthy forest there's no raw/summary duplication: only retained frontier roots populate coverage, and merged children are never recalled.
- The new L1's
sourceIds/sourceRangestill come only fromchunk.messages(:6484-6505), so recall and raw context never leak into provenance. - Transitive expansion is cycle-safe and linear in covered DAG size, since frontier roots are leaf-disjoint.
- The new test is red on main and non-vacuous for the direct-L1 case.
- main + #114–#118 merged together builds and passes 852/852 (node 22).
Method
Two reviewers in parallel. Sol (Codex gpt-5.6-sol, xhigh, read-only, no network; ~13 min, 90 commands, 4.1M input tokens (3.8M cached), 33k output) read the PR worktree against main and the sibling PRs. Claude built main and the five-PR merge and ran the repro above. It also checked every Sol citation. Both found #1 independently. Claude reproduced it at runtime; Sol traced the missing admission check. Sol only: #2, #3, #4. Claude only: #5, the WebUI/document-mark trigger path. Both: #6. Sol rated #2–#4 major; they were lowered because they're narrow or partly pre-existing.
A raw (level-0) pin is a promise about every model-facing view, including the internal compression call. The live selector honored it, but the L1 prompt still recalled the summary covering a pinned span. Exclude any frontier summary that transitively covers a force-raw message, and derive the covered set from the frontier only (dropping the every-merged-L1 pass), so the pinned span renders raw instead of disappearing. Test: summary-reasoning-roundtrip "compression honors level-0 pins by replacing the covered recall with raw source" (red without the fix). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…in precedence Review follow-up (anima-research#116): - A frontier summary covering a force-raw pin (or with empty content) is descended, not dropped: children replace it, so only the pinned branch opens. Reviewer's repro (240 msgs, one pinned message deep in an L3): old messages raw in the next L1 prompt 185 on the previous head -> 9 now, matching the live view; unpinned siblings stay as recall pairs. - isForceRawPinBound: level wins over maxLevel, as in kv-stable. - Comment and changelog narrowed to the canonical L1 prompt. - Tests: L2+ frontier with a single-message pin; precedence table (classic, level 0, maxLevel 0, overlapping pins). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
08b4314 to
a577fbc
Compare
|
Thanks. The blocker was real and reproduced exactly as described. Addressed in a577fbc, with the branch rebased onto current main (d1d17b0). 1. A mixed frontier, not a dropped one. A frontier summary that covers a force-raw pin (or has empty content) is now descended: its children take its place, recursively, so only the branch containing the pin opens up, and the raw added is just the pinned L1 chunk. On your repro (240 messages, one pinned message deep in the oldest top-level summary), old messages raw in the next L1 prompt: 185 on the previous head, 9 now, the same 9 your live 2. Precedence: Full suite: 855/855 (node 22). 🤖 Generated with Claude Code |
|
| const priorSummaryMessageIds = new Set<MessageId>(); | ||
| for (const s of this.summaries) { | ||
| if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds); | ||
| } | ||
| for (const s of priorSummaries) { | ||
| this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds); | ||
| } |
There was a problem hiding this comment.
Pinned history disappears When a raw pin covers a summarized message before a reset head-window start, the new frontier removes its recall pair. The head loop begins after that message, and the raw-middle loop begins after the head, so neither emits it. The pinned history disappears from the canonical L1 prompt instead of appearing raw.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5399-5402
Comment:
**Pinned history disappears** When a raw pin covers a summarized message before a reset head-window start, the new frontier removes its recall pair. The head loop begins after that message, and the raw-middle loop begins after the head, so neither emits it. The pinned history disappears from the canonical L1 prompt instead of appearing raw.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const descend = (s: SummaryEntry): void => { | ||
| if (recallable(s)) { mixedFrontier.push(s); return; } | ||
| if (s.level <= 1) return; // an L1 that can't be shown: its chunk stays raw |
There was a problem hiding this comment.
Pinned turns appear out of order When an old L1 is opened by a pin, its raw turns are emitted after all retained recall pairs, including pairs for later events. The compressor then sees those later events before the older conversation, rather than the chronological order used by the live view. This can distort the next memory it writes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5372-5374
Comment:
**Pinned turns appear out of order** When an old L1 is opened by a pin, its raw turns are emitted after all retained recall pairs, including pairs for later events. The compressor then sees those later events before the older conversation, rather than the chronological order used by the live view. This can distort the next memory it writes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (recallable(s)) { mixedFrontier.push(s); return; } | ||
| if (s.level <= 1) return; // an L1 that can't be shown: its chunk stays raw |
There was a problem hiding this comment.
Wide pins exceed context When a pin spans many compressed L1s, this change replaces each touched summary with its full raw chunk. The recall budget caps only the summaries that remain, and the canonical request is sent without the context-budget check used for fallback variants. If those raw chunks exceed the compression model's window, the next L1 repeatedly fails with a context-length error instead of compressing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5373-5374
Comment:
**Wide pins exceed context** When a pin spans many compressed L1s, this change replaces each touched summary with its full raw chunk. The recall budget caps only the summaries that remain, and the canonical request is sent without the context-budget check used for fallback variants. If those raw chunks exceed the compression model's window, the next L1 repeatedly fails with a context-length error instead of compressing.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| for (const childId of s.sourceIds) { | ||
| const child = summariesById.get(childId); | ||
| if (child) descend(child); |
There was a problem hiding this comment.
Missing children lose coverage Loading legacy state can remove an empty child summary while leaving its ID in a higher-level parent's
sourceIds. If a pin on a surviving sibling opens that parent, this loop silently skips the missing child. Its span loses recall coverage and may be replayed raw, needlessly enlarging later L1 prompts. Please handle an incomplete branch explicitly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5375-5377
Comment:
**Missing children lose coverage** Loading legacy state can remove an empty child summary while leaving its ID in a higher-level parent's `sourceIds`. If a pin on a surviving sibling opens that parent, this loop silently skips the missing child. Its span loses recall coverage and may be replayed raw, needlessly enlarging later L1 prompts. Please handle an incomplete branch explicitly.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const descend = (s: SummaryEntry): void => { | ||
| if (recallable(s)) { mixedFrontier.push(s); return; } | ||
| if (s.level <= 1) return; // an L1 that can't be shown: its chunk stays raw | ||
| for (const childId of s.sourceIds) { | ||
| const child = summariesById.get(childId); | ||
| if (child) descend(child); |
There was a problem hiding this comment.
Cyclic summaries overflow descent The existing leaf-expansion walk guards against cycles in stored summary graphs, but this new descent does not. If a raw pin makes an ancestor in a cyclic graph non-recallable, descent revisits it until the stack overflows and compression aborts. A visited guard would keep a damaged store from causing that failure.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5372-5377
Comment:
**Cyclic summaries overflow descent** The existing leaf-expansion walk guards against cycles in stored summary graphs, but this new descent does not. If a raw pin makes an ancestor in a cyclic graph non-recallable, descent revisits it until the stack overflows and compression aborts. A visited guard would keep a damaged store from causing that failure.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Problem
A raw (level-0) pin means the pinned span renders raw in the live view. The live selector honors it. The canonical L1 compression prompt did not: it still recalled the frontier summary covering the pinned span, so the compressor saw a summary where the resident sees raw text.
Fix
isForceRawPinBound: a fixedlevelwins overmaxLevel).Not covered
Tests
test/l1-recall-raw-pins-frontier.test.ts: an L2+ frontier with one pinned message. Old messages raw in the next L1 prompt: 185 on the previous head, 9 with this change (matching the live view). Siblings stay as recall pairs; the pinned chunk is raw. Also a precedence table (classic,level: 0,maxLevel: 0, overlapping pins). Fails on the previous head and on main.test/summary-reasoning-roundtrip.test.ts: the direct-L1 case.🤖 Generated with Claude Code