|
| 1 | +# TITLE |
| 2 | + |
| 3 | +feat(insertion-normalization): un-merge CC's join-moves, and stop a re-served entry from re-binding |
| 4 | + |
| 5 | +# BODY |
| 6 | + |
| 7 | +> **DRAFT — stacks on #272 and #276, review alongside them.** This branch is |
| 8 | +> cut from #272's head (`b713b2f`) and merges #276's updated head, because its |
| 9 | +> tests assert against `tools/replay.mjs`'s gate functions. Base is |
| 10 | +> `upstream/main`, so the diff shown by GitHub includes both parents' content; |
| 11 | +> the material new to *this* PR is the six commits after the merge. Same |
| 12 | +> precedent as #281. |
| 13 | +
|
| 14 | +## What this is |
| 15 | + |
| 16 | +CC sometimes **merges a `<system-reminder>` message and its immediate |
| 17 | +standalone neighbour into a single message** mid-history, then sometimes |
| 18 | +un-merges them again a few requests later. Every one of those flips rewrites |
| 19 | +history the model has already seen, which busts the prefix cache and re-bills |
| 20 | +the whole conversation. |
| 21 | + |
| 22 | +#272 gave the proxy the ability to recognise a message across |
| 23 | +re-serializations and to pin the first-seen bytes. This PR closes the two |
| 24 | +cases that pinning alone could not: |
| 25 | + |
| 26 | +**1. The join-move un-merge.** When we recognise that a message CC used to send |
| 27 | +standalone has been absorbed into its neighbour, we serve the first-seen |
| 28 | +(un-merged) form upstream instead of the newly merged bytes. The join grammar |
| 29 | +is a single `"\n\n"` separator — the same literal the duplicate-suppression |
| 30 | +path already keys on — and the probe is byte-exact: the merged wire message's |
| 31 | +text must equal `pinnedReminderText(predecessor) + "\n\n" + absorbed.text`, both |
| 32 | +messages must be `role: "system"`, and the absorbed entry's neighbourhood |
| 33 | +bounds must resolve on the current wire. Anything else fails closed: no |
| 34 | +substitution, raw forward, today's behaviour. |
| 35 | + |
| 36 | +**2. The identity fix — a re-served entry leaves the wire-identity space.** |
| 37 | +This is the part that took a rebuild rather than a patch. Pinned entries are |
| 38 | +keyed by `(content-hash, role, occurrence-ordinal-within-the-request)`. A |
| 39 | +recognised move keeps the absorbed entry alive in *our* canonical while CC has |
| 40 | +stopped sending it — so its ordinal is a claim about an array it is not in. |
| 41 | +The moment a later request carries **one more copy of the same recurring text** |
| 42 | +(measured: a fresh tail reminder taking `o=7`), the stale entry binds to that |
| 43 | +unrelated copy at an inverted position, which both removes it from the dropped |
| 44 | +set — so no move recognition can fire — and trips the subsequence check. The |
| 45 | +merged message then goes out raw and *our* bytes flip at an index where CC's |
| 46 | +were identical. |
| 47 | + |
| 48 | +The fix marks such an entry `rs: true` and takes it out of `(h, r, o)` matching |
| 49 | +entirely. Its identity becomes its stored first-seen bytes plus the canonical |
| 50 | +slot where we last forwarded them. Each request it gets exactly one of three |
| 51 | +dispositions, checked in order: **re-fire** (the merged form is present again → |
| 52 | +re-serve), **reclaim** (CC flipped back to the original form → clear the mark |
| 53 | +and rebind as an ordinary matched entry), **lapse** (neither form present → the |
| 54 | +entry is dropped, never re-served into a region CC no longer carries). Entries |
| 55 | +that were never re-served keep absolute `(h, r, o)` matching byte-for-byte. |
| 56 | + |
| 57 | +## Measured — A/B over the live corpus |
| 58 | + |
| 59 | +Two detached worktrees differing only by this diff, replayed over 8.5 GB of |
| 60 | +real capture (36 captures, ~10 000 requests) under the serving gate set: |
| 61 | + |
| 62 | +| capture | before | after | requests (identical both runs) | |
| 63 | +|---|---|---|---| |
| 64 | +| s-dc3f8071 | 2 | **0** | 769 | |
| 65 | +| s-58c979ce | 2 | **0** | 2073 | |
| 66 | +| s-633915a8 | 2 | **0** | 2630 | |
| 67 | +| s-9f9d8a9d | 1 | **0** | 209 | |
| 68 | +| s-0d6f38ba | 3 | **2** | 1058 | |
| 69 | +| **corpus total** | **10** | **2** | | |
| 70 | + |
| 71 | +Cross-request byte-stability violations go **10 → 2**, and the two survivors |
| 72 | +are attributed by the gate's own attribution line to a *different* extension |
| 73 | +(`deferred-tool-rewrite`) and are byte-for-byte the same two pairs in both |
| 74 | +trees. **Zero insertion-normalization stability violations remain in the whole |
| 75 | +corpus.** Safety, conservation, sequence and canonical order read 0 on every |
| 76 | +capture in both trees. |
| 77 | + |
| 78 | +Worth stating plainly: the same ordinal collision was firing on **four** |
| 79 | +captures, not the one it was found on. That only became visible because the |
| 80 | +measurement was corpus-wide rather than fixture-wide. |
| 81 | + |
| 82 | +Two honesty notes about the sweep as an A/B. The capture *count* differs (33 vs |
| 83 | +36) — three tiny captures (2, 13 and 1 requests) were present only for the |
| 84 | +second run, all clean, none in the failing set. And several captures are live |
| 85 | +and still growing, so two sequential 8 GB sweeps are confounded in principle — |
| 86 | +but the per-capture request counts are identical for every capture in the table, |
| 87 | +so for the captures the comparison is about, both trees replayed the same input. |
| 88 | + |
| 89 | +**Old-canon compatibility** was measured, not argued: `tools/verdict-ab.mjs |
| 90 | +--seed-from-a` replays decisions over canon files written by the pre-change |
| 91 | +code and is **identical across 44 verdict lines / 6 corpora**. `rs` is a new |
| 92 | +optional field; canon files from the old code contain none, and under the new |
| 93 | +code they take identical decisions. A restart shipping this is |
| 94 | +cache-transparent for every existing conversation. |
| 95 | + |
| 96 | +## Non-Functional Requirements |
| 97 | + |
| 98 | +- **Size/complexity budget.** ~590 added lines in |
| 99 | + `proxy/extensions/insertion-normalization.mjs` (no new production file, no |
| 100 | + new abstraction, no new env var — it extends `classifyPinned`, |
| 101 | + `resetKeepingPins` and `findJoinMoves` in place). The originating directive |
| 102 | + budgeted 120–200 LOC; the overrun is the reconciliation with #272's |
| 103 | + reset-path duplicate suppression, which had to unify two declaration paths |
| 104 | + rather than add a second one. Tests: ~1,170 lines across three files, plus a |
| 105 | + 20.6k-line harvested fixture — see the fixture caveat below. |
| 106 | +- **Threat model.** Conversation fidelity is the protected property; the |
| 107 | + conservation and stability gates are the enforcement. The new risk this |
| 108 | + design introduces is re-serving stored bytes into a context CC has pruned or |
| 109 | + compacted away. The lapse disposition is the mitigation and it fails **closed** |
| 110 | + — no re-serve — whenever its preconditions are not byte-established on the |
| 111 | + current wire. No new persisted state shape beyond `rs`, one optional boolean |
| 112 | + on an existing entry. |
| 113 | +- **Maintainability.** The join grammar stays single-copy (`JOIN_SEPARATOR`); |
| 114 | + the merged-form probe is the same literal the duplicate suppression already |
| 115 | + uses, seen from the other side. |
| 116 | +- **Performance/reliability.** The disposition pass is O(reserved entries × |
| 117 | + neighbourhood) per request; reserved entries measured at 1–2 per conversation |
| 118 | + in every observed instance. |
| 119 | +- **Load-bearing? YES.** It changes canonical state entries and the bytes |
| 120 | + forwarded on the wire. |
| 121 | + |
| 122 | +## Open, and inherited from #272 |
| 123 | + |
| 124 | +- **#272's four blockers are not addressed here.** In particular the |
| 125 | + reminder-only-edit case (blocker 1) and the canon-file permissions (blocker 3) |
| 126 | + are properties of the base this branch sits on. Whatever lands there lands |
| 127 | + here. |
| 128 | +- **`test/proxy-read-dedupe.test.mjs:505` is red on this branch**, exactly as it |
| 129 | + is on #272 — the extension-order adjacency assertion. Reproduced at #272's |
| 130 | + head before any commit here: 41/42 on that file alone. Left for the |
| 131 | + deliberate fix #272's review asked for (move the order, or update the |
| 132 | + assertion) rather than patched in a stacked PR. |
| 133 | +- **Fixture sanitization is an open question, not a settled one.** This branch |
| 134 | + carries `test/fixtures/harvested/reset-move-s-dc3f8071-196-197.json` (the |
| 135 | + reset leg) and, via the #276 merge, `flap-s-0d6f38ba-86.json` (the |
| 136 | + oscillation leg). #272's review flagged the harvested-fixture class as a |
| 137 | + blocker and asked us to agree a path before pushing changes. Neither fixture |
| 138 | + should be treated as reviewed on that axis by this PR. |
| 139 | + |
| 140 | +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
0 commit comments