Skip to content

feat(backends/memory): add the memory backend and its eval harness - #583

Merged
lIang70 merged 35 commits into
mainfrom
feat/backends-memory-eval
Oct 8, 2026
Merged

lIang70 merged 35 commits into
mainfrom
feat/backends-memory-eval

Conversation

@lIang70

@lIang70 lIang70 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds backends/memory — the FlowCraft memory backend — plus backends/memory/eval, its own
module, which owns every measurement the backend has.

backends/memory assembles the memory.Assembly contract: message/document sources, the chat
and knowledge lines, fact/summary/document views, BM25 + entity + vector projections, fusion,
hydration, packing, maintenance and verification, over the workspace, sqlite and postgres
storage drivers.

backends/memory/eval is a separate Go module, so the backend itself never pulls a provider
driver. It carries the LoCoMo and LongMemEval loaders, the run harness (-answer, -prepare-all,
-resume, -answer-concurrency, -answer-style, -judge-style, -images), the official LoCoMo
token-F1 scorer, and the credentialed live probes.

Wiring. go.work gains both modules; the Makefile discovers nested backend modules and exposes
make memory-eval-probes; CI discovers nested go.mods and gains test-memory-postgres (real
Postgres 16, -race) and test-memory-windows lanes, both feeding ci-pass. make ci stays
offline: every credentialed probe self-skips without MEMORY_EVAL_LIVE=1.

Derivation generations and retention

Everything derived from a conversation belongs to a generation whose identity is the policy
digest, and replaced generations stay stored — that is what makes a rollback possible, and it is why
retention exists. Assembly.RetireGenerations(ctx, scope, conversationID, keep...) is the sweep: it
drops every generation a policy no longer keeps, across the facts, the summary bookmarks, the
projection lanes, and the derivation progress that would otherwise resume in the middle of them, in
one call.

The sweep is ordered, and now guarded, because each derived view addresses its stored state
differently:

  • The progress goes with the generation. A watermark is where the next pass under that policy
    resumes, so a generation whose facts are retired while its cursor stays at the end of the stream is
    one a rollback resumes in the middle of: it derives the tail, publishes a generation missing the
    rest, and the commits the cursor passed are never derived again. In the fixture that reproduced it,
    the sweep retired RetireResult{Facts:1, Summaries:1, LaneEntries:1}, the rollback derived 1 commit
    and the conversation served 1 fact of the 2 it had. RetireResult.Watermarks reports the cursors
    the sweep retired, and they go before anything else: the cursor is the one thing a sweep removes
    that is not derived state, so an interruption leaves facts without a cursor — which re-deriving
    reproduces, since the derived addresses are content derived — rather than the state the sweep
    exists to prevent, facts gone under a cursor that still points past them. The published
    generation's cursor is refused (Processor.RetireProgress): an ordinary pass under a policy whose
    generation is visible would derive the stream a second time, and what a re-derivation produces is
    the model's, so the visible generation would grow beside itself instead of converging. A sweep
    never names it.

  • The lanes go next. A lane entry is addressed by the id of the fact it projects, and the
    converge walks the stored generations, so once a generation's facts are retired those addresses
    can never be enumerated again: the lane keeps offering a candidate the read path cannot hydrate,
    and Verify cannot see it — it replays the projection chain, which recomputes the digests the
    leaked entries were chained with. The generation that reaches that state is one whose pass failed
    after projecting.

  • The bookmarks go before the facts, so an interrupted sweep leaves unreachable facts, which the
    next sweep collects, instead of a bookmark to facts that no longer exist.

  • Derivation has to be quiesced, and the sweep enforces it. It refuses while a pass over the
    conversation is running, or while this worker has derived its own generation without publishing
    it, and returns worker.ErrDerivationUnsettled; Assembly.DerivationState reports the same state
    before the fact. A pass publishes the generation it owns whether or not the sweep emptied it, and
    it resumes after its own watermark, so a sweep beside it costs the commits that generation had
    already covered — in the fixture that reproduces it, a sweep that retired the generation the
    worker owed (Facts:1, LaneEntries:1) left the republished generation serving 1 of the 2 facts
    the conversation had derived. Both refusals are retryable and both mean the sweep retired nothing:
    derive again (RunOnce is synchronous) and sweep after the pass.

  • A rollback into a swept generation derives it again. Retirement takes the generation's cursor
    with it, so the pass starts at the head of the stream instead of after the commits whose facts the
    sweep removed, and compaction publishes the manifest for the generation it summarises, so the
    summaries come back with the facts rather than leaving the branch where the sweep left it.

  • A pass that derives nothing still moves the views. The summary branch is republished with the
    generation the pass is about to publish, so a rollback — which finds its watermark already at the
    end of the stream — serves its own summaries rather than the replaced generation's.

  • Documents are not generation scoped, so they are not swept with one. A chunk id comes from the
    document's provenance and text, not from the policy that derived it, so deriving a document again
    under an earlier policy reproduces the build it published then — there is nothing a rollback would
    need to find. What a document does leave behind is a superseded build: a new revision, a changed
    chunking, or a publish that failed after writing its chunks and before moving the pointer. Reads
    resolve only the active build, and the per-conversation sweep never touches the view, so the
    maintenance pass reclaims the superseded builds at scope granularity instead — Assembly.Maintain
    reports Plan.ReclaimedChunks and keeps the active build of every document of the scope. In the
    fixture, two revisions of one document left 4 superseded chunk records: the first pass removed all
    4, the second removed none, and the active build still served.

The full contract, including what a sweep keeps when the views disagree and why per-view sweeps stay
available for tooling, is in backends/memory/README.md.

Measurements

LoCoMo, 1540 questions, categories 1–4. Category 5 (adversarial) is excluded by design and printed
as skipped_adversarial=446 on every run. Config: -samples 10 -max-items 30 -max-tokens 6144 -images native -answer -answer-style short -prepare-all -answer-concurrency 4.

metric value
official token-F1 (categories 1 / 2 / 3 / 4) 59.85% (43.6 / 63.7 / 36.4 / 66.5)
questions whose full evidence was recalled 1380/1540 = 89.6%
turn-level evidence recall 2113/2354 = 89.8%
answer accepted by containment grading (no judge model) 544/1540 = 35.3%
strict judge, long-answer configuration 78.4%
locomo judge, long-answer configuration 90.1%
retrieval latency 535 ms per question (10.4 s before)
full run, end to end ~29 min

The two judge rows are a different protocol from everything else in this
table and from the answer-model comparison below: they graded the long-answer
prompt (answer-policy-v2) with judge-v1, while the short-answer runs use
answer-short-v1 with judge-v2. A judge scores terse answers harder (there is
less text for it to accept), and judge-v2 records an ungraded verdict instead
of guessing, so 78.4%/90.1% and 67.8%/85.9% are not comparable numbers -- only
the official token-F1 is, and it is 59.85% in both.

Containment grading is a string-containment grader: it accepts an answer only when
the gold text appears in it. A paraphrasing model scores low on it without being
wrong (Counseling and mental health vs a gold Counseling and mental health is
accepted; Counseling or mental health. is not), which is why the cross-model
number below is the token-F1 one.

Answer-model comparison, one frozen derivation

-skip-derive answers from the derivation the workspace already holds, so both
runs retrieve the identical context and a difference is the answering model's.
Both arms answer short and grade themselves with -judge-style both:

answering + judging model official token-F1 (1 / 2 / 3 / 4) strict judge locomo judge evidence recall wall
deepseek/deepseek-flash 59.85% (42.5 / 63.9 / 35.0 / 67.0) 67.79% 85.91% 2107/2354 = 89.5% 24m47s
openai/gpt-4o-mini 51.32% (38.4 / 60.3 / 15.4 / 56.3) 56.17% 82.08% 2102/2354 = 89.3% 17m54s

