-
Notifications
You must be signed in to change notification settings - Fork 13
fix(compression): honor raw pins in L1 recall #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - The canonical L1 compression prompt honors raw (level-0) pins. The live selector already did, but the prompt still recalled the summary covering a pinned span. A frontier summary that covers a force-raw message is now descended rather than recalled: its children take its place, so only the branch containing the pin opens and the raw added is just the pinned L1 chunks (sibling branches stay summarized). Empty-content frontier summaries are descended the same way instead of exposing their whole span raw. Pin precedence matches the kv-stable selector (`isForceRawPinBound`: a fixed `level` wins over `maxLevel`). Merge recall, L3+ merge inputs, transition summaries and the source-only L1 do not consult pins. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2402,6 +2402,18 @@ export class AutobiographicalStrategy implements ResettableStrategy { | |
| * a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored | ||
| * solely by the KV-stable controller — see `ProtectedRange`. | ||
| */ | ||
| /** | ||
| * Whether a pin bound forces a message raw, with the same precedence as the | ||
| * kv-stable selector: a fixed `level` wins over `maxLevel`, so | ||
| * `{level: 2, maxLevel: 0}` is held at L2 (not raw). A pinned position with | ||
| * no level bound is a classic raw pin. | ||
| */ | ||
| static isForceRawPinBound(bound: { level?: number; maxLevel?: number } | undefined): boolean { | ||
| if (bound === undefined) return true; | ||
| if (bound.level !== undefined) return bound.level === 0; | ||
| return bound.maxLevel === 0; | ||
| } | ||
|
|
||
| protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> { | ||
| const out = new Map<number, { level?: number; maxLevel?: number }>(); | ||
| if (this.pins.length === 0) return out; | ||
|
|
@@ -5318,12 +5330,55 @@ export class AutobiographicalStrategy implements ResettableStrategy { | |
| const messageOrder = new Map<MessageId, number>( | ||
| allMessages.map((message, index) => [message.id, index]), | ||
| ); | ||
| const priorSummaries = this.summaries | ||
| // Skip empty-content summaries: emitting `{type:'text', text:''}` as a | ||
| // recall pair triggers Anthropic 400 "text content blocks must be | ||
| // non-empty", which stalls ALL compression (mirrors the render-path guard | ||
| // + load-drop). A single empty summary otherwise poisons every compression. | ||
| .filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0) | ||
| // Raw pins in the canonical L1 prompt's recall. The live view shows a | ||
| // force-raw message raw; recall here used to include the summary covering | ||
| // it anyway. A frontier summary that can't be shown as a recall pair (it | ||
| // covers a force-raw message, or its content is empty) is DESCENDED, not | ||
| // dropped: its children take its place, so only the branch containing the | ||
| // pin opens up, sibling branches stay folded, and the raw added to the | ||
| // prompt is just the pinned L1 chunks. This mirrors the live selector | ||
| // (kv-control keeps protected raw leaves beside their group's recall). | ||
| // Dropping the whole frontier summary instead expanded an entire L3/L4 to | ||
| // raw on every L1 while one pin existed (#116 review). | ||
| // | ||
| // Scope: the canonical L1 prompt. Merge recall, L3+ merge inputs, | ||
| // transition summaries and the source-only L1 do not consult pins. | ||
| const summariesById = new Map<string, SummaryEntry>(); | ||
| for (const s of this.summaries) summariesById.set(s.id, s); | ||
| const pinnedPositionsSet = this.pinnedPositions(allMessages); | ||
| const pinBounds = this.pinLevelBounds(allMessages); | ||
| const rawPinnedMessageIds = new Set<MessageId>(); | ||
| for (let i = 0; i < allMessages.length; i++) { | ||
| if (!pinnedPositionsSet.has(i)) continue; | ||
| if (AutobiographicalStrategy.isForceRawPinBound(pinBounds.get(i))) { | ||
| rawPinnedMessageIds.add(allMessages[i].id); | ||
| } | ||
| } | ||
| const summaryTouchesRawPin = (summary: SummaryEntry): boolean => { | ||
| if (rawPinnedMessageIds.size === 0) return false; | ||
| const leaves = new Set<MessageId>(); | ||
| this.expandSummaryToLeafMessageIds(summary, summariesById, leaves); | ||
| for (const id of leaves) if (rawPinnedMessageIds.has(id)) return true; | ||
| return false; | ||
| }; | ||
| // Skip empty-content summaries as recall pairs: emitting `{type:'text', | ||
| // text:''}` triggers Anthropic 400 "text content blocks must be | ||
| // non-empty", which stalls ALL compression (mirrors the render-path guard | ||
| // + load-drop). They are descended like pinned ones, so their children | ||
| // still represent the span instead of all of it going raw. | ||
| const recallable = (s: SummaryEntry): boolean => | ||
| !!s.content && s.content.trim().length > 0 && !summaryTouchesRawPin(s); | ||
| const mixedFrontier: SummaryEntry[] = []; | ||
| 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 | ||
|
Comment on lines
+5373
to
+5374
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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); | ||
|
Comment on lines
+5375
to
+5377
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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.
Comment on lines
+5372
to
+5377
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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. |
||
| } | ||
| }; | ||
| for (const s of this.summaries) if (!s.mergedInto) descend(s); | ||
| const priorSummaries = mixedFrontier | ||
| .sort((a, b) => { | ||
| const aOrder = messageOrder.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER; | ||
| const bOrder = messageOrder.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER; | ||
|
|
@@ -5337,14 +5392,11 @@ export class AutobiographicalStrategy implements ResettableStrategy { | |
| // a budget-dropped summary doesn't make its raw messages reappear. | ||
| // Expand summary sourceIds down to leaf message IDs — an L2's | ||
| // sourceIds are L1 IDs, not message IDs; a flat walk would miss | ||
| // every message it transitively covers (Bug 10). Also expand merged | ||
| // L1s as defense in depth. | ||
| const summariesById = new Map<string, SummaryEntry>(); | ||
| for (const s of this.summaries) summariesById.set(s.id, s); | ||
| // every message it transitively covers (Bug 10). The frontier is the | ||
| // authority here: a second pass expanding every merged L1 would hide the | ||
| // raw source of a frontier summary excluded by the pin rule above. | ||
| // (summariesById is built above, for that pin-aware recall filter.) | ||
| 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); | ||
| } | ||
|
Comment on lines
5399
to
5402
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * #116 review: a raw pin inside a merged summary must open only the branch | ||
| * that contains it. Before the mixed frontier, one pinned message made the | ||
| * canonical L1 prompt replay the whole covering L3 raw (reviewer's repro: 187 | ||
| * old messages raw vs 9 in the live view), on every L1 while the pin existed. | ||
| */ | ||
| import { describe, it, after } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { ContextManager, AutobiographicalStrategy } from '../src/index.js'; | ||
|
|
||
| const dir = mkdtempSync(join(tmpdir(), 'l1-recall-pins-')); | ||
| after(() => rmSync(dir, { recursive: true, force: true })); | ||
|
|
||
| async function forest(name: string) { | ||
| const requests: any[] = []; | ||
| let calls = 0; | ||
| const membrane = { complete: async (req: unknown) => { 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: join(dir, name), strategy, membrane: membrane as never }); | ||
| const ids: string[] = []; | ||
| for (let i = 0; i < 240; i++) ids.push(cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `OLD-${i} ${'word '.repeat(30)}` }])); | ||
| for (let r = 0; r < 200; r++) { await cm.compile(); await cm.tick(); if (calls > 150) break; } | ||
| return { cm, strategy, ids, requests }; | ||
| } | ||
|
|
||
| function firstL1After(cm: ContextManager, requests: any[]) { | ||
| return async () => { | ||
| requests.length = 0; | ||
| for (let i = 0; i < 20; i++) cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `NEW-${i} ${'word '.repeat(30)}` }]); | ||
| await cm.compile(); await cm.tick(); | ||
| const l1 = requests[0]; | ||
| const texts: string[] = l1.messages.flatMap((m: any) => m.content).filter((b: any) => typeof b.text === 'string').map((b: any) => b.text); | ||
| return { oldRaw: texts.filter((t) => t.startsWith('OLD-')).length, recall: texts.filter((t) => t.startsWith('[CM] Recall memory')).length }; | ||
| }; | ||
| } | ||
|
|
||
| describe('raw pins open only their own branch of the L1 recall frontier', () => { | ||
| it('one pinned message deep in a merged summary adds at most its chunk raw', async () => { | ||
| const { cm, strategy, ids, requests } = await forest('one-pin'); | ||
| const levels: Record<number, number> = {}; | ||
| for (const s of (strategy as unknown as { summaries: Array<{ level: number; mergedInto?: string }> }).summaries) if (!s.mergedInto) levels[s.level] = (levels[s.level] ?? 0) + 1; | ||
| assert.ok((levels[2] ?? 0) + (levels[3] ?? 0) > 0, `need an L2+ frontier, got ${JSON.stringify(levels)}`); | ||
|
|
||
| const baseline = await firstL1After(cm, requests)(); | ||
| cm.pinRange(ids[5], ids[5], { name: 'one-message' }); | ||
| const pinned = await firstL1After(cm, requests)(); | ||
|
|
||
| // The pinned message's L1 chunk (a handful of messages) opens; nothing else. | ||
| assert.ok(pinned.oldRaw - baseline.oldRaw <= 12, `raw grew by ${pinned.oldRaw - baseline.oldRaw} (was 180+ before the fix)`); | ||
| assert.ok(pinned.oldRaw > baseline.oldRaw, 'the pinned chunk itself is shown raw'); | ||
| assert.ok(pinned.recall >= baseline.recall, 'sibling branches stay summarized as recall pairs'); | ||
| cm.close(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('pin precedence matches the kv-stable selector', () => { | ||
| const raw = AutobiographicalStrategy.isForceRawPinBound; | ||
| const cases: Array<[string, { level?: number; maxLevel?: number } | undefined, boolean]> = [ | ||
| ['classic raw pin (no bound)', undefined, true], | ||
| ['level: 0', { level: 0 }, true], | ||
| ['maxLevel: 0', { maxLevel: 0 }, true], | ||
| ['level: 2', { level: 2 }, false], | ||
| ['maxLevel: 2', { maxLevel: 2 }, false], | ||
| ['overlapping {level: 2, maxLevel: 0}: level wins', { level: 2, maxLevel: 0 }, false], | ||
| ['overlapping {level: 0, maxLevel: 3}', { level: 0, maxLevel: 3 }, true], | ||
| ]; | ||
| for (const [name, bound, expected] of cases) it(name, () => assert.equal(raw(bound), expected)); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AI