From 74f484e1be7e0fa19179d8a58788d2afb81f7ae1 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 4 Nov 2026 13:33:24 +0800 Subject: [PATCH 1/7] kernel: record superseded-by lineage when a fold consumes an active block --- kernel/src/compress.ts | 8 +- kernel/src/report.ts | 4 +- kernel/src/types.ts | 19 ++++ kernel/tests/supersedes-lineage.test.ts | 136 ++++++++++++++++++++++++ src/storage/ingest.ts | 2 +- 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 kernel/tests/supersedes-lineage.test.ts diff --git a/kernel/src/compress.ts b/kernel/src/compress.ts index b23e4cdeb..2bfed5a51 100644 --- a/kernel/src/compress.ts +++ b/kernel/src/compress.ts @@ -1835,7 +1835,13 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome { for (const consumedId of consumedBlockIds) { const consumed = blockById(input.state, consumedId); - if (consumed) consumed.active = false; + if (consumed) { + consumed.active = false; + // Lineage: name the consumer so "inactive" stays diagnosable — an + // orphaned inactive block with no supersedes id is a GC/orphan/expand + // event, one WITH it was absorbed into that parent fold. + consumed.supersedesBlockId = blockId; + } } return { tokens: compressedTokens, warnings }; diff --git a/kernel/src/report.ts b/kernel/src/report.ts index 29e10a276..90e14b78e 100644 --- a/kernel/src/report.ts +++ b/kernel/src/report.ts @@ -602,7 +602,9 @@ export function buildRecap(state: CompressionState, blockId?: string): string { return `Block ${blockId} not found. Active blocks: ${activeList}`; } if (!block.active) { - return `Block ${blockId} is inactive (deactivated by nested compression).`; + return block.supersedesBlockId + ? `Block ${blockId} is inactive (deactivated by nested compression, superseded by ${block.supersedesBlockId}).` + : `Block ${blockId} is inactive (deactivated by nested compression).`; } const range = `${block.effectiveMessageIds.length} messages`; return `[Compressed conversation section]\n${block.summary}\n\n[${blockId} | ${range} | topic: "${block.topic ?? "(none)"}"]`; diff --git a/kernel/src/types.ts b/kernel/src/types.ts index c26b5a494..69cd208bf 100644 --- a/kernel/src/types.ts +++ b/kernel/src/types.ts @@ -107,6 +107,15 @@ export interface CompressionBlock { * Undefined = unstamped traffic or provably-loose coverage; never guessed. */ startSeq?: number; endSeq?: number; + /** Nested-compression lineage: id of the newer block that CONSUMED this one. + * Set once at applySingleRange's single consumption site (which deactivates + * every input block). Absent = never consumed — all other deactivation causes + * (orphan GC, host expand, stale-view reconciliation) leave no lineage, so + * storage/web/stats derivations can tell "absorbed into parent" apart from + * every other reason a block went inactive. Round-trips through the + * payload_json serialization; the read-side column mapping lives in + * src/storage/ingest.ts. */ + supersedesBlockId?: string; } /** A compression block's current ref span, pre-resolved for display. */ @@ -634,6 +643,16 @@ export interface CompressibleRange { * has no index gap and the index check alone would let mergeRangesToThreshold * bridge back into one doomed span bracketing the dead ref. */ noMergeBefore?: boolean; + /** Display-only honesty flag (#5508 family): active block ids whose owned + * messages sit in ref-number holes INSIDE this range's label — territory + * invisible on surface-replacing (pruned) views, where the folded messages + * left the array without leaving a gap flag, so a contiguous run renders as + * one label that actually spans an already-folded block. NEVER participates + * in eligibility (KDD #12: the projection/fold gate is the single source of + * truth); it only tells the model which advertised spans already contain + * folded territory (blindly resubmitting such a span nests those blocks + * into a redundant parent fold). Absent on hand-built ranges. */ + crossesCoveredBy?: string[]; } export interface ProtectedRange { diff --git a/kernel/tests/supersedes-lineage.test.ts b/kernel/tests/supersedes-lineage.test.ts new file mode 100644 index 000000000..f39de6aa1 --- /dev/null +++ b/kernel/tests/supersedes-lineage.test.ts @@ -0,0 +1,136 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { assignRefs } from "../src/refs.js"; +import { syncBlocks } from "../src/sync.js"; +import type { Config, CoreMessage } from "../src/types.js"; + +/** Nested-compression lineage: a block consumed by a later fold is deactivated + * but nothing recorded WHICH block consumed it — the DB column + * supersedes_block_id has no writer anywhere (grep-verified), so orphaned + * inactive blocks are indistinguishable from GC/orphan deactivations and the + * web SAVED derivation (max(counter, active-block sum)) loses the consumer's + * accounting silently. These tests pin the lineage writer at the single + * consumption site (applySingleRange) and its storage mapping. */ + +function msg( + id: string, + text: string, + role: CoreMessage["role"] = "user", +): CoreMessage { + return { id, role, contentType: "text", text }; +} + +function config(overrides: Partial = {}): Config { + return { + tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 }, + nudge: { + maxContextLimitPct: 0.55, + minContextLimitPct: 0.45, + frequency: 5, + iterationThreshold: 15, + force: "soft", + growthRatio: 0.05, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: 5000, + minGrowthRatio: 0.45, + emergencyThresholdPct: 0.98, + }, + promotionThreshold: 5, + truncate: { threshold: 1 }, + merge: { maxSummaryLength: 3000, minOldGenBlocks: 3 }, + compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 0, + preserveRecentTokens: 0, + modelContextLimit: 100000, + ...overrides, + }; +} + +function makeMessages() { + return [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a2 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; +} + +test("a fold that consumes an active block records the consuming block id on it", () => { + const core = createCore(); + const state = createInitialState(); + const messages = makeMessages(); + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + + // First fold: m00003..m00004 -> block #1. + const first = core.applyCompression({ + ranges: [{ startRef: "m00003", endRef: "m00004", summary: "s".repeat(80) }], + messages, + state, + config: config(), + }); + assert.equal(first.result.errors.length, 0); + assert.equal(first.result.blocksCreated, 1); + const firstBlock = first.state.blocks[0]!; + assert.equal(firstBlock.active, true); + // Negative gate (#3617): a plain fold records no lineage. + assert.equal(firstBlock.supersedesBlockId, undefined); + + // Second fold over the SAME full log spans covered + new content + // (m00003..m00006): projection splits the span into new direct messages + // plus the already-active block as consumed input — the nested path. + const second = core.applyCompression({ + ranges: [{ startRef: "m00003", endRef: "m00006", summary: "s".repeat(120) }], + messages, + state: first.state, + config: config(), + }); + assert.equal(second.result.errors.length, 0); + assert.equal(second.result.blocksCreated, 1); + const secondBlock = second.state.blocks[second.state.blocks.length - 1]!; + assert.deepEqual(secondBlock.directBlockIds, [firstBlock.blockId]); + + const storedFirst = second.state.blocks.find( + (b) => b.blockId === firstBlock.blockId, + )!; + assert.equal(storedFirst.active, false); + // THE assertion: the deactivated block names its consumer. + assert.equal(storedFirst.supersedesBlockId, secondBlock.blockId); +}); + +test("non-consumption deactivation carries no lineage", () => { + // A block dropped by view reconciliation (orphan GC / expanded / stale) + // must NOT gain a supersedes id: absence of the field is what separates + // "consumed by parent" from every other deactivation cause. + const core = createCore(); + const state = createInitialState(); + const messages = makeMessages(); + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + const applied = core.applyCompression({ + ranges: [{ startRef: "m00003", endRef: "m00004", summary: "s".repeat(80) }], + messages, + state, + config: config(), + }); + const block = applied.state.blocks[0]!; + + // View that no longer represents the block at all (neither originals nor + // rendered summary) -> reconciliation deactivates it without any consumer. + const strippedView = [messages[0]!, messages[6]!]; + const { state: synced } = syncBlocks(strippedView, applied.state); + const syncedBlock = synced.blocks.find((b) => b.blockId === block.blockId)!; + assert.equal(syncedBlock.active, false); + assert.equal(syncedBlock.supersedesBlockId, undefined); +}); diff --git a/src/storage/ingest.ts b/src/storage/ingest.ts index 3b54cd609..938af9721 100644 --- a/src/storage/ingest.ts +++ b/src/storage/ingest.ts @@ -422,7 +422,7 @@ function finishPlan( tier: b.tier, summary: JSON.stringify(full), one: one === null ? null : JSON.stringify(one), - supersedesBlockId: null, + supersedesBlockId: b.supersedesBlockId ?? null, active: b.active, createdAt: b.createdAt, coveredHash: sha256Hex(JSON.stringify(covered)), From 4fe3852fc898c4f836c6bcada7023d99770708e8 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 4 Nov 2026 13:41:27 +0800 Subject: [PATCH 2/7] kernel: annotate range labels that span already-folded blocks --- kernel/src/nudge-text.ts | 17 ++- kernel/src/recommend.ts | 47 ++++++++ kernel/src/report.ts | 40 ++++++- kernel/tests/crosses-covered-spans.test.ts | 118 +++++++++++++++++++++ src/nudge-decide.ts | 2 +- 5 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 kernel/tests/crosses-covered-spans.test.ts diff --git a/kernel/src/nudge-text.ts b/kernel/src/nudge-text.ts index 0c8463c15..210235b73 100644 --- a/kernel/src/nudge-text.ts +++ b/kernel/src/nudge-text.ts @@ -121,6 +121,7 @@ export function formatRanges( textPct: number; dangerous: boolean; noMergeBefore: boolean; + spansFolded: string[]; } const refNum = (ref: string): number => { const m = ref.match(/\d+/); @@ -147,6 +148,7 @@ export function formatRanges( protectedTools: [], dangerous: r.dangerous ?? false, noMergeBefore: r.noMergeBefore ?? false, + spansFolded: r.crossesCoveredBy ?? [], }); } for (const r of protectedRanges) { @@ -169,6 +171,7 @@ export function formatRanges( protectedTools: [...r.tools], dangerous: false, noMergeBefore: r.noMergeBefore ?? false, + spansFolded: [], }); } // Order/merge by POSITION, never ref number: refs can be non-monotonic vs array @@ -193,6 +196,9 @@ export function formatRanges( last.protectedTokens += e.protectedTokens; last.protectedCount += e.protectedCount; if (e.dangerous) last.dangerous = true; + for (const s of e.spansFolded) { + if (!last.spansFolded.includes(s)) last.spansFolded.push(s); + } for (const t of e.protectedTools) { if (!last.protectedTools.includes(t)) last.protectedTools.push(t); } @@ -211,13 +217,18 @@ export function formatRanges( e.dangerous && e.compressibleTokens > 0 ? " ⚠️ NOT recommended unless you are certain." : ""; + // The span label can bridge folded territory invisible on surface- + // replacing views — name the hidden blocks so a resubmit reads as a + // re-fold, not a fresh fold. + const spanNote = + e.spansFolded.length > 0 ? ` · spans folded ${e.spansFolded.join(", ")}` : ""; if (e.protectedTokens > 0 && e.compressibleTokens === 0) { - return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} — not compressible]${suffix}`; + return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} — not compressible]${spanNote}${suffix}`; } if (e.protectedTokens > 0 && e.compressibleTokens > 0) { - return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [${formatK(e.compressibleTokens)} compressible | ${formatK(e.protectedTokens)} protected: ${e.protectedTools.join(", ")}]${userNote(e.userMsgs)}${suffix}`; + return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [${formatK(e.compressibleTokens)} compressible | ${formatK(e.protectedTokens)} protected: ${e.protectedTools.join(", ")}]${spanNote}${userNote(e.userMsgs)}${suffix}`; } - return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [tool ${e.toolPct}% | text ${e.textPct}%]${userNote(e.userMsgs)}${suffix}`; + return ` ${lo}–${hi} ${e.count} msgs ${formatK(e.tokens)} [tool ${e.toolPct}% | text ${e.textPct}%]${spanNote}${userNote(e.userMsgs)}${suffix}`; }); return `Compressible ranges (${merged.length}, oldest first):\n${lines.join("\n")}`; } diff --git a/kernel/src/recommend.ts b/kernel/src/recommend.ts index 23a2ca263..82c0cc846 100644 --- a/kernel/src/recommend.ts +++ b/kernel/src/recommend.ts @@ -283,6 +283,7 @@ export function buildCompressibleRanges( isTool: boolean; isUser: boolean; index: number; + crosses?: string[]; }[] = []; const protectedMsgs: { ref: string; @@ -294,6 +295,36 @@ export function buildCompressibleRanges( const covered = coveredMessageIds(state); + // Display-only honesty (#5508 family): on surface-replacing hosts the ids + // an active block already owns leave the array leaving NO slot and NO skip + // flag, so compressible runs render as one contiguous label spanning + // folded territory. Eligibility is never touched (KDD #12 keeps the + // projection as the single gate); this only NAMES which active blocks sit + // inside such a label, so blindly resubmitting it is recognizable as a + // re-fold instead of a fresh fold. + const ownerByMsg = new Map(); + for (const blk of state.blocks) { + if (!blk.active) continue; + for (const id of blk.effectiveMessageIds) ownerByMsg.set(id, blk.blockId); + } + const ownedHoles: { n: number; owner: string }[] = []; + for (const [ref, raw] of Object.entries(state.messageRefs.byRef)) { + const n = refNumber(ref); + if (n === null || !covered.has(raw)) continue; + const owner = ownerByMsg.get(raw); + if (owner !== undefined) ownedHoles.push({ n, owner }); + } + ownedHoles.sort((a, b) => a.n - b.n); + const crossingOwnersBetween = (lo: number, hi: number): string[] | undefined => { + if (hi - lo <= 1 || hi - lo > 512) return undefined; + const found: string[] = []; + for (const h of ownedHoles) { + if (h.n >= hi) break; + if (h.n > lo && !found.includes(h.owner)) found.push(h.owner); + } + return found.length > 0 ? found : undefined; + }; + // Pairing: a tool-result may carry only toolCallId (no toolName). Collect the // callIds of protected tool-calls first, then protect matching results too. const protectedCallIds = collectProtectedToolCallIds(messages, config); @@ -406,6 +437,17 @@ export function buildCompressibleRanges( skipSinceCompressible = true; } } + // Only a message CONTINUING the open group can hide covered territory in + // its ref-number hole (a broken group bridges nothing by definition). + const crosses = + !skipSinceCompressible && + curRefNum !== null && + lastCompressibleRefNum !== null + ? crossingOwnersBetween( + Math.min(curRefNum, lastCompressibleRefNum), + Math.max(curRefNum, lastCompressibleRefNum), + ) + : undefined; compressibleMsgs.push({ id: msg.id, ref, @@ -415,6 +457,7 @@ export function buildCompressibleRanges( isTool: isToolMessage(msg), isUser: msg.role === "user", index: msgIndex, + crosses, }); if (curRefNum !== null) lastCompressibleRefNum = curRefNum; skipSinceCompressible = false; @@ -494,6 +537,8 @@ export function buildCompressibleRanges( } range.textPct = 100 - range.toolPct; } + const crossed = [...new Set(group.flatMap((info) => info.crosses ?? []))]; + if (crossed.length > 0) range.crossesCoveredBy = crossed; compressible.push(range); } @@ -566,6 +611,8 @@ function mergeBatch(batch: CompressibleRange[]): CompressibleRange { // position-based merging (formatRanges) still sees it after this batch is // re-emitted as a single range. if (first.noMergeBefore === true) merged.noMergeBefore = true; + const crossed = batch.flatMap((r) => r.crossesCoveredBy ?? []); + if (crossed.length > 0) merged.crossesCoveredBy = [...new Set(crossed)]; return merged; } diff --git a/kernel/src/report.ts b/kernel/src/report.ts index 90e14b78e..45dece4ee 100644 --- a/kernel/src/report.ts +++ b/kernel/src/report.ts @@ -3,7 +3,7 @@ import { isToolMessage } from "./message-kind.js"; import { BLOCKED_REF, orderedRefPair, refForRaw } from "./refs.js"; import { countMessageTokens } from "./tokenize.js"; import { segmentGroups } from "./segment.js"; -import { isLiveCheckpointCarrier } from "./state.js"; +import { coveredMessageIds, isLiveCheckpointCarrier } from "./state.js"; import { computeProtectedRefs } from "./recommend.js"; import { buildProjectionShared, @@ -423,6 +423,41 @@ function renderUncompressedRanges( // ordering would mis-sort equal-size ranges. if (sort !== "time") merged.sort((a, b) => b.tokens - a.tokens || a.startIndex - b.startIndex); + // Honest span labels: rows derived from surface-replacing views can bridge + // folded territory (the covered ids left no array slot or gap flag). Name + // the hiding active blocks inline — display only, eligibility untouched. + const byRef = state.messageRefs.byRef; + const refNumOf = (ref: string): number | null => { + const m = /^m(\d+)$/i.exec(ref); + return m !== null ? Number(m[1]) : null; + }; + const foldedOwner = new Map(); + for (const blk of state.blocks) { + if (!blk.active) continue; + for (const id of blk.effectiveMessageIds) foldedOwner.set(id, blk.blockId); + } + const coveredRaw = coveredMessageIds(state); + const ownedHoles: { n: number; owner: string }[] = []; + for (const [ref, raw] of Object.entries(byRef)) { + const n = refNumOf(ref); + if (n === null || !coveredRaw.has(raw)) continue; + const owner = foldedOwner.get(raw); + if (owner !== undefined) ownedHoles.push({ n, owner }); + } + ownedHoles.sort((a, b) => a.n - b.n); + const crossedFolded = (r: Merged): string | undefined => { + const [lo, hi] = orderedRefPair(r.startRef, r.endRef); + const loN = refNumOf(lo); + const hiN = refNumOf(hi); + if (loN === null || hiN === null || hiN - loN <= 1 || hiN - loN > 512) + return undefined; + const found: string[] = []; + for (const h of ownedHoles) { + if (h.n >= hiN) break; + if (h.n > loN && !found.includes(h.owner)) found.push(h.owner); + } + return found.length > 0 ? found.join(", ") : undefined; + }; // #3236: screen displayed rows through the SAME projection applySingleRange // executes so a row that would be rejected wholesale carries a ⚠ flag. The // diagnostic view flags rather than drops — it must keep showing what is @@ -485,8 +520,9 @@ function renderUncompressedRanges( ? r.startRef : orderedRefPair(r.startRef, r.endRef).join("–"); const flag = flagFor ? flagFor(r) : null; + const crossed = crossedFolded(r); lines.push( - ` ${range} (${r.count} msgs, ${formatTokens(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${dominantTool(r.toolTokens)}${flag ? ` ${flag}` : ""}`, + ` ${range} (${r.count} msgs, ${formatTokens(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${dominantTool(r.toolTokens)}${crossed ? ` · spans folded ${crossed}` : ""}${flag ? ` ${flag}` : ""}`, ); } if (merged.length > limit) { diff --git a/kernel/tests/crosses-covered-spans.test.ts b/kernel/tests/crosses-covered-spans.test.ts new file mode 100644 index 000000000..99b20e846 --- /dev/null +++ b/kernel/tests/crosses-covered-spans.test.ts @@ -0,0 +1,118 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { assignRefs } from "../src/refs.js"; +import { buildCompressibleRanges } from "../src/recommend.js"; +import { prune } from "../src/prune.js"; +import { buildStatusReport } from "../src/report.js"; +import type { Config, CoreMessage } from "../src/types.js"; + +/** Honest span labels (#5508 family): on surface-replacing views the ids an + * active block owns leave the array WITHOUT leaving an array slot or a gap + * flag, so compressible runs render as one contiguous label spanning folded + * territory. A model submitting the literal span then re-folds the block + * into a redundant parent (the r3 incident). The classification itself must + * not move (KDD #12: the projection/fold gate stays the single source of + * truth); the range/ranges must instead NAME the hidden block(s). */ + +function msg( + id: string, + text: string, + role: CoreMessage["role"] = "user", +): CoreMessage { + return { id, role, contentType: "text", text }; +} + +function config(): Config { + return { + tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 }, + nudge: { + maxContextLimitPct: 0.55, + minContextLimitPct: 0.45, + frequency: 5, + iterationThreshold: 15, + force: "soft", + growthRatio: 0.05, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: 5000, + minGrowthRatio: 0.45, + emergencyThresholdPct: 0.98, + }, + promotionThreshold: 5, + truncate: { threshold: 1 }, + merge: { maxSummaryLength: 3000, minOldGenBlocks: 3 }, + compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 0, + preserveRecentTokens: 0, + modelContextLimit: 100000, + }; +} + +function foldedState() { + const core = createCore(); + const state = createInitialState(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a2 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + const applied = core.applyCompression({ + ranges: [{ startRef: "m00003", endRef: "m00004", summary: "s".repeat(80) }], + messages, + state, + config: config(), + }); + assert.equal(applied.result.errors.length, 0); + return { full: messages, pruned: prune(messages, applied.state), state: applied.state }; +} + +test("a range spanning a folded block on a pruned view names the hidden block", () => { + const { pruned, state } = foldedState(); + const { compressible } = buildCompressibleRanges(pruned, state, config()); + // The synthetic summary node carries no ref and consumes no slot, so the + // run bridges across m00003/m00004 — the shape models were resubmitting + // blind. segmentGroups still splits at the >=3-message user boundary, so + // the expected shape is TWO ranges whose FIRST one spans the folded block. + // Geometry must be UNCHANGED by the annotation: + assert.equal(compressible.length, 2); + const span = compressible[0]!; + assert.equal(span.startRef, "m00001"); + assert.equal(span.endRef, "m00006"); + assert.equal(span.count, 4); + // ...and the label now carries the hidden block id: + assert.deepEqual(span.crossesCoveredBy, ["b1"]); + // The tail range after the user-boundary split hides nothing: + const tail = compressible[1]!; + assert.equal(tail.startRef, "m00007"); + assert.equal(tail.crossesCoveredBy, undefined); +}); + +test("full-log ranges never carry crossesCoveredBy (covered ids leave gap flags)", () => { + const { full, state } = foldedState(); + const { compressible } = buildCompressibleRanges(full, state, config()); + assert.ok(compressible.length >= 1); + for (const r of compressible) { + assert.equal(r.crossesCoveredBy, undefined, `${r.startRef}-${r.endRef}`); + } +}); + +test("the uncompressed status rows name the blocks their labels span", () => { + const { pruned, state } = foldedState(); + const report = buildStatusReport(state, pruned, (t) => Math.ceil(t.length / 4), { + scope: "uncompressed", + sort: "size", + }); + assert.ok(report.includes("UNCOMPRESSED"), report); + assert.match(report, /spans folded b1/, report); +}); diff --git a/src/nudge-decide.ts b/src/nudge-decide.ts index 985c848e6..2af0d1093 100644 --- a/src/nudge-decide.ts +++ b/src/nudge-decide.ts @@ -60,7 +60,7 @@ function formatK(n: number): string { * a checklist attached. */ export function buildDecisionPrompt(ranges: CompressibleRange[]): string { const spans = ranges.slice(0, SPANS_SHOWN).map( - (r) => ` ${r.startRef}\u2013${r.endRef} ${r.count} msgs ${formatK(r.tokens)} [tool ${r.toolPct}% | text ${r.textPct}%]`, + (r) => ` ${r.startRef}\u2013${r.endRef} ${r.count} msgs ${formatK(r.tokens)} [tool ${r.toolPct}% | text ${r.textPct}%]${r.crossesCoveredBy && r.crossesCoveredBy.length > 0 ? ` \u00b7 spans folded ${r.crossesCoveredBy.join(", ")}` : ""}`, ); return [ "[bili-compress-decision] Side-channel question \u2014 answer ONLY this. Do not continue your task, do not call any tools.", From 00cc4f06c14d5b17fa94bf3509dc795b7318a0b5 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 4 Nov 2026 13:42:53 +0800 Subject: [PATCH 3/7] kernel: surface projection-failure detail in status flags instead of a flat verdict --- kernel/src/report.ts | 17 +++- kernel/tests/status-flag-detail.test.ts | 107 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 kernel/tests/status-flag-detail.test.ts diff --git a/kernel/src/report.ts b/kernel/src/report.ts index 45dece4ee..d2e4be0c5 100644 --- a/kernel/src/report.ts +++ b/kernel/src/report.ts @@ -1,4 +1,4 @@ -import { clampPrefix } from "./truncate.js"; +import { clampPrefix, scrubLoneSurrogates } from "./truncate.js"; import { isToolMessage } from "./message-kind.js"; import { BLOCKED_REF, orderedRefPair, refForRaw } from "./refs.js"; import { countMessageTokens } from "./tokenize.js"; @@ -498,8 +498,19 @@ function renderUncompressedRanges( protectedMessageIds: protectedRefs, shared, }); - } catch { - return "\u26a0 not foldable: refs unresolvable"; + } catch (err) { + // A thrown resolution failure means the row's endpoints cannot be + // mapped onto the apply array class at all (stale/BLOCKED/surface- + // drift refs). Surface the underlying reason verbatim-ish: the flat + // "refs unresolvable" verdict sent agents down dead-end theories + // (they read it as ledger corruption when it was one bad endpoint). + const detail = scrubLoneSurrogates( + err instanceof Error ? err.message : String(err), + ) + .replace(/[\r\n]+/g, " ") + .trim() + .slice(0, 120); + return `\u26a0 not foldable: projection failed${detail ? ` (${detail})` : ""}`; } if (proj.fatal?.kind === "zone") return "\u26a0 not foldable: protected zone only"; diff --git a/kernel/tests/status-flag-detail.test.ts b/kernel/tests/status-flag-detail.test.ts new file mode 100644 index 000000000..50243ea0e --- /dev/null +++ b/kernel/tests/status-flag-detail.test.ts @@ -0,0 +1,107 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { assignRefs } from "../src/refs.js"; +import { buildStatusReport } from "../src/report.js"; +import type { Config, CoreMessage } from "../src/types.js"; + +/** Flag-detail honesty (#5508 family / #3406): when an uncompressed row's + * endpoints cannot be mapped onto the apply array class (stale, BLOCKED or + * surface-drift refs), flagFor used to catch the projection throw and render + * one flat verdict — "refs unresolvable" — for every possible cause. That + * verdict reads like ledger corruption and sent agents into dead-end + * theories instead of naming the failing endpoint. The flag must carry the + * underlying reason. */ + +function msg( + id: string, + text: string, + role: CoreMessage["role"] = "user", +): CoreMessage { + return { id, role, contentType: "text", text }; +} + +function config(): Config { + return { + tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 }, + nudge: { + maxContextLimitPct: 0.55, + minContextLimitPct: 0.45, + frequency: 5, + iterationThreshold: 15, + force: "soft", + growthRatio: 0.05, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: 5000, + minGrowthRatio: 0.45, + emergencyThresholdPct: 0.98, + }, + promotionThreshold: 5, + truncate: { threshold: 1 }, + merge: { maxSummaryLength: 3000, minOldGenBlocks: 3 }, + compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 0, + preserveRecentTokens: 0, + modelContextLimit: 100000, + }; +} + +test("a doomed row surfaces the projection failure reason, not a flat verdict", () => { + const core = createCore(); + const state = createInitialState(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a2 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + const applied = core.applyCompression({ + ranges: [{ startRef: "m00003", endRef: "m00004", summary: "s".repeat(80) }], + messages, + state, + config: config(), + }); + assert.equal(applied.result.errors.length, 0); + const st = applied.state; + + // A ref assigned by the ledger but ABSENT from the array class apply runs + // on (the production shape of every "refs unresolvable" row): visible in + // the render view, unresolvable against `original`. + const late = msg("late-1", "x8 ".repeat(200)); + st.messageRefs.byRaw["late-1"] = "m00008"; + st.messageRefs.byRef["m00008"] = "late-1"; + + const report = buildStatusReport(st, [ + ...messages.slice(0, 2), + late, + ...messages.slice(4), + ], (t) => Math.ceil(t.length / 4), { + scope: "uncompressed", + sort: "size", + config: config(), + originalMessages: messages, + }); + + assert.match(report, /\u26a0 not foldable: projection failed \(/); + // Exactly the doomed row carries it — cleanly-resolving rows stay clean. + assert.equal(report.split("projection failed").length - 1, 1); + assert.ok( + !report.includes("not foldable: refs unresolvable"), + report, + ); + // The detail names the failing endpoint so the next reader can act on it. + const flagLine = report + .split("\n") + .find((l) => l.includes("projection failed"))!; + assert.ok(flagLine.includes("m00008") || flagLine.includes("late-1"), flagLine); +}); From e6f5fd8980203e1d9aa525f75d773b2beea9cef4 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 4 Nov 2026 13:43:03 +0800 Subject: [PATCH 4/7] kernel: bump acp-kernel to 0.0.181 --- kernel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel/package.json b/kernel/package.json index 3c313f565..1f8a27ec2 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.180", + "version": "0.0.181", "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", "license": "MIT", "author": "ranxianglei", From e76803ef30888e3d1c4ab6722ba45b84c68d1919 Mon Sep 17 00:00:00 2001 From: ci-check Date: Wed, 4 Nov 2026 18:37:44 +0800 Subject: [PATCH 5/7] chore(#5668): regenerate both lockfiles for the kernel 0.0.181 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bump commit moved kernel/package.json to 0.0.181 without regenerating the lockfiles that record it; the lock-sync gate fails on that drift. Regenerated with the same commands the job runs (npm install --package-lock-only at root and in kernel/) — version lines only. --- kernel/package-lock.json | 4 ++-- package-lock.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kernel/package-lock.json b/kernel/package-lock.json index f7b408676..afce17e85 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.180", + "version": "0.0.181", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.180", + "version": "0.0.181", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package-lock.json b/package-lock.json index 2ce6f1414..74d37a296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.180", + "version": "0.0.181", "dev": true, "license": "MIT", "devDependencies": { From 6ab0c17fb39cf8444697f46d443cae746142a923 Mon Sep 17 00:00:00 2001 From: ci-check Date: Wed, 4 Nov 2026 18:43:57 +0800 Subject: [PATCH 6/7] kernel: share the owned-hole span-label derivation between recommend and report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for #5668: the #5508-family span-label logic (active-block ownership of covered ref holes + strictly-between scan with the 512 cap) landed as two near-identical copies in recommend.ts and report.ts, with a duplicated ref-number parser. Extract ownedHoles()/crossingOwnersBetween() into recommend.ts and have both label sites consume them; export refNumber() so report.ts drops its local regex copy. Behavior unchanged — same algorithm, same caps, display-only in both places. --- kernel/src/recommend.ts | 66 +++++++++++++++++++++++++++-------------- kernel/src/report.ts | 43 ++++++++------------------- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/kernel/src/recommend.ts b/kernel/src/recommend.ts index 82c0cc846..e3af263a1 100644 --- a/kernel/src/recommend.ts +++ b/kernel/src/recommend.ts @@ -198,11 +198,51 @@ export function computeProtectedRefs( // ─── 2. Build Compressible + Protected Ranges ──────────────────────────────── /** Numeric part of a message ref ("m00005" → 5); null for non-m refs. */ -function refNumber(ref: string): number | null { +export function refNumber(ref: string): number | null { const m = /^m(\d+)$/i.exec(ref); return m ? Number(m[1]) : null; } +/** (#5508 family, display-only) ref-number holes owned by ACTIVE blocks: + * covered ids that surface-replacing views drop from the array without + * leaving an array slot or gap flag. Sorted by ref number. Shared by the + * recommend and report span-label surfaces so the two can't diverge. */ +export function ownedHoles( + state: CompressionState, +): { n: number; owner: string }[] { + const ownerByMsg = new Map(); + for (const blk of state.blocks) { + if (!blk.active) continue; + for (const id of blk.effectiveMessageIds) ownerByMsg.set(id, blk.blockId); + } + const covered = coveredMessageIds(state); + const holes: { n: number; owner: string }[] = []; + for (const [ref, raw] of Object.entries(state.messageRefs.byRef)) { + const n = refNumber(ref); + if (n === null || !covered.has(raw)) continue; + const owner = ownerByMsg.get(raw); + if (owner !== undefined) holes.push({ n, owner }); + } + holes.sort((a, b) => a.n - b.n); + return holes; +} + +/** Owners of holes STRICTLY between two ref numbers; undefined when the pair + * is too narrow (≤1 hides nothing) or too wide (>512 scan cap) to label. */ +export function crossingOwnersBetween( + holes: { n: number; owner: string }[], + lo: number, + hi: number, +): string[] | undefined { + if (hi - lo <= 1 || hi - lo > 512) return undefined; + const found: string[] = []; + for (const h of holes) { + if (h.n >= hi) break; + if (h.n > lo && !found.includes(h.owner)) found.push(h.owner); + } + return found.length > 0 ? found : undefined; +} + /** True when a recorded-dead ref sits STRICTLY between two live ref numbers — * the endpoints themselves are live (a dead endpoint can't be a compressible * message), so only interior holes matter (#3192/#3205). */ @@ -302,28 +342,7 @@ export function buildCompressibleRanges( // projection as the single gate); this only NAMES which active blocks sit // inside such a label, so blindly resubmitting it is recognizable as a // re-fold instead of a fresh fold. - const ownerByMsg = new Map(); - for (const blk of state.blocks) { - if (!blk.active) continue; - for (const id of blk.effectiveMessageIds) ownerByMsg.set(id, blk.blockId); - } - const ownedHoles: { n: number; owner: string }[] = []; - for (const [ref, raw] of Object.entries(state.messageRefs.byRef)) { - const n = refNumber(ref); - if (n === null || !covered.has(raw)) continue; - const owner = ownerByMsg.get(raw); - if (owner !== undefined) ownedHoles.push({ n, owner }); - } - ownedHoles.sort((a, b) => a.n - b.n); - const crossingOwnersBetween = (lo: number, hi: number): string[] | undefined => { - if (hi - lo <= 1 || hi - lo > 512) return undefined; - const found: string[] = []; - for (const h of ownedHoles) { - if (h.n >= hi) break; - if (h.n > lo && !found.includes(h.owner)) found.push(h.owner); - } - return found.length > 0 ? found : undefined; - }; + const holes = ownedHoles(state); // Pairing: a tool-result may carry only toolCallId (no toolName). Collect the // callIds of protected tool-calls first, then protect matching results too. @@ -444,6 +463,7 @@ export function buildCompressibleRanges( curRefNum !== null && lastCompressibleRefNum !== null ? crossingOwnersBetween( + holes, Math.min(curRefNum, lastCompressibleRefNum), Math.max(curRefNum, lastCompressibleRefNum), ) diff --git a/kernel/src/report.ts b/kernel/src/report.ts index d2e4be0c5..18451d02e 100644 --- a/kernel/src/report.ts +++ b/kernel/src/report.ts @@ -3,8 +3,13 @@ import { isToolMessage } from "./message-kind.js"; import { BLOCKED_REF, orderedRefPair, refForRaw } from "./refs.js"; import { countMessageTokens } from "./tokenize.js"; import { segmentGroups } from "./segment.js"; -import { coveredMessageIds, isLiveCheckpointCarrier } from "./state.js"; -import { computeProtectedRefs } from "./recommend.js"; +import { isLiveCheckpointCarrier } from "./state.js"; +import { + computeProtectedRefs, + crossingOwnersBetween, + ownedHoles, + refNumber, +} from "./recommend.js"; import { buildProjectionShared, collectCoverage, @@ -426,37 +431,13 @@ function renderUncompressedRanges( // Honest span labels: rows derived from surface-replacing views can bridge // folded territory (the covered ids left no array slot or gap flag). Name // the hiding active blocks inline — display only, eligibility untouched. - const byRef = state.messageRefs.byRef; - const refNumOf = (ref: string): number | null => { - const m = /^m(\d+)$/i.exec(ref); - return m !== null ? Number(m[1]) : null; - }; - const foldedOwner = new Map(); - for (const blk of state.blocks) { - if (!blk.active) continue; - for (const id of blk.effectiveMessageIds) foldedOwner.set(id, blk.blockId); - } - const coveredRaw = coveredMessageIds(state); - const ownedHoles: { n: number; owner: string }[] = []; - for (const [ref, raw] of Object.entries(byRef)) { - const n = refNumOf(ref); - if (n === null || !coveredRaw.has(raw)) continue; - const owner = foldedOwner.get(raw); - if (owner !== undefined) ownedHoles.push({ n, owner }); - } - ownedHoles.sort((a, b) => a.n - b.n); + const holes = ownedHoles(state); const crossedFolded = (r: Merged): string | undefined => { const [lo, hi] = orderedRefPair(r.startRef, r.endRef); - const loN = refNumOf(lo); - const hiN = refNumOf(hi); - if (loN === null || hiN === null || hiN - loN <= 1 || hiN - loN > 512) - return undefined; - const found: string[] = []; - for (const h of ownedHoles) { - if (h.n >= hiN) break; - if (h.n > loN && !found.includes(h.owner)) found.push(h.owner); - } - return found.length > 0 ? found.join(", ") : undefined; + const loN = refNumber(lo); + const hiN = refNumber(hi); + if (loN === null || hiN === null) return undefined; + return crossingOwnersBetween(holes, loN, hiN)?.join(", "); }; // #3236: screen displayed rows through the SAME projection applySingleRange // executes so a row that would be rejected wholesale carries a ⚠ flag. The From 9163578d72ccd8c558b8533b07d33b43a9985c41 Mon Sep 17 00:00:00 2001 From: ci-check Date: Wed, 4 Nov 2026 18:46:37 +0800 Subject: [PATCH 7/7] kernel: bump acp-kernel to 0.0.182 and sync both lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-required for the review follow-up above (kernel/src changed). Same shape as e76803ef3 (which synced both lockfiles for the 0.0.181 bump): regenerated with npm install --package-lock-only at root and in kernel/ — version lines only. --- kernel/package-lock.json | 4 ++-- kernel/package.json | 2 +- package-lock.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kernel/package-lock.json b/kernel/package-lock.json index afce17e85..eddb848f0 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.181", + "version": "0.0.182", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.181", + "version": "0.0.182", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index 1f8a27ec2..ab632703e 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.181", + "version": "0.0.182", "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", "license": "MIT", "author": "ranxianglei", diff --git a/package-lock.json b/package-lock.json index 74d37a296..65d92a4c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.181", + "version": "0.0.182", "dev": true, "license": "MIT", "devDependencies": {