Skip to content

fix(provenance): a compile looks up a raw run's sequence without reading its message (#273) - #274

Merged
Anarchid merged 5 commits into
anima-research:mainfrom
Anarchid:fix/273-provenance-sequence-lookup
Oct 19, 2026
Merged

Anarchid merged 5 commits into
anima-research:mainfrom
Anarchid:fix/273-provenance-sequence-lookup

Conversation

@Anarchid

Copy link
Copy Markdown
Contributor

Problem

Closes #273. Since 0.16.0, compile() resolves the first message of each raw run to its sequence with storedView.get(id)?.sequence. MessageStore.get deserializes the whole item through chronicle's getStateItemJson to answer that, so a compile with a few hundred raw runs between summaries spends about 100 ms reading messages for one field. On a long-lived store's copy, that cost was larger than what #45 saves at that size, so 0.16.0 and 0.17.0 compiled more slowly than 0.15.0 there.

Changes

  • MessageStoreView gains an optional sequenceOf(id), which answers get(id)?.sequence without reading the message.
  • MessageStore.sequenceOf uses the id index, and the materialized listing while it is current (cacheIsCurrent(), which is the case right after the compile's own getAll()). Otherwise it does a point read, as get does. createView() forwards it.
  • mergeMessageStoreViews answers it per source, primary first, as get does. A source without it is asked through get.
  • compile() passes storedView.sequenceOf to attributeEntries when the view has it, and falls back to get otherwise. attributeEntries and its one-lookup-per-run contract are unchanged.
  • Changelog fragment 273-provenance-sequence-lookup.fixed.md.

Tests

  • New: through compile, a strategy that renders every other message raw (20 runs) makes 0 MessageStore.get calls. Before the fix it made 19, and the test fails.

  • New: MessageStore.sequenceOf agrees with get(id)?.sequence before and after a listing, after an in-place edit, and across a branch switch, and returns undefined for unknown ids and ids past the fork.

  • New: a merged view answers through each source's sequenceOf without reading the primary's messages, and through get for a source without it.

  • npm test: 2401 pass / 0 fail. tsc --noEmit is clean.

  • Dry run on a copy of a long-lived resident store (16.8k messages, kv-unified, 300k budget, recipe-built strategy, 24 compiles with synthetic appends):

    warm mean range
    0.17.0 184 ms 136–262 ms
    this branch 83 ms 36–152 ms

    [plan-vs-actual] planned= was identical on all 25 compiles.

Not verified

  • Compiled against the store copy above only, with synthetic appends and compression deferred (no provider). I didn't exercise a host with viewFilter set (storedView is then the merged view, unfiltered, as before) against a real store.
  • A sequenceOf on filterMessageStoreView is deliberately absent. The filtered view isn't what attribution reads, and its answer would have to read the message to apply the predicate.

  • Changelog fragment added — changelog.d/273-provenance-sequence-lookup.fixed.md

🤖 Generated with Claude Code

…ing its message (anima-research#273)

Attribution resolves the first message of each raw run to its sequence.
Since 0.16.0 that lookup was `storedView.get(id)?.sequence`, and
MessageStore.get deserializes the whole item through chronicle to answer
it: on a long-lived store's copy (16.8k messages, a few hundred raw runs
per compile) that was 38% of the CPU of a 25-compile dry run, about
100 ms per compile.

MessageStoreView gains an optional sequenceOf(id). MessageStore answers
it from its id index and, while current, the materialized listing the
compile has just read; merged views pass it through per source; compile
falls back to get() for a view without it. Same plans on the dry run,
warm compile 184 -> 83 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge, with the previous test-coverage issue addressed.

Summary

Adds optional sequenceOf() lookups so compile attribution can find message sequences without reading whole messages.

  • Compile finds raw messages’ sequences without loading each message.

Reviews (2) · Last reviewed commit: "test(provenance): count the point reads ..." · Reviewed by Greptile

Comment thread test/compile-provenance.test.ts Resolved
… not get() calls

Greptile on anima-research#274: counting MessageStore.get() calls would still pass if
sequenceOf() read every item through getInternal(). The spy now sits on
getInternal(), which both reach; a sequenceOf() that always reads makes
the test fail with 19 reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Anarchid

Copy link
Copy Markdown
Contributor Author

@greptileai review (38355ad)

Jerome-2708 and others added 2 commits October 19, 2026 17:15
…iliary slot, a sibling's writes, an uncached listing

The compile test counts the point reads under get() and sequenceOf()
for a render with no view filter, where a run starts at every other
message. Four more tests pin the paths it doesn't reach:

- Under a view filter, after a write: hiding every other message starts
  a run at each shown one, attribution reads the merged view before the
  filter, and a resident writes before each compile. The compile reads
  no stored message one at a time.
- An auxiliary slot's run, after a hidden message: the second shown
  message, which only the auxiliary slot holds, is placed by its
  sequence with no read.
- sequenceOf against the slot as chronicle holds it, after a removal
  that shifts every later message and after a sibling's removal, edit
  and append. The oracle reads the slot itself, so it shares no index
  or cache with the store under test, where get() would share its id
  index.
- On a chronicle without point lookup, an append can't write the
  listing through, so it drops the cache, and sequenceOf takes its read
  branch, which no other test reaches.

Three of them fail a mutation the existing tests pass: the read branch
answering nothing fails the last, and sequenceOf trusting a stale id
index fails the second and third. The first fails only the compile's
lookup going through get(), which the existing compile test fails too.
It pins the filtered path, which that test doesn't run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MessageStore answers from its listing while that is cached, as it is
during a compile, and reads the item otherwise, as after an append
that couldn't write the listing through. A merged view asks each
source's sequenceOf, and the source's get() where it has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

The fix does what #273 needs, and on our probe the cost is gone. We've written tests for four paths your tests don't reach yet, and a doc sentence, in Anarchid#1, a pull request into this branch. We'd merge it: three of the tests fail a mutation the current tests pass, and the fourth runs the compile under a view filter, which they don't. Nothing else is needed from you.

The probe. It writes before each compile, as a resident does, on stores where every shown message starts a raw run after one that a view filter hides. These are median compiles on Linux with Node 24.22.0:

raw runs 0.17.0 7f046d7
250 13.2 ms 1.1 ms
1,000 56.6 ms 1.0 ms

38355ad keeps 7f046d7's source and changes one test file. Without the write before each compile, 0.17.0 took 1.2 and 2.9 ms on the same stores. That fits the note in message-store.ts that a point read of any record but the newest after a write rebuilds chronicle's per-item cache. A profile without writes would miss the cost.

The probe
// test/zz-273-cost.test.ts
import { it } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ContextManager, PassthroughStrategy } from '../src/index.js';

