feat(context): working-set layer with exact recall and the structural-v1 policy - #183
Closed
akougkas wants to merge 53 commits into
Closed
feat(context): working-set layer with exact recall and the structural-v1 policy#183akougkas wants to merge 53 commits into
akougkas wants to merge 53 commits into
Conversation
Adds contextEviction and contextRecall session entry kinds, the working-set contract (view, policy, plan, recall types), the context.workingSet settings type, and updates every switch on entry.kind so the tree typechecks with the new kinds. No behavior change: nothing writes the new kinds yet.
foldWorkingSet turns contextEviction/contextRecall entries into a view, selecting the active path through filterEntriesToActivePath so forks and /tree switches never project an abandoned branch's evictions (#94).
…ection resolveRecall/buildRecallFields resolve an evicted ref on the active path to its byte-exact tool_result body with typed errors naming the nearest evicted ref. context(scope="recall", ref=...) returns the body through the observation envelope, appends a contextRecall entry with the tool call id, and errors cleanly without a session. The /context overlay gains a working set section (policy, evicted items and tokens, events, recalls, churn) and an evicted-tokens line outside the meter categories.
The recalled body is returned in the recall tool result at the tail of the working set. Readmitting it at the original position would duplicate the bytes and cold the prefix cache for every later message; the marker stays byte-stable and repeated recalls of one ref are the churn signal.
… ledger Slice 1 core: the projection, the marker, the age-horizon policy, and the planner that turns a selection into a ledger entry. marker.ts renders one byte-stable line per evicted body (no timestamp, no counter) naming the ref, reason, tool, size, and the exact recall call. project.ts applies a folded view to a ledger slice: tool-result bodies become their marker with pairing and details intact, thinking blocks leave closed turns, and usage recorded before the event stops anchoring the estimate. Entries the view does not name come back by reference. policies/age-horizon.ts reproduces maskStaleObservations' selection exactly (same turn-start definition, same cutoff, same skips) so slice 1 changes one thing: the ledger stops being rewritten. engine.ts prices each candidate against the projection the model will receive. Session format bumps to 4 and runMigrations now refuses a session written by a newer Clio instead of silently dropping kinds it cannot read. The transcript tags evicted tool rows with their reason and still renders the full body: it shows the ledger, never the projection.
(cherry picked from commit 7a10385479f5241bc4e9007143b1f0ae94194f2a)
One pure pass over the active-path entries answering what the session has observed about which files: op, canonical path, line range, the paths a listing surfaced, whether the call failed, and where in the turn sequence it sits. The structural policy reads it to decide staleness, supersession, failure resolution, and listing consumption; the replay reference graph reads the same index to label file_reread, file_discovery, and file_rewrite. One index, two consumers, so a rule and its measurement cannot disagree about what the session did. Generalizes extractFileOps in compaction/compact.ts with the same argument reading and the same tool-call pairing chat-renderer.ts uses. No filesystem access: paths resolve lexically against the session cwd when the slice carries the JSONL header, and stay as written when it does not, so a replay run and a live run index the same ledger identically.
The context tool reports a successful recall through an injected onRecalled callback; the orchestrator maps it onto BusChannels.ContextRecalled. Drops the temporary bus-wiring allowlist.
One pure pass over the active-path entries answering what the session has observed about which files: op, canonical path, line range, the paths a listing surfaced, whether the call failed, and where in the turn sequence it sits. The structural policy reads it to decide staleness, supersession, failure resolution, and listing consumption; the replay reference graph reads the same index to label file_reread, file_discovery, and file_rewrite. One index, two consumers, so a rule and its measurement cannot disagree about what the session did. Generalizes extractFileOps in compaction/compact.ts with the same argument reading and the same tool-call pairing chat-renderer.ts uses. No filesystem access: paths resolve lexically against the session cwd when the slice carries the JSONL header, and stay as written when it does not, so a replay run and a live run index the same ledger identically. (cherry picked from commit eeefab2)
The age rule asks how old a result is. This one asks what happened to it since: the file was rewritten, a later read covered the same lines, the failure was resolved, the listing was walked. Six rungs in charter 4.5 order, each emitting newest-first, every candidate through the protection predicates, no unit claimed twice. Rungs 1 to 5 run whatever the pressure, because redundant content is free to drop; rung 6 is the age rule as the last resort, gated on being over threshold and stopping the moment the projection reaches target. protect.ts is the absolute list: only tool-result bodies and thinking ever leave, never inside the protection horizon, never under the floor, never a blocked row, never a mutation the active turn stands on, and never a failure nothing has resolved. The rule that evicts a resolved failure and the predicate that protects an unresolved one call the same lookup, so a failure can never be both. horizon.ts holds the cutoff arithmetic both policies share. engine.ts exports tokensFreedByEviction so rung 6 does its headroom arithmetic against the same numbers planEviction records; planEviction now calls it too and its behavior is unchanged. A failure_resolved marker renders first_line instead of preview: failures are evidence, and the line that says what failed is the part worth a marker's tokens. The default policy stays age-horizon until the replay table reports.
akougkas
marked this pull request as draft
August 21, 2026 16:01
Contributor
Author
|
Holding as draft. An adversarial review of the merged slices found six should-fix items, four of which touch code in this PR and are being fixed now on the integration branch:
The fix commits will be cherry-picked onto this branch and the PR re-readied once CI is green again. |
…can see Both policies were given the whole active path, so after a compaction summary every pressure crossing planned evictions of results already behind the cut. Those priced as real savings: the ledger recorded tokens it never freed, the structural age rung stopped early, and the LLM summary ran again for nothing. selectVisibleEntries applies the same active-path and firstKeptTurnId cuts the replay builder applies, and runAutoCompact and the replay runner both feed it to planEviction. The fold still runs over the full active path so evicted refs stay known.
firstMutationAfter skipped the isError check its sibling rules already apply, so a read followed by an edit that failed was evicted as stale_after_mutation and the model was told to re-read a file that had not changed.
…niffing a header that never arrives
The live ledger readers strip the JSONL header before any entry reaches the
policy, so the cwd sniff in buildPathIndex was dead in production and every
relative path stayed exactly as the model spelled it. Listing output, which
grep and find print relative to the search root, was never joined onto that
root unless the root was absolute, so listing_consumed could only fire for a
search of ".".
buildPathIndex takes { cwd } explicitly; PolicyInput and Trace carry cwd;
runAutoCompact passes the session's, the Clio loader reads the header's, and
the CLI reads meta.json's. Listing output joins onto its root before
canonicalizing, and relative spellings normalize so src/a.ts, ./src/a.ts and
the absolute form key the same file. Test fixtures stop injecting a header and
the header-less listing_consumed case is covered.
…s that free nothing minEvictableTokens compared the whole payload estimate, which counts details, resultSummary, and the observation envelope the model never sees. A two-byte bash result under a fat exec record cleared the floor and was replaced by a marker longer than its body. The floor now measures the body text, and planEviction drops any item whose tokensFreed is not positive regardless of the policy that proposed it. The age-horizon parity test states the one rule the plan adds over the destructive mask instead of hiding it behind a zero floor.
An eviction rewrites the prefix, so the next call is cold on Anthropic as much as on a single-slot local backend. The reason was dropped with the tier gate that dispatch and compaction disturbances still need; it now stamps on every tier while those two keep the local-native gate.
…on 4 Version 4 only added the working-set ledger kinds; a version-3 ledger reads as-is. Rejecting it stranded every session the operator had on upgrade, and the picker still listed them. runMigrations now returns a migrated result for 3, resumeSessionState restamps the metadata so the next reader sees 4, and the rejects for < 3 and > 4 stay exactly as they were.
findLaterSuccess treated any non-error later call as the success that resolves a failure, and a call the safety rails refused carries no isError. The path index now records the admission verdict and the resolver skips it.
…e path The nearest-ref guess is a prefix match over time-ordered ids and usually names an unrelated result; the listing is what lets the next call succeed, so it is no longer gated on the guess coming up empty.
`/context recall <ref>` reads an evicted tool-result body back into the transcript. It resolves through the same `resolveRecall` fold at the same live leaf as the tool path, appends a `contextRecall` entry with `trigger: "operator"`, and publishes `BusChannels.ContextRecalled`, so an operator recall counts as churn exactly like a model recall. The body goes to the transcript and nowhere else: an operator recall answers the person, so it never becomes model context and never costs window. The `/context` cache section now reads back the reasons `noteColdReason` stamped on the last run. A cold verdict Clio caused says `last cold turn: working-set eviction (expected)` and drops the shell-reused-but-backend-cold warning, which was reporting Clio's own eviction as a provider disagreement. An unexplained cold turn under a reused shell stays a warning. `recallParentTurnId` moves into recall.ts so the tool path and the operator path anchor the record identically; a record parented anywhere else folds onto the wrong branch.
The unit tests are pure over entry arrays. These drive the charter's section 7 scenarios through the product: a session written by the session domain into an isolated CLIO_CODER_HOME, pressure forced over the threshold with a stubbed target, the real `runAutoCompact` stage, and then the readers that actually disagree on purpose. S1 checks that the ledger keeps every body byte-for-byte while the projection carries markers, that `/resume` rehydration tags each evicted row with its reason, that the `/export` HTML keeps the bodies, and that the evidence bundle names every evicted ref. S2 recalls an evicted bash body through the context tool and proves the marker stays. S9 covers forks either side of the event and the #94 `/tree` case. S11 covers the thinking rule, S12 both halves of the default-off safety net including the legacy escape hatch, S3/S4/S5 the three structural reasons and their `by` refs, and the last file proves replay and live select the same refs over the frozen fixture. `tests/harness/working-set-session.ts` is the shared driver. One harness at a time: `isolateClioEnv` holds a process-wide env lock for the life of a window, so a scenario that builds a second harness before disposing the first deadlocks rather than failing.
Replayed over 165 Claude Code transcripts (clio-coder project, default filter) at 32k, 64k, and 128k budgets with protection horizons of 6 and 2 turns, structural-v1 retention is at or above age-horizon in every cell and clearly above it once the budget stops saturating (0.831 vs 0.781 at 128k, random 0.779), with higher eviction precision, fewer tokens evicted, and lower churn. age-horizon is within a point of random at every budget, as ctx-rm predicted for any age policy. Tables and commands are committed under benchmarks/results/context-replay/.
selectReplayEntries and selectVisibleEntries each computed "everything after the latest compaction's firstKeptTurnId". compactionCut in visible.ts is now the one definition and the replay builder composes it with its own orphan repair. Also: path-index imports isTurnStart from horizon.ts instead of keeping a copy, the isReplayTurnStart alias is gone, and the unused graph-free oraclePolicy export is removed.
recall.ts carried private copies of isRecord, textFromContent, resultText, extractToolResult, and offloadPathOf that payload.ts already exports for the projection and the marker. One reader for the body the model saw.
…uessing the nearest one The longest-common-prefix guess over time-ordered ids named an unrelated result more often than not, and both callers already appended the listing that actually helps. recallErrorMessage now owns the listing (eight refs, then a count) and RecallError loses its nearest field. Tests: the two nearest-ref cases become listing cases.
… projecting to empty content Local reasoning models close a turn with reasoning and no answer text. Projected, that turn reached the provider as an assistant with content: [] or vanished and left two user turns adjacent. The projection now leaves such a turn alone and planEviction prices it at zero, so no eviction is recorded for it.
…re the operator sees it The ledger entry priced the visible ledger slice while the notice, the ContextPruned toast, and the overlay's last-compaction line priced the agent message list, about 15 percent apart for the same event. All of them now quote the plan's tokensBefore/tokensAfter; the live estimate stays what the meter and the post-eviction re-check read. The notice also says how to get a body back.
The read tool records no details.paths, so every evicted read rendered without path= and the model had only a 120-character preview to decide between recall and re-read. The marker now falls back to the call's own path argument, as the model wrote it; policies price with the same map so the recorded marker is the one that was priced.
…recision Replay churn counted evicted items the session referenced again, which is exactly the complement of eviction precision, and the name collided with live churn (recalls over items evicted). The table and the JSON lose the column; the test that pinned churn=1 already pinned precision=0.
context-working-set.md and the changelog still said age-horizon in the settings block and table; the guide listed Claude Code replay as not shipped and the cache-honesty paragraph said only local-native targets stamp reasons. Also: age-horizon's missing target stop is now stated as deliberate, the /context prose mentions recall, the guide gains a See also for context replay and context working-set, and the two policy header comments stop describing slice 1.
…one assistant message Claude Code writes thinking, text, and tool_use as separate JSONL records sharing message.id; the loader emitted one assistant entry per record, so in replay most thinking lived in entries of its own. With the projection now keeping a thinking-only turn, that shape protected nearly all replayed thinking and moved the tables. Records with the same id fold into one entry the way Clio persists a message; id-less records stay separate.
…ader with the default-policy rule Both protectLastTurns settings re-run at ca3f49b. README carries the corpus, the metric definitions, the rule, the headline grid with the verdict per cell, and the one cell that misses it: precision 0.979 against random's 0.980 at 6/128k. Retention leads age-horizon on every budget at the shipped setting. The changelog and the defaults comment quote the new numbers.
…ng an empty table A missing path or a corpus the filter rejects entirely produced a full Markdown report of zeros and exit 0. It now writes the cascade to stderr and exits 1.
The listing included evicted assistant turns, whose thinking is not recallable, so a caller following it hit a second failure. Tool results only, and the wording says so.
After a summary compaction the markers before the cut leave the working set, and the listing was the model's only way to learn which ref held which file; with bare ids it recalled five bodies to find one. Each ref now carries the tool and the path the call named.
… indent style biome rejected the committed artifacts because the CLI emitted two-space JSON; tab indentation keeps a fresh run lint-clean without a reformat step.
akougkas
marked this pull request as ready for review
August 21, 2026 19:24
This was referenced Aug 21, 2026
Closed
Contributor
Author
|
Landed on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #180, #181, #182. Part of #179.
What changes
Eviction is a projection. When pressure crosses
compaction.threshold,runAutoCompactasks a policy which tool-result bodies and closed-turn thinking blocks leave the model's working set, appends onecontextEvictionentry, and the projection (fold.ts+project.ts) swaps those bodies for byte-stable markers when the model's messages are built. The ledger is never rewritten:/resume,/tree,/fork, the transcript, and the HTML export keep every byte.context(scope="recall", ref=...)and/context recall <ref>return the exact body; a recall does not un-evict, so the marker and the prefix cache stay put and repeat recalls are the churn signal.Slice 1 (#180): non-destructive working set.
src/domains/context/working-set/(contract,fold,project,marker,protect,engine,recall,payload,visible,horizon), two append-only ledger kinds at session format v4 (v3 migrates in place; only a newer build's session is refused), theage-horizonpolicy as the recorded form of the old mask selection,context.workingSetsettings, thecontexttool'srecallscope, the/contextoverlay section, and theevicted · <reason>transcript tag.CLIO_CODER_LEGACY_MASK=1keeps the destructive stage for one release.Slice 2 (#181):
structural-v1, path index, replay. Typed path-keyed rules in charter order (stale_after_mutation,superseded_read,failure_resolved,listing_consumed,thinking_turn_closed, thenage_horizonbatched totarget), protection predicates that run before every rung, a deterministic path index shared by the policy and the replay reference graph, andclio-coder context replayover Clio ledgers and Claude Code transcripts withnone,random, andoraclecontrols and an incremental runner. The Claude Code loader folds per-block assistant records into one message the way Clio persists them.Slice 3 (#182): attribution, operator surfaces, docs.
working_set_evictstamped onpromptCache.expectedColdReasonson every tier, the overlay'slast cold turn: working-set eviction (expected)line,/context recall <ref>,clio-coder context working-set --session,docs/context-working-set.md, thecontext-engine.mdrewrite around eviction / recall / summary, glossary entries 41 to 45, and the changelog.Default policy
context.workingSet.policydefaults tostructural-v1. The tables underbenchmarks/results/context-replay/(165 Claude Code transcripts, budgets 32k/64k/128k, bothprotectLastTurns6 and 2, sourceca3f49b6) and the rule are in that directory's README. At the shippedprotectLastTurns: 6:Retention leads
age-horizonon every budget. The rule's precision clause fails one cell by 0.001 at 128k; it is recorded in the README rather than rewritten around. Two things the rule did not ask about are also in the README: random-to-target retains more than either real policy at 32k and 64k because both real policies evict about 13 percent more tokens, and the retention metric charges asuperseded_readeviction even when a newer copy of the file is still in the working set. Both are follow-ups on #179. The tables committed beforeca3f49b6came from a loader that split each Claude Code message into three entries and are superseded.Review and simplification on top of the slices
compactionCutinvisible.ts) shared byselectReplayEntriesand the policy input;recall.tsreads payloads throughpayload.ts; the replaychurncolumn (exactly1 − precision) is gone; thenearestref guess is gone and every recall failure lists the recallable refs with their tool and path instead.content: [](N3); areadmarker names its file from the call'spathargument (the read tool records nodetails.paths); every surface that describes an eviction event quotes the plan's token numbers, so the notice, the toast, the overlay, and the ledger agree.context replaywith nothing kept exits 1 instead of printing an empty table.mask-observations.tskeeps its helper copies (importing fromcontext/would add a session→context edge for a module deleted next release); the path index is rebuilt perselect();age-horizonhas no target stop (now documented); a fork at the exact eviction anchor inherits the view (pinned by scenario S9).Verification
npm run ciat9073e395: typecheck, lint (check-hygiene 13/13), skills:check, build, 24 test shards with 4529 pass and 0 fail, trace-viewer tests.qwen3.8-27b-dynamo, threshold 0.16,protectLastTurns: 1): twostructural-v1events (7 and 3 items,age_horizon,thinking_turn_closed,superseded_read), markers withpath=,expectedColdReasons: ["working_set_evict"]on the next assistant,evicted · superseded_readon the rehydrated transcript row after/resume, the overlay's working-set section,/context recallwith a real and a bogus ref, and the model recovering an evictedsrc/fold.tsbody throughcontext(scope="recall")after a summary compaction had removed the markers.Known follow-ups (tracked on #179)
Auto-readmission, the cost model and deferred scheduling (including whether rungs 1 to 5 should run below threshold), intra-turn eviction, worker-runtime adoption, a retention variant that credits a surviving newer copy, the
mask-observationsdeletion next release, and the per-tool-result summary attempt when pressure is over threshold with nothing evictable inside one turn.