Evidence recall differs by 0.2pp, so the memory and the recalled context are the
same in both arms and the gap belongs to the answering model: 8.5pp on the
official metric, 11.6pp on the strict judge, concentrated in category 1 and
category 3. gpt-4o-mini answers ~28% faster and emits far fewer output tokens
(15k against 971k for the answer pass), which is the trade it offers.

An earlier pass on the previous derivation (before chat.AlgorithmVersion
1.3.0) measured the same ordering: 60.02% for deepseek-flash answer-only
against 52.56% for gpt-4o-mini, both with containment grading instead of a
judge.

Reranking ships off: after the tail-scoring fix, the A/B moved strict by +0.7pp and lenient by
−1.1pp, both inside the noise band the README documents (±1 question, <1pp — same-config reruns of
the full set differ by 2 questions). retrieval.decompose was removed: it measured net negative.
The context budget was not relaxed either; 45 items was measured and rejected.

Model configuration (eval/deploy.yaml)

  • deepseek (deepseek-flash) extracts facts; deepseek/deepseek-v4-pro is the other model the
    endpoint serves, and any other name is silently substituted — TestConfiguredModelsExistInCatalog
    now fails a run that names one.
  • bytedance (doubao-embedding-vision) embeds: one text/image item per request, pinned to the
    dated snapshot in the profile.
  • openai (gpt-4o-mini) is declared for alternative answer runs. It is multimodal, so its
    capabilities declare image input; it rides the responses surface (the chat surface rejects the
    json_schema format the fact extractor asks for) and states wire.store: false explicitly.
    The same live test now checks the OpenAI catalog too and fails on a model whose shutdown_date
    is set, rather than discovering a retirement mid-run.

Reproduce

cd backends/memory/eval
cp .env.example ../../../.env   # DEEPSEEK_API_KEY, ARK_API_KEY (OPENAI_API_KEY for the openai provider)
go run ./cmd/memory-eval -deploy deploy.yaml -env-file ../../../.env \
  -samples 10 -max-items 30 -max-tokens 6144 -images native \
  -answer -answer-style short -prepare-all -answer-concurrency 4 \
  -judge-style none -out /tmp/locomo10.json
MEMORY_EVAL_REPORT=/tmp/locomo10.json go test ./official/ -run TestScoreStoredReport -v

@lIang70 lIang70 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Posting my own review notes on this PR so reviewers have the full list up front.
I went back through the new modules (storage drivers, sources, lines, views,
retrieval, worker, eval harness) and did targeted checks against the code.
Quick summary: 1 privacy bug and 1 correctness bug against a stated invariant
(items 1-2), 2 driver-contract inconsistencies (3-4), then a batch of medium
items and nits. Details below; everything is verifiable at commit 51131d23
(the current head).

One note up front: I did not re-run the live eval lanes in this pass (they need
MEMORY_EVAL_LIVE=1 plus provider keys), so the reported measurements are not
reproduced here - this is code review only. And for a diff this size I focused
on the new modules; the Makefile/go.work/CI wiring I only skimmed.

The areas I am most confident in are the durability semantics (log-then-KV,
publish-forward/rollback on crash, idempotent commit replay), the shared
conformance suite, content-addressed ids, and the retrieve/pack budget
discipline. The items below are what I want resolved or consciously accepted
before merge.

Should fix

1. retrieval/provider.go:722-742 - the source-quote cache is keyed without
scope, so quotes can leak across scopes.

quoteText caches message text in provider.quoteTexts under sourceID only
(the id is conversationID + "/" + messageID, built at provider.go:646 and
worker/processor.go:686). The cached value is read under the request's scope
(provider.Messages.Get(ctx, scope, ...) at provider.go:732), but the key
omits it, and one Provider is shared by every scope an assembly serves. If two
scopes ever use the same conversation id (caller-supplied; nothing enforces
cross-scope uniqueness), the second scope's facts get the first scope's raw
message text folded into their content as "Source turn: ...". Conversation ids
like conv-1 are exactly the value that collides across tenants. The flag is
off by default but on in the measurement config (eval/deploy.yaml:153 sets
source_quotes: 3). Fix: include scope.HardPartitionKey()
(core/memory/scope.go:41-43, \x00 separator) in the key, and consider
bounding the map - it is process-lifetime and unbounded today. The existing test
only exercises one scope (source_quotes_test.go).

2. retrieval/pack/pack.go:165-183 - the newest recent turn can still be
dropped, contrary to the stated invariant.

provider.go:292-296 truncates the newest turn to recentTokenLimit "so the
packer can still admit it", and context_recent.go:101 says "The newest turn is
bounded, never dropped". But within the recent class, items are admitted
oldest-first: prepared is sorted by lessItem, which orders recent items by
ascending Sequence (pack.go:296+), and the first pass walks that order
(pack.go:165-172). The newest item is admitted last, and the lending pass only
adds - it never rebalances (pack.go:175-183). Concrete case with
budget.max_tokens = 100: older recent turns m1 = 55 tokens, m2 = 30 tokens get
admitted (m1 in the class pass, m2 via lending); the newest turn, truncated to
min(recent lane, 100), then fails both the recent class cap (55 + 100 > 60)
and the global cap (85 + 100 > 100) and is dropped. The existing
TestProviderBoundsOversizedNewestTurn pins only the single-turn case; adding
one older sibling turn makes the newest disappear. Fix options: reserve a slot
and budget for the newest item, or select the recent class in descending
sequence and rely on the final re-sort (pack.go:187-190) to restore reading
order - plus extend that test with an older turn.

3. storage/sqlite/store.go:301-326 and storage/postgres/store.go:286-311 -
ListStreams("") returns nothing on SQL drivers, everything on the workspace
driver.

With an empty prefix, likePrefix("") yields "/%" (sqlite :641-644,
postgres :616-619), which matches no stream, so the post-filter
if prefix == "" || ... (sqlite :321, postgres :306) is dead code - it is
written as if "" meant "all streams", and the workspace adapter implements
exactly that (workspace_log.go:246+ walks from the log root; pinned by
workspace_log_test.go:159-161). The interface comment (storage/log.go:62-65)
does not pin the empty-prefix case and the shared conformance suite has no
empty-prefix subtest (internal/storagetest/storagetest.go:28-30), so the
drivers disagree silently. The only production caller uses a non-empty
partition prefix (sources/message/store_impl.go:245), which is why it has not
bitten yet. Pick the contract ("" = all streams looks intended), make the
SQL drivers honor it, and add a conformance subtest.

4. storage_drivers.go:54-56,86 - omitting schema in a Postgres config opens
two pools where one is promised.

postgresKey builds DSN + "\x00" + config.Schema from the raw config, but
postgresstore.Open normalizes an empty schema to DefaultSchema, and
DSNKey() returns the normalized pair (storage/postgres/store.go:45-49,86-90).
So for a config without schema, the dedupe check at storage_drivers.go:86
can never match and log + KV get separate pools and two migrations -
contradicting README.md:102 ("KV sharing one DSN+schema share one pool").
Fix: compare against the normalized key (e.g. default the schema in
postgresKey via postgresstore.DefaultSchema).

Worth a decision / lower priority

5. eval/official/f1.go:14-35 - the scorer's normalization is not identical to
the reference harness.

The reference (snap-research/locomo, task_eval/evaluation.py) strips commas,
then deletes ASCII punctuation membership-style
("".join(ch for ch in text if ch not in string.punctuation)), then stems with
NLTK's PorterStemmer. This implementation replaces every Unicode
punctuation/symbol rune with a space (f1.go:18-24) and stems with
reiver/go-porterstemmer. Observable differences: Caroline's -> reference
carolines, ours caroline s; gpt-4o -> gpt4o vs gpt 4o; 1,000 ->
1000 vs 1 000; non-ASCII punctuation (curly quotes, dashes) is kept by the
reference but split into separate tokens here. Since the package exists so numbers
can sit "next to published ones" (report_test.go:16-18), I would either align
both normalization details exactly (comma strip + ASCII-only deletion) and
cross-check the stemmer on a small fixture of tricky strings, or soften the
"same ruler" wording and document the accepted divergence.

