Skip to content

fix: segment compressible ranges by array adjacency, not ref arithmetic (#207) - #209

Merged
ranxianglei merged 1 commit into
masterfrom
2026-09-07_range-array-adjacency
Sep 7, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-09-07_range-array-adjacency

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Fixes #207.

Problem

buildCompressibleRanges (src/recommend.ts) segmented ranges with hasGap = info.refNum > prevRefNum + 1 — correct only when refNum order equals message-array order. Surface-replacing hosts (e.g. billion-context-dsh) break that assumption two ways:

  1. compressed messages physically leave the next turn's message array while their refs stay assigned → holes in the ref map → hasGap fires spuriously → every range fragments;
  2. the model's summary node is inserted at the replaced span's position (MID-ARRAY) and assignRefs gives it a fresh HIGH ref → the previous group flushes and a descending pair (startRef > endRef) is emitted (live example: m00110295..m00106762).

Result on the affected host: fragmented ranges, large tool results dropping out of recommendations, and the benefit floor suppressing every nudge while the session keeps growing.

Fix

Segment by array adjacency, never ref arithmetic: a range splits only when a numbered-ref message is physically skipped between two list entries (protected tool, synthetic/covered, protected zone) or at user-turn boundaries. Entries carry a gapBefore boolean instead of refNum. Unrefed/BLOCKED messages consume no slot and never interrupt a range.

Behavior audit

On dense append-only hosts (pai-acp / billion-context-pi / opencode-acp) a numbered-ref message is skipped between two entries iff a refNum gap exists — all five skip categories verified one-to-one against the old rule, so recommended ranges are identical and the existing suite stays green with zero test edits.

On replace-style hosts holes are no longer interruptions, and mid-array summary nodes extend the range instead of flushing it.

Tests

New regression tests in tests/recommend.test.ts:

  • holes do not fragment (turn-1 [a,b,c,d,e], surface shrinks to [a,e] → one range m00001..m00005, count 2);
  • mid-array non-synthetic summary node extends the range instead of emitting a descending pair ([a, summary(m00006), d, e] → one range, count 4);
  • synthetic [Compressed conversation section] nodes still split (preserved legacy behavior);
  • protected groups split when an interleaved numbered message is present (dense-host behavior preserved) but survive bare holes.

Verification: npm run typecheck ✓ · npm test 584/584 pass ✓ · npm run build ✓

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-07_range-array-adjacency (07ecd94)

Option A — Install from npm PR tag (recommended)

In your adapter project:

npm install acp-kernel@pr-209

Each push to this PR publishes a new version under the pr-209 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf acp-kernel-pr209.tgz
npm install ./package

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

Reviewed PR #209 end-to-end. Verdict: favorable — the fix is correct, minimal, and correctly scoped. Details below.

Reproduction (verified, not taken on faith)
Checked out the PR head and ran the new regression tests against master's src/recommend.ts:

  • ref-map holes do NOT fragment → fails on master, passes with fix
  • mid-array summary node … never a descending pair → fails on master, passes with fix
  • protected groups segment by array adjacency too → fails on master, passes with fix
  • synthetic (covered) summary node still splits → passes on both (correctly pins preserved behavior — guards against over-correction)

So 3/4 fail without the patch and all 4 pass with it: the tests catch a real defect, not tautology. Both reported symptoms trace exactly — hasGap = info.refNum > prevRefNum + 1 fragments on ref-map holes, and a mid-array summary node (allocateFreeRef skips used slots → fresh high ref) flushes the head and emits a startRef > endRef pair like m00110295..m00106762.

Layer judgment — root cause correctly located. Recommendation inferred continuity from ref arithmetic; the apply side is already order-agnostic. Confirmed in src/boundaries.ts:91-94 (anchors resolve via indexByMessageId, message-id → array index) and :114-116 (swaps inverted indices), so even a descending pair applies to the right span today. Confining the change to src/recommend.ts is justified.

Solution assessment

  • Array-adjacency via a per-entry gapBefore is the right model. Audited every skip category one-to-one against the old rule: unrefed/BLOCKED consume no numbered slot (no old numeric gap, no new interruption); synthetic/pruned, protected-tool, and protected-zone entries each consume a slot and interrupt under both rules. Hence dense append-only hosts produce byte-identical ranges → existing suite stays green with zero test edits (confirmed: the test-file diff is purely additive).
  • assignRefs untouched — append-only, no recycling/pruning. The Revert "fix: reclaim dead message refs, reuse exhausted capacity (#176)" — ref slot reuse is unsafe #191 invariant ("refs never reused/duplicated — ever") holds.
  • Symptom chain confirmed: recommendNode (src/compress.ts:586-599) feeds buildCompressibleRanges().compressible → mergeRangesToThreshold (src/recommend.ts:328, positional char-accumulation, no ref math) → pendingByTier (src/compress.ts:1095, pure token sum). Fragmented singletons are exactly what kept T1 pending under the benefit floor ([Enhancement] decideNudge pressure band injects micro/zero-yield EMERGENCY nudges — no minimum-benefit gate #198). Fixing segmentation clears it. No ref-arithmetic re-fragmentation remains anywhere in the recommendation→decision path.

Pre-flight (on PR head): typecheck ✓ · tests 584/584 ✓ · build ✓.

Non-blocking observations (out of scope for #207, reporting only)
Two display-side modules still segment by ref number rather than array adjacency:

  • src/nudge-text.ts:88-99 — coalesces the merged list with e.startNum <= last.endNum + 1.
  • src/report.ts:249 — status "uncompressed ranges" uses strict num === last.startNum + last.count.

Neither feeds the compression decision or the benefit floor, and neither can emit a descending pair (entries are sorted ascending / strictly contiguous). On surface-replace hosts they may cosmetically under-/over-merge in rendered text. Leaving them is defensible here; if you want display parity on replace hosts that'd be a small follow-up, not a blocker for this PR.

Minor nit (no action): the patch moves the isSyntheticOrPruned check to run after the !ref || ref === "BLOCKED" guard. Net effect is more precise — an unrefed synthetic node now falls into the "consumes no slot" branch instead of setting gap flags, which is correct; no existing test depends on the old ordering.

Dup screen: no related open/closed issue or competing PR; single PR closing #207 (the documented alternative to reverted #176, not a duplicate).

Per AGENTS.md this needs a second independent review before merge, and merge is human-only. Ready for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Range segmentation assumes refNum order == message array order — surface-replace hosts get end<start ranges + spurious fragmentation

1 participant