feat(memory): validity windows on memory_units (valid_to + /invalidate) — revives #1395 - #1878
feat(memory): validity windows on memory_units (valid_to + /invalidate) — revives #1395#1878slayoffer wants to merge 3 commits into
Conversation
…ize-io#1395) Revives the abandoned PR vectorize-io#1395 by @nikolay-bratanov: - ALTER TABLE memory_units ADD COLUMN valid_to TIMESTAMPTZ NULL - Partial index idx_memory_units_active WHERE valid_to IS NULL - POST /v1/default/banks/{bank_id}/memories/{memory_id}/invalidate - Recall filter to skip rows with valid_to <= now() - get_memory_unit returns valid_to so clients can see invalidation status - Tests in tests/test_invalidate_memory.py covering engine + dialect filter This commit applies the squashed diff verbatim from vectorize-io#1395 with one trivial conflict resolution in retrieval.py where upstream/main moved the surrounding extra_where wiring after vectorize-io#1395 was opened (one hunk rebased onto the new extra_where_clause variable). Subsequent commits resolve the mechanical blockers that closed the original (migration revision id collision, test import typo) and ship a design memo (in the PR description) answering the question raised in vectorize-io#1391 about responsibility for invalidation. Co-authored-by: Nikolay Bratanov <56667908+nikolay-bratanov@users.noreply.github.com>
a2b3c4d5e6f7 was reused by add_text_signals_column on upstream/main after vectorize-io#1395 was opened. Renumbering to 39a06891cc3f (fresh hex) and chaining off the current upstream head c1d2e3f4a5b6 (merge of graph queue and vchord heads) clears the collision flagged by @yonefive71 on the original PR thread.
…_memory The class is named PostgreSQLDialect across the codebase (e.g. in hindsight_api/engine/sql/postgresql.py and test_db_abstraction.py). PR vectorize-io#1395's test file used PostgresDialect, causing an ImportError. Aliasing on import keeps the test body unchanged (still references PostgresDialect()) while making it run. Credit: @yonefive71 in the original PR review.
|
Really appreciate this PR — both the direction and the thorough write-up. I'm a production Hermes Agent user running Hindsight as the sole memory backend, and we've been actively trying to figure out memory pruning over the past few days. This is exactly the missing piece. Our bank has ~54K nodes / ~1K documents after a few weeks of use. We went through the same discovery process you described:
The One practical question: for batch dedup scenarios (say a script identifies 200 near-duplicate memories), would the intended workflow be to call Hope the maintainers can take a look at this — the in-stream consolidation improvements alone are valuable, but the invalidation endpoint is what makes pruning actually viable for production users. |
|
I had my agent delete memories mentioning the stuff that got injected constantly, my laptop then spent 1 day recomputing the database (LLM). While ago I reinstalled hermes and hindsight (as self hosted external), webui is useful, but I just hit the same problem as before - the agent refuses to use some tools, because it know from memory those don't work, even though I've fixed them, or tries to inject non-relevant memory facts everywhere, though this might be a hermes bug (or maybe my LLMs are too stupid). |
|
superseded by #1976 |
Summary
Revives the abandoned PR #1395 by @nikolay-bratanov, which implemented validity windows on
memory_units(valid_to TIMESTAMPTZ NULLcolumn + partial index +POST /memories/{memory_id}/invalidateendpoint + recall filter + tests). Resolves the two mechanical blockers diagnosed by @yonefive71 in the original PR review:a2b3c4d5e6f7, which was subsequently reused byadd_text_signals_columnonmain. Renumbered to a fresh hex id, chained off the current upstream head.from hindsight_api.engine.sql.postgresql import PostgresDialect→... import PostgreSQLDialect as PostgresDialect. One-line aliasing, no test-body changes.The substantive design question from issue #1391 ("who's responsible to invalidate facts?") is addressed in the design memo below — short answer: split by signal source (in-stream vs out-of-band), not by responsible actor. The consolidator + a better-shaped
observations_missionhandles in-stream contradictions;/invalidateis the missing primitive for out-of-band cases (user retracts, agent self-correction, post-hoc dedup of pre-existing duplicate clusters). They are complementary, not competing.Marked as draft to give the maintainer space to react to the design framing before this goes to ready-for-review.
Original code is preserved verbatim across the 6 touched files; only the migration filename + revision frontmatter + one test import line differ from PR #1395.
Commits
valid_to+/invalidateendpoint) #1395) — squashed apply of the original diff,Co-authored-by: Nikolay Bratanov.Design context — fact obsolescence in Hindsight
TL;DR
valid_to+/invalidateendpoint) #1395 ("who's responsible to invalidate facts?") has a concrete answer: both, but for different signal classes. The consolidator handles implicit invalidation (new fact contradicts an old fact in the same retain stream); the agent/user needs an explicit/invalidatefor everything else (user retracts, agent self-correction, post-hoc dedup, out-of-band staleness like a server being decommissioned). These are different problems with different signal sources — neither subsumes the other.observations_mission. On a sandbox bank seeded with 12 raw facts (3 contradiction cases + 4 protection cases), the default mission produced 5 unmerged observations from just the BMW/Toyota contradiction pair; a mission rewrite produced 1 cleanly merged observation with both states + transition date, while preserving every legitimate historical fact and multi-facet split. Numbers and outputs below.valid_to+/invalidateendpoint) #1391 / PR feat(memory): validity windows on memory_units (valid_to+/invalidateendpoint) #1395. The mission improves consolidation when the signal is in-stream; it cannot help when the signal is out-of-band (user clicks "this is wrong," agent detects a stale operational fact, dedup script wants to retire 1,280 byte-identical clusters as in @yonefive71's data). Both layers are needed.docs/concepts/. A short "in-stream vs out-of-band invalidation" docs section keeps the framing clear so future users reach for the right tool.1. The framing question — who invalidates?
@nicoloboschi on #1391:
Both halves of the dichotomy are partially true but each one alone is insufficient. The proposed answer is to split the problem by signal source, not by responsible actor:
observations_missionPOST /memories/{id}/invalidateendpoint (this PR)/invalidateif it has high confidenceThe current implementation only addresses row 1, and it does so conservatively. Rows 2 and 3 are not addressed at all today. This PR is the missing primitive for rows 2 and 3. It is not in competition with the consolidator; it complements it.
The "if it's the agent, this will not work consistently" concern is real, but it is a policy concern (when should the agent be trusted to invalidate?), not a mechanism concern. Even an explicit endpoint can be gated — IAM-style, with admin-only by default and an opt-in
allow_agent_invalidateper-bank flag. The mechanism needs to exist regardless of the policy.2. State of the art — three patterns, one good fit
We surveyed how other LLM memory systems handle this:
valid_at/invalid_at/created_at/expired_at)memory_units+ LLM-synthesisedobservations+ soft recency boost in rerankingThe Graphiti bi-temporal model maps most naturally onto Hindsight's existing architecture:
memory_unitsare already immutable append-only rows; observations are already synthesised summaries with ahistoryJSONB audit column. Adding avalid_to TIMESTAMPTZ NULLcolumn tomemory_units(this PR) is the smallest possible step toward bi-temporal semantics — it adds one bit of state (active/invalidated) without committing to a full temporal-graph refactor.mem0's v2→v3 retreat is instructive. The reason their explicit UPDATE/DELETE classifier failed was that they ran it as a separate per-fact LLM call without sufficient context, leading to high noise. Hindsight's consolidator avoids this trap because UPDATE/DELETE decisions already happen inside the existing consolidation LLM call, which already loads related observations via recall — the signal is there, only the prompt shape is weak. This is empirically validated below.
3. Empirical evidence — mission rewrite alone closes 80%+ of the in-stream gap
Tested on a fresh sandbox bank (
obsolescence-test) on a production Hindsight deployment (API v0.6.2) on 2026-05-31. Same engine, same default LLM (gpt-5-mini), no code changes — only per-bankobservations_missiondiffered.Test setup
12 raw facts retained via
POST /v1/default/banks/{bank_id}/memories, with backdated timestamps so recency reasoning had real time gaps to work with:Baseline — default
observations_missionThe default mission is
"Track every detail: names, numbers, dates, places, and relationships. Prefer specifics over abstractions, never generalise."(seehindsight-api-slim/hindsight_api/engine/consolidation/prompts.py:_DEFAULT_MISSION).After retain + explicit consolidation, observations for the BMW/Toyota pair alone:
5 unmerged observations from 2 raw facts. None marked superseded. Recall for
"what cars does Alice like"returned the BMW raw fact at position 1 despite being 3.5 months older — the embedding-similarity advantage swamped the ~6.8% recency penalty (engine/search/reranking.py:101-129). This is the failure mode reported by @yonefive71 (1,280 duplicate cluster, 17,569 total) and @1ain0516 (multi-agent bank with English/Chinese duplicates).After — rewritten
observations_missionAfter
bank clear-observations+bank consolidatewith the new mission, the BMW/Toyota pair collapses to a single observation:Adding facts 3–9 and re-consolidating, the full bank produced 8 observations (vs the ~15–20 a baseline default would produce extrapolating from the 5-for-2 baseline ratio):
Recall for
"where does Bob work"returns row 2 only — single clean answer. Recall for"what cars does Alice prefer"returns row 1 first — the merged superseding observation. Recall for"what pets has Alice had"returns rows 4 then 3 — Luna first (recent), Rex with death info second (history). All three queries give the agent immediately usable, deduplicated, recency-correct answers.What this empirically confirms
_PROCESSING_RULES(rule 7: "PRESERVE HISTORY: …. Be very conservative with deletes") is not the binding constraint. The mission, which is injected above the rules (build_batch_consolidation_prompt), takes priority when the LLM has to choose. A mission that explicitly says "UPDATE for preferences/state, PRESERVE for dated events" successfully overrides the default conservative bias._PROCESSING_RULES— facet matching, multi-entity protection) still work under the aggressive mission. We saw no regressions on Tesla+boat (multi-facet), Rex (dated event), or Luna (different-entity-same-facet).What this does NOT solve
The raw
memory_unitsrows still contain the BMW fact verbatim. If a downstream consumer queries byfact_type=worldorfact_type=experience, they still get the conflict. Only observation-layer queries are clean. This is exactly the gap this PR is designed to address — when you want raw recall to skip a fact, the only available mechanism today is DELETE (audit gone) or "wait and hope consolidation handles it." Mission rewrite does not give youvalid_to.It also does nothing for the @yonefive71 case (1,280 exact-duplicate
memory_unitsalready in the live bank from past sessions). Mission only affects future consolidation; it cannot retire pre-existing duplicates. A/invalidateendpoint can — and per @yonefive71's analysis, the trivialGROUP BY text HAVING count(*) > 1sweep would reclaim ~825 rows on day one.4. Proposed path forward
docs/concepts/distinguishing in-stream vs out-of-band invalidation. Reference the mission-rewrite pattern as the first-line in-stream improvement. Reference/invalidateas the explicit primitive for everything else. Happy to do this in a follow-up PR.allow_agent_invalidatebool to bank config, defaultingfalse(only API-key holders can invalidate). This addresses the maintainer's "if it's the agent, this will not work consistently" concern by making agent-driven invalidation an explicit opt-in. The mechanism still exists for the cases where it's needed (admin scripts, user-explicit retracts). Can be a follow-up PR or included here if you prefer.valid_totogether cover most of the pain. Triples are a larger architectural conversation that should not block this work.5. Acknowledgements
Test plan
pytest hindsight-api-slim/tests/test_invalidate_memory.pypasses after thePostgreSQLDialectalias fix (5 tests).multiple headsafter revision-id renumber).docs/concepts/distinguishing in-stream vs out-of-band invalidation — follow-up PR after design is approved.🤖 Authored by Claude (Anthropic) in collaboration with @nikolay-bratanov, @yonefive71, and the other thread contributors named above.