6. eval/cmd/memory-eval/main.go:193-225 - -skip-derive does not fingerprint
the frozen memory it reads.

The fingerprint pins mode (derive=reused), deploy document, models and
budgets, but not which derivation the workspace holds. The README pitches the
mode as "compare two answering models against one frozen memory"
(eval/README.md:149-157); nothing stops -resume/-baseline from mixing runs
whose "frozen" memory actually changed between them (another derive pass, a
Maintain run, a workspace swap). Cheap fix: fold a workspace-state digest into
the fingerprint (watermarks + record counters from Diagnostics, or a hash over
the facts/summary projection manifests).

7. factory.go:171-174 vs :230-233 - swapping in a deriver via WithDeriver
does not change the policy digest.

policyDigest covers settings only (policyInput, :507-537), while
buildDeriver returns the caller's deriver unconditionally (:230-233).
Watermarks are keyed by that digest (worker/checkpoint.go:31, used from
worker/processor.go:310,351), so after a host replaces the deriver, existing
scopes are considered up to date and are never re-derived. Either let
WithDeriver carry a policy version that folds into the digest, or document the
re-derive procedure.

8. lines/chat/fact_extractor.go:665-671 - a missing timestamp becomes Unix
epoch, not "unknown".

artifactEventTime returns time.Unix(0, 0) when the artifact carries no
event_time, and withinFiveMinutes only guards IsZero (:726-732), so
epoch == epoch makes untimestamped facts look "within 5 minutes" of each other;
maintain.recencyKey (maintain/plan.go:166-170) also prefers the epoch over
CreatedAt, landing those facts at the decay floor. The extractor prompt says
the opposite: "If no timestamp is available anywhere, omit the date rather than
fabricating one" (prompts/fact_system.tmpl:15-17). The built-in worker always
sets event_time (worker/processor.go:620), so this is reachable through the
public Deriver contract with host-supplied artifacts. Fix: return a zero
time.Time / omit the metadata key and let consumers default it.

9. sources/importer/importer.go:134 - AllowLoopback: true disables the
whole address guard.

The flag's documentation says "permits loopback targets (tests and local
development)" (:28-29), but the condition at :134 skips blockedAddress
entirely, unlocking RFC1918, CGNAT, link-local (including 169.254.169.254
metadata) and reserved ranges for a caller that only wanted local dev. Only
tests use it today - good moment to split the policy (AllowLoopback vs an
explicit AllowPrivateNetworks), or at minimum keep metadata/link-local blocked
unconditionally.

10. assembly.go:102-183 - small lifecycle gaps.
cancel/done are written inside Wire and read in Close with no
synchronization (:102-125 vs :127-143), so concurrent Wire/Close is a data
race; and Close-before-Wire silently does nothing, after which Wire can still
start the runner (nothing can stop it) and use already-closed pools. In
runLoop (:175-177), RunOnce's error is dropped with a comment saying it is
available via Diagnostics/LastError - true for processor failures
(worker/processor.go:166-171 records them), but a catalog.List failure
(:150-155) returns before the processor and is never recorded. Suggest a
closed-state guard plus recording that error via processor.RecordError.

11. lines/chat/fact_extractor.go:636-641 - the "rich" strategy's schema
requires fields the prompt never describes.

For StrategyRich, schemaFor requires predicate and temporal_detail,
while the only prompt template lists exactly three keys ("text", "entities",
"event_time"; prompts/fact_system.tmpl:40-43). On strict providers the model
must invent two fields; on the json_object fallback they silently arrive
empty; and nothing downstream consumes them beyond persisting them
(worker/processor.go:649, views/fact/fact_store.go:130,
views/fact/types.go:32-33). Either document them in the template (with
examples) and use them, or drop them from the rich schema.

12. Release plumbing - decide the intended story.
backends/memory requires modernc.org/sqlite v1.59.0 while
backends/checkpoint pins v1.56.0 (backends/checkpoint/go.mod:7): under the
workspace, checkpoint's CI lane compiles and tests against 1.59.0, so what it
ships (1.56.0) is not what CI validated. Separately, eval/go.mod:72-74
promises "drop this replace and pin the released version when backends/memory is
published", but tools/releasegate/main.go:21 only knows core - the publish
path is not wired. Either extend releasegate (and add a pinned/GOWORK=off lane
like examples/forge gets in ci.yml:106-107) for the new modules, or convert
that comment into a tracked issue and state explicitly that backends/memory is
workspace-only for now.

Minor / nits

  • retrieval/rerank/rerank.go:169-179: the comment concludes "so v1 stays" but
    AlgorithmVersion = "rerank-v2", and that string is a fingerprint input
    (eval/cmd/memory-eval/main.go:214). Reconcile so run labels are
    unambiguous.
  • sources/message/store_impl.go:296-305: readRecords guards with
    uint64(len(events)) != last-first+1, which wraps to 0 for an inverted range;
    a corrupt commit marker with last < first passes and then records[0]
    panics (:133, :233, :291). Add a last < first / empty check.
  • views/summary/store.go:380-384 (+ :126): equivalentRecord ignores
    GenerationID, so re-Adding identical content under a new generation
    returns the old record, with the old GenerationID. The active path is fine,
    but generation-filtered reads will disagree with what a caller just wrote -
    decide and document.
  • views/summary/search.go:23-48: LoadActive and ListActive take the lock
    separately; a publish in between makes the candidates carry the older
    manifest's generation id (and a filtered ListActive can return empty). One
    locked read would make the lane consistent.
  • views/fact/fact_store.go:676-679: the first condition in stateContains can
    never change the outcome (the final sourcesContain dominates); as written it
    reads like a deliberate guard that is not. Collapse it or comment the intent.
  • Live-only tests: eval/cmd/memory-eval/context_size_live_test.go:143,148
    update capped/tokens outside mu, a data race under -race when
    MEMORY_EVAL_LATENCY_WORKERS>1; rejudge_live_test.go:180-200 never
    increments the judge-v1 lenient counter, so that log line always reads
    lenient=0.0000.

What I checked and found fine (non-exhaustive)

Storage: workspace log write/publish ordering with crash-forward/rollback
recovery tests, idempotent commit replay and cursor semantics; workspace KV
compare-and-swap; the shared conformance suite design. Sources: document/dataset
log-then-KV with repair, version/tombstone monotonicity; SSRF resolve-then-dial
ordering (before the AllowLoopback caveat). Retrieval: hydrate/pack
determinism, dedupe by address, total budget never exceeded, budget clamping;
fusion and scorer unit tests. Views: fact merge/aggregate replay and digest
determinism; summary base + delta catalog algebra and digest verification. Eval:
fingerprint coverage of model/budget changes (item 6 is about the memory state
itself, not config coverage). The READMEs are unusually thorough.

Limits

I did not re-run the live lanes in this pass, so the reported numbers are not
independently reproduced; everything above is code-level. The "found fine" list
names where I looked closely, not a proof of absence. Unless reviewers prefer
otherwise, my plan is to file the should-fix and decision items as issues and
fold the two P1 fixes into this branch.

Comment thread backends/memory/retrieval/provider.go
Comment thread backends/memory/retrieval/pack/pack.go
Comment thread backends/memory/storage/sqlite/store.go
Comment thread backends/memory/storage_drivers.go
Comment thread backends/memory/eval/official/f1.go Outdated
Comment thread backends/memory/views/summary/store.go
Comment thread backends/memory/views/summary/search.go Outdated
Comment thread backends/memory/views/fact/fact_store.go
Comment thread backends/memory/eval/cmd/memory-eval/context_size_live_test.go Outdated
Comment thread backends/memory/eval/cmd/memory-eval/rejudge_live_test.go Outdated
lIang70 added 26 commits October 8, 2026 16:19
…ness

