Skip to content

feat(mental-models): graded write-time identifier-retention gate — refuse refreshes that silently drop the document's anchors - #3306

Open
JoshFunnell wants to merge 19 commits into
vectorize-io:mainfrom
JoshFunnell:upstream/identifier-retention-gate
Open

feat(mental-models): graded write-time identifier-retention gate — refuse refreshes that silently drop the document's anchors#3306
JoshFunnell wants to merge 19 commits into
vectorize-io:mainfrom
JoshFunnell:upstream/identifier-retention-gate

Conversation

@JoshFunnell

@JoshFunnell JoshFunnell commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

A graded write-time identifier-retention gate for mental-model refreshes: a refresh that drops too many anchored identifiers (dates, file paths, commit shas, UUIDs, URLs, env vars, ports, versions) from existing content is refused with the existing preserve-and-fail contract; smaller losses proceed with a warning that names the lost identifiers verbatim.

Stacked on #3290. Most of this branch is that PR's two-tier work plus catch-up merges, so the commit count is misleading. The only commit that is this PR's own change is 17fb9908 feat(mental-models): graded write-time identifier-retention gate — reviewing that one commit reviews this PR.

Everything else is #3290 carried forward: merges of main and of the two-tier branch, and the CI fixes that landed there (an i18n prose-check fix, a duplicate OpenAPI enum title that broke Rust client generation, and the regenerated spec/client artifacts). All of it disappears from this diff once #3290 merges.

(Corrected 2026-08-11: this note previously said "only the top commit is new here", which stopped being true once main was merged in to resolve conflicts.)

Why

A refresh can lose its anchors while the document grows, so neither the empty-candidate guard nor any length check can see it. Measured over 364 real refresh events on one production bank:

lost identifiers events
0 337 (92.6%)
1 17
2 3
3+ 7 (1.9%) — the class worth refusing

One of those seven dropped three file names and a commit sha from a single model in one write, while the document got longer. Losing one or two is frequently legitimate churn (a superseded date, a path that stopped being relevant), which is why the gate is graded rather than absolute: refusing on any loss would fail ~1 refresh in 14 and quickly be switched off.

How

  • Pure logic in engine/identifier_retention.py (no DB, no LLM): extract identifier sets from previous/candidate content, set-difference them (an identifier that moves counts as kept), grade against a threshold.
  • Call site in _execute_mental_model_refresh, deliberately after the empty-candidate guard and the Delta refresh fallback overwrites the whole document with a new-facts-only synthesis #3112 delta-window guard, so those more precise refusals win. An engine-level test pins the ordering.
  • Own outcome value refresh_failed_identifier_retention (enum + OpenAPI + control-plane types/labels updated): the refused candidate is definitionally non-empty, so reusing refresh_failed_empty_candidate would make anything keyed on the outcome enum misclassify these. The raised error names the lost identifiers.
  • Warning persisted under reflect_response.identifier_retention on warn and refuse paths alike, so the signal is readable without keep_trace.
  • Bootstrap-safe: with no real previous content (PENDING/empty), the gate never fires — same condition as the sibling guard.
  • Knob: HINDSIGHT_API_MENTAL_MODEL_IDENTIFIER_LOSS_REFUSE (default 3; 0 = warn-only). Documented in configuration.md + env.example.

Tests

  • 14 unit tests on the pure module: taxonomy coverage, moved-identifier-counts-as-kept (the main false-positive direction), single-loss-warns, threshold edges incl. 0, bootstrap-never-refused, env-knob parsing (garbage falls back rather than breaking a write path), and a pinned taxonomy limit (the port class needs a host prefix).
  • 2 engine-level dry-run tests: the refusal reports its own outcome end to end with content preserved, and refresh_failed_delta_not_applied wins over identifier loss when both apply (guard ordering).
  • Full mental-model selection green locally (292 passed; the only errors are pre-existing tests requiring a live HINDSIGHT_API_LLM_API_KEY).

`_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.
A refresh can silently drop anchored identifiers -- dates, file paths, commit
shas, registry ids, env vars -- while the document GROWS, so neither the
emptiness guard nor any length check can see it.

Measured over 364 real refresh events on one production bank:

    lost 0 identifiers: 337 (92.6%)
    lost 1:              17
    lost 2:               3
    lost 3 or more:       7

One of those seven dropped TRIAL-CLOSE-RUNBOOK.md, TRIAL-EVIDENCE-PLAN.md,
build_spec_a_overlay.py and a commit sha from a single model in one write.
Losing one or two is frequently legitimate churn, so the gate is GRADED:
warn (naming the lost identifiers verbatim) below the threshold, refuse at or
above it. Default 3 therefore blocks 1.9% of refreshes rather than 7.4%.

Refusal is preserve-and-fail under its own outcome value,
refresh_failed_identifier_retention: content and watermark stay untouched and
the raised error names the lost identifiers verbatim. A distinct outcome
(rather than reusing refresh_failed_empty_candidate) keeps anything keyed on
the outcome enum honest -- the refused candidate is definitionally non-empty
here. The warning is also persisted under
reflect_response.identifier_retention on the warn and refuse paths alike, so
the signal is readable without keep_trace.

The gate runs AFTER the vectorize-io#3112 delta-window guard: a failed delta's
window-only candidate would also read as identifier loss, and the more
precise "candidate is one window, not a document" refusal must win. An
engine-level test pins that ordering.

The gate only applies when has_delta_baseline is true -- a bootstrap write
over empty/PENDING content cannot clobber anything.

The identifier taxonomy is byte-identical to the offline retention probe's:
two instruments that disagreed about what counts as an identifier would give
contradictory evidence about the same event.

Threshold knob: HINDSIGHT_API_MENTAL_MODEL_IDENTIFIER_LOSS_REFUSE (default 3,
0 = warn-only), documented in configuration.md and env.example.

Logic is pure functions in engine/identifier_retention.py so it is testable
without a DB or an LLM; the engine call site is a few lines. 14 unit tests
cover the two false-signal directions (a moved identifier counts as kept; the
gate never refuses without a baseline), the env-knob edges, and a measured
limit of the shared taxonomy (the port class needs a host prefix). Two
engine-level dry-run tests cover the refusal outcome end to end and the
guard ordering. The control-plane diagnostics view labels the new outcome in
all ten locales.
@JoshFunnell
JoshFunnell force-pushed the upstream/identifier-retention-gate branch from f5ceffd to 17fb990 Compare August 10, 2026 07:38
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.
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