Skip to content

fix(kernel): fold summaries may never precede the pinned first user message (#2733) - #2755

Merged
ranxianglei merged 8 commits into
masterfrom
2026-10-11_fold-summary-head-stability
Oct 11, 2026
Merged

ranxianglei merged 8 commits into
masterfrom
2026-10-11_fold-summary-head-stability

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Part of #2733 · Closes #2741

kernel-change: fold-summary anchor/insertAt fix in kernel/src/prune.ts (rebuildMessages) for the #2733 per-fold prefix-cache collapse; adds PruneOptions.headSummaryAnchors so handoff/restore docs keep chronological head placement while every request path stays pin-safe; acp-kernel 0.0.116 → 0.0.117 (master took 0.0.116 via #2669).

Summary

Fixes the per-fold double prefix-cache penalty behind the low cache hit rate in #2733 (89.8% weighted, 79.4% per-request; 56% of all uncached tokens = fold request + next request both matching only the ~11K immutable instructions+tools head).

Root cause

When a fold block's anchor ids resolve to index 0, rebuildMessages splices the rendered summary ahead of the pinned first user message:

  • a range rooted at the pinned message itself — real production geometry: session 01a1210e in the 192版本缓存命中率低codex #2733 log has foldPoint=m00001 folds; or
  • a chain re-fold whose effective (merged) ids were all consumed and pruned — the old insertAt: earliest ?? 0 fallback.

The pinned first user message is the provider prefix-cache head. Rewriting the slot that holds it collapses the cached prefix at every fold; the next request can then only match the constant head. This is exactly what the kernel's own compression telemetry predicted vs. what the provider reported:

[acp-compress-obs] shrink 11% (~109597/1016079 tok) foldPoint=m01360 … anchor≈34427 tok
→ next request: cached=11125 (3%)   # expected ≥ anchor

Fix (kernel/src/prune.ts)

  1. collectSummaryAnchors prefers block.directMessageIds over effective (merged) ids, and falls back to messages.length (tail) instead of 0.
  2. rebuildMessages receives knownSummaryIds built from all state.blocks (including consumed ones) and drops rendered copies of any of them — stale copies of merged blocks can no longer leak into loop rebuild views.
  3. Anchor insertAt is clamped to >= firstUserIndex + 1: a summary may never precede the pinned first user message.
  4. The stale-copy drop set (knownSummaryIds) is scoped to injectSummaries: true — with injection off there is no fresh render to substitute, so already-rendered copies are kept (the opt-out path keeps its pre-change semantics).
  5. New PruneOptions.headSummaryAnchors (default false): session handoff/restore docs have no provider prefix cache at stake, so renderHandoff opts into chronological head anchoring; all request-path callers keep the pin-safe placement.

Test

tests/responses-refold-head-stability.test.ts: 20-turn harness, a fold plus two chain re-folds rooted at the pin ([uniq[0], uniq[4]], the production geometry). Asserts every summary-carrying upstream view keeps input[1] byte-identical to the pinned m00001 message. Red on the old kernel (view 12's head slot = [Compressed conversation section] — CHAIN2…), green with the fix.

Also adds tests/responses-refold-cache-seam.test.ts: wire-level regression for the mid-history seam the [cache-seam] detector (src/cache-ledger.ts) flagged in the #2733 log, driven on the production consumption geometry (a fold whose range nests over a prior fold's covered region, rooted at the pin). Hard assertions: the pinned first user message stays the first history item across the consumption re-fold; the rewrite starts exactly at item[2] (right after the pin); exactly one summary survives in both the re-request and the next steady view (stale consumed-block copies dropped); and the two builds share the merged summary byte-for-byte at the same position (no one-build drift). Red on the old kernel, green with the fix.

Kernel-layer tests moved with the behavior: kernel/tests/regression-promote-after-prune.test.ts now asserts the summary sits AFTER the pinned first user message (its subject — consumption of a pin-covering block via the surviving raw — is unchanged), and kernel/tests/state-prune.test.ts pins the injectSummaries:false retention semantics above.

Behavior change (wire layout after folds)

  • Old: post-fold summary placed at the earliest MERGED-effective-id position, or index 0 when unresolved — could precede the pinned first user message.
  • New: earliest DIRECT-range-id position, clamped so a summary may never precede the pinned first user message; unresolved anchors park at the tail. Handoff/restore docs are exempt via headSummaryAnchors (their placement is unchanged from pre-change).
  • One-time migration: persisted views built by older versions move any pre-pin summary to post-pin once after upgrade. Stale rendered copies of consumed/decomposed blocks are now dropped from request-path rebuild views (previously leaked next to successor summaries).
  • No config/env added; no persistence format change; message-ref space untouched.

Verification

  • kernel unit suite: 1272/1272
  • targeted bili superset (fold/refold/prune/absorb/responses/anthropic/compress, 84 files): 651/651
  • both new lanes verified red on the old kernel (swapped master's prune.ts in: head slot = rendered summary ahead of the pin — the exact production symptom — then restored)
  • tsc --noEmit clean, scripts/check-unused-exports.mjs clean

Fold double-penalty component of #2733; its remaining facets are tracked separately: Class A preflight wire shape → #2735, Class B early native compaction and the D1 nudge window → the #2733 thread.

…essage (#2733)

Production logs (issue #2733, codex/gpt-6-astra wire mode) show the request
after a fold matching only the ~11K immutable instructions+tools head while
the kernel expected the pre-foldPoint prefix (anchor) to stay cached:

  shrink 11% foldPoint=m01360 ... anchor≈34427 tok
  → next request cached=11125 (3%) instead of ≥ anchor

Root cause: when a summary block's anchor ids resolve to index 0 — a range
rooted at the pinned first user message (session 01a1210e: foldPoint=m00001),
or a chain re-fold whose effective ids are all pruned — rebuildMessages
splices the summary AHEAD of the pinned first user message. The pinned
message is the provider prefix-cache head; rewriting that slot collapses
the cached prefix on every fold, and the next request re-warms from the
constant head only. That double prefix-cache penalty per fold is 56% of all
uncached tokens in the #2733 log.

Fix (kernel/src/prune.ts), four parts:
- collectSummaryAnchors now prefers block.directMessageIds over effective
  (merged) ids and falls back to messages.length (tail) instead of 0, so a
  block whose ids were all consumed parks its summary after the surviving
  material, never at the head.
- rebuildMessages drops rendered summary copies of ANY known block
  (knownSummaryIds from state.blocks, including consumed ones) so stale
  copies of merged blocks cannot leak into loop rebuild views.
- after resolving anchors, insertAt is clamped to >= firstUserIndex + 1:
  a summary may never precede the pinned first user message.

tests/responses-refold-head-stability.test.ts pins the invariant across a
fold + two chain re-folds rooted at the pin: every summary-carrying view
must keep input[1] byte-identical to the pinned m00001 message. Red on the
old kernel (view 12 head = CHAIN2 summary), green with the fix.
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-10-11_fold-summary-head-stability (08dbc45)

Option A — Install this exact build (recommended)

npm install -g billion-context@0.0.3022-pr.2755.964227f

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr2755.tgz
npm install -g package

Install instructions are refreshed on each push.

… pin-safe

The #2733 clamp landed as unconditional, which broke two kernel tests:
renderHandoff re-derives the model view through prune(), and handoff docs
WANT the summary of older absent material at the head (no provider prefix
cache at stake) — 're-pruning the same snapshot without folded resurrects
the summary at index 0'. PruneOptions now carries headSummaryAnchors
(default false = the pin-safe request path: tail parking + the
after-pin clamp); renderHandoff passes true. The promote-after-prune
regression's placement assertion moves to the new invariant (summary
after the pinned first user message) — its actual subject, consumption
of a pin-covering block via the surviving raw, is unchanged.

kernel suite 1271/1271; repo handoff/kernel/loop/responses/compress/
fold/anthropic/prune/absorb suites green; tsc + unused-exports clean.
@ranxianglei
ranxianglei force-pushed the 2026-10-11_fold-summary-head-stability branch from aaf6568 to 7adc878 Compare October 11, 2026 08:51
ework-agent added 2 commits October 11, 2026 17:00
…2733)

Review finding on the head-stability fix: knownSummaryIds was built from
state.blocks unconditionally, so with injectSummaries:false — a path with no
fresh render at all — rebuildMessages would DROP already-rendered summary
copies instead of keeping them, silently inverting that opt-out's semantics
(pre-change: copies stayed). No caller uses injectSummaries:false today
(kernel handoff/compress, host persist/plugin all use the default), but the
opt-out is part of the prune() contract, so the drop set is now scoped to
inject=true and a state-prune.test.ts case pins the retention behavior.
tests/responses-refold-cache-seam.test.ts shipped with soft diagnostics only
('pin after confirmation') and its PR-body label described a detector this
lane does not exercise. It now pins the post-fix invariants hard, on the
production consumption geometry (a fold whose range nests over a prior
fold's covered region, rooted at the pin):

(1) the pinned first user message stays the first history item across the
    consumption re-fold;
(2) the rewrite starts exactly at item[2] — right after the pin;
(3) exactly one summary survives in both the re-request and the next
    steady view (stale consumed-block copies dropped);
(4) re-request and steady turn share the merged summary byte-for-byte at
    the same position (no one-build drift).

Red on the old kernel (head slot = merged F2A summary), green with the fix.
@ranxianglei
ranxianglei force-pushed the 2026-10-11_fold-summary-head-stability branch from 7adc878 to 08dbc45 Compare October 11, 2026 09:00
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷

① 事故披露(必须放在最前):冲突唤醒后我基于旧分支头做了 rebase + force-push,随后发现远端分支上已有 5 个新提交(版本号修复、merge master、headSummaryAnchors option),被这次 force-push 覆盖。已立即恢复:验证了这 5 个提交全部可取回,把分支重建在它们之上(原 sha 逐字保留为祖先),只叠加 review 补充提交,再次 force-push。当前分支 = 原 PR 全部 6 个提交 + 2 个 review 补充提交,无任何丢失。新增的 2 个:

② 结构化八问

  1. 是什么问题:每次 fold 之后,渲染出的 summary 可能被拼到 pinned first user message(provider prefix-cache 头)之前,导致 fold 请求与下一个请求都只能命中 ~11K 不可变头——192版本缓存命中率低codex #2733 低命中率的主因(fold 双重罚没占 uncached token 的 56.4%)。
  2. 什么原因导致的:三个因素叠加——锚点用 merged effective ids 解析(chain re-fold 的消费会回溯到祖先块/pin);解析不到时 fallback 是 index 0 而非尾部;且没有任何约束阻止 summary 排在 pin 之前。修在 producer 侧(锚点解析 + clamp 单一 choke point),不是传输层过滤字节。
  3. 影响范围与严重程度:凡 fold 覆盖范围回溯到 pinned message、或发生 chain re-fold 的会话;生产日志证实 257/303 folds 有可测 follow-up excess、14/14 抽样模式确定复现。严重度:功能不受损,但每次 fold 约双倍 token 成本(计费影响)。
  4. 怎么修复的:collectSummaryAnchors 优先 directMessageIds;fallback 改尾部停靠;insertAt clamp 到 ≥ pin+1;knownSummaryIds 丢弃消费块遗留的陈旧 summary 副本。后续分支上加的 headSummaryAnchors option(handoff/restore 文档保留时序头部锚定,无 prefix cache 可失)方向正确,我原样保留未动。
  5. 是否有回归问题:逐项清点——普通 mid-history fold 不变(clamp 对 pin 之后的锚点是 no-op);pin 保护逻辑未动;tool-pairing 安全保持(clamp 落点恰在 user message 右边界);decompressed 块副本现在也被正确丢弃;token 计量/nudge 阈值不受影响;plugin/proxy 两种压缩模式都推演过;golden wire-contract 未触碰。另发现并修复一个潜在语义反转:knownSummaryIds 原来无条件构建,injectSummaries:false(当前无调用方)路径下已渲染 summary 会被静默删除而非保留——drop set 已限定在 inject=true,并新增 state-prune.test.ts 用例钉住该语义(旧代码红、新代码绿)。另:我早先草稿里翻动 handoff.test.ts 断言与改 handoff.ts doc 的两处,看到 headSummaryAnchors 方案后已作废(handoff 保留头部锚定,原测试依然成立),均已撤回;regression-promote-after-prune.test.ts 的断言翻转与我的独立判断语义一致,采用了分支上的版本。
  6. 是否破坏缓存:正是本修复的目标——改善缓存,不引入任何失效策略。一次性迁移:旧版本持久化的 view 里 pre-pin 的 summary,升级后首个 rebuild 会移到 pin 之后(一次 churn,此后稳定)。
  7. 是否改了设计方向:没有——同一 rebuild/prune 管线,仅新增一个 PruneOptions opt-in(默认 false,所有既有 request-path 调用方行为不变,仅 renderHandoff 显式选择旧行为)。
  8. 是否引入新配置:没有(env/config/默认值零变更)。

③ 合规与描述修复(kernel-guard 红灯根因 + body 勘误)

④ 验证证据:kernel suite 1272/1272(含分支上的 2 处测试更新 + 我新增 1 例);bili 定向 superset(fold/refold/prune/absorb/responses/anthropic/compress,84 文件)651/651;tsc + unused-exports clean;两条新 lane 均做红-绿双向验证。全量回归按本地测试纪律交给 CI——当前 head 08dbc45c3 的 CI 已在跑(E2E real codex 已过,其余 in_progress),merge 状态 clean(非冲突)。

⑤ 判定:机制级根因修复、方向正确、测试有牙、合规项已补齐。这是 kernel 变更,按 Kernel Boundary 属 human-gated,不适用 auto-merge。建议:等本轮 CI 全绿后你终审合并;若对 headSummaryAnchors 的 handoff 豁免或注入语义 gate 有任何异议,两个补充 commit 独立成块,可单独摘除而不影响主修复。

@ranxianglei ranxianglei added the p1 label Oct 11, 2026
@ranxianglei
ranxianglei merged commit 1acb789 into master Oct 11, 2026
28 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

📦 Merged Build Published

This merge to master was published as 0.0.271-master.1acb789 (rolling master tag).

npm install -g billion-context@0.0.271-master.1acb789  # this exact build
npm install -g billion-context@master  # always the newest merged build

npm view billion-context@master prints the current version at any time.

ranxianglei added a commit that referenced this pull request Oct 11, 2026
…, not the stale corpus manifest (#2671)

After the 0222ef2 master merge the shadow e2e went red against the
corpus manifest digests. Root cause is NOT parity breakage: master's
#2755 legitimately evolves folded-view serialization (fold summaries now
park after the first user message; newer builds materialize stats
counters the old pins predate), so a fresh save of an old-world corpus
case can no longer digest to the pinned value. The shadow layer's own
reconciliation (shadowDigest(loadLegacyView(rows)) vs shadowDigest
(legacyLoad(record)) per write) stays clean throughout.

The e2e now pins the contracts that actually matter, version-stably:

- record parity: unified rows must digest identically to the record the
  legacy file holds after the SAME write (recordDigest maps the envelope
  payload onto the view digest shape)
- fixed point: reload + re-save must produce a byte-equal digest — an
  unstable round-trip would reshuffle the tail on every save

The corpus manifest stays the oracle for raw ingest in
storage-corpus.test.ts (old corpus still digests to its pins).

Also pins the mode-label matrix against the developer machine's config
file (BILI_CONFIG_FILE → missing file), which otherwise leaks a
storage.unified=full file tier into the legacy expectation.
ranxianglei added a commit that referenced this pull request Oct 11, 2026
…, not the stale corpus manifest (#2671)

After the 0222ef2 master merge the shadow e2e went red against the
corpus manifest digests. Root cause is NOT parity breakage: master's
#2755 legitimately evolves folded-view serialization (fold summaries now
park after the first user message; newer builds materialize stats
counters the old pins predate), so a fresh save of an old-world corpus
case can no longer digest to the pinned value. The shadow layer's own
reconciliation (shadowDigest(loadLegacyView(rows)) vs shadowDigest
(legacyLoad(record)) per write) stays clean throughout.

The e2e now pins the contracts that actually matter, version-stably:

- record parity: unified rows must digest identically to the record the
  legacy file holds after the SAME write (recordDigest maps the envelope
  payload onto the view digest shape)
- fixed point: reload + re-save must produce a byte-equal digest — an
  unstable round-trip would reshuffle the tail on every save

The corpus manifest stays the oracle for raw ingest in
storage-corpus.test.ts (old corpus still digests to its pins).

Also pins the mode-label matrix against the developer machine's config
file (BILI_CONFIG_FILE → missing file), which otherwise leaks a
storage.unified=full file tier into the legacy expectation.
YunzhouLi-hub pushed a commit to YunzhouLi-hub/billion-context that referenced this pull request Oct 11, 2026
v0.1.192 was a carve-out release (78df4ea, ranxianglei#2664 only) and its version
bump never flowed back to master, so master still declares 0.1.191 while
npm latest is 0.1.192. That trips the Release one-click Drift guard:

  ::error::master package.json (0.1.191) != npm latest (0.1.192)

ranxianglei#2664's changes themselves are already in master (f0b3f5d), so this is
metadata-only: bump package.json + package-lock.json (root and
packages."" entries) to 0.1.192 so the next one-click release (0.1.193,
carrying the ranxianglei#2755 fold-summary head-stability fix) can proceed.
LoveDoLove pushed a commit to LoveDoLove-Forked-Projects/billion-context that referenced this pull request Oct 14, 2026
…ane (ranxianglei#2997)

ranxianglei#2755/ranxianglei#2741 and their regression tests cover only the Responses-wire / proxy-mode
lane. DSH-native is plugin mode over /v1/chat/completions (folds arrive via
/__bili/plugin/tool) and had no post-fold cache-seam coverage. Add a hermetic
backstop asserting that across a multi-disjoint-range fold the pinned first user
message never moves, no summary precedes it, and consecutive steady-state builds
are byte-identical except at the tail — the invariant whose violation produces
ranxianglei#2997's early-prefix cache miss. Carrier-agnostic (holds under either the
acp_summary or the client tool-pair carrier).
@ranxianglei
ranxianglei deleted the 2026-10-11_fold-summary-head-stability branch October 15, 2026 05:33
ranxianglei added a commit that referenced this pull request Oct 15, 2026
…3194 CI)

Two defects surfaced by the storage-ext / sampled-verification CI legs:

1. dropResidentMetadata crashed on hand-built sessions with no meta
   (TypeError reading 'protocol') — guard with optional chaining, the
   lazy loader degrades to the id-only lookup.

2. After ②a's foldResidentTail folds an idle session's tail, subsequent
   saves still presented the folded re-render as the ⑤ rawTail override.
   The re-render reorders fold summaries (#2755: the summary yields to
   the leading user message on first re-render), but the equal-length
   watermark delta trusted the claimed rawness and skipped the
   tail-pointer rebuild — the unified table kept the pre-fold ordering
   while the record moved on, and the sampled digest correctly flagged
   drift on every 21st save. The override now requires
   lastMessagesFolded !== true: folded saves present the envelope render
   (no override), which planIngestDelta already routes to the full
   ingest, rebuilding tailSeqs in the render's order. Raw rows interned
   by earlier raw saves stay put (append-only).

storage-unified oracle updated: parity compares against the override
input when a live raw tail exists, else against the written record.

Tested: storage-ext 5/5, storage-save-path 3/3, storage-unified 13/13,
memory families + persist-tail + corpus + issue404 42/42.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

post-fold double prefix-cache penalty: the request AFTER a fold matches only the ~11K head (one-build wire drift after compress)

1 participant