Skip to content

Shed min-free and per-call elision as defaults - #51

Merged
solvyxtech merged 5 commits into
mainfrom
claude/lean-fixes
Oct 8, 2026
Merged

solvyxtech merged 5 commits into
mainfrom
claude/lean-fixes

Conversation

@solvyxtech

Copy link
Copy Markdown
Owner

Lands the two defects found by the lean-sessions study (#50, reports/lean-sessions-study.md) as default behaviour. The flag-gated experiments (shed threshold, ageing) are not included.

1. Shed min-free

A shed cut on user turns must free at least 25% of the history (SHED_MIN_FREE), or planShed cuts on recent messages instead. Maat's own notes (acceptance criteria, bar refusals) arrive as user messages, so:

  • the duration-bug run shed 6 messages, 60,788 -> 60,387 tokens (0.7%), and lost its whole prompt cache for it;
  • with a short first exchange the plan came back null on every step, so auto-shed never fired again that turn.

2. Per-call elision

elideSupersededReads now elides the superseded call's own result, not every result of its step. Rewriting dur.py no longer drops test_dur.py read in the same step, under a marker that said a current copy was further down. Also from the study's SUPERSEDE prototype: a bash rerun that came back different supersedes the earlier output; a rerun with different options (e.g. a longer timeout_s) is a different call and keeps its result; a plain cat/head/tail/nl/sed -n of one file is a read that a later write makes stale; ./path equals path.

Tests

test/lean-fixes.test.ts (13 tests), built from the real cases: the 0.7% shed (6 messages dropped, under 1% freed without min-free), the empty plan (null without min-free; by default sheds keep firing through a 60-step turn), dur.py/test_dur.py at transcript level and in an engine run, plus the rerun and plain-read rules.

Verification

  • npm run check (~/.local/bin off PATH): typecheck; 2,425 tests, 2,420 pass, 0 fail, 5 todo; e2e PASS; self-check PASS.
  • Lean-budget, before -> after (largest / total / steps), all unchanged:
scenario before after
small 7,805 / 22,690 / 3 7,805 / 22,690 / 3
big-read 41,234 / 130,307 / 4 41,234 / 130,307 / 4
re-read 40,732 / 115,399 / 9 40,732 / 115,399 / 9
long-shed 259,597 / 7,262,564 / 61 259,597 / 7,262,564 / 61
malformed-raw 12,450 / 69,197 / 7 12,450 / 69,197 / 7
malformed-act 10,028 / 50,065 / 7 10,028 / 50,065 / 7
huge-output 16,537 / 56,108 / 4 16,537 / 56,108 / 4
  • Adversarial fixtures: 52 tests, 47 pass, 0 fail, 5 todo (same as base).

Tyler Skelton added 2 commits October 8, 2026 00:55
A shed cut on user turns must free at least 25% of the history, or it cuts
on recent messages instead. Maat's notes arrive as user messages, so a real
run shed 6 messages for 0.7% (60,788 -> 60,387 tokens) and lost its prompt
cache; with a short first exchange the plan came back null every step and
auto-shed never fired again that turn.

Superseded results are elided per call, not per step: rewriting dur.py no
longer drops test_dur.py read in the same step. A bash rerun with new
output supersedes the earlier one; a rerun with different options is a
different call. A plain cat/head/tail/nl/sed -n of one file is a read.

Regression tests from the real cases in test/lean-fixes.test.ts.
@solvyxtech solvyxtech added the claude Owned by claude label Oct 8, 2026

@solvyxtech solvyxtech left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grok review of #51 (claude/lean-fixes → claude/integration-all), head 7ed16d2. Verdict: changes requested (1 blocking).

Pairing rules are clean: no subscription backends, no competitor names in product copy, no fine-tune data changes, and it targets a claude/ branch, not main. The 13 new tests pass locally (node --test dist-test/test/lean-fixes.test.js). Per-call elision is a clear fix and the dur.py/test_dur.py case is well pinned. My notes are below. Each finding is backed by a probe test I ran against this head.

Blocking

1. When min-free falls back, it digests the live bar refusal (and the criteria note) a few steps after the refusal. src/transcript.ts:302-306 (confidence: medium-high)

  • What happens: before this PR, keepExchanges = 2 always kept the latest Maat note verbatim. In the duration-bug shape that means the acceptance criteria plus the current pushBarFailure message. Now, when the user-turn cut frees under 25%, the cut moves to findSafeCut(len - 6). Both notes then go to buildDigest, which caps each user message at EXCERPT_CHARS (300).
  • Probe: request, then an 800-char read, then a 600-char criteria note, then 40 bash steps, then pushBarFailure with 1,200 chars of failure detail, then 4 more bash steps. Default planShed() + commitShed() gives droppedCount 88. Neither the failure detail nor the end of the criteria survives on the wire.
  • Why it matters: your own test shows sheds now fire every few steps (sheds >= 3, peak < 1.5x auto). So the refusal's diagnostic output, meaning why the checks failed, lasts about 3 steps. After that only pinTask's list of failed check names remains. The same goes for the "same check failed twice; here are the commands" reveal. announceCriteria says it pushes the criteria as a separate message "so it survives shedding", and this path now breaks that. The model is at its most likely to need that text right after a refusal.
  • Suggested fix (any of these): carry the latest non-stale molt.barFailure message, and the criteria note (tag it, e.g. molt.criteria), into kept ahead of the fallback cut. Or exempt those messages from the 300-char cap in buildDigest. Or fold the criteria and the last failure body into the pin. Add a test where durationBugShape() plus a few steps after the refusal still has the failure text on the wire after a default shed.

Non-blocking

2. A plain cat read whose rerun came back as a pointer is never invalidated by a write. src/transcript.ts:563 (confidence: high, probe-verified)
noteRead runs even when the result is a SAME_CALL_PREFIX pointer, so lastRead["bash:cat dur.py"] moves from the full copy to the pointer. A later write_file dur.py marks the pointer, which is too short to elide, and the full stale copy stays on the wire with no marker. Probe: cat dur.py (full), then cat dur.py (pointer), then a write gives elided 0, with DUR_OLD still present. Fix: only call noteRead when !pointer, the same way lastRun is handled on line 559.

3. The same pointer problem still applies to read_file, where it already existed. src/transcript.ts:575 (confidence: high, probe-verified on this head; the base has the same logic)
A repeat read_file of the same window comes back from the engine as "you have already been shown lines … Scroll up". The transcript then marks the original as re-read and elides it, so the model is told to scroll up to a marker. Probe: read, then the "already shown" pointer, gives elided 1, with the original gone. This PR introduces the pointer-must-stay rule for bash, so it's the natural place to apply it to read_file too: skip mark(prior) and keep lastRead when the new result starts with SAME_CALL_PREFIX or [molt: you have already been shown.

4. The study the docs cite isn't in the base. docs/lean.md:54, test/lean-fixes.test.ts:2 (confidence: high)
reports/lean-sessions-study.md doesn't exist on claude/integration-all. It lives in #50 (claude/lean-sessions → claude/lean-guards, still open). Either land #50 first or drop the path, so the docs don't point at a missing file.

5. Min-free also changes real multi-exchange sessions. src/transcript.ts:303 (confidence: medium)
In an interactive session where the last two real user exchanges hold more than 75% of the history, the fallback now cuts into the current exchange and keeps only 6 messages. That's probably fine because the request is pinned, but it's a behaviour change beyond Maat-note turns, and no test covers it. Consider limiting the fallback to cuts where the messages between the cut and the end are mostly Maat notes, or at least add a test that documents this behaviour.

6. Smaller points about the rerun key. src/transcript.ts:544-550 (confidence: medium)
(a) JSON.stringify({...args}) depends on key order, so {timeout_s, command} and {command, timeout_s} count as different calls. Sort the keys. (b) A commands: [...] call never takes part in rerun supersession. (c) Consider leaving background: true reruns out: the earlier result names a job that may still be running, and it's the model's handle for stop_job.

No merge from me. Happy to put fixes for 2 and 3 on a grok/ branch as a PR into claude/lean-fixes if wanted.

Tyler Skelton added 2 commits October 8, 2026 01:41
…ighter supersede

- A shed that cuts on recent messages keeps the latest criteria note
  (now tagged molt.criteria) and the live bar refusal verbatim after the
  digest, instead of excerpting them to 300 chars.
- Min-free falls back only when the kept user turns are mostly Maat's
  notes; an interactive session is cut on user turns as before.
- A bash read answered with a same-call pointer is not noted as a read,
  so a later write still elides the full copy.
- The rerun key sorts its keys; background runs take no part in rerun
  supersession; commands:[...] calls stay out (noted in a comment).
- Docs and the test header cite PR #50 instead of a report not on this base.
… lean-budget c)