backends/memory assembles the memory.Assembly contract: message and document sources, the chat and knowledge lines, fact/summary/document views, BM25 + entity + vector projections, fusion, hydration, packing, maintenance and verification, over workspace, SQLite and Postgres storage drivers.

backends/memory/eval is its own module and owns the measurement surface: the LoCoMo and LongMemEval loaders, the run harness with its two-phase and resume schedules, the official LoCoMo token-F1 scorer, and the credentialed live probes that stay out of make ci.

Workspace wiring: go.work gains both modules, the Makefile discovers nested backends modules and exposes memory-eval-probes, and CI gains real-Postgres and Windows lanes for the backend alongside nested-module discovery.

Measured on LoCoMo (1540 questions, categories 1-4; category 5 is excluded by design): official token-F1 59.85%, judge-v2 grading 89.6% of answers, turn-level evidence recall 0.8976, and 535ms retrieval per question after the vector snapshot cache (from 10.4s).
A local workspace pins its root with an os.Root, so the directory stays open until Close. On Windows that made t.TempDir cleanup fail with "being used by another process" in 16 packages. Test helpers now register the close, and the document package's package-level fixture releases its root handles from TestMain.

CI: unblocks the test-memory-windows lane added in this PR.
The harness always ingested and derived before answering, so the only way to re-answer a corpus was a full derivation pass — which is LLM work (facts and summaries are generated per run) and therefore hours, not minutes.

-skip-derive answers from the derivation the workspace already holds: retrieval and answering only, with the worker reporting zero commits processed. It is the cheap way to compare answering models against one frozen memory, and the fingerprint records derive=reused so such a report cannot be mistaken for a full run (or resumed into one).

Refuses to combine with -prepare-all, whose ingest and derive pass it would contradict.
P1, both with regression tests: the source-quote cache is keyed by hard partition key plus source id and bounded, so two scopes that share a conversation id can no longer be handed each other's raw text (message ids are per-stream sequences, so they collided); and the packer admits the newest recent turn before the class and lending passes, which only add and never rebalance, so an older sibling can no longer eat the budget and drop the newest turn the provider promises to keep.

Driver contract: sqlite and postgres ListStreams("") list every stream like the workspace driver (the shared conformance suite now pins the empty prefix), and the postgres pool identity normalizes the schema, so a config that omits schema shares one pool instead of opening two.

Hardening: WithDeriverVersion (or the conservative custom-deriver marker) folds into the policy digest so replacing the deriver re-derives instead of trusting watermarks; a missing event_time stays missing and the worker defaults it to the commit's creation time instead of 1970; AllowLoopback relaxes loopback only, keeping private, CGNAT, link-local and reserved ranges blocked; Close is final and idempotent, Wire after Close fails, and the runner records RunOnce errors that used to be dropped; the rich prompt documents and exemplifies the predicate and temporal_detail fields its schema requires.

Nits: summary search reads manifest and records under one lock (ActiveSnapshot), readRecords rejects inverted ranges, stateContains drops a condition that could not change the outcome, equivalentRecord documents the generation stance, rerank's AlgorithmVersion names what actually runs (v1), and the eval live probes guard their counters with the mutex and report the stored lenient verdict instead of a hardcoded zero.
Answers "is this model good enough to extract?" before anyone spends a full derivation pass on it: the production extractor (same prompt, same response schema, same bounds) runs over the same sampled turns with two generate models, reporting call/schema failures, facts per turn, how much of each turn's surface detail (dates, numbers, quoted titles, multi-word proper nouns) survives into the facts, and how many facts the turn does not support.

Gated like the other live probes (MEMORY_EVAL_LIVE=1 plus provider keys), so make ci stays offline; registered in make memory-eval-probes and the README probe table.

First run, 30 turns: deepseek-flash 30/30 calls, 14.6 facts/turn, 54.5% surface coverage, 0% unsupported, p50 20.1s; gpt-4o-mini 30/30 provider_failure in 877ms. The failure is a schema compatibility bug this probe exists to catch: OpenAI strict structured outputs require every property to be listed in required, and schemaFor marks entities/event_time optional, so OpenAI answers 400 invalid_json_schema while DeepSeek accepts the same schema.
…atible

Strict json_schema response formats require every property to appear in required; the fact schema listed only text and left entities/event_time optional, which DeepSeek accepts and OpenAI rejects with 400 invalid_json_schema. The extraction A/B probe caught it the cheap way: all 30 gpt-4o-mini calls failed in 877ms, while 30 raw responses calls with a strict-shaped schema returned 200.

The schema now requires text, entities and event_time (plus predicate and temporal_detail for the rich strategy), and the prompt says how to express an absent value: [] for no entities, "" for no time, never an invented one. The runtime already validates model responses against the requested schema, so the stale test fixtures are updated too, and TestResponseSchemaIsStrictCompatible pins the rule for both strategies.

chat.AlgorithmVersion 1.2.0 -> 1.3.0: the schema and the prompt are part of the derivation policy, so existing workspaces re-derive instead of keeping facts extracted under the old contract.
The official scorer replaced punctuation with a space; the reference (snap-research/locomo task_eval/evaluation.py) strips commas and then deletes ASCII punctuation, so a possessive, a hyphenated name and a grouped number stayed one token there and became two here. Punctuation is now deleted the same way, which also pins "1,000" to one token and keeps non-ASCII punctuation, as the reference does.

The stemmer stays reiver/go-porterstemmer rather than NLTK's PorterStemmer; the divergence is documented at Normalize. Re-scoring the two stored baselines moved them by 0.15pp (deepseek-flash 59.85 -> 59.71, gpt-4o-mini 51.32 -> 51.17), so the old numbers were essentially right; only the last decimal was a normalizer artefact.

Closes the measurement half of #585: the ruler now differs from the reference only in the stemmer.
The style asks the answering model to quote the memory lines that bear on the question before answering, keeps the short-answer shape the token-F1 scorer grades (the answerer returns only the text after the "Short answer:" marker, with a documented fallback), and carries its own prompt version so fingerprints cannot confuse it with the official short protocol.

Measured on the frozen LoCoMo memory (1540 questions, deepseek-flash answering and judging): strict judge 67.79% -> 76.36%, locomo judge 85.91% -> 88.77%, but official token-F1 59.71% -> 54.23%. The style makes the model answer with longer phrases built from its quotes (63 against 31 characters on average) and pull neighbouring details into the answer, which the judges accept and the token-F1 precision does not. Kept as an opt-in style for follow-up experiments (terse-span variant, question-type-aware prompts), not as a default.
… behind a flag

The reference LoCoMo harness appends " Use DATE of CONVERSATION to answer with
an approximate date." to every category-2 question before it builds the prompt
(task_eval/gpt_utils.py, get_gpt_answers). This harness dropped that suffix, so
a temporal question was asked differently from the published protocol.
-temporal-hint appends it verbatim (byte-identical, pinned by a test) and is
recorded in the run fingerprint; it stays off by default because it routes on
the dataset's category label, which the product answering path must not do.

Measured over the 321 category-2 questions of locomo10.json against the frozen
LoCoMo memory (-skip-derive -answer -answer-style short -judge-style strict, so
the suffix is the only difference): strict judge 234/321 = 72.90% -> 231/321 =
71.96%, official token-F1 62.83% -> 62.03%, output tokens 191k -> 279k, mean
latency 1.33s -> 1.50s. The strict delta runs both ways across the ten
conversations (4 better, 3 worse, 3 level) and sits in the ±1 question/scenario
noise band; the F1 slip tracks marginally longer visible answers (3.94 -> 4.11
words) while the extra output is the model deliberating about dates. Enabling
the suffix does not buy accuracy here, so it is reproducibility, not a default.

