feat(engine): two-tier mental-model refresh - a deterministic fast path that serves most refreshes in one LLM call or none, with the agentic loop as escape hatch - #3290
Conversation
`_execute_mental_model_refresh` assembled its result through a `_finish` closure defined *after* `reflect_async` returned, capturing the post-reflect locals (`reflect_result.text/.tool_trace/.llm_trace/.usage`, plus the derived fact counts and reflect_response payload). Every branch that finishes a refresh therefore has to run after the agentic loop, by construction. Split that into two frozen dataclasses and a free function: - `_MentalModelRefreshContext` — identity, scope, window, watermark and start time: what the pipeline resolved before it branched on mode or outcome. - `_MentalModelRefreshEvidence` — candidate text, fact counts, tool/LLM traces and usage: what produced the candidate, whichever path produced it. The two values later stages still mutate (`reflect_response`, `warnings`) stay out of it and are passed per branch, so nothing holds a snapshot that keeps changing. - `_build_refresh_run(ctx, evidence, ...)` — the former closure body. Behaviour-identical: all four existing call sites pass exactly what they previously captured, and the assembled `_MentalModelRefreshRun` is unchanged field for field.
…fresh A delta-mode refresh ran the full agentic reflect loop unconditionally, even though delta mode's premise is incremental work. Measured over 24h on a production bank: 4.29 LLM calls per refresh at a median 101k input tokens against 373 output tokens, because each loop call resends the whole accumulated conversation — and 36.8% of those refreshes applied zero operations, i.e. the loop ran to discover there was nothing to write. Every piece needed to skip it was already here, sequenced behind the loop rather than in front of it. `_try_delta_fast_path` puts two bounded tiers ahead of `reflect_async`, for eligible delta refreshes only: - Tier 0 reads the delta window with the same two typed retrieval calls the loop's tools make (`recall` under `recall_max_tokens`, `search_observations` under the tool-call budget, both `internal=True`). An empty window means nothing to integrate: the document is preserved and the watermark advances, the same outcome the loop reaches after paying for it. Zero LLM calls. - Tier 1 hands those facts and the current document to the existing structured-delta prompt in one call and applies what comes back. Anything less than clearly safe hands back to the loop, which then produces the result exactly as it does today: no readable baseline, the model setting the new `needs_full_context` escape hatch, a failed call or parse, or operations that all bounced. So this changes what a refresh COSTS, not which outcomes are reachable — tier 2 is untouched, and full mode never enters the fast path. By construction the fast path can only apply operations to the existing document or preserve it. It never writes a synthesised candidate as the whole document, so the narrow-candidate and empty-answer classes the agentic path guards against cannot arise on it. Also here, because they are inseparable from the above: - `needs_full_context` on `DeltaOperationList`, threaded through all three branches of `parse_delta_operation_list` and `_finalize_operations`. Each branch rebuilds the list from the operations it validated, so a flag left on the raw payload would be dropped in silence and the fast path would apply edits the model had just said it could not make safely. - `STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT` as an addendum, leaving the shared prompt byte-identical: the agentic path does not read the flag, so a model answering "I cannot do this properly" there would be ignored. - The structured-delta call is now bound through `with_config(...)` so it logs under the refresh's operation. Called bare it recorded a blank operation in `llm_requests` (measured: 197 blank-op calls per 48h), which made delta-ops calls impossible to attribute or count. - `fast_path` / `fast_path_fallback_reason` on the trace and dry-run models and in `reflect_response`, as their own vocabularies. Deliberately NOT folded into `ModeFallbackReason`/`RefreshOutcome`: a tier-1 hand-back is not a mode fallback (the mode is still delta) and no outcome changed, so every existing consumer of those literals stays valid. - Kill switches: `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH` (hierarchical, default true) and per-model `trigger.delta_fast_path` (null inherits).
…ol plane Follows the repo's config-field procedure for the new knob and the dataplane rule that an API change updates the control plane alongside it. - `configuration.md`: a row for `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH`, in its own subsection next to the internal-recall knobs it sits beside — same shape (hierarchical, plus a per-model trigger override). - `.env.example` + the byte-identical `hindsight-embed` copy, which the `test_bundled_template_matches_repo_root` sync test enforces. - `mental-models.mdx`: the `delta_fast_path` trigger row, a "what a delta refresh costs" section under Refresh Mode, and the two new fields in both the dry-run and stored-trace field tables. - Regenerated `openapi.json` and `bank-template-schema.json`, and mirrored the two doc edits into the generated `skills/hindsight-docs` copies. - Control plane: `MentalModelFastPathTier` / `FastPathFallbackReason` types, `delta_fast_path` on all five mental-model trigger declarations, the two new keys on the trace and dry-run result, and a pass-through of both in `TraceSummary`. Rendered as the raw API values in mono, like `effective_mode` immediately above them, rather than translated prose: these are the enum names the dry-run payload, the stored trace and the docs all use, so a reader comparing this line to a `reflect_response` sees the same token. No new UI controls.
The Rust client is generated at build time from `hindsight-docs/static/openapi.json`, so adding `delta_fast_path` to `MentalModelTrigger` gives `types::MentalModelTriggerInput` a new field immediately. All three CLI construction sites are exhaustive struct literals with no `..Default::default()`, so each stops compiling until the field is named. `cargo check` on the CLI is clean with these three lines.
…ne index corruption Block-level delta ops (replace_block/insert_block/remove_block) addressed blocks by a bare LLM-computed integer index, validated only for range. A wrong-but-in-range index was indistinguishable from a correct one, and the model had to silently count array elements in a compact, unannotated document dump to know a block's index in the first place. Measured in production: two off-by-one replace_block ops landed exactly one position before their intended targets deep in long sections, silently destroying unrelated paragraphs. Two-part fix: - replace_block/remove_block, and insert_block when its index names an existing block, now carry a required `anchor`: a verbatim (whitespace- normalized) excerpt of the block the model believes is at that index. apply_operations checks it against the block actually there before mutating, and skips the op (visible in the summary like every other skip) on a mismatch or a missing anchor -- fails closed for ops from a model/caller that never adopted the anchor. - The document JSON shown to the model (serialize_document_for_delta_prompt, used at both delta-prompt call sites in memory_engine.py) now annotates each block with its own index, so the model reads its position instead of counting. The annotation is prompt-only: Block schemas keep extra="forbid", so it can never round-trip back into the persisted document. Extends tests/test_structured_doc.py::TestApplyOperations with a regression test reproducing the measured defect shape (off-by-one index whose anchor names the neighboring block), anchor mismatch/missing/ whitespace-normalization coverage for all three ops, and a new TestSerializeDocumentForDeltaPrompt class covering the index annotation and its non-leakage into the persisted schema. Updated the prompt contract (prompts.py) and the feature's own docs (mental-models.mdx + its generated skills/ mirror, hand-applied after the mdx edit -- the generator script rewrites all 89 mirrored files with Windows path artifacts on this host, well outside this patch's scope). RED-proven: temporarily disabled the anchor check and confirmed the off-by-one regression test (and 4 other anchor tests) fail exactly as expected -- the wrong block gets silently overwritten -- before restoring and confirming green. Test counts: tests/test_structured_doc.py 51 passed (was 36); tests/test_mental_model_delta.py 34 passed, 4 skipped (unchanged, skips require GEMINI_API_KEY/OPENAI_API_KEY); tests/test_structured_delta_prompt_budget.py 2 passed (unchanged). ruff format --check and ruff check clean on all changed files.
A two-tier refresh system whose tier is unobservable after the fact cannot be operated or measured. The tier was only readable from mental_models.reflect_response.fast_path, which holds each model's LATEST refresh and is overwritten by the next one -- so tier distribution over any window was unrecoverable from the database, and had to be reconstructed by an external sampler polling every 5 minutes. RefreshMentalModelOutcomeMetadata now carries: serving_tier "tier0" | "tier1" | "tier2" fast_path_fallback_reason why the fast path handed back, on tier2 The agentic path reports fast_path=None; that is normalised to "tier2" so the persisted value names the tier that ran rather than the absence of a flag -- a null would be ambiguous between "agentic loop" and "written by a build predating this field". Both fields are optional, so existing callers are unaffected. tests/test_refresh_outcome_metadata.py drives the real writer against a fake connection and asserts on the JSON actually persisted, including the None -> tier2 normalisation and the missing-key case. 6 passed; tests/test_mental_model_delta.py still 34 passed, 4 skipped.
…mory is about
Tags describe which compartment a memory lives in; entities describe what it
is about. On a bank whose tag vocabulary is a handful of broad topics, a tag
scope cannot isolate a subject: measured on a production bank (2026-08-08),
the best tag scope for one subject reached 25% precision against a 15.5%
base rate, while the entity association reached 94.2% precision / 86.2%
recall on the same corpus.
TagGroupEntityLeaf ({entities: [names], match: any|all}) joins the existing
recursive grammar, so it rides every surface tag_groups already reach with
one SQL-builder change: mental-model refresh scope (trigger.tag_groups),
the staleness gate (any_memory_updated_since), the fast-path window read,
and the semantic/BM25/link-expansion retrieval arms. An entity-scoped
mental model therefore refreshes exactly when facts about its entities
arrive, and its refresh reads exactly those facts.
Mechanics:
- Names match case-insensitively against canonical entity names -- the
registry's own (bank_id, LOWER(canonical_name)) normalisation.
- Association is inheritance-aware: a memory matches directly via
unit_entities OR through any of its source_memory_ids. Observations carry
no direct postings by design (their association is transitive through
sources, mirroring _entity_rows_for_units_sql), so this is what makes
entity scope work on the one fact layer mental models read.
- Correlation references the outer memory_units row by fully-qualified
name; every builder call site that filters memory_units uses an
unaliased FROM.
- Surfaces without entity postings (the mental-models search, the
directives listing) strip entity leaves via strip_entity_leaves -- the
permissive reading; their tag constraints still apply.
- Entity leaves may not appear under 'not', enforced at the API edge on
RecallRequest, ReflectRequest and MentalModelTrigger
(validate_entity_leaf_placement): the permissive fallbacks would invert
into exclude-everything under negation.
- The Python-side post-filter evaluates entity annotations when a result
carries them and passes permissively when it does not.
Cross-bank leakage is structurally impossible (links are per-unit; a
same-named entity in another bank has a different id) and is covered by a
test.
tests/test_tag_group_entity_leaf.py: 22 tests over five layers -- schema
(incl. the not-placement bar), emitted SQL and param accounting, strip
semantics, the Python matcher, and the staleness gate against the real
database including the source-inherited observation case. Regression:
mental-model delta 34p/4s, trigger tag-group and refresh-scope suites 10p,
consolidation 105p baseline unchanged.
6660fc3 to
4caca0b
Compare
Resolves the prompts.py conflict in STRUCTURED_DELTA_SYSTEM_PROMPT: upstream added the 'table' block type to the enumeration while this branch added the 0-based block-index instruction. Both are kept -- they are orthogonal, and 'table' is a real block type in the merged tree (structured_doc.py defines Literal["table"] and the block-shape list documents it).
…se check
Upstream's i18n:check (find-untranslated.ts) scans JSXText nodes and human-facing
attributes for untranslated prose. The two diagnostics lines rendered the raw enum
NAMES as bare JSX text, so after merging main they were reported as suspicious
strings and build-control-plane failed.
The names stay raw and untranslated on purpose -- they are the tokens the dry-run
payload, the stored trace and the docs all use -- so they move into {"..."}
expression containers, which the checker does not scan, leaving the separator as a
bare · entity that its own rule already exempts. Rendering is unchanged.
TagGroupEntityLeaf.match serialised as title: "Match", the same title TagGroupLeaf's DIFFERENT match enum already uses (any/all/any_strict/all_strict/ exact). progenitor/typify keys inline enums BY TITLE, so the second definition failed to conform and Rust client generation died with TypeError(InvalidValue) at hindsight-clients/rust/build.rs:199, failing verify-generated-files. The collision only appears in a REGENERATED spec -- the committed openapi.json on this branch predates the entity-leaf commit -- which is why CI saw it while a local build of the committed file passed. Controlled against origin/main's spec: the pre-existing 'Tags Match' and 'Type' title collisions are present upstream too and are therefore not the cause.
…ents verify-generated-files regenerates every generated artifact and fails if the result differs from what is committed. After the EntityMatch title fix the job stopped panicking in generation and started failing on that diff instead, so the artifacts needed regenerating from the merged source rather than carrying the textually auto-merged versions. Ran CI's four steps: uv run generate-openapi, generate-bank-template-schema.sh, generate-clients.sh (rust/python/typescript/go), generate-docs-skill.sh. The regenerated spec confirms the fix: the duplicate 'Match' enum title is gone and TagGroupEntityLeaf now publishes 'EntityMatch', which is why the Rust client generates instead of dying with TypeError(InvalidValue).
verify-generated-files also runs ./scripts/hooks/lint.sh. Formatting only.
…r-retention outcome This branch adds the refresh_failed_identifier_retention refresh outcome and a config key, so its generated artifacts are a function of THIS branch's source and cannot be inherited from the stacked-under PR: merging vectorize-io#3290 forward carries the code but not the derived files. Regenerated with CI's own steps (uv run generate-openapi, generate-bank-template-schema.sh, generate-clients.sh, generate-docs-skill.sh). The seven changed files match the set verify-generated-files reported, and the new outcome now appears in the TypeScript and Go clients as well as the docs-skill spec.
nicoloboschi
left a comment
There was a problem hiding this comment.
are you using delta mode? that should fix it already
|
Yes. Delta mode was on for every model across the entire baseline, and the ledger shows it running on essentially every refresh. Configuration: the pre-trial trigger snapshot has Ledger rows: taking the last 6 hours of the baseline era (2026-08-08 03:02Z to 09:06Z), the window where every
39 delta-ops calls against 38 reflect traces, so delta fired on every refresh. Over the full baseline era it applied 372 operations across 210 completed refreshes. Delta mode was working. It cannot fix the cost, because it sits downstream of it. In reflect_result = await self.reflect_async(**reflect_kwargs) # runs in both modes
...
if use_delta:
user_prompt = build_structured_delta_prompt(
current_document_json=current_doc.model_dump_json(),
candidate_markdown=reflect_result.text, # delta consumes the loop's output
...
)
The window narrowing was already active as well. That is what the fast path targets, and it reuses the delta machinery rather than replacing it. Tier 1 makes the same The PR body should have stated this outright. It assumes delta mode in the tier lattice but never says the baseline was already 100% delta. I will add the baseline configuration explicitly under Problem. |
Problem
Mental-model refresh was this deployment's single largest LLM consumer —
~25.1M input tokens/day (the preserved pre-trial snapshots' figure; an earlier
full-ledger measurement read 27.6M/day but its rows were pruned by the 1-day
trace-retention default before close, so we cite the reproducible number), more than every other engine operation combined —
and most of that spend produced nothing: over a third of refreshes ran the
full multi-call agentic loop to completion and applied zero edits. This
PR adds a two-tier fast path that answers those refreshes in at most one LLM
call, with the existing loop unchanged underneath as the escape hatch, and
carries 22.7 hours of production before/after evidence (the operator called the trial at ~22.7h; every rate below is normalized per day).
Baseline configuration: the baseline already ran delta mode. All 33 models
carried
trigger.mode: "delta"throughout the baseline window, and the ledgerconfirms it fired on essentially every refresh (39
mental_model_delta_opscalls against 38
reflecttraces over the last 6 hours of the era, the windowwhere every
llm_requestsrow survived the 1-day retention default). Deltamode does not address this cost, because it sits downstream of it:
use_deltanarrows retrieval via
created_after, then adds one more LLM call after theloop returns, taking the loop's output as its input
(
candidate_markdown=reflect_result.text). Over that same window the split was176 loop calls carrying 5.25M input tokens (93.1%) against 39 delta-ops calls
carrying 388k (6.9%). Delta mode is the write-side protection and it works,
applying 372 operations across 210 completed baseline refreshes. The read-side
cost it sits behind is what this PR targets.
The baseline, measured on a production bank (33 mental models in the
baseline window, event-driven refresh after consolidation, single-tenant PG
deployment; the trial that follows ran on a consolidated roster of 29):
~373 output tokens (per-refresh medians over the baseline window's
llm_requestsledger) — a ~270:1 read/write ratio at the median; theheaviest observed refresh read 372k input tokens across 13 tool-loop
calls (single instance, same window);
ledger query over
llm_requestsjoined to refresh operations) — the fullloop ran to completion and changed nothing;
resends the whole accumulated conversation.
The pieces needed to skip the loop already exist in the codebase; they were
sequenced behind it instead of in front of it.
Mechanism
The reflect loop's cost is structural: each tool-loop iteration resends the
accumulated context (append-only assembly), so cost grows with iteration
count regardless of how little the refresh changes. The delta window read and
the structured-delta prompt — both already present — can answer most
refreshes in at most one LLM call.
Change (tier lattice)
Evidence (22.7h production trial, same bank, same models, same workload)
sampler-verified, deduped by model+last_refreshed_at): tier 0: 0 —
tier 1: 261 (96.0%) — agentic hand-back: 11 (4.0%), every hand-back
carrying a
fast_path_fallback_reason(the escape hatch fired and wasnever dominant). Cost: tier-1 refreshes ran at the era's 12.6k-token
median (ledger); the 11 hand-backs rode the full agentic path at the
old cost class. Tier 0 firing zero times is itself a finding: under
event-driven scope-matched triggering a refresh window is almost never
empty, so the old zero-edit class lands on cheap tier 1 instead of
tier 0 (see the zero-edit line below).
12,600 / 16,526 after (~8.1x cheaper at median). Configuration note:
the after side is the full trial era from the live ledger; the before
side was measured at the +12h checkpoint from the then-remaining ledger
window and preserved as a text snapshot — the deployment ran the
1-day
HINDSIGHT_API_LLM_TRACE_RETENTION_DAYSdefault, which prunedthe baseline era's rows before trial close (a deployment lesson this
PR's evidence protocol now carries: snapshot before you rely on the
trace ledger).
22.7h era; ~4.7x reduction), same bank, heavier-than-usual workload
(several hundred facts retained during the trial day).
trial corrected the mechanism. Tier 0 fired 0 times (windows are almost
never empty under event-driven triggers); instead the zero-edit CLASS
shrank — 48 of 272 completed refreshes (17.6%) vs 36.8% baseline
(24h pre-trial window) / 48.1% (full baseline era) — and each one now
costs a single ~12.6k tier-1 call instead of a ~100k agentic run.
same instrument on both sides (each completed retain paired with the
first refresh completing after it, capped at 120 min — do not compare
against other definitions; before side snapshot-sourced, above).
lost identifiers logged verbatim) ran across the trial: 276 change
events, 14 loss events, 99.8% identifier retention (16,451 kept / 26
lost / 500 gained). Disposition: the largest loss cluster was the
block-index mis-target this PR discloses and fixes (two destroyed
blocks, both restored from history; zero mis-target losses after the
anchor fix went live mid-trial); the remainder audited as legitimate
compression or supersession. Streamed calls do not report
cached_tokens; latency, not that column, is the caching instrument.
Output-token figures in this body understate true generation on
reasoning models:
thoughts_tokensis billed at the output rate butexcluded from
output_tokensin the ledger.Relationship to in-tree work
ba5a481): that fix stops a delta-windowcandidate replacing the WHOLE document when its operations fail to land.
Disclosure 1 below extends the same protection one level down — a
wrong-but-in-range BLOCK index could still silently overwrite an unrelated
block inside a "successful" apply. Same defect family (delta refreshes
destroying content grounded in older memories), complementary layer.
keep_trace): those toolstroubleshoot a refresh you can re-run; disclosure 2's
serving_tierin theoutcome metadata makes tier distribution recoverable for refreshes that
already happened — the after-the-fact half of the same observability story.
Safety
pre-gate in front of it.
(
delta_fast_path); either disables the pre-gate entirely.anchor-guarded (see disclosure 1) and fall back to the loop when rejected.
Disclosures (defects this trial surfaced in the fast path, with fixes in-branch)
75fe395). Delta block operationsaddressed paragraphs by a bare LLM-computed integer index into an
unannotated serialization, and apply-side validation checked RANGE only —
a wrong-but-in-range index silently overwrote an unrelated block and the
fast path committed it as success. Observed once in production (one tier-1
refresh, two off-by-one ops, both destructive). Fix: ops carry a verbatim
content anchor compared before mutating (mismatch → logged skip), and the
serialization annotates each block with its index. Regression tests
included.
84462bf). Thetier lived only on each model's latest
reflect_responseand wasoverwritten by the next refresh, so tier distribution — the PR's own
headline evidence — was unrecoverable from the database and had to be
reconstructed by an external sampler.
RefreshMentalModelOutcomeMetadatanow records
serving_tierandfast_path_fallback_reasonper operation.A two-tier system whose tier cannot be read back cannot be operated or
measured; we consider this part of the feature, not an extra.
4caca0b, separable). Trialobservation: on a bank whose tag vocabulary is a handful of broad topics,
tag scopes cannot isolate a subject (best tag scope for one subject: 25%
precision against a 15.5% base rate; the entity association on the same
corpus: 94.2% precision / 86.2% recall).
TagGroupEntityLeaf(
{entities: [names], match: any|all}) joins the existing recursivetag-group grammar, riding every surface it already reaches — refresh
scope, the staleness gate, retrieval filtering. Association is
inheritance-aware (direct postings OR through
source_memory_ids, thelane observations use by design). Entity leaves are barred under
notatthe API edge because two surfaces evaluate them permissively (rationale in
code). 22 tests. Happy to split into its own PR if preferred.
Open questions for maintainers
mental_model_delta_fast_path = true) orship opt-in?
needs_full_contextas the hand-back reason name — better ideas welcome.DELTA_SYSTEM_PROMPTconstant: remove in this PR or leave fora cleanup pass?