feat(memory): validity windows on memory_units (valid_to + /invalidate endpoint) - #1395
feat(memory): validity windows on memory_units (valid_to + /invalidate endpoint)#1395nikolay-bratanov wants to merge 1 commit into
valid_to + /invalidate endpoint)#1395Conversation
…date` endpoint)
Adds a nullable `valid_to TIMESTAMPTZ` column to `memory_units`, a partial
index limited to `valid_to IS NULL` rows, and a
`POST /memories/{memory_id}/invalidate` endpoint so that *superseded* facts
can be soft-retired without losing their timeline. Recall queries (semantic,
BM25, date-range entry-point CTE, graph-spreading neighbours, and the
Oracle-fallback semantic-only path) all skip rows whose `valid_to` has
elapsed; `GET /memories/{id}` and `GET /memories/{id}/history` still return
them, preserving the audit trail.
Closes vectorize-io#1391.
## Why
Today, when a fact in Hindsight becomes stale (a server is decommissioned, a
person changes role, a default value changes), the only options are *delete*
(audit trail gone) or *leave it* (recall returns it next to the corrected
fact, and the LLM has to disambiguate at recall time). Other learning-oriented
memory systems (e.g. MemPalace's `triples`/`attributes` tables) make
`valid_from` / `valid_to` first-class. This PR adds the same to Hindsight in
the smallest possible shape.
## What's included
* **Migration** `a2b3c4d5e6f7_add_valid_to_to_memory_units.py` — `ALTER TABLE
memory_units ADD COLUMN valid_to TIMESTAMPTZ NULL` plus partial index
`idx_memory_units_active (bank_id, fact_type) WHERE valid_to IS NULL`. Both
`IF NOT EXISTS` so the migration is idempotent. Down-migration drops both.
* **Model** `MemoryUnit.valid_to` and the corresponding `Index` in
`__table_args__`.
* **Recall filter**: a `validity_clause = "AND (valid_to IS NULL OR valid_to >
{dialect.current_timestamp()})"` is composed once in `retrieval.py` and
injected into the four WHERE blocks via the existing `extra_where`
parameter on `build_semantic_arm` / `build_bm25_arm` (semantic UNION arms,
BM25 UNION arms, date-range entry-points CTE, graph-spreading neighbour
join). The Oracle-fallback semantic-only path also gets the filter.
* **Engine** `MemoryEngine.invalidate_memory_unit(bank_id, memory_id, *,
valid_to=None, reason=None, request_context)` — single UPDATE with explicit
`$3::timestamptz` cast (avoids `AmbiguousParameterError` when `valid_to` is
also written into JSONB metadata in the same statement). Default `valid_to`
is UTC `now()`. Operation goes through `validate_bank_write` like the
existing `clear_observations_for_memory`.
* **Endpoint** `POST /v1/default/banks/{bank_id}/memories/{memory_id}/invalidate`
with `InvalidateMemoryRequest` / `InvalidateMemoryResponse` Pydantic
models. Returns 404 when the memory unit isn't found, 400 on a malformed
`valid_to`, and propagates `OperationValidationError` and `audited(...)`
bookkeeping like sibling memory endpoints.
* **`get_memory_unit`** also returns `valid_to` so clients can see invalidation
status without hitting `/history`.
* **Tests** in `tests/test_invalidate_memory.py` covering the engine method
(default-now, not-found, malformed-uuid) and the dialect-level guarantee
that semantic and BM25 arms include the validity filter.
## Validation
Locally I've been running the equivalent of this patch against a managed
Hindsight install (~1.4k memory units, ~720 entities) for several days. End
to end:
* Insert a memory → recall returns it.
* `POST /memories/{id}/invalidate` → recall stops returning it.
* `GET /memories/{id}/history` → still shows it.
* `recall` over a 91-result window after invalidation → invalidated row is
filtered out; ordering of the rest is unchanged.
The local version did the work via text-replace on the installed
`retrieval.py` and out-of-band DDL; this PR does it cleanly through a real
alembic migration and the SQL builder.
## Notes for reviewers
* Naming: `valid_to` matches the convention in `MemPalace`'s schema and pairs
naturally with eventual `valid_from` if we want to make that explicit
(today it's implicit in `mentioned_at` / `event_date`). Open to
`superseded_at` / `retired_at` if you have a strong preference.
* HTTP verb: `POST .../invalidate` — non-idempotent w.r.t. metadata
(`invalidation_reason` overwrites). `PATCH /memories/{id}` with a partial
body would also be reasonable; happy to switch.
* Worker-side awareness: this PR does not yet teach consolidation /
reflect workers to skip `valid_to IS NOT NULL` rows. If you'd like that in
the same PR, let me know — straightforward but increases scope.
* `as_of` recall: a natural follow-up — recall with `as_of: <ISO>` to "see
what was active at that point". Out of scope here.
|
Second production datapoint in support of this PR. Running Hindsight v0.13 inside a Hermes Agent install. Posting current SQL counts from the live bank (not estimates): The top-10 clusters by size are all the same handful of Hindsight self-canon sentences ("Hindsight is the working-memory layer between llm-wiki and MemPalace…", "Hindsight explicitly excludes Minerva brand/product canon…", the nightly-dedupe-script summary) getting re-extracted on essentially every session that touches the topic. ~5% of the bank is byte-identical repeats, before we even talk about paraphrase clusters. Why I think soft-invalidate is the right shape (not hard DELETE). I tried writing a doc-level paraphrase-cluster pruner last week. Dry-run found two clusters at Jaccard ≥0.65: one was 10 distinct sessions from a deck-authoring arc linked only by shared vocabulary (false positive — deleting would erase 4 days of session continuity), the other was 3 auto-curator pings (legitimate). Hard DELETE at doc level can't tell those apart without manual review of every cluster. Per-MU soft-invalidate cleanly resolves both cases: the duplicate facts die, the session transcripts that contain them stay alive, audit trail intact via This also matches how I already use MemPalace's Adoption. If this merges I'd run it immediately:
On the closed PRs (#794 / #796) and #1228. I read the thread. Understand the security gate on One small ask, no objection if you'd rather defer. The submitter mentions worker-side awareness (consolidation/reflect skipping Happy to provide more diagnostic data (anonymized recall blocks, before/after token counts, the exact dup-cluster query plan) if useful. I'm planning to run this PR's branch locally against the production bank while we wait on merge — happy to report back if anything surfaces. Thanks for the project, and to @nikolay-bratanov for the well-scoped PR. |
|
Quick heads-up from running the branch locally against current Two separate things going on:
Everything else looked good locally:
Tiny test-file fix I had to apply locally so the suite runs: - from hindsight_api.engine.sql.postgresql import PostgresDialect
+ from hindsight_api.engine.sql.postgresql import PostgreSQLDialect as PostgresDialectThe class is named Happy to open a small follow-up PR against your branch with the rename + revision-id bump if it'd save you a round-trip. Otherwise no rush — just wanted the collision on the record so it doesn't bite at merge. |
|
please reply to these questions before working on the pr - closing for now #1391 (comment) |
Closes #1391.
Summary
Adds a nullable
valid_to TIMESTAMPTZcolumn tomemory_units, a partial index limited tovalid_to IS NULLrows, and aPOST /memories/{memory_id}/invalidateendpoint so that superseded facts can be soft-retired without losing their timeline. Recall queries skip rows whosevalid_tohas elapsed;GET /memories/{id}andGET /memories/{id}/historystill return them, preserving the audit trail.What's included
a2b3c4d5e6f7_add_valid_to_to_memory_units.py—ALTER TABLE memory_units ADD COLUMN valid_to TIMESTAMPTZ NULLplus partial indexidx_memory_units_active (bank_id, fact_type) WHERE valid_to IS NULL. BothIF NOT EXISTSso the migration is idempotent. Down-migration drops both.MemoryUnit.valid_toand the correspondingIndexin__table_args__.engine/search/retrieval.py— avalidity_clause = "AND (valid_to IS NULL OR valid_to > {dialect.current_timestamp()})"is composed once and injected into the four WHERE blocks via the existingextra_whereparameter onbuild_semantic_arm/build_bm25_arm(semantic UNION arms, BM25 UNION arms, date-range entry-points CTE, graph-spreading neighbour join). The Oracle-fallback semantic-only path also gets the filter. No dialect-level changes — portability comes fromdialect.current_timestamp().MemoryEngine.invalidate_memory_unit(bank_id, memory_id, *, valid_to=None, reason=None, request_context)— single UPDATE with explicit$3::timestamptzcast (avoidsAmbiguousParameterErrorwhenvalid_tois also referenced inside JSONB metadata in the same statement). Defaultvalid_tois UTCnow(). Operation goes throughvalidate_bank_writelike the existingclear_observations_for_memory.POST /v1/default/banks/{bank_id}/memories/{memory_id}/invalidatewithInvalidateMemoryRequest/InvalidateMemoryResponsePydantic models. Returns 404 when the memory unit isn't found, 400 on a malformedvalid_to, and propagatesOperationValidationError+@audited(...)bookkeeping like sibling memory endpoints.get_memory_unitalso returnsvalid_toso clients can see invalidation status without hitting/history.tests/test_invalidate_memory.pycovering the engine method (default-now, not-found, malformed-uuid) and the dialect-level guarantee that semantic and BM25 arms include the validity filter.Diff size: +438 / −4 across 6 files (4 modified + migration + test).
Why
See #1391 for the full design rationale. Short version: when a fact in Hindsight becomes stale (a server is decommissioned, a person changes role, a default value changes), today's options are delete (audit trail gone) or leave it (recall returns it next to the corrected fact, and the LLM has to disambiguate at recall time). Other learning-oriented memory systems (e.g. MemPalace's
triples/attributestables) makevalid_from/valid_tofirst-class. This PR adds the same to Hindsight in the smallest possible shape: one column + one index + one endpoint, no schema breaking changes.Validation
I've been running the equivalent of this patch against a managed Hindsight install (~1.4k memory units, ~720 entities) for several days. End to end:
POST /memories/{id}/invalidate→ recall stops returning it.GET /memories/{id}/history→ still shows it.The local version did the work via text-replace on the installed
retrieval.pyand out-of-band DDL; this PR does it cleanly through a real alembic migration and the SQL builder.Test plan
pytest hindsight-api-slim/tests/test_invalidate_memory.pyNotes for reviewers
valid_tomatches the convention in MemPalace's schema and pairs naturally with eventualvalid_fromif we want to make that explicit (today it's implicit inmentioned_at/event_date). Open tosuperseded_at/retired_atif you have a strong preference.POST .../invalidate— non-idempotent w.r.t. metadata (invalidation_reasonoverwrites).PATCH /memories/{id}with a partial body would also be reasonable; happy to switch.valid_to IS NOT NULLrows. If you'd like that in the same PR, let me know — straightforward but increases scope.as_ofrecall: a natural follow-up — recall withas_of: <ISO>to "see what was active at that point". Out of scope here.🤖 Generated with Claude Code