The new tests pin the labelling boundary from both sides: the answer prompt
carries the question and the recalled context but never the gold, the evidence
ids or the category; the judge does see the gold; retrieval's ContextRequest
carries the query alone; and the suffix reaches category-2 questions only, in
every answering style.
…on prompt

The extractor's worked examples were copied from LoCoMo. The temporal example
read "On 7 May 2023, Caroline went to an LGBTQ support group." -- Caroline, the
date and the support-group fact are all in locomo10.json, and "7 May 2023" is
that very question's gold answer; the self-containedness example used Ren, and
the entity and response examples reused the same scenario. A prompt partly
tuned on the benchmark it is scored on is a validity problem, not a style
problem, so the examples now use invented people and dates (Nadia, Priya,
12 March 2023, 19 September 2023, 2 February 2024) that appear nowhere in the
dataset. The rules themselves are unchanged.

AlgorithmVersion 1.3.0 -> 1.4.0, since prompt text is not part of the policy
digest: without the bump an existing workspace would keep facts derived by the
old prompt alongside new ones. Extraction quality is unmoved on the extraction
A/B probe (30 sampled turns, caption mode, deepseek-flash): surface coverage
53.5% -> 54.9%, unsupported facts 0% in both, facts per turn 15.10 -> 14.37 --
inside that probe's run-to-run variance. The full effect of the prompt change
needs a re-derive, which the bump makes mandatory rather than optional.
…ce harness

The eval README described how to run the harness but not what it measures: a
reader could take the rates as leaderboard numbers. It now spells out the
differences against snap-research/locomo task_eval/ -- context (whole
conversation windowed with DATE/CONVERSATION headers vs a recalled pack),
prompt shape, the category-2 suffix, category 5 (a two-way choice there,
excluded and counted here), grading (token-F1 there; the same rules re-scored
offline here, plus a strict and a lenient judge over live runs), and the
categories each side scores -- with the reference scoring rules restated as
official.Score implements them, including the stemmer caveat.

The two places where the reference protocol is dataset-label routing are called
out with their measurements: the category-2 suffix (documented above) and the
fact-extraction prompt's now benchmark-free examples.
… the url

Every failed image fetch wrote the same 24h !failed marker, so a timeout, a 5xx
and a dead link were indistinguishable afterwards -- and with a 5s request
timeout and no retry, an image that was merely slow became a missing image for
the rest of the batch. That made a run's image set a function of the network's
luck, which is the one thing the multimodal A/B cannot have.

Failures are now classified: permanent (404/403, a hotlink block, a payload that
is not an image), busy (408, 425, 429, 5xx), transient (a timeout, a refused
connection, a reset stream) and oversized. Only a class that describes the url
is remembered across runs, and the marker carries its reason; a busy status is
retried once in-process, and every attempt is memoized for the rest of the run,
so a url shared by several turns is not re-waited-on per turn. The request
timeout goes 5s -> 20s, and an image over the 4MB inline budget is re-encoded to
fit instead of being dropped to its caption (images_shrunk). The classes are
printed and stored in the report, because they are what says whether two runs
saw the same images.

Measured on a slice of the 219 urls the old rule had frozen (all 10
conversations, 221 turns), same hour, same network: attached 41/221 -> 105/221,
failed 180 -> 116 (permanent 79 / transient 33 / busy 2 / oversized 2), shrunk
22. 41 of the urls the old binary had recorded as "permanently unavailable" came
back for the new one, which is the point: those markers described the moment,
not the url.
… it cost

A stored report carried the per-scenario rates and the run fingerprint, so a
rate could be compared with the next run but not explained: the loader's
conversion (including why images did or did not make it in), the per-role token
spend, and the derivation the answers were read from all lived on stdout or
nowhere. The quoting protocol's 2.6x answer-token cost could not be priced from
a report that had already been written, and the library block could not be
reconstructed after the fact.

The envelope now carries three blocks: `loader` (images_attached /
images_failed / images_shrunk and the image_failures breakdown by cause),
`usage` (per-role calls and input/output tokens for the answering model and both
judges, which the printed line now names too) and `library` (the derivation
state the answers were read from). They live in the envelope rather than the
fingerprint because they describe the run, not the configuration -- they change
while the fingerprint must not, or -resume would refuse a continuation it should
accept.

`library` is what makes `derive=reused` legible: answering with -skip-derive
grades whatever the workspace holds, while the fingerprint's policy_digest
describes the code. Derived watermarks are keyed by policy digest, so a
workspace derived under an older policy reports every conversation as
underived; the run warns on stderr and the report records it. Without it, two
runs over one workspace and two derivation policies carried the same
fingerprint.

The fingerprint's values map also gains `rerank`: the effective
retrieval.rerank setting, read off the deploy document. rerank_policy names the
policy version linked into the binary whether or not retrieval calls it, so two
runs that differed in that switch used to share a fingerprint.

Also fixes TestScoreStoredReport resolving a relative MEMORY_EVAL_REPORT against
the package directory: `report-x.json` failed with no such file, which reads
like a missing report rather than a resolved path.
… reply

The quoting protocol (the short-shaped `evidence` style) quotes its evidence
first and answers after a marker. Two parser bugs there: the marker search only
recognized the "Short answer:" spelling, so a reply that used the product
shape's "Answer:" was scored with its quoted scratch work; and a marker whose
answer half was empty returned the empty string, which Answer() turns into the
empty-answer error -- one formatting slip then fails the scenario and, with it,
the run. The search now accepts either spelling and still takes the last marker
(a reply may quote a line that itself says "answer:"), and an empty half falls
back to the raw reply, so the question is graded instead of the run aborting
behind it.

The function is named answerHalf rather than evidenceAnswer because only the
quoting protocol answers after a marker: the short and product shapes reply with
the bare answer, which Answer() passes through untouched.

What lands beside it is the measurement, not a prompt change. A v3 that made the
product prompt quote before answering was built and measured over the frozen
LoCoMo library with everything else held fixed, then dropped before it shipped:
the same 1540 questions went 78.51% -> 78.25% strict (paired n=1529, -0.26pp, 26
improved / 30 regressed, McNemar p=0.69, lenient -0.25pp) while the answer side
went 779 -> 2034 output tokens per question. The same instruction is worth
+8.57pp strict on the official short shape, which is the shape it was developed
on; the product shape already quotes and dates its evidence without being asked,
which is the likeliest reason the step does not transfer. AnswerPromptVersion
therefore stays at v2, and the README's new "Answering policy" section carries
the four-row table and the paired numbers, so re-proposing v3 costs a re-run
rather than a re-derivation.
A turn carried one Metadata map for the whole commit, so a host importing a
conversation from elsewhere had nowhere to keep the source's own identifiers --
a dataset turn id, for instance -- beside the message they name. Without them a
message can only be recognized afterwards by its text, and that is what fails
when a loader and a store disagree about how a turn renders: one puts an image
caption into the text, the other attaches the image as a part and keeps the
caption out of it.

Turn.MessageMetadata is positional and aligned with Messages, and Validate
rejects a slice that is neither empty nor one entry per message: a short slice
would land the tags on the wrong messages, which is worse than no tags at all.
An empty entry falls back to the turn's own Metadata, so a caller that tags some
messages does not have to repeat the commit's metadata on the others. Clone
deep-copies the entries the way it does Metadata and Messages.

The message form of SourceRef.ID is documented while here: "<conversation>/
<message>" is the form hydration validates a candidate against and the form
retrieval quotes a message back from, so a bare message id resolves to nothing.
…cal source refs

Two things a recalled recent item has to get right for a host to trace it back to
what it came from.