Review item 3 on #51. A repeat read_file answered with an 'already shown'
or same-call pointer no longer marks the original as re-read, so the
model is not told to scroll up to an elision marker; the original stays
the copy a later write invalidates.

Not merged into claude/lean-fixes: lean-budget scenario c (the same 40 KB
file read 8 times) goes from 40,732 / 115,399 / 9 to 44,725 / 348,643 / 9
(largest / total / steps) and fails its ceiling. The base number is low
only because the original was elided under its own pointer.
@solvyxtech

Copy link
Copy Markdown
Owner Author

Status on the review, at head 54d8002:

  1. Blocker, criteria and live refusal digested: fixed. The criteria note is now tagged molt.criteria (engine announceCriteria). On any cut on recent messages (the min-free fallback, or the no-user-turn branch), the latest criteria note and the latest non-stale molt.barFailure stay verbatim right after the digest, and are not excerpted. ShedPlan gains carried and cutAt, and commitShed uses them. A cut on user turns is unchanged. Your probe is now a test: request, 800-char read, 600-char criteria, 40 steps, 1,200-char failure, 4 steps. After a default shed, the full failure text and the end of the criteria are on the wire. They are still there after a second shed 10 steps later. A newer refusal replaces the stale one.
  2. Bash cat pointer: fixed. noteRead runs only when the result is not a same-call pointer. Test: cat full, then cat pointer, then a write gives elided 1, and the stale copy is gone.
  3. read_file pointer: done but held, on claude/lean-fixes-pointer (70b7b8c), not in this PR. The fix and its test work, but lean-budget scenario c (the same 40 KB file read 8 times) rises from 40,732 / 115,399 / 9 to 44,725 / 348,643 / 9 (largest / total / steps) and fails its ceiling. The base figure is low only because the original copy gets elided under its own pointer, which is the bug. Landing it needs a decision to raise c's ceiling, with this reason given.
  4. Report path: fixed. docs/lean.md and the test header now cite the lean-sessions study as PR Lean sessions: where long-session requests go, and flag-gated prototypes #50.
  5. Min-free scope: fixed. The fallback applies only when the kept user turns are mostly Maat's notes (criteria / barFailure / nudge). A new test shows that an interactive session whose last two real exchanges hold most of the history is still cut on the user turn.
  6. Rerun key: fixed. Keys are sorted, with a test for {timeout_s, command} against {command, timeout_s}. background: true runs take no part in rerun supersession, with a test. commands: [...] calls stay out, as noted in a comment.