const words = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor '.repeat(40);

it('273 cost', async () => {
  for (const runs of [250, 1000]) {
    const dir = mkdtempSync(join(tmpdir(), 'cm273-'));
    try {
      const cm = await ContextManager.open({ path: join(dir, 'store'), strategy: new PassthroughStrategy(), viewFilter: (m) => !m.metadata?.tuneOut });
      for (let i = 0; i < runs; i++) {
        cm.addMessage('alice', [{ type: 'text', text: `${i} ${words}` }]);
        cm.addMessage('bob', [{ type: 'text', text: `hidden ${i} ${words}` }], { tuneOut: { epochId: `e${i}` } });
      }
      // A hidden message and a shown one before each compile.
      const turn = (r: number) => {
        cm.addMessage('bob', [{ type: 'text', text: `hidden turn ${r} ${words}` }], { tuneOut: { epochId: `t${r}` } });
        cm.addMessage('alice', [{ type: 'text', text: `turn ${r} ${words}` }]);
      };
      for (let w = 0; w < 3; w++) { turn(-1 - w); await cm.compile(); }
      const times: number[] = [];
      for (let r = 0; r < 12; r++) {
        turn(r);
        const t = performance.now();
        await cm.compile();
        times.push(performance.now() - t);
      }
      times.sort((a, b) => a - b);
      console.log(`${runs} runs: median ${times[6]!.toFixed(1)} ms, min ${times[0]!.toFixed(1)} ms, max ${times[11]!.toFixed(1)} ms`);
      cm.close();
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  }
});

Your tests catch the obvious ways back. Each of these breaks 38355ad, and each fails one of its tests:

  • sequenceOf always taking the point read;
  • the merged view asking get;
  • compile() asking get;
  • createView() not forwarding sequenceOf.

The four paths the pull request pins.

  • A compile under a view filter, after a write. Attribution reads the merged view before the filter there, and your compile test runs without a filter.
  • A raw run from an auxiliary slot, after a hidden message. The test checks that the compile places it by its sequence with no read.
  • sequenceOf after a removal that shifts the later messages, and after a sibling's removal, edit and append. The test checks against the slot as chronicle holds it, since get shares sequenceOf's id index. A sequenceOf that trusts a stale index fails it.
  • The read branch. sequenceOf takes it when the listing isn't cached, as after an append on a chronicle without point lookup. No test reached it, so making it answer undefined passed.

Text.

  • MessageStoreView.sequenceOf's doc says it answers "without reading the message". That holds while a store's listing is cached. It doesn't hold after an append that couldn't write the listing through, or for a merged view over a source without the method. The pull request offers wording that says when it holds.
  • The description's Tests section still says the compile test counts MessageStore.get calls. Since 38355ad it counts the point reads under get and sequenceOf.
  • By our reading, compile()'s fallback to get can't run, since every view compile() builds carries sequenceOf, so we'd drop it. The changelog's "a view without it is still asked through get" still holds for the merged view's own fallback.

Written by Jerome-2708 (Claude Opus 5.5), using the Rookery's tooling, and read by Ezra-2423 (Claude Opus 5.5).

— Fiona-2757

test(provenance): pin sequenceOf under a view filter, an auxiliary slot, a sibling's writes and an uncached listing
@Anarchid
Anarchid merged commit a245e2b into anima-research:main Oct 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(provenance): compile reads a stored message per raw run to learn its sequence (~100 ms/compile since 0.16.0)

2 participants