AppendRequest gains MessageMetadata, aligned with Messages the way core Turn's
is: an empty entry stores the commit's Metadata, so a caller tagging only some
messages does not have to repeat it, and a slice that is neither empty nor one
entry per message is rejected rather than silently shifting every tag onto the
neighbouring message. CommitTurn forwards it, so a host that imports a
conversation keeps the source's identifiers (a dataset turn id) on the committed
message, through the log and back out of a read.

The recent lane also quoted a message source as the bare record id. Nothing
resolves that form: hydration validates a candidate against
"<conversation>/<message>", and a host that splits a source id the same way has
no separator to split on, so every raw recent item's own provenance resolved to
nothing. The item now carries the canonical form, which is what makes a recent
turn matchable by identity rather than by the text it happens to hold.
RecentSettings returns the window the assembly is actually serving with, with
the library defaults applied, so a host can stop restating the flag it passed.
The settings document sets recent.max_items itself, so the runner's
-default-items flag named a value no run used: every run through deploy.yaml
stamped 20 while retrieval served 8, and two runs that differed only in that
setting agreed on a fingerprint -- which is the one thing a fingerprint exists to
prevent. Both values move with this, so a report stored before it disagrees with
a rerun of the same configuration until it is regenerated.

The accessor answers with the defaults for a nil assembly too, so a host that
failed to build one does not report a zeroed window as a measured value.
…ntity

Evidence recall was decided by committed text alone: an item counted for a
dataset turn when its own content, or a text its provenance resolved to,
contained the turn's text. That rule cannot match a turn whose stored text
differs from what the loader rendered -- the loader writes an image caption into
the turn's text, while a store ingested with -images native attaches the image as
a part and keeps the caption out of the text it holds -- and it cannot say which
of several similar turns a derived fact came from.

Ingest now tags every committed message with the dataset turn it was loaded from
(DatasetTurnMetadataKey), and the match asks identity first: an item whose
sources name the turn counts, whatever the text says. Matcher holds that rule in
one place -- grading, the retrieval probes and the diagnostic all ask it, so a
report and a diagnosis cannot define recall differently -- and resolves an item's
provenance once per question instead of once per evidence turn. TurnCoverage
keeps the distinction the report already made: ViaRaw means the turn's own
wording reached the pack (its own message, or an item carrying its committed
text), while a covered turn without it arrived as a derived item's provenance.

The tag only reaches a workspace ingested after it existed: the sessions commit
under fixed idempotency keys, so re-ingesting an older workspace replays the
original commits and writes nothing. Which rule a store allows is therefore a
property of the store, not of the run, so the new evidence_matching fingerprint
value says which one a run was graded by. The fingerprint's recent_items and
recent_tokens move with it, from the flags the run passed to the window the
assembly serves with (Assembly.RecentSettings, deploy document included): every
run through deploy.yaml stamped 20 while serving 8.

The probes and the runner move onto the shared matcher, and the runner's wiring
moves into internal/host so a diagnostic can build the same deployment from the
same document instead of a second one of its own.

Measured end to end on the one conversation this could be built against (150
questions, 202 evidence turns, 4500 packed items): 419 of 419 committed records
carried a dataset turn id, every packed item resolved to at least one source
(none unresolvable), and evidence covered by committed text and by
text-or-identity are the same 184, 0.911 -- where the text and the turns agree,
identity agrees with text and adds nothing. The difference shows up where they
disagree, which is the image turn the README's numbers record.
A report says that a question's evidence did not reach the prompt; it does not
say whether retrieval ever had it, and that is the difference between a ranking
problem and a coverage problem. memory-eval-diag prints which one it is, one
question at a time: the pack's source classes and item kinds, where every
evidence turn the dataset records for the question ended up (packed / pool-only /
absent / missing), and the top long-term items with the dataset turns their
sources stand for. The README already pointed at `go run ./cmd/memory-eval-diag`;
the command behind that line had never been committed.

The pool probe is what separates the two failure kinds. The pack budget and the
candidate depth retrieval reads are coupled -- the provider reads three
candidates per packed item -- so "did retrieval ever have this turn" can only be
asked by asking for a much larger pack. -probe-items re-reads each question at
that item budget and reports where the turn ranked there: pool-only means the
budget cut it (the rank says how far the pack would have to reach), absent means
it is outside the depth probed. The rank is a bound rather than a measurement:
the top of the ranking is stable, near-ties below it can still move.

It is retrieval-only (no answer, no judge, nothing beyond the lanes retrieval
itself calls), it matches evidence through the same Matcher the report uses, and
it builds its deployment through internal/host -- so it reads the workspace the
run under investigation read instead of a second one it wired for itself. The
header states the library the workspace holds and which evidence rule the store
allows, and warns when the store's derivation belongs to another policy
generation.

Also adds TestIngestTagsDatasetTurns, the live probe for that rule's channel:
ingest one conversation into a workspace of its own (the only way to get a store
carrying ids -- a re-ingest of the runner's replays the original commits and
writes nothing), assert that every committed record carries the dataset turn its
message came from, and that the runner's CarriesTurnIDs guard agrees with them.
writeLiveDeploy rewrites deploy.yaml for a probe's private workspace, and it
inserted derive.concurrency before a line the document no longer carries: the
retrieval line reads {source_quotes: 3} now, so a live run failed before
measuring anything with "deploy.yaml no longer contains the expected retrieval
line". The document also declares a derive block of its own, which the insert
would have duplicated, leaving which block wins to the settings decoder.

It replaces the concurrency the document sets instead, matched whatever the value
is, so the derive-concurrency probe varies the setting it means to vary and other
probes can reuse the copy for a workspace of their own.
The module accumulates local working material next to its code: a measurement
store kept so a diagnosis can be rerun without re-deriving it, and a deploy copy
pointed at that store rather than at ./workspace. The .gitignore named the two
artefacts the runners write themselves (workspace*/, report*.json), so a store
under any other name showed up as untracked and would have been swept into a
commit by `git add -A`. zz-* covers that scratch by prefix, files and
directories.
…ingests with

The probe checks the turn id channel, not how a turn with an image renders, so it
asks for the caption-folded ("annotation") shape explicitly: it downloads no
image, and the store it writes holds the loader's captions in the message text.
…enance

A pack was classified item by item, so the first item in pack order that stood
on an evidence turn decided the label: a fact derived from the turn's session
ranked above the turn's own message (retrieval.source_quotes folds the session's
first messages into the fact, and a fact scores higher), and the run reported a
raw hit as a paraphrase while the prompt held the graded wording.

Cover now asks the pack two questions in order -- does any item carry the turn's
wording, does any item stand on the turn -- so the wording wins at any rank.
Measured on the 60-question LoCoMo run this was found on: 86 of 96 evidence
turns packed, 26 raw / 60 provenance before, 49 raw / 37 provenance after, and
exactly the 23 turns whose message was in the pack too. Evidence recall is
unchanged; only the two columns that describe what the model read move.

The identity channel keeps its own preference: an item that *is* the turn's
message counts as carrying the wording however the loader's rendering of the
turn and the stored text disagree about a caption.
…er reads

The diagnostic loads the dataset to build the text index an evidence turn is
matched by, and only that: it prints item kinds, ranks and text, and never looks
at a picture. Native mode downloaded every image of the conversation anyway --
27s of a 1-question run, spent on links that time out, and an index that could
differ from one run to the next because a fetch failed.

LoaderOptions.ReferenceImages keeps the shape the mode asks for and attaches the
url instead of the payload. The same 1-question run is now 1.1s, no image
request is made, and the text still matches a native store's. The stats report
images_referenced apart from images_attached, so a conversion says whether it
embedded pictures or named them.

The diagnostic sets it exactly when it reads a store it did not write
(-skip-derive); an ingesting run still downloads the bytes the derivation and the
embedder read.
… cannot

