Repository navigation
fix(recall): bound temporal entry-point scan to top-50-per-fact_type (alternative to #1958) - #1983
Merged
Merged
Conversation
…overage
retrieve_temporal_combined Phase 1 ranked the *entire* date-window match set by
COALESCE(occurred_start, mentioned_at, occurred_end) and kept the 50 most recent.
Two problems, one perf and one functional:
- Perf: on banks with dense/near-uniform date metadata (e.g. a retain pipeline
that stamps a large batch with one date) any recall window intersects
(near-)all rows, so Phase 1 degraded to a full sequential scan + disk-spilling
sort. EXPLAIN on a 680k-row bank: Seq Scan 680k + Sort 680k to keep 50
("Rows Removed by Filter: 679,950"), ~672ms Phase 1 alone (30s+ in prod).
- Functional: ranking by recency biases results toward the END of the window,
and when dates are degenerate the "50 most recent" is a near-random sample
that can drop the single most relevant in-window memory before similarity is
ever considered.
Switch the entry-point gate to embedding similarity within the window
(ORDER BY embedding <=> query, per fact_type, LIMIT pool), then narrow the pool
to N per fact_type with coverage-first round-robin across time-buckets so the
entry points span the window's range instead of clustering. Degenerate dates
collapse to plain similarity order.
The planner serves the similarity-ordered window query from the existing
per-(bank, fact_type) HNSW index when the window is broad (the dense case) and
from the existing partial date indexes + an exact sort when it is narrow — so no
new index is needed. (An earlier revision of this PR added a recency expression
index; Option A makes it unnecessary, so it's removed.)
Measured on a 680k-row dense-date bank (recall_perf): temporal arm
1.174s -> 0.009s; the arm is now both fast and returns the most relevant
in-window memories, spread across the window.
This is the alternative to #1958, which skipped the temporal arm entirely above a
planner row estimate (losing temporal recall on large banks).
- tests (no LLM): coverage round-robin + degenerate-date fallback (pure
selector); similarity-over-recency selection and window filtering (DB-backed)
- recall_perf: `generate --event-date` (dense zone) + `benchmark
--temporal-date` (forces the temporal arm) to reproduce and track this
nicoloboschi
force-pushed
the
fix/temporal-recall-bounded-scan
branch
from
June 5, 2026 08:23
e1edf2e to
673ce76
Compare
3 tasks
nicoloboschi
added a commit
that referenced
this pull request
Jun 5, 2026
…o unnest) The temporal-recall entry-point selection was rewritten on main (#1983) to gate candidates by embedding similarity within the window. That new query expanded the fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has no Oracle equivalent — so after merging main, Oracle recall would again fail with ORA-03048 on any temporal query, in the entry-point query this time (the spreading guard added here only covers the multi-hop spread). Rebuild the entry-point query as a UNION ALL of one similarity-ranked, window-filtered arm per fact_type with the fact_type inlined as a literal — the same shape retrieve_semantic_bm25_combined already uses and which the Oracle backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone. Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type) vector index; selection + coverage logic is identical) — verified by the existing temporal selection tests and the recall_perf temporal benchmark (temporal arm ~0.002s on the 680k dense bank). Oracle output verified through the real _rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.
nicoloboschi
added a commit
that referenced
this pull request
Jun 5, 2026
…backend (#1980) * fix(oracle): make recall and mental-model history work on the Oracle backend Two code paths emitted PostgreSQL-specific SQL that has no Oracle equivalent and is not handled by the PG→Oracle query rewriter, so they raised hard errors on the Oracle 23ai backend: 1. Recall — `retrieve_temporal_combined` expands a batch of seed ids for multi-hop temporal-link spreading with `FROM unnest($2::uuid[]) AS src(from_unit_id)`. Oracle has no `unnest`, so recall raised `ORA-03048` whenever the matched memories had temporal/causal links (the common case). Fix: guard the spreading loop on the connection's `backend_type`; on backends without `unnest` we skip only the multi-hop spread. The temporal entry points are still returned, and the semantic / keyword / graph retrievers are unaffected. 2. Mental-model history — `update_mental_model` trims the history array in SQL with `jsonb_agg(... ORDER BY ...)` over `jsonb_array_elements(...) WITH ORDINALITY`, which raised `ORA-00907` and made mental-model creation fail (the create path triggers a refresh that updates content). Fix: on Oracle, compute the trimmed history in Python (we already fetch the current array) and bind it as a single JSON value. The PostgreSQL SQL path is unchanged. Both are instances of the dialect-asymmetry trap called out in CLAUDE.md. Test plan: - Oracle 23ai e2e smoke + HTTP integration: mental-model create/CRUD and full-lifecycle (previously failing with ORA-00907) now pass. - Full Oracle integration suite shows zero ORA-03048 occurrences. - PostgreSQL mental-model history unit tests (including max-entries trimming) still pass — the PG path is byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(oracle): return CLOB columns from RETURNING without a 4000-byte cap The Oracle backend's RETURNING handler bound every non-numeric, non-timestamp output column as DB_TYPE_VARCHAR. VARCHAR out-binds cap at 4000 bytes, so any CLOB-backed column returned via a RETURNING clause raised `ORA-22835: buffer too small for CLOB to CHAR conversion` once its value exceeded 4000 bytes. This surfaced as mental-model creation failing on Oracle: the post-create refresh UPDATEs `content` (a CLOB) with `RETURNING content`, and a sufficiently long synthesized snapshot (>4000 bytes) aborted the update. Fix: bind known CLOB columns (the JSON-as-CLOB set plus the large-text columns content/text/context/structured_content/text_signals/search_vector) as DB_TYPE_CLOB in the RETURNING var setup, and read the LOB handle back to a string in _read_returning_values (the async pool yields AsyncLOB, whose read() is awaited). Non-CLOB columns are unchanged. Verified against Oracle 23ai: - A 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously ORA-22835); other columns (RAW(16) ids, etc.) still convert correctly. - Mental-model create/refresh with large content succeeds. - RETURNING-heavy Oracle integration tests (retain, tags, document/memory CRUD, http retain/recall, full lifecycle) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(oracle): make the temporal entry-point query Oracle-compatible (no unnest) The temporal-recall entry-point selection was rewritten on main (#1983) to gate candidates by embedding similarity within the window. That new query expanded the fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has no Oracle equivalent — so after merging main, Oracle recall would again fail with ORA-03048 on any temporal query, in the entry-point query this time (the spreading guard added here only covers the multi-hop spread). Rebuild the entry-point query as a UNION ALL of one similarity-ranked, window-filtered arm per fact_type with the fact_type inlined as a literal — the same shape retrieve_semantic_bm25_combined already uses and which the Oracle backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone. Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type) vector index; selection + coverage logic is identical) — verified by the existing temporal selection tests and the recall_perf temporal benchmark (temporal arm ~0.002s on the 680k dense bank). Oracle output verified through the real _rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST. --------- Co-authored-by: DK09876 <dk09876@DK09876s-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
nicoloboschi
added a commit
that referenced
this pull request
Jun 8, 2026
The existing recall suites only exercise the temporal retrieval arm incidentally. This adds a dedicated 'recall-temporal' suite that stamps all memories with one event_date and augments every query with a 1-day window on it, so the temporal entry-point scan matches (near-)all rows — the dense-temporal-zone regime from #1958 that #1983 bounded. - _populate_bank gains an optional event_date for the clustered regime - registered in SUITES; runs by default in the daily all-suites job - added to the workflow_dispatch suite choices for manual single runs Results flow to the perf dashboard automatically (publish script keeps the full suites[] array); a matching 'Recall + temporal' page has been added there.
nicoloboschi
added a commit
that referenced
this pull request
Jun 8, 2026
…2046) The existing recall suites only exercise the temporal retrieval arm incidentally. This adds a dedicated 'recall-temporal' suite that stamps all memories with one event_date and augments every query with a 1-day window on it, so the temporal entry-point scan matches (near-)all rows — the dense-temporal-zone regime from #1958 that #1983 bounded. - _populate_bank gains an optional event_date for the clustered regime - registered in SUITES; runs by default in the daily all-suites job - added to the workflow_dispatch suite choices for manual single runs Results flow to the perf dashboard automatically (publish script keeps the full suites[] array); a matching 'Recall + temporal' page has been added there.
slurrr
added a commit
to slurrr/hindsight
that referenced
this pull request
Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
retrieve_temporal_combinedPhase 1 ranked the entire date-window match setwith a window function and then discarded all but the top 50 per
fact_type.On banks with dense or near-uniform date metadata — e.g. a retain pipeline that
stamps a large batch with a single
mentioned_at/event_date— any temporalrecall window intersects (near-)all rows, so the query degraded to a full
sequential scan plus a disk-spilling sort. This is the 30s+ prod temporal recall
behind #1958.
This PR fixes the query so the temporal arm stays correct and fast at scale,
rather than skipping it.
Root cause (reproduced + EXPLAIN'd on a 680k-row dense-date bank)
The window function forces a full sort:
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC)must rankevery matched row before the outer
rn <= 50filter applies. EXPLAIN ANALYZE:The date filter is index-able; the ranking sort is the trigger. When all
events share ~one date the sort key is degenerate (all tied), so the "top 50 most
recent" is 50 arbitrary rows produced by sorting the whole bank.
Fix
Rewrite Phase 1 as a
CROSS JOIN LATERALwithORDER BY … LIMIT 50perfact_type, backed by a new partial expression index:The planner walks the index in recency order and stops after 50 matching rows
per fact_type, so work is bounded to ~
50 × len(fact_types)index entriesregardless of how many rows fall in the window. Semantics are unchanged (50
most-recent in-window per type → similarity rerank → top 10). The
NULLS LASTinthe index is load-bearing: it matches the query's
DESC NULLS LASTso the indexsupplies the ordering directly (a regression test pins this).
Results (680k-row dense-date bank,
recall_perf)(Local pg0 has more
work_memthan prod, so the absolute local number is ~1s vsprod's 33s, but the pathology and the fix are the same.)
vs #1958
#1958 added a planner-estimate circuit breaker that skips the temporal arm
entirely above a row estimate — losing temporal recall on exactly the large
banks where it matters. This PR keeps the arm working and makes it fast, so the
circuit breaker isn't needed.
Reproduce
generate --event-datestamps all memories into one date (dense zone);benchmark --temporal-dateappends an absolute date to the query so the temporalarm fires on that window. Both flags are new in this PR.
Tests
tests/test_temporal_recall_bounded_scan.py(deterministic, no LLM):is excluded even with a perfect-match embedding
DESC NULLS LASTordering with no Sort node(guards a NULLS-order / index-shape regression)
test_migration_shape.pypasses (migration dispatches throughrun_for_dialect;PG-only, Oracle slot intentionally absent since the query uses pgvector
<=>).