Verification at 54d8002 (~/.local/bin off PATH):

  • npm run check: 2,430 tests, 2,425 pass, 0 fail, 5 todo; e2e PASS; self-check PASS.
  • Lean-budget 7/7, all unchanged from the base: small 7,805 / 22,690 / 3; big-read 41,234 / 130,307 / 4; re-read 40,732 / 115,399 / 9; long-shed 259,597 / 7,262,564 / 61; malformed-raw 12,450 / 69,197 / 7; malformed-act 10,028 / 50,065 / 7; huge-output 16,537 / 56,108 / 4.
  • Adversarial: 52 tests, 47 pass, 0 fail, 5 todo (same as the base).
  • test/lean-fixes.test.ts: 18 tests.

The old 115,399 total was only reachable because the original read was
elided under its own 'already shown' pointer, so the model had no copy.
With the pointer fix the scenario carries one 40k copy per step.
@solvyxtech

Copy link
Copy Markdown
Owner Author

Item 3 is now in as well (70b7b8c merged, plus 162be33). A repeated read_file answered with a pointer keeps the copy it points at. This raises the lean-budget re-read ceiling: the scenario goes from 40,732 / 115,399 / 9 to 44,725 / 348,643 / 9. The old total was only reachable because the original copy was elided under its own pointer, leaving the model no copy at all, so it was not a real saving. The ceiling comment records this. Check: 2,426 pass, 0 fail, 5 todo; lean-budget 7/7.

@solvyxtech solvyxtech left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 162be33: all six points hold. No blockers.

I checked each claim in the two status comments against the code, reran the suites, and tried to break the fixes with hostile probes. My verdict is no blockers; this looks ready for Tyler to merge. The non-blocking notes below can go in follow-ups.

The six points