The diagnostic splits packed evidence into raw and provenance but says nothing
about the channel that found it, and the README note on why evidence recall
needs the ingest-side turn id carried a figure -- 231 of 7500 packed items -- no
store or report behind it reproduces: the stored full runs pack 46200 items and
the per-category probe packs 3000 at its defaults, so 7500 matches no run here
and the number could only be re-asserted, not re-derived.

The summary now prints by-text beside packed -- how many of the packed turns
committed text alone would have found, with the tag ignored -- and a per-question
line marks a packed turn only the tag named (id-only). Measured on conv-26, 152
questions, 30 items, 4560 packed items, 201 evidence turns:

  untagged store (deploy.yaml)  native      185 packed, 185 by text
                                annotation  128 packed, 128 by text
  tagged store, ingested native native      181 packed, 181 by text
                                annotation  181 packed, 121 by text

So the flag costs 57 of 185 packed turns when nothing but text can name a turn,
and costs nothing once the store carries ids. Both directions are now stated in
the README with these numbers, and the mechanism is stated precisely: an image
turn has two renderings, the store holds one of them, and the mode it was written
with is recorded nowhere in it. The packing probe's comment carried the same
unreproducible figure and gets the same correction.
…eneration

A fact is a function of the canonical messages and of the derivation policy
that read them, so the policy's digest now names the generation a fact, a
watermark, a summary and a projection entry belong to.

The fact view stores one subtree per generation and publishes the one readers
resolve: derivation reads the generation it is building, readers resolve the
conversation's active one. A policy change therefore writes beside the
generation it replaces instead of merging into it, and a rollback is switching
the published generation back rather than restoring a backup. The first
generation a conversation writes is adopted as active, so a fresh
conversation's facts are readable as they are derived, while a re-derivation
never exposes a half-built replacement for the generation still current.

The worker hands its own digest to every artifact it derives, and the chat
line links new facts against the generation the pass owns instead of whichever
one is published. The pass converges the projection lanes with the generation
it is about to publish before the pointer moves -- an earlier switch pruned the
facts of the generation it replaced, and this one may own some of them -- so
the lanes never hold a mixture of two policies. Lane entries are addressed per
conversation: a fact id is content-addressed, and two conversations that
derived the same text were competing for one entry.
The read plane labels a request with the generation the conversation resolves,
so a lane that publishes its own generation can be told its view belongs to a
replaced policy. Summaries carry the generation that published them, and a
policy rollback drops them rather than serving two policies' views at once.

The label is resolved once per request and a failure fails the request instead
of degrading: reading whatever generation a lane's manifest happens to hold is
a correctness leak, not a fallback. The acceptance test re-derives one
conversation under a second policy, checks the replaced generation leaves every
read path in a single step, and rolls back to find facts and lanes back on the
generation that is current again.
Replaced generations stay stored, which is what makes a rollback a switch
rather than a restore, and that makes forgetting them a decision of its own:
ListGenerations reports what one conversation holds, and RetireGeneration /
RetireGenerations drop what a caller no longer keeps. Which generations to keep
is a retention decision -- the active one plus its predecessor is the
conservative choice, since the predecessor is what a rollback reads -- so the
caller names them instead of the store guessing.

The active generation cannot be retired: reads have to resolve somewhere. A
sweep is retryable because retiring an absent generation is a no-op, and
retirement is unreachability rather than erasure: the merge-event streams live
in the append-only Log, whose contract has no delete and which is what a repair
replays from, so a retired generation stops being readable while its history
stays auditable.
…nd the importer

Derivation is the worker's per-conversation pass: it reads the commits after a
watermark and hands them to the line's deriver. The DAG that scheduled that
work, the registry that held the four component slots and the document importer
were machinery of a pipeline nothing routes through anymore -- each had no user
left outside its own tests -- and dropping them leaves one path from a commit to
the facts it derives.

The README describes what the module does now: the generation a fact belongs
to, how a policy change and a rollback move reads, and what retirement can and
cannot undo.
…n it serves

A generation writes its summaries beside the generation it replaces, but the
branch publishes one active manifest, and compaction publishes it during the
pass. A policy that came back to a generation whose watermark was already at
the end of the stream derived nothing, so nothing republished the branch, and a
reader resolving that generation was served no summaries at all while its facts
were served.

The manifest a generation publishes is now bookmarked under that generation, and
serving a generation again is pointing the conversation back at its bookmark.
The catalog chains are unchanged: a bookmark names a head in the same
append-only publication history, so a generation materializes exactly the
manifest it published -- same records, same frontier, same publication time --
and a generation that publishes further after a rollback forks off its own head
rather than inheriting the segments of the generation it replaced.

One builder now assembles a manifest from the records it lists, so the manifest
a compaction publishes and the manifest of the same records are the same
manifest, coverage included. The switch republishes before the generation
pointer moves, exactly as the lanes are converged before it, so readers never
resolve a generation whose summaries are still the replaced one's.

A workspace whose generations published before the bookmarks exist keeps the
behaviour it had -- no summaries to restore for a replaced generation -- until
that generation publishes again.
…y derive

Retirement was fact-only: a sweep dropped a generation's facts and left its
summary manifest bookmarked, so a rollback to that generation moved the branch
onto a manifest whose L0 records summarise facts that no longer exist -- served
as published, with Verify reporting a rebuild per record.

Assembly.RetireGenerations is now the sweep: one host-invoked action over the
derived views that share the generation identity. It keeps every generation a
view is serving, so a policy change that has left the facts and the summary
branch on different generations is not pruned while they disagree, and it
retires the bookmarks before the facts, so a sweep interrupted between the two
leaves unreachable facts the next sweep collects rather than a bookmark to facts
that are gone. RetireResult reports what each view dropped.

SummaryStore gains ListGenerations, RetireGeneration and RetireGenerations
beside the fact view's, and drops bookmarks only: a summary record is a content
address shared by every generation that compacted the same inputs, so removing
the records one generation referenced would break the generations that stay. A
retired generation has nothing left to publish, and PublishGeneration reports
found=false instead of serving a manifest whose inputs are gone.

The README's lifecycle claim follows: maintenance and retirement are both
host-invoked through the assembly SPI, and the sweep is documented with what it
keeps and why the views are swept together.
@lIang70
lIang70 force-pushed the feat/backends-memory-eval branch from 04e94e9 to 5a5e0e3 Compare October 8, 2026 08:28
…y project

Retirement dropped a generation's facts and left the entries the lanes hold for
them. A lane entry is addressed by the id of the fact it projects, and the
converge enumerates the generations still stored in the fact view, so once the
facts were retired no pass could ever find those entries again: the lane kept
offering a candidate the read path resolves against a generation that no longer
has it, and every query matching it paid a hydrate failure and a diagnostic,
for good. Verify cannot see it either -- it replays the projection chain, which
recomputes the digests the leaked entries were chained with.

The generation that reaches it is one whose pass failed after projecting: its
facts are stored and its entries are in the lanes, but it never published, so no
switch reconciled the lanes with it. Assembly.RetireGenerations now purges the
generation it is about to retire from the lanes first, before the summary
bookmarks and the facts, and reports the entries it dropped as
RetireResult.LaneEntries. Purging first is what keeps the sweep retryable: the
entries of a generation that is still stored are simply dropped again, and a
sweep interrupted between the two steps leaves the lanes holding less than they
should rather than entries nothing can enumerate after the fact. Entries the
generation readers resolve also owns stay, so a fact two generations derived
keeps being projected.

Processor.PurgeGenerations is the worker side of it: the worker remains the only
writer of the lanes, and the sweep reaches them through it. Sweeping expects
derivation over the conversation to be quiesced, since the generations to purge
are listed before they are retired; that is documented on the sweep.
…not settled

The sweep listed the generations it retires before it retired them, and the pass
that owns a generation writes and publishes it again whether or not the sweep
emptied it -- and it resumes after its own watermark, so the commits that
generation had already covered are never derived a second time. A sweep beside a
running pass, or over a generation the worker had derived but not published,
therefore lost facts for good:

  facts of the owed generation before the sweep: 1
  sweep retired RetireResult{Facts:1, LaneEntries:1}
  the published generation serves 1 fact (the conversation derived 2)

