Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions kernel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion kernel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-kernel",
"version": "0.0.195",
"version": "0.0.196",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
18 changes: 18 additions & 0 deletions kernel/src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
buildCompressibleRanges,
mergeRangesToThreshold,
screenRecommendedUnits,
warnScreenDrop,
} from "./recommend.js";
import {
runPipeline,
Expand Down Expand Up @@ -1501,6 +1502,23 @@ const recommendNode: PipelineNode = {
ctx.config,
computeProtectedRefs(screenArray, io.state, ctx.config, ctx.countTokens),
);
// Production 2026-11-09 mega-turn incident: the funnel can zero out BELOW
// the "nothing compressible" level — segments exist and hold messages, but
// every island dies at the merge gate or the screen, and the host only
// sees recommendedRanges=[] while its own message-level diagnostic still
// counts collectible material ("collectible>0 yet ranges=0" was
// undiagnosable from the host side for a full night). Warn (budgeted)
// with the stage-by-stage numbers so the killing stage is named directly.
if (contextRanges.compressible.length > 0 && recommendedRanges.length === 0) {
const segMsgs = contextRanges.compressible.reduce((s, r) => s + r.count, 0);
const minChars = ctx.config.compress.minCompressRange;
const subGate = contextRanges.compressible.filter(
(r) => (r.chars ?? r.tokens * 4) < minChars,
).length;
warnScreenDrop(
`funnel zeroed with material present: segments=${contextRanges.compressible.length} (${segMsgs} msgs, ${subGate} below ${minChars}-char merge gate) → all died at merge/screen; host sees no-ranges`,
);
}
const recommendation: Recommendation = {
contextRanges,
recommendedRanges,
Expand Down
149 changes: 133 additions & 16 deletions kernel/src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ export function buildCompressibleRanges(
id: string;
ref: string;
gapBefore: boolean;
/** True when this message's preceding gap is HARD (contains at least one
* skip the apply side cannot tolerate inside a spanning fold — see
* hardGapSinceCompressible). False = the gap is span-safe (protected-tool
* exchanges and/or integrity-withdrawn members only) or absent. */
gapHard: boolean;
tokens: number;
chars: number;
isTool: boolean;
Expand Down Expand Up @@ -316,6 +321,20 @@ export function buildCompressibleRanges(
// two rules coincide, so ranges are byte-identical to the old behavior.
let skipSinceCompressible = false;
let skipSinceProtected = false;
// Span-safety classification (production 2026-11-09 mega-turn incident): a
// gap the apply side tolerates INSIDE a spanning fold — protected-tool
// exchanges (filterProtectedToolMessages carves them, "Bug 39") and
// integrity-withdrawn members (the fold gate withdraws them again at apply,
// they stay visible; #564 reverse-split semantics) — is SPAN-SAFE, and
// mergeRangesToThreshold may bridge across it so sub-gate islands can
// accumulate to minCompressRange. Every OTHER skip (block coverage, live
// checkpoint carrier, unrecoverable media, soft zone — whose interior hits
// TRUNCATE the fold at the first protected message (#3953) — dead-ref
// holes, BLOCKED/unrefed) is hard: no bridge, ever. Set on any skip other
// than the protected-exchange branch; cleared when a compressible message
// is pushed. The protected branch leaves it untouched, so a gap that mixes
// protected + hard skips stays hard in either order.
let hardGapSinceCompressible = false;
let msgIndex = -1;
// #3192/#3205: numeric set of recorded-dead refs. A dropped message leaves a
// hole in the ref map that array-adjacency segmentation bridges, so we break
Expand All @@ -330,10 +349,17 @@ export function buildCompressibleRanges(
for (const msg of messages) {
msgIndex++;
const ref = state.messageRefs.byRaw[msg.id];
if (!ref || ref === "BLOCKED") continue;
if (!ref || ref === "BLOCKED") {
// No ref = no slot in the ref map: mergeRangesToThreshold sees an index
// hole here even though no skip flag fires. Record it as a hard gap so
// bridging never spans an unrefed message (its foldability is untested).
hardGapSinceCompressible = true;
continue;
}
if (isSyntheticOrPruned(msg, covered)) {
skipSinceCompressible = true;
skipSinceProtected = true;
hardGapSinceCompressible = true;
continue;
}

Expand All @@ -349,6 +375,7 @@ export function buildCompressibleRanges(
if (isLiveCheckpointCarrier(msg, state)) {
skipSinceCompressible = true;
skipSinceProtected = true;
hardGapSinceCompressible = true;
continue;
}

Expand All @@ -361,6 +388,7 @@ export function buildCompressibleRanges(
if (hasUnrecoverableMediaPayload(msg)) {
skipSinceCompressible = true;
skipSinceProtected = true;
hardGapSinceCompressible = true;
continue;
}

Expand All @@ -382,12 +410,16 @@ export function buildCompressibleRanges(
});
skipSinceProtected = false;
skipSinceCompressible = true;
// Protected-exchange skip: the ONLY bridge-able skip class (see the
// hardGapSinceCompressible doc). Leave hardGapSinceCompressible
// untouched — a protected gap following a hard gap stays hard.
continue;
}

if (protectedZoneRefs?.has(ref)) {
skipSinceCompressible = true;
skipSinceProtected = true;
hardGapSinceCompressible = true;
continue;
}

Expand All @@ -404,12 +436,14 @@ export function buildCompressibleRanges(
const hi = Math.max(curRefNum, lastCompressibleRefNum);
if (hi - lo > 1 && hasDeadRefBetween(deadRefNums, lo, hi)) {
skipSinceCompressible = true;
hardGapSinceCompressible = true;
}
}
compressibleMsgs.push({
id: msg.id,
ref,
gapBefore: skipSinceCompressible,
gapHard: hardGapSinceCompressible,
tokens: countMessageTokens(msg, countTokens),
chars: (msg.text ?? "").length,
isTool: isToolMessage(msg),
Expand All @@ -419,6 +453,7 @@ export function buildCompressibleRanges(
if (curRefNum !== null) lastCompressibleRefNum = curRefNum;
skipSinceCompressible = false;
skipSinceProtected = true;
hardGapSinceCompressible = false;
}

// Foldability: the fold gate (src/compress.ts) withdraws every message whose
Expand All @@ -444,15 +479,31 @@ export function buildCompressibleRanges(
covered,
).withdrawn;
if (unfoldedIds.size > 0) {
// Withdrawn members are SPAN-SAFE (apply withdraws them again, they stay
// visible — see the hardGapSinceCompressible doc): removing one merges the
// gap before it with the gap after it, and the merged gap stays span-safe
// iff both were. Chain the hardness across consecutive withdrawals; the
// surviving record inherits the combined classification.
let gapPending = false;
let pendingHard = false;
const kept: typeof compressibleMsgs = [];
for (const info of compressibleMsgs) {
if (unfoldedIds.has(info.id)) {
gapPending = true;
pendingHard = pendingHard || info.gapHard;
continue;
}
kept.push(gapPending ? { ...info, gapBefore: true } : info);
kept.push(
gapPending
? {
...info,
gapBefore: true,
gapHard: pendingHard || info.gapHard,
}
: info,
);
gapPending = false;
pendingHard = false;
}
compressibleMsgs = kept;
}
Expand All @@ -474,6 +525,9 @@ export function buildCompressibleRanges(
toolPct: first.isTool ? 100 : 0,
textPct: first.isTool ? 0 : 100,
userMsgs: first.isUser ? 1 : 0,
...(first.gapBefore && !first.gapHard
? { bridgeableGapBefore: true }
: {}),
};
for (let i = 1; i < group.length; i++) {
const info = group[i]!;
Expand Down Expand Up @@ -566,6 +620,10 @@ 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;
// Bridge flag propagates the same way: a batch that BRIDGED into this
// range keeps the marker so any later re-merge sees the span crosses
// span-safe gaps (and only those — hard gaps never set it).
if (first.bridgeableGapBefore === true) merged.bridgeableGapBefore = true;
return merged;
}

Expand All @@ -586,7 +644,22 @@ function rangeChars(r: CompressibleRange): number {
* `minChars`. A sub-threshold tail is folded into the preceding batch
* (overshoot allowed); if no batch precedes it, nothing is emitted — the
* whole remainder is below the gate, so no selection of it can pass and
* offering it only yields guaranteed-rejected calls (billion-context #847). */
* offering it only yields guaranteed-rejected calls (billion-context #847).
*
* Exception — span-safe gap bridging (production 2026-11-09 mega-turn):
* batches accumulate ACROSS a gap the apply side tolerates inside a spanning
* fold (`bridgeableGapBefore`: gaps made exclusively of protected-tool
* exchanges — carved at apply, filterProtectedToolMessages "Bug 39" — and/or
* integrity-withdrawn members — withdrawn again at apply, they stay visible)
* instead of closing at it. The per-CALL #847 gate sums chars over every
* range in the request, so a bridged span clears the gate that each island
* individually missed. Hard gaps (blocks, carriers, media, dead-ref holes,
* unrefed messages, soft zone — whose interior hits truncate the fold at the
* first protected message, #3953) still close the batch — their spans are
* doomed or carve-to-nothing (#498 / #2663). Sub-threshold remnants STILL
* emit nothing: the apply gate is per-call, but a lone island below it has
* no legal partner to ride with, and advertising it only yields the
* guaranteed-rejected call #847 exists to prevent. */
export function mergeRangesToThreshold(
ranges: CompressibleRange[],
minChars: number,
Expand All @@ -596,10 +669,11 @@ export function mergeRangesToThreshold(
let batch: CompressibleRange[] = [];
let batchChars = 0;
// Close the running batch at a boundary: emit it only when it alone clears
// the gate. A sub-threshold segment bounded by a gap cannot be offered (it
// fails the apply-side #847 gate) and must not be stretched across the gap
// to reach the threshold, or the emitted span covers non-compressible
// content (#498).
// the gate. A sub-threshold segment bounded by a hard gap cannot be offered
// (it fails the apply-side #847 gate) and must not be stretched across the
// gap to reach the threshold, or the emitted span covers non-compressible
// content (#498). A span-safe gap does NOT close the batch — the span
// bridges it and apply carves/withdraws the interior (see the header doc).
const closeBatch = () => {
if (batch.length > 0) {
if (batchChars >= minChars) result.push(mergeBatch(batch));
Expand All @@ -610,17 +684,22 @@ export function mergeRangesToThreshold(
for (const r of ranges) {
// #3192/#3205: hard boundary at a dead-ref hole — never bridge across it,
// even though the hole carries no array-index gap (see noMergeBefore).
// (A dead-ref gap is a hard gap, so bridgeableGapBefore is unset on
// ranges following one; the explicit check keeps that invariant
// authoritative even on hand-built ranges that set both.)
if (r.noMergeBefore) closeBatch();
const prev = batch[batch.length - 1];
// A physical array-index gap between the previous range and this one marks
// a block / protected / pruned boundary (buildCompressibleRanges leaves a
// slot hole there; user-turn splits stay contiguous). Without index data we
// keep the legacy accumulate-across-everything behavior.
// A physical array-index gap between the previous range and this one
// marks a block / covered / carrier / pruned boundary (buildCompressibleRanges
// leaves a slot hole there; user-turn splits stay contiguous). Without
// index data we keep the legacy accumulate-across-everything behavior —
// EXCEPT that a span-safe gap does not close the batch (bridge).
if (
prev &&
prev.endIndex !== undefined &&
r.startIndex !== undefined &&
r.startIndex > prev.endIndex + 1
r.startIndex > prev.endIndex + 1 &&
!r.bridgeableGapBefore
) {
closeBatch();
}
Expand All @@ -634,7 +713,8 @@ export function mergeRangesToThreshold(
!batch[0]!.noMergeBefore &&
(prev.endIndex === undefined ||
batch[0]!.startIndex === undefined ||
batch[0]!.startIndex <= prev.endIndex + 1);
batch[0]!.startIndex <= prev.endIndex + 1 ||
batch[0]!.bridgeableGapBefore === true);
if (contiguous) {
result[result.length - 1] = mergeBatch([prev, ...batch]);
} else if (batchChars >= minChars) {
Expand Down Expand Up @@ -694,19 +774,34 @@ export function screenRecommendedUnits(
// config-validation warn in processTurn); behavior is unchanged —
// still return false, the unit is never advertised.
if (!(err instanceof BoundaryNotFoundError)) {
console.warn(
`[acp-kernel] screenRecommendedUnits dropped unit ${unit.startRef}..${unit.endRef} after projection error: ${
warnScreenDrop(
`dropped unit ${unit.startRef}..${unit.endRef} after projection error: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return false;
}
if (!projection || projection.fatal !== null) return false;
if (!projection || projection.fatal !== null) {
// Production 2026-11-09 mega-turn incident: whole-funnel zeroing is
// undiagnosable when units vanish here with no trace — the host only
// sees "kernel ranges=0" while its own message-level diagnostic still
// counts collectible material. Warn (budgeted, same channel as the
// projection-error warn above) so a total screen wipe is attributable.
if (projection?.fatal) {
warnScreenDrop(
`dropped unit ${unit.startRef}..${unit.endRef}: ${projection.fatal.error.message}`,
);
}
return false;
}
if (
projection.filteredIds.length === 0 &&
projection.consumedBlockIds.length === 0
) {
warnScreenDrop(
`dropped unit ${unit.startRef}..${unit.endRef}: nothing foldable in span (filteredIds=0, consumedBlocks=0)`,
);
return false;
}
// #3371: the apply lane's livelock guard (compress.ts, pi#199) throws on
Expand All @@ -725,8 +820,30 @@ export function screenRecommendedUnits(
!projection.isBlockBoundary &&
evaluateRefold(state, unit).kind !== "refold"
) {
warnScreenDrop(
`dropped unit ${unit.startRef}..${unit.endRef}: consumes only active block(s), not a refold`,
);
return false;
}
return true;
});
}

// Budgeted warn channel for screen-stage drops: the first SCREEN_DROP_WARN_BUDGET
// drops in the process are logged verbatim, afterwards only every Nth (a run of
// doomed units over a long session must stay observable without flooding the
// host log — the production incident produced 28 no-range events/day). Exported
// for the recommend pipeline node's funnel-zero warn (same channel, same budget).
const SCREEN_DROP_WARN_BUDGET = 40;
const SCREEN_DROP_WARN_STRIDE = 100;
let screenDropWarnCount = 0;
export function warnScreenDrop(detail: string): void {
screenDropWarnCount += 1;
if (
screenDropWarnCount > SCREEN_DROP_WARN_BUDGET &&
screenDropWarnCount % SCREEN_DROP_WARN_STRIDE !== 0
) {
return;
}
console.warn(`[acp-kernel] screenRecommendedUnits ${detail}`);
}
16 changes: 16 additions & 0 deletions kernel/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,22 @@ 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;
/** buildCompressibleRanges sets this when the gap immediately before this
* range is SPAN-SAFE: composed exclusively of members the apply side
* tolerates INSIDE a spanning fold — protected-tool exchanges
* (config.protectedTools / always-protected list, carved by
* filterProtectedToolMessages, "Bug 39") and integrity-withdrawn members
* (the fold gate withdraws them again at apply; they stay visible, #564
* reverse-split semantics). mergeRangesToThreshold bridges across such
* gaps so sub-gate islands can accumulate to minCompressRange instead of
* all dying below it (production 2026-11-09: a mega-turn with a protected
* exchange every ~4 rounds fragmented every island to 3-5K chars,
* mergeRangesToThreshold dropped them all → recommendedRanges=[] → the
* host's emergency lane fell back to irreversible physical pruning while
* the host's four-bucket diagnostic still showed 84 "collectible"
* messages). Hard gaps (blocks, carriers, media, dead refs, unrefed
* messages, soft zone) never set this — they keep splitting ranges. */
bridgeableGapBefore?: boolean;
}

export interface ProtectedRange {
Expand Down
Loading
Loading