Skip to content

fix(recall): enforce expiry/supersession in top-K recall SQL (TAP-4586)#221

Merged
wtthornton merged 1 commit into
mainfrom
tap-4586-recall-temporal-sql
Jul 4, 2026
Merged

fix(recall): enforce expiry/supersession in top-K recall SQL (TAP-4586)#221
wtthornton merged 1 commit into
mainfrom
tap-4586-recall-temporal-sql

Conversation

@wtthornton

Copy link
Copy Markdown
Owner

What

Push the expiry/supersession predicate into the KNN + FTS recall SQL so expired and superseded rows no longer consume the top-K budget. Previously they were dropped only after the top-K cut by the Python is_temporally_valid filter, so stale rows could crowd out live ones under budget pressure.

Live-row predicate (added to _postgres_private_sql.py):

AND superseded_by IS NULL
AND (valid_until IS NULL OR valid_until = '' OR valid_until::timestamptz > now())

valid_until is TEXT NOT NULL DEFAULT '' (migration 001), so a live row is the empty string — the issue's literal valid_until IS NULL OR valid_until > now() would have dropped every live row. Adjusted to the canonical form that mirrors MemoryEntry.is_temporally_valid.

How

  • Predicate uses now()/IS NULL only — no bound params, so existing positional param tuples are unchanged and tenant/RLS predicates (project_id/agent_id) are untouched.
  • Thread include_expired: bool = False through build_search_sql + new build_knn_search_sql, the sync + async backends, MemoryStore/QueryMixin, and the PrivateBackend protocol so the include_historical recall path still surfaces stale rows for the Python filter to decide.
  • Suppress the predicate when as_of is set — point-in-time queries resolve validity via the bi-temporal valid_at/invalid_at window and legitimately want the superseded version. (This fixed a TestTemporalFiltering::test_search_as_of_returns_old_version regression the naive predicate introduced.)
  • Python is_temporally_valid gate kept as defense-in-depth (unchanged).

Test

tests/integration/test_recall_temporal_sql.py (@pytest.mark.requires_postgres, ran — not skipped, against an isolated throwaway pgvector:pg17 container):

  • Expired + superseded rows placed with a closer vector distance / higher FTS rank than the live row → asserted they do NOT occupy top-K while the live row does.
  • Live-row (valid_until='') and future-valid_until rows still returned.
  • include_expired=True escape hatch surfaces stale rows (KNN + FTS).
  • as_of point-in-time still returns the superseded version.
  • Cross-project rows never leak (tenant scoping intact) for both KNN and FTS.

Results: new test 10 passed; with the temporal unit tests 13 passed; existing recall integration 25 passed (no regression); affected-module unit sweep 512 passed / 26 skipped. ruff clean; mypy --strict src/tapps_brain/ → no issues in 152 files.

Closes TAP-4586.

🤖 Generated with Claude Code

Expired (valid_until in the past) and superseded (superseded_by set) rows
previously won top-K by vector distance / FTS rank and were only dropped
afterward by the Python is_temporally_valid filter, wasting the top-K budget
so live rows could be crowded out.

Push the live-row predicate into the KNN and FTS recall SQL:
  superseded_by IS NULL
  AND (valid_until IS NULL OR valid_until = '' OR valid_until::timestamptz > now())

valid_until is TEXT NOT NULL DEFAULT '' (migration 001), so the canonical live
row is the empty string — the issue's literal `valid_until > now()` would have
dropped every live row; adjusted to mirror MemoryEntry.is_temporally_valid.

- Predicate uses now()/IS NULL only — no bound params, so existing param tuples
  are unchanged and tenant/RLS predicates (project_id/agent_id) are untouched.
- Thread include_expired (default False) through build_search_sql /
  build_knn_search_sql, the sync+async backends, MemoryStore/QueryMixin, and the
  PrivateBackend protocol so the include_historical recall path still surfaces
  stale rows for the Python filter to decide.
- Suppress the predicate when as_of is set: point-in-time queries resolve
  validity via the bi-temporal valid_at/invalid_at window and legitimately want
  the superseded version.
- Python is_temporally_valid gate kept as defense-in-depth (unchanged).

New requires_postgres test proves stale rows with a closer vector / higher FTS
rank never occupy top-K when live rows exist, the include_expired + as_of
escape hatches work, and cross-project rows never leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wtthornton wtthornton force-pushed the tap-4586-recall-temporal-sql branch from 158e1c9 to 6d41eb9 Compare July 4, 2026 23:27
@wtthornton wtthornton merged commit 3a80734 into main Jul 4, 2026
2 checks passed
@wtthornton wtthornton deleted the tap-4586-recall-temporal-sql branch July 4, 2026 23:34
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