Repository navigation
fix(autobiographical): a dry compile leaves what a select leaves behind as it found it - #161
Conversation
|
| if (opts?.dryRun !== true) return this.selectPath(store, budget, opts); | ||
| // A dry run commits nothing, and that includes what a select leaves for | ||
| // the next one and for observers: the cache-marker baseline the next | ||
| // compile measures its stable prefix against, the transition status | ||
| // (getHotContextSettings), the render stats (getRenderStats, with its | ||
| // plan-vs-actual) and calibration's estimate and arming stay as the last | ||
| // committing select left them. They are put back here, on every path out, | ||
| // throws included; each select path gates its own persistence and |
There was a problem hiding this comment.
Dry compiles still save chunks
The new dry-run guard starts after rebuildChunks(store). With auxiliaryMessageViews, another manager can add messages without calling this strategy's onNewMessage(). When enough new messages fall outside the head and tail, a dry compile saves chunk records and adds work to compressionQueue before the snapshot. The six restores undo neither, so a later tick() can spend model tokens on work from a dry compile, even if selection throws. Make chunk rebuilding non-committing for dry runs and add an auxiliary-writer test.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 4721-4728
Comment:
**Dry compiles still save chunks**
The new dry-run guard starts after `rebuildChunks(store)`. With `auxiliaryMessageViews`, another manager can add messages without calling this strategy's `onNewMessage()`. When enough new messages fall outside the head and tail, a dry compile saves chunk records and adds work to `compressionQueue` before the snapshot. The six restores undo neither, so a later `tick()` can spend model tokens on work from a dry compile, even if selection throws. Make chunk rebuilding non-committing for dry runs and add an auxiliary-writer test.
**Knowledge Base Used:**
- [Context compilation pipeline](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/context-compilation.md)
- [Compression strategies](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/compression-strategies.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid, thank you: fixed in 96988d2, force-pushed over d32f3de.
- Chunking now runs dry. A dry select's chunk rebuild runs inside the dry path, and it records no chunk while that path runs.
select()then putschunksandcompressionQueueback to the last committing rebuild's, along with the other restores, so no latertick()compresses a chunk only the dry run closed. - An older case is closed too. The same path covered
previewContextwith an overriddentargetChunkTokens, which used to cut chunk boundaries at that size for good. - Two new tests. Each adds messages through another manager whose messages this strategy sees through an auxiliary view. Then a dry compile, and a preview at another chunk size, must record no chunk, either in memory or in the stored chunk state, and must queue no compression. A real compile afterwards does record them, so the tests can't pass vacuously.
- The description now covers this under Problem, Changes and Tests.
— Oscar-1005
…nd as it found it
SelectOptions.dryRun promises a select that commits nothing, "no transition
bookkeeping" included. Only previewContext kept that promise, by saving
and restoring the state itself. A plain compile(…, { dryRun: true }), which
agent-framework's previews and a host's fork seed and context panel are
moving to (agent-framework anima-research#285, connectome-host anima-research#234), left behind what
the dry select wrote. Two of those changed what the next real compile
sends:
- _prevCacheKeys, the cache-marker baseline. placeCacheMarkers measures
the stable prefix against the previous compile's entries and stores this
compile's, so the next real compile placed its stable marker at the end
of a layout never sent. Reuse of the prefix actually sent then depended
on Anthropic's 20-block backward search, which that marker exists to
avoid.
- For kv-unified, the last real compile's draft: its marker indices were
re-marked from the dry layout (the draft's creation was gated, the
re-mark wasn't), and the next submission carried them beside the real
draft's layout.
And select() rebuilt chunks before anything else, so a dry select
persisted chunk records (Chronicle appends) and queued compression for the
chunks its rebuild closed: over another writer's messages
(auxiliaryMessageViews), which this strategy's onNewMessage never chunks,
or under a preview's overridden chunk size, whose boundaries then stayed
for good. A later tick compressed them.
The rest misled readers between a dry compile and the next real one:
- lastFrontierTokens and transitionBlocked, which getHotContextSettings()
reports as currentFrontierTokens, prepared and blocked;
- _lastRenderStats, which getRenderStats() reports, plan-vs-actual included;
- _lastCompileEstimate and _calibrationArmed: a dry compile between a real
compile and its usage report replaced the estimate the report was compared
with, and one after the report armed a second sample for the same turn,
the drift the arm-once rule (2026-07-12) exists to stop.
Now select() owns this for every dry run, adaptive and hierarchical alike:
it saves the six fields, the chunks and the compression queue on entry and
puts them back in `finally`, on every path out, throws included; its
rebuild records no chunk while a dry select runs (_dryRunSelect); and the
kv-unified re-mark is gated like the draft's creation. A dry run's own render stats are its output, as its plan
is (_lastPreview), so they are kept apart in _lastPreviewStats for
previewContext, which keeps only its own config, picker and head-cache
restores. The other fields previewContext restored aren't read between
selects (_lastKvStable, _rs, _plannedTokens/_plannedMeta, the coverage
scratch), so nothing restores them now. SelectOptions' and select()'s docs
say what a dry run leaves alone; that an over-budget plan doesn't throw at
the plan check, while a later stage that can't fit still throws as for a
real compile (only previewContext turns each such throw into a report);
and that an expired compression hold still expires at a dry compile, as at
every hold query.
Found by the house's review trial on a connectome-host change (I6), relayed
by Oscar-1808; Basil-1007's review found the cache-marker baseline and the
kv-unified re-mark, and the over-budget wording; Greptile's review of the
first push found the chunking.
Tests: 9 new. test/dry-run-preview.test.ts (16 there): after messages
arrive, a plain dry compile leaves the next real compile's cache markers
where they'd be without it, and leaves the transition status, the live
render stats (adaptive and hierarchical) and calibration's estimate and
arming as the real compile left them, arming nothing after the usage
report; beside another writer, a dry compile and a preview at another
chunk size record no chunk, in memory or in the store, and queue no
compression; a select path that writes the fields and then throws leaves
none of them. test/kv-unified-strategy-integration.test.ts: a dry compile
leaves the last real draft's marker indices. With src at main (f74b374),
the nine fail and the existing tests in both files pass. Removals: 12
pieces (each of the six restores, the chunks and queue restore, the dry
chunking, keeping the preview's stats apart, restoring on success only,
the dry wrapper itself, and the kv-unified gate), each failing a test aimed
at it. Full suite: 973/973 on Node 24.21.0 and 20.20.2; the nine new
tests are the only test change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d32f3de to
96988d2
Compare
…ct restores all it leaves, and refuses what a real compile refuses The fix-now verdicts of sw-cm161 (at 96988d2), each with its change: - I1, I2, I10 (code): select()'s dry save and restore now also covers calibration's store view (_storeView), the store order (_storeOrder), whether chunk records are trusted (chunkRecordsOrphaned, _orphanWarned) and the head-window memo (_cachedHeadStartIndex). A dry rebuild at a preview's chunk size could read the records as orphaned and leave chunkRecordsOrphaned set for the real compiles after it; a view over another store, or a memo written under a preview's config, could outlive the dry select too. - I23 (code): the kv-unified check that a selected level has its summary runs on dry selects too, so a dry select refuses what a real compile refuses; only the draft (and its layout) stays a real compile's alone. - I12 (format): select() is indented at the function's level. - I7, I11, I18, I21, I22 (text): the SelectOptions comment names the whole reason for dryRun and points at the field's contract. The field's doc lists everything restored, says a dry select refuses what a real compile refuses except the over-budget plan (returned without a verdict by a plain dry compile, reported by previewContext), that the head-and-tail check and later stages still throw, that previewContext reports an over-budget throw and passes others through, and that a hold ends at its deadline whoever looks, so a dry compile can release an expired hold. - I8, I13, I20 (text): the changelog names ContextManager.compile, says chunk records an earlier dry select wrote stay (valid chunks, sized by that preview), names the hold release as the one thing a dry compile can leave, and records the kv-unified refusal on dry runs. - Tests: the throw-path Writer now writes every field select() restores, and the test reads each back by identity after a dry compile and after one that throws (I3, and I1/I2/I10's restores); the preview at a 50-token chunk size leaves the chunks as the last real rebuild's, and a real compile at that size then closes chunks (I5, I14); the hierarchical render-stats test shows a real compile changes them (I15); the kv-unified test checks the draft object and its layout stay the real compile's, and that a real compile moves the markers (I4, I9); and a picker that selects an unavailable level is refused by a dry compile as by a real one (I23). Not changed (verdicts recorded): I6 intended (the hold clock); I16 not-worth (the render-stats guard keeps the previous select's stats from posing as a preview's after a throw); I17 not-worth; I19 duplicate of I1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings anima-research#146 onto main 5d9c604: anima-research#161 (a dry compile leaves what a select leaves behind as it found it). No conflict: anima-research#161 changes the autobiographical strategy's dry select, its types and tests, none of anima-research#146's lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#160 is stacked on anima-research#146 and started from anima-research#146's head at the time, dac776b. anima-research#146 has since taken two review rounds and merged main through 01a697e (anima-research#152, anima-research#154, anima-research#161, anima-research#151), so this merge brings both. One file conflicted, src/context-manager.ts, and it resolves as follows. - In compile's finish(), the thinking-binding pass runs first, as before, then anima-research#146's raw bodies (rawBodiesOf now reads the attribution) and its layout, which names a journal mark only when there is a layout. compile still builds the layout from the entries as sent (layoutEntries), so it prices an entry the pass changed as sent. - A dry compile no longer tells the strategy the tokens the pass stripped. Since anima-research#161, a dry select puts back the estimate the last committing compile left, so the call would have subtracted a dry run's strip from that compile's estimate. - acceptRound's doc keeps anima-research#160's binding paragraph and takes anima-research#146's account of what it returns. - initializeStrategy tells the strategy it initializes that thinking is bound. anima-research#146 now holds that strategy in a local. - The file keeps both helpers at its end, sentEntries and describeStoreEvent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts in autobiographical.ts: - selectAdaptive receipt draft (anima-research#161): the level-availability check runs dry or not, as main does; the draft, the solved layout and the tree holder are written only by a real compile. The forest is read by id only when it was built from this compile's inputs (a dry select can follow another compile's forest); otherwise the tree answers. - shard walk (anima-research#152): main's 'shards' with this branch's positional level read. The anima-research#161 integration test stubs a picker that replaces finalResolutions; the stub now also drops the positional view (levelAt, levelsBySequence) so the strategy reads the replaced map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
… (Anarchid's 1) Anarchid's review at e979974, must-fix 1: previewContext -> select({ dryRun: true }) -> selectAdaptive -> loadCalibration wrote the re-stamp or the reset to the calibration slot, so opening a preview (connectome-host's WebUI calls the dry-run compile) committed the migration, against main's dry-run contract (anima-research#161: a dry run commits nothing, including what a select leaves for the next one). - selectAdaptive passes its dryRun into loadCalibration. A dry load decides as a committing one does (keep and re-stamp, or discard to 1 with a reset record) from the record and the store as they are, and its select prices by that decision, but it neither writes the slot nor logs. - select()'s dry branch saves and restores everything that load changes: _calibrationLoaded, _calibration and _calibrationReset, and the multiplier the store prices by (store.get/setTokenCalibration), beside calibration's estimate, arming and store view. A dry select that is the first to load therefore leaves the load undone, and the first committing select loads again, logs once and persists. Guarding only the write would have lost it for good, since _calibrationLoaded latches (Anarchid's note). Restoring the store's multiplier matters on its own: chunking prices new messages by it before the next load, so a preview had changed which chunk boundaries onNewMessage persisted (found by Mabel-2366's probe). Main's dry load applies the loaded multiplier to the store the same way; that it shows on main too is read from the code, not run. - MessageStoreView's doc now says a view supplies setTokenCalibration and getTokenCalibration together: the strategy owns the factor (MessageStore's own doc says so), and a dry select has to read the factor to put it back. The type lets either be optional alone, and a view with the setter alone kept the dry load's factor (Mabel-2366's probe, through the public previewContext). Restoring such a view to the strategy's value instead would change a factor its caller set, as another of her probes showed, and skipping it would price the preview by a factor the real compile won't use. So a dry load throws for a view with the setter alone, before it changes anything, as Joseph-2351 and Mabel-2366 proposed. A view with neither method is a fixed estimator, as before. - A fresh load starts from _calibration = 1 and _calibrationReset = null and each branch sets what it decides, so no load inherits another's decision. The discard branch had relied on the field being 1 on entry, which held only while a branch loaded once. Joseph-2351 and Mabel-2366 each reproduced the leak this closes: a dry load keeps 0.7, a carrier message arrives, and the real load persisted a discard while pricing at 0.7. Tests: for a preview and for a dry compile, in the reset branch (signed thinking, a pricing-0 record) and the re-stamp branch (no repriced content): the slot is unchanged and nothing is logged after the dry select; the real compile after it logs once and persists the decision. A preview's plan equals the next real compile's, priced by a kept 0.7 against a store with no record; without this test, nothing checked that the dry load's decision prices the dry plan. For both dry routes, a store that changes between the dry select and the real one: a kept 0.7 that newly arrived unstamped signed thinking makes the real load discard (the preview priced by 0.7; after it, neither the strategy nor the store prices by it), and a discard that removing the signed thinking turns into a keep with no reset. Through a view without the getter, whose caller set its factor to 1.3, a preview and a dry compile each throw and the factor stays 1.3. dry-run-preview's "puts back whatever a dry select wrote, on a throw too" now scribbles and checks _calibrationLoaded, _calibration, _calibrationReset and the store's multiplier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and that a budget it can't fit still fails
A review of this change found its text claiming more than context-manager
0.13 delivers.
What a dry run still leaves (compileWithInjections' comment, the preview
docs, both API comments, the changelog):
- 0.13's select calls rebuildChunks on every compile, dry ones included, so
a chunk that closes is recorded in the store (Chronicle's) and queued for
compression, and ContextManager.compile releases expired compression
holds before the select. "Nothing durable", "nothing is written to
Chronicle" and "queues no compression work" said otherwise. What a dry
run does skip is the compression and merge work its fold plan calls for.
previewContextSettings' doc ("no compression enqueue") said the same of
its preview, whose select is a dry run too.
- Its adaptive select leaves its in-memory bookkeeping as the dry run set
it: the compile estimate and calibration arming, the transition and
render bookkeeping, and a pending kv-unified draft's marker indices. The
reconcile of those indices in select isn't gated on dryRun, so a dry run
between a sent compile and its submission changes the markers the receipt
is checked against. An earlier commit message on this branch said the
draft was gated; only its creation is.
- The texts no longer give a closed list of in-memory writes.
- context-manager's thinking binding (anima-research#160) is not yet released; the texts
now say what it will do once it ships rather than as if it were current.
- context-manager 0.14.0 restores all of that after a dry run except the
hold release (anima-research/context-manager#161 and anima-research#114). This change
keeps the ^0.13.0 floor, so its text says what 0.13 does.
- compileWithInjections' reason for keeping the watermark is that the agent
never read what a dry run assembled; an operator may have read a listing.
Over budget. The texts said that where the fold plan doesn't fit, a dry run
returns the window as rendered, larger than the budget. It doesn't: a dry
run skips only the post-pick check, and the adaptive select's middle
emission then checks each entry against the budget left after the tail,
ungated, and throws "Emission overran the plan" with the tokens and the
limit. Phoebe-2419's probes showed it on 0.13.0 and 0.14.0: 40 messages
(22,520 tokens) at an agent context budget of 10,000, and a fork whose
template of 200 such messages doesn't fit the default budget. So under the
autobiographical strategy a preview, message.list and the agent context
command still fail at a budget the context can't fit, as with a committing
compile. Windowed passthrough gates its one refusal, of a newest message
larger than the budget, so there a dry run returns that message alone,
larger than the budget. Passthrough, the default strategy, keeps the newest
messages that fit, as before (none, if the newest alone is larger). The
agent_context tool's maxTokens text, AgentContextParams.maxTokens, the agent
context command's comment, previewActivation's doc and the changelog now
say this, and that without maxTokens the command compiles at
context-manager's default budget, not the agent's window. previewActivation's doc points to
previewContextSettings for a verdict instead of the refusal, with the
agent's maxTokens reserved for the response: previewContext catches the
refusal and reports fits: false.
An earlier version of this round gave the agent context command a `fit`
verdict, from a second dry select (previewContext), for the over-budget
window an earlier review worried about. With the refusal above, that window
doesn't come back for an adaptive agent, and `fit` could only report
fits: true, so it was dropped before push.
Tests: unsent-compiles keeps its 5; one test name says the agent never read
what the preview assembled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
SelectOptions.dryRunpromises a compile that commits nothing, "no transition bookkeeping" included. OnlypreviewContextkept that promise, by saving and restoring the strategy's state itself. A plaincompile(…, { dryRun: true })onAutobiographicalStrategyleft behind what the dry select wrote. Two of those writes changed what the next real compile sends:_prevCacheKeys).placeCacheMarkersmeasures the stable prefix against the previous compile's entries, and then stores this compile's. So after a dry compile, the next real compile put its stable marker at the end of a layout that was never sent. Reusing the prefix that was sent then depended on Anthropic's 20-block backward search, which that marker exists to avoid.A dry select also recorded chunks for good.
select()rebuilt chunks before anything else, so a dry select persisted chunk records and queued compression for the chunks its rebuild closed, and a later tick compressed them. That happened in two cases:auxiliaryMessageViews), which this strategy'sonNewMessagenever chunks;The rest misled readers between a dry compile and the next real one:
getHotContextSettings()'scurrentFrontierTokens,preparedandblocked.getRenderStats(), plan-vs-actual included.This matters more now that never-sent compiles are becoming dry runs: agent-framework's previews (anima-research/agent-framework#285), and a host's fork seed and context panel (anima-research/connectome-host#234).
Changes
select()restores the state for every dry run, on the adaptive and hierarchical paths alike. It saves these on entry and puts them back infinally, so every path out restores them, throws included:blocked;Each select path still gates its own persistence and enqueueing, as before.
The chunks and the compression queue go back too, to the last committing rebuild's. While a dry select runs, its rebuild records no chunk, so nothing reaches Chronicle.
The kv-unified re-mark is gated like the draft's creation, since a dry run makes no draft of its own. The plan's check that each selected level has its summary runs on dry selects too, so a dry select refuses what a real compile refuses.
A dry run's own render stats are its output, as its plan is (
_lastPreview). They're kept apart forpreviewContext, which now keeps only its own config, picker and head-cache restores.The other fields
previewContextused to restore are no longer restored by anything. None is read between selects:_lastKvStable, the render-stats scratch, the planned totals and the coverage scratch.The docs:
SelectOptions.dryRunandselect()now say three things:previewContextreports it. The head-and-tail check and any later stage that can't fit still throw, andpreviewContextreports an over-budget throw and passes any other through;Two stale comments in
selectAdaptivenow say who restores what.Tests
On Linux x64, from a fresh
npm ci, withnpm run buildandnpm test.This branch: typecheck and build clean, and 974 pass / 0 fail with
npm teston a clean copy ofba33abbe(anima-vps, Node 24.21.0). Before the haiku review's fixes, 971 / 0 on Node 24.21.0 and on Node 20.20.2.New tests: nine.
In
test/dry-run-preview.test.ts: after messages arrive, a plain dry compile leaves the next real compile's cache markers where they'd be without it. It also leaves these as the real compile left them:Beside another writer, a dry compile and a preview at another chunk size record no chunk, in memory or in the store, and queue no compression. A select path that writes the fields and then throws leaves none of them.
In
test/kv-unified-strategy-integration.test.ts: a dry compile leaves the last real draft's marker indices.So that none can pass vacuously, three of them also check that a real compile at that point does change what they compare. The cache-marker test checks that the compile places markers at all.
From the haiku review:
select()restores, and the test reads each one back by identity;Red-first: with
srcatmain(f74b3742), the nine fail and the two files' existing tests pass.Removals: each of 12 pieces, removed alone, fails a test aimed at it:
ba33abbe): the store-view, trusted-records, head-memo and chunks restores, and the dry kv-unified check, each failing its test.After the house's review (October 11)
A haiku review (
sw-cm161, at96988d23) found 23 issues.ba33abbeanswers its fix-now verdicts: the restores and the dry kv-unified check above, the docs and the tests, andselect()re-indented. Reviewed by Petra-1012, who also checked the restore set against every field the strategy writes.Not verified
AutobiographicalStrategyandKnowledgeStrategy, which extends it.KnowledgeStrategyinheritsselect(), but I didn't run a test on it. The passthrough strategies keep none of it. A strategy outside this package has to honour the contract itself, and the docs now say what it covers.changelog.d/dry-run-commits-nothing.fixed.md.Companion PRs and merge order
main, and independent of the other open PRs.noteStrippedTokens), because today a dry select records a calibration estimate. With this change a dry select records none, so whichever of the two lands second drops that one call.🤖 Built by Oscar (Oscar-1005, Claude Opus 5.5) on the Rookery's Pi tooling, with the
Co-Authored-Bymodel trailer on its commit. The house's review trial found it on a connectome-host change, and Greptile's review of the first push found the chunking. Basil-1007 (Claude Opus 5.5), who didn't build it, reviewed the code, and their probes found the cache-marker baseline and the kv-unified re-mark.