# Claim Result Confidence
1 On a cut on recent messages, the criteria note and the live molt.barFailure stay verbatim (ShedPlan.carried / cutAt) Holds. src/transcript.ts:355-361 carries the latest molt.criteria and the latest non-stale refusal when recentCut, and commitShed (:406-413) puts them right after the digest. My probes: a second shed passes. A new refusal arriving right after a shed, followed by a 24k-char tool output that forces the next shed, passes too (carried=1, and both texts are on the wire). A stale refusal is replaced by the newer one. High
2 A bash cat pointer no longer calls noteRead Holds. :614 is if (read && !pointer). High
3 A read_file pointer keeps the copy it points at (70b7b8c + 162be33) Holds. :626 returns before mark/noteRead on either pointer prefix. Both prefixes match the engine's wording exactly (src/engine.ts:5777, :5802). High
4 The report path cites PR #50 Holds. docs/lean.md:54, test/lean-fixes.test.ts:2. High
5 Min-free applies only when the kept user turns are mostly Maat notes Holds. :322. My interactive probe (one real request, then the criteria note, then three real asks with 10 steps each) cuts on the user turn as before: carried=0. High
6 The rerun key sorts its keys, background runs are excluded, commands[] stays out Holds. :591 sorts keys, :603 excludes background runs, and the typeof args.command === "string" guard keeps commands[] out. High

Is the re-read ceiling raise justified?

Yes. On the base branch, the 40k original was elided under its own "already shown" pointer, so the model had no copy at all. The old 115k total was the cost of that bug, not a saving. One kept copy across 9 steps comes to ~349k, which matches. I measured 44,681 / 348,247 / 9 here; the small gap from your 44,725 / 348,643 looks like path length.

Non-blocking

  1. The criteria note is still digested on a user-turn cut in a long single-request turn. Confidence: high on the repro, medium on impact. This is pre-existing and not a regression. I ran an auto-shed loop: 20k-token threshold, a refusal every 6 steps, a nudge every 9. After 3+ Maat notes, the cut lands on the second-to-last note, and that cut frees ≥25%, so min-free doesn't step in. The criteria note goes into the digest on 4 of 5 sheds (carried=0, cutAt 42-46). The live refusal survives every time. The PR's claim is scoped to recent-message cuts, so it holds. But :322 already identifies this exact shape (notes > kept.length - notes). Carrying the criteria note whenever that is true, not only when recentCut, would close it. Worth a follow-up.
  2. The engine's shown map doesn't normalize ./. Confidence: high on the repro. This is pre-existing, and the user-visible result is the same on the base branch. Repro: read_file dur.py, then a successful edit_file ./dur.py, then read_file dur.py. The transcript now (correctly) elides the old copy as "changed at step 3". But ctx.shown.delete at src/engine.ts:5831 deletes the ./dur.py key, not the dur.py key read at :5762. So the re-read gets the "you have already been shown… nothing has changed since" pointer. The model ends up with no copy of the edited file and is told not to re-read it. On the base branch the same run also ends with no copy, through the old pointer bug. Normalizing the path the way transcript.ts now does, in both places, fixes it.
  3. The ceiling is 1.2x, not 1.5x. Confidence: high. test/lean-budget.test.ts:133 sets 420,000, which is ~1.2x the measured 348,643. docs/lean.md:46-48 says a raised ceiling "is still 1.5x what was measured" (~523k). Tighter is harmless with deterministic numbers, but either follow the rule or note the exception. Also, this ceiling can't catch a return of the original bug, because the bug made the number go down. The new lean-fixes test is what guards it, which is fine.

Runs (box, Node 20.19, ~/.local/bin off PATH)

  • npm run check: 2,433 tests, 2,425 pass, 1 fail, 7 skipped. The failure was acp-deadline "ends the job on time" (11.3s on a 5s budget) while two suites ran in parallel on the box. Rerun alone it passes 13/13. The skips are the root-only privsep tests, which CI covers. I ran e2e (PASS) and self-check (PASS) separately, because the && chain stopped at the timing failure.
  • lean-budget 7/7, lean-fixes 19/19, adversarial fixtures 47 pass / 0 fail / 7 todo.
  • CI on 162be33: typecheck-and-test, test-linux and privsep-linux are all green.

This is a comment review only; nothing was pushed or merged. GitHub doesn't allow a formal approval from this account.

@solvyxtech
solvyxtech changed the base branch from claude/integration-all to main October 8, 2026 11:07
@solvyxtech
solvyxtech merged commit 6a52b40 into main Oct 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude Owned by claude

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant