Skip to content

feat(context): working-set layer with exact recall and the structural-v1 policy - #183

Closed
akougkas wants to merge 53 commits into
mainfrom
feat/180-working-set
Closed

feat(context): working-set layer with exact recall and the structural-v1 policy#183
akougkas wants to merge 53 commits into
mainfrom
feat/180-working-set

Conversation

@akougkas

@akougkas akougkas commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #180, #181, #182. Part of #179.

What changes

Eviction is a projection. When pressure crosses compaction.threshold, runAutoCompact asks a policy which tool-result bodies and closed-turn thinking blocks leave the model's working set, appends one contextEviction entry, 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), the age-horizon policy as the recorded form of the old mask selection, context.workingSet settings, the context tool's recall scope, the /context overlay section, and the evicted · <reason> transcript tag. CLIO_CODER_LEGACY_MASK=1 keeps 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, then age_horizon batched to target), protection predicates that run before every rung, a deterministic path index shared by the policy and the replay reference graph, and clio-coder context replay over Clio ledgers and Claude Code transcripts with none, random, and oracle controls 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_evict stamped on promptCache.expectedColdReasons on every tier, the overlay's last cold turn: working-set eviction (expected) line, /context recall <ref>, clio-coder context working-set --session, docs/context-working-set.md, the context-engine.md rewrite around eviction / recall / summary, glossary entries 41 to 45, and the changelog.

Default policy

context.workingSet.policy defaults to structural-v1. The tables under benchmarks/results/context-replay/ (165 Claude Code transcripts, budgets 32k/64k/128k, both protectLastTurns 6 and 2, source ca3f49b6) and the rule are in that directory's README. At the shipped protectLastTurns: 6:

budget retention mean: age-horizon / structural-v1 / random precision: random / structural-v1 saturated (structural)
32k 0.479 / 0.480 / 0.485 0.947 / 0.953 0.963
64k 0.582 / 0.590 / 0.603 0.960 / 0.962 0.922
128k 0.788 / 0.812 / 0.798 0.980 / 0.979 0.856

Retention leads age-horizon on 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 a superseded_read eviction even when a newer copy of the file is still in the working set. Both are follow-ups on #179. The tables committed before ca3f49b6 came from a loader that split each Claude Code message into three entries and are superseded.

Review and simplification on top of the slices

  • One compaction cut (compactionCut in visible.ts) shared by selectReplayEntries and the policy input; recall.ts reads payloads through payload.ts; the replay churn column (exactly 1 − precision) is gone; the nearest ref guess is gone and every recall failure lists the recallable refs with their tool and path instead.
  • A thinking-only assistant keeps its thinking rather than projecting to content: [] (N3); a read marker names its file from the call's path argument (the read tool records no details.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 replay with nothing kept exits 1 instead of printing an empty table.
  • Left as is, deliberately: mask-observations.ts keeps its helper copies (importing from context/ would add a session→context edge for a module deleted next release); the path index is rebuilt per select(); age-horizon has no target stop (now documented); a fork at the exact eviction anchor inherits the view (pinned by scenario S9).

Verification

  • npm run ci at 9073e395: typecheck, lint (check-hygiene 13/13), skills:check, build, 24 test shards with 4529 pass and 0 fail, trace-viewer tests.
  • Live TUI run against a local LM Studio target (qwen3.8-27b-dynamo, threshold 0.16, protectLastTurns: 1): two structural-v1 events (7 and 3 items, age_horizon, thinking_turn_closed, superseded_read), markers with path=, expectedColdReasons: ["working_set_evict"] on the next assistant, evicted · superseded_read on the rehydrated transcript row after /resume, the overlay's working-set section, /context recall with a real and a bogus ref, and the model recovering an evicted src/fold.ts body through context(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-observations deletion next release, and the per-tool-result summary attempt when pressure is over threshold with nothing evictable inside one turn.

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
akougkas marked this pull request as draft August 21, 2026 16:01
@akougkas

Copy link
Copy Markdown
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 policy is fed the whole active path, so after an LLM summary it records phantom evictions of entries behind the compaction cut (claimed savings that did not happen; cold-reason stamp for an undisturbed cache).
  • minEvictableTokens is measured on the whole payload including details, so envelope-heavy results with a two-character body are evicted for a 115-byte marker and tokensFreed: 0.
  • working_set_evict cold-reason attribution only reaches local-native tiers; it must stamp on every tier.
  • The v3 to v4 format bump rejects every existing session although the change is additive; v3 will migrate in place.

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

Copy link
Copy Markdown
Contributor Author

Landed on v0.3.4 at f404745f; closing without merge, v0.3.4 carries it to main.

@akougkas akougkas closed this Aug 21, 2026
@akougkas
akougkas deleted the feat/180-working-set branch August 21, 2026 21:30
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.

feat(context): slice 1 — non-destructive working set with age-horizon policy and exact recall

1 participant