The sweep now enforces that precondition instead of documenting it. It refuses
while a pass over the conversation is running in this processor, or while this
processor has stored facts under its own policy generation that no pass has
published -- the run stopped between writing them and the switch that serves
them. Both conditions are retryable, both mean the sweep retired nothing, and
both are answered by deriving again (RunOnce is synchronous) and sweeping after
the pass. The refusal is worker.ErrDerivationUnsettled; the state is observable
before the fact as Assembly.DerivationState, so a host can sweep a settled
conversation instead of retrying.

Commits no pass has scanned yet are deliberately not part of it. A sweep only
removes generations readers do not resolve, so it is safe beside a lagging
watermark, and the pass that catches up converges the lanes with the generation
it publishes: the guard refuses work this processor has started, not work it has
left.

The pass announces itself where the sweep can read it: Processor.ProcessConversation
marks the conversation in flight for its whole run and clears the marker however
it returns, so a failed pass does not leave a conversation looking busy for good.
…ion it belongs to

The sweep dropped the facts, the lane entries and the summary bookmark of a
generation, and left its watermark where the pass that failed had stopped. The
watermark is where the next pass under that policy resumes, so a rollback to a
generation the sweep retired derived only the commits after the cursor and
published a generation missing the rest -- the commits it had already covered
before the failure are never derived again, and the facts of exactly those
commits are gone:

  the sweep retired RetireResult{Facts:1, Summaries:1, LaneEntries:1}
  the rollback derived 1 commit and served 1 fact, of the 2 the conversation has

Retirement now takes the cursor with the generation. Assembly.RetireGenerations
retires the progress of the generations it drops before it touches anything
else, and reports it as RetireResult.Watermarks; dropping the cursor makes the
next pass under that policy start at the head of the stream, so a rollback
derives the conversation again from the canonical commits the sweep left in
place. The summaries come back the same way, when compaction publishes the
manifest for the generation it summarises.

Progress goes first because it is the one thing a sweep removes that is not
derived state: an interrupted sweep then leaves facts without a cursor, which
re-deriving reproduces -- the derived addresses are content derived -- rather
than the state the sweep exists to prevent, facts gone under a cursor that still
points past them.

The published generation's cursor is refused (Processor.RetireProgress): an
ordinary pass under a policy whose generation is visible would derive the stream
a second time, and what a re-derivation produces is the model's, so the visible
generation would grow beside itself instead of converging. A sweep never names
it, because it retires what the views do not serve.

CheckpointStore gains RetireWatermarks, which drops the cursors of the named
policies on one stream and reports how many were stored; the KV-backed store is
its only implementation.
…tenance pass

A document view publishes one immutable build per chunk set and moves an active
pointer at it, and nothing ever removed the builds the pointer left behind: a new
revision, a re-derivation under changed chunking, or a publish that failed after
writing its chunks all left a full copy of the document in the store, unreadable
because Get and List resolve the active build. The fact sweep cannot collect
them, and should not: it is per conversation, while the document view is
addressed by dataset and document.

They are reclaimed at scope granularity instead, where they belong, and without
the generation machinery -- a chunk id comes from the document's provenance and
text, not from the policy that derived it, so deriving a document again under an
earlier policy reproduces the build it published then. There is nothing a
rollback would have to find, which is why this view keeps no generation and why
Assembly.RetireGenerations never touches it. That boundary is now stated where it
matters: the view's own doc comment, RetireGenerations, and the README.

- DocumentViewStore.ListBuilds reports the builds stored for one document,
  RetireStaleBuilds removes every one of them except the active build, and
  RetireScopeBuilds runs that over every document of a scope. A document whose
  pointer never landed keeps none of its builds, since none of them has a reader,
  and a build removed here is written again by the next publish that needs it.
- maintain.Service gains an optional Documents view and RunScope reclaims
  through it after saving the overlay, so a pass that fails while reclaiming still
  publishes what it detected; the reclaim is idempotent, so a retried pass finds
  less to remove. Plan.ReclaimedChunks reports what it removed, beside the
  deterministic detection rather than as part of it.
- Assembly holds the view and exposes it as DocumentViews, next to Facts and
  Summaries.

Tests cover the reclaim of a superseded build, of the builds a failed publish
left, of a whole scope (leaving another scope's partitions alone), the service
wiring with and without a view, and the end-to-end pass over a document published
twice: 4 superseded chunk records removed on the first pass, none on the second,
with the active build still served.
@lIang70
lIang70 merged commit c555e59 into main Oct 8, 2026
27 checks passed
@lIang70
lIang70 deleted the feat/backends-memory-eval branch October 8, 2026 09:35
lIang70 added a commit that referenced this pull request Oct 8, 2026
…tings parity (#595)

## Why

A host that wires MCP servers into its runtime can reach their tools
only through the tool registry, which renames a tool (`server__tool`),
scopes it to an agent and authorizes the call against that agent's grant
set. That is the right contract for tools a model may pick from, and the
wrong one for a graph node that already knows which tool it means — the
shape the plugin-provided graph nodes consume.

Two smaller gaps come with it:

- A registry created from a deployment's `tool.registry` settings stayed
frozen at construction, so a source that publishes after the fact (a
refresh, a background MCP connect) reached an assembly mount point but
never the mounted registry.
- An agent registered after the build (`Runtime.RegisterAgent`) or
re-bound by `Runtime.Reload` expanded its settings with `nil` resolvers:
a card or engine setting using a custom scheme or `${secret:...}` worked
in a deployment and failed the moment the same definition arrived at
runtime.

## What changed

- **core/tool/mcp** — `Source.CallTool(ctx, server, tool, args)` calls
one tool on a connected server directly, bypassing the registry. The
result is raw JSON: the text content when the server sent any, the
structured content when it sent no text mirror (SEP-2106 lets a server
answer with data and no text, and reading "no text" as "no result"
collapsed a real answer into `{}` without an error), `{}` when the
result carries neither, and non-JSON text as a JSON string so the caller
always holds valid JSON. An unknown server is NotFound, a server whose
handshake has not finished is NotAvailable instead of a nil-session
panic, and an errored result is an ordinary error carrying the server's
own text or its structured content.
- **core/tool** — `RegistryFactory` attaches every source that
implements `RegistryAttacher` right after construction, so a registry
built from settings stays live exactly like an assembly mount point.
- **core/deploy** — `Result.Resolver()` and `Result.Secrets()` expose
the effective settings reference resolver and secret resolver the build
assembled (`nil` when the deployment declared none).
- **core/runtime** — `RegisterAgent` and `Reload` pass those resolvers
into `deploy.BindAgent`, so a dynamic or reloaded agent expands custom
schemes and `${secret:...}` exactly like a deployment-time one.
- **tests** — `TestDynamicAgentExpandsLikeDeployment` (an engine factory
captures the settings it was constructed with, on both the register and
the reload re-bind path) and the `CallTool` suite (payload shapes,
unknown server, unconnected server, error propagation).

## Verification

- `make ci` (vet + `go test ./...` across the workspace) passes.
- `make lint` — 0 issues; `gofmt -s -l core` is empty; `git diff
--check` clean.
- `make release-check` — changesets valid; `make release-plan` reports
`core: 0.4.9 -> 0.4.10 (patch), tag core/v0.4.10`.

## Release notes

- `.release/core-v0.4.10.json` covers the accumulated delta since
`core/v0.4.9`: this PR plus the memory provenance change
(`Turn.MessageMetadata`, `SourceRef.ID`) that shipped in #583.
- No coordinated release: `core` is the only release-managed module and
nothing here changes a contract a `driver/*` module compiles against, so
`make release-preflight` is not needed.
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