Skip to content

perf(core): hash each context block once per turn, not once per step#732

Draft
macanderson wants to merge 1 commit into
mainfrom
perf/receipts-hash-memoization
Draft

perf(core): hash each context block once per turn, not once per step#732
macanderson wants to merge 1 commit into
mainfrom
perf/receipts-hash-memoization

Conversation

@macanderson

Copy link
Copy Markdown
Owner

What & why

The last named follow-up from #725. Each tool result was JSON-serialized and SHA-256'd twice per step, and each block's content hashed twice more — all unconditionally, all over the whole transcript:

  • decompose derived every block's block_id and content_digest on every step. Those hash overlapping preimages (kind_tag ‖ NUL ‖ content and content) and so cannot share a hash state. Add a full character count for token_cost, and a serde_json::to_string of every tool call and tool result just to obtain the preimage.
  • snapshot_result_identities derived every uncompacted tool result's identity on every step — another serialization plus another hash, again transcript-wide.

The manifest has to name every block each step. The bytes behind an unchanged block do not change, so naming it again is the only part that has to repeat. Both are now memoized by (message_index, ordinal).

A position is a stable name for bytes only while nothing rewrites what is already there, so TranscriptRevision counts in-place rewrites as distinct from appends. It is bumped where the mutation happens — compaction's pure passes, and the overflow summarizer. The summarizer bumps whenever it runs rather than only when it splices: over-invalidating costs one turn's memo on a rare path, under-invalidating serves a digest for bytes that moved.

run_compaction_pass reports that through a #[must_use] CompactionPass rather than a &mut out-param — it was already at clippy's argument limit, and making the return impossible to discard turns "remember to invalidate" from a convention into a compile error.

Also drops a copy nothing needed: BlockDraft::content was an owned String for gap kinds, so the system prefix — the largest gap block there is — was re-cloned on every step to hand to a BlockRegistered that had already fired. It borrows now, and copies at the one site that sends it.

Refs #546

The witness

  • This PR includes a witness test (fails on main, passes here)

Measured at the single choke point every hash in the module funnels through. Doubling a turn's steps takes hashing from 24 → 44 passes (linear); before it was 64 → 188 (superlinear). An 8-step turn drops from 188 hashes to 44, and the gap widens with turn length.

Five witnesses, each verified to fail when the thing it guards is removed — which mattered, because two of them did not at first:

test control that must break it
per_step_hashing_grows_with_the_turn_not_with_its_square memo disabled → ratio 2.94 vs 1.83 ✗
a_rewritten_block_is_never_served_from_the_digest_memo ledger invalidation disabled → cites a pre-rewrite id ✗
compaction_mid_turn_invalidates_the_receipt_ledgers_digest_memo driver's revision.rewritten() deleted ✗
every_rewrite_compaction_performs_is_recognized_as_compacted is_compacted_output stops recognizing aging ✗
a_step_walks_the_transcript_to_estimate_it_at_most_twice (#725) still holds

The gate

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace — 61 test binaries, 0 failures
  • make check green; no baseline moves (driver.rs went 2256 → 2078, below its ceiling)
  • Doc comments updated where the reasoning changed

Ground-rule check

  • No I/O added to stella-core; no new deps
  • No new outbound network calls

Anything reviewers should know?

Two of my five tests were wrong in ways that would have shipped the bug. Both are worth knowing about because they're the failure mode this whole series is about — a green suite that proves nothing.

  1. My first version of the wiring test asserted "no manifest cites an unregistered block". A stale id is still a registered id, so it passed even with the driver's invalidation signal deleted. The memo could have shipped serving stale digests on every compacting turn with the suite green. It now drives a real turn over a tight budget and compares manifests against rewrites point-in-time, walking the event stream in order — comparing against the turn's whole eviction set instead flags a block for being cited on the step that produced it and evicted on the next, which is correct behavior.
  2. My first version of the invariant test evicted its aged results, so the aging elision marker never survived to be checked and it passed with or without the branch it was guarding. It now ages without evicting.

One claim I am deliberately not making. Clearing the identity memo is belt-and-braces, not load-bearing, and disabling it breaks no test. That is not missing coverage: every rewrite leaves content is_compacted_output recognizes (three stubs, or the aging marker), and the sweep skips a recognized result before deriving anything — so a stale entry there is unobservable today. Rather than let that look like a gap, the cross-module invariant it depends on is pinned by its own test, and the reasoning is documented at the memo. A future pass that rewrote a result into something unrecognized would make it start serving pre-rewrite identities; the clearing is what keeps that a caught bug instead of a silent one.

Still not done, and conditional rather than per-step: compaction::compact recomputes every tool result's pre-mutation block_id when it runs. Sharing the ledger's memo would couple a pure module to receipt state, and it only fires on an over-budget step, so it stays.

Structural churn is the file-size gate, not preference. The driver's loop-detection evidence (ResultIdentities, snapshot_result_identities, recent_call_records, comparable_output, call_identity_key) moves to driver/loop_evidence.rs — the gate asking for a module instead of a raised baseline. It takes driver.rs from 2256 to 2078 lines, so unlike #725 this PR moves no baseline at all.

Process note: this is rebased onto 006fec3e (#729), which landed while I was working. Worth a look that the loop-evidence extraction and #729's retrieval-plane restructuring don't overlap — they shouldn't, this PR touches only stella-core.

The last named follow-up from #725. Each tool result was JSON-serialized and
SHA-256'd twice per step and each block's content hashed twice more, all
unconditionally, all over the whole transcript:

- `decompose` derived every block's `block_id` AND `content_digest` on every
  step — two SHA-256 passes over overlapping preimages (`kind_tag ‖ NUL ‖
  content` and `content`), which cannot share a hash state — plus a full
  character count for `token_cost`, plus a `serde_json::to_string` of every tool
  call and tool result just to obtain the preimage.
- `snapshot_result_identities` derived every uncompacted tool result's identity
  on every step, each one a serialization plus a hash.

The manifest has to NAME every block each step; the bytes behind an unchanged
block do not change, so naming it again is the only part that has to repeat.
Both are now memoized by `(message_index, ordinal)`.

A position is a stable name for bytes only while nothing rewrites what is
already there, so `TranscriptRevision` counts in-place rewrites as distinct from
appends. It is bumped where the mutation happens — compaction's pure passes, and
the overflow summarizer, which is bumped whenever it RUNS rather than only when
it splices, because over-invalidating costs one turn's memo on a rare path while
under-invalidating serves a digest for bytes that moved.

`run_compaction_pass` reports that through a `#[must_use] CompactionPass` rather
than a `&mut` out-param: it was already at clippy's argument limit, and making
the return impossible to discard turns "remember to invalidate" from a
convention into a compile error.

Also drops a copy nothing needed: `BlockDraft::content` was an owned `String`
for gap kinds, so the system prefix — the largest gap block there is — was
re-cloned on every step to hand to a `BlockRegistered` that had already fired.
It borrows now, and copies at the one site that sends it.

Measured, with a counter at the single choke point every hash in the module
funnels through. Doubling a turn's steps takes hashing from 24 to 44 passes
(linear); before it was 64 to 188 (superlinear). An 8-step turn drops from 188
hashes to 44, and the gap widens with turn length.

Five witnesses, each verified to FAIL when the thing it guards is removed —
which mattered, because two of them did not at first:

- `per_step_hashing_grows_with_the_turn_not_with_its_square` — fails with the
  memo disabled (ratio 2.94 vs 1.83).
- `a_rewritten_block_is_never_served_from_the_digest_memo` — the ledger's
  contract; fails with invalidation disabled.
- `compaction_mid_turn_invalidates_the_receipt_ledgers_digest_memo` — the
  WIRING. My first attempt at this asserted the wrong thing and passed even with
  the driver's signal deleted, meaning the memo could have shipped serving stale
  digests on every compacting turn with a green suite. It now drives a real turn
  over a tight budget and fails when `revision.rewritten()` is removed.
- `every_rewrite_compaction_performs_is_recognized_as_compacted` — pins the
  cross-module invariant the identity memo's soundness actually rests on (see
  below). Also got this wrong first: the fixture evicted its aged results, so the
  elision marker never survived to be checked and the test passed either way. It
  now ages without evicting, and fails if `is_compacted_output` stops recognizing
  aging.
- `a_step_walks_the_transcript_to_estimate_it_at_most_twice` (from #725) still
  holds.

Stated rather than glossed: clearing the identity memo is belt-and-braces, not
load-bearing. Every rewrite leaves content `is_compacted_output` recognizes, and
the sweep skips a recognized result before deriving anything, so a stale entry
there is unobservable today. That is why disabling its clearing breaks no test,
and it is documented at the memo alongside the invariant test, rather than left
to look like missing coverage.

Structural: the driver's loop-detection evidence (`ResultIdentities`,
`snapshot_result_identities`, `recent_call_records`, `comparable_output`,
`call_identity_key`) moves to `driver/loop_evidence.rs`. That is the file-size
gate asking for a module instead of a raised baseline, and it takes driver.rs
from 2256 to 2078 lines — below its recorded ceiling, so no baseline moves.

Still not done, and conditional rather than per-step: `compaction::compact`
recomputes every tool result's pre-mutation `block_id` when it runs. Sharing the
ledger's memo would couple a pure module to receipt state, and it only fires on
an over-budget step, so it stays as it is.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Jul 26, 2026 8:34pm

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.

1 participant