Files
ely-eye/backend/tests/test_as_of_search.py
ZacharyZhang-NY 92237c8f10 Let a retired memory keep its vector and its place in the present
The second review round of p2-4 measured three behaviour defects behind the
as_of feature it had just accepted, and all three came from one mistake: the
code asked "is this memory current?" in three places and answered differently.

- A memory that has closed kept no vector at all, so the dense leg could only
  exclude an old fact, never recall it. Vectors now follow existence, not
  currency; `_allowed` decides who may see one.
- "Open right now" meant `valid_to IS NULL` in the view and in the words leg
  but `valid_to IS NULL OR valid_to > now` in the vector leg, so a supersede
  booked for next year hid a fact that is still true today. All three now read
  the same rule; schema v12 records the change and the upgrade drops the old
  view, since CREATE VIEW IF NOT EXISTS would have kept the old body.
- The dense as_of test was名不副实: dense is off by default, so it passed while
  the vector side had no coverage. It now turns dense on with a stand-in model
  and pins both `VectorIndex.search(as_of=…)` and the plumbing in retrieval.

Five mutations, each caught by exactly one test: the dense as_of branch, the
retrieval hand-off, the three vector reads, the words-leg currency, the view.

Record side: the round-2 line quoted a D-list I had misread out of the
reviewer's PASS tables (cf2de31 fixed those, and they were its precision notes,
not its findings), the stale line-number parenthetical, an acceptance grep that
scanned CHECKLIST's own quotations, and a Gaps.md tally that disagreed with its
own table (8 closed, 3 closed with reservations, 1 partial).

Gates: 411 passed; ruff, filesize, audit 33/2/6 clean; pyright 0 on the six
touched files; mypy cold cache Success on the same six; scripts/key_test.sh
exit 0 (6 PASS, 1 NOT MEASURED).
2026-10-09 22:13:11 -04:00

220 lines
8.9 KiB
Python

"""As-of search: what was true then, not what is true now.
The store keeps two clocks — when a memory was true and when the store learned it — so a
question about last week has to be answerable after the fact. These tests pin that the words
leg and the dense leg read the same window, and that `as_of` moves only the moment being asked
about, never who is allowed to see the answer.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import ely_eye.api as api_module
from ely_eye import lifecycle_store
from ely_eye.config import Settings
from ely_eye.db import Database
from ely_eye.memory import MemoryService
from ely_eye.schemas import MemoryOrigin, SearchFilters, TimePrecision
DRAWER = "钥匙放在玄关抽屉里"
CAR = "钥匙现在在车里"
WEEK = timedelta(days=9)
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(home=tmp_path, default_holder="wife", auth_token="test-token")
@pytest.fixture
def client(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
return TestClient(api_module.create_app(), headers={"Authorization": "Bearer test-token"})
def hits(client: TestClient, payload: dict) -> list[str]:
response = client.post("/api/search", json=payload)
assert response.status_code == 200, response.text
return [hit["atom"]["text"] for hit in response.json()["hits"]]
def store_the_pair(client: TestClient) -> tuple[str, datetime]:
"""The drawer memory is nine days old; the car replaces it now."""
week_ago = datetime.now(UTC) - WEEK
response = client.post(
"/api/memory",
json={"text": DRAWER, "event_time": week_ago.isoformat(), "event_time_precision": "day"},
)
assert response.status_code == 200, response.text
return response.json()["atom_id"], week_ago
def test_a_retired_memory_answers_for_the_time_it_was_true(client: TestClient) -> None:
atom_id, week_ago = store_the_pair(client)
superseded = client.post("/api/memory/supersede", json={"atom_id": atom_id, "text": CAR})
assert superseded.status_code == 200, superseded.text
assert hits(client, {"query": "钥匙"}) == [CAR], "asked now, the current memory answers"
asked_then = hits(
client, {"query": "钥匙", "filters": {"as_of": (week_ago + timedelta(days=1)).isoformat()}}
)
assert asked_then == [DRAWER], "asked about last week, the retired memory answers"
def test_a_memory_that_had_not_happened_yet_does_not_answer(client: TestClient) -> None:
next_week = datetime.now(UTC) + timedelta(days=7)
response = client.post(
"/api/memory",
json={"text": CAR, "event_time": next_week.isoformat(), "event_time_precision": "day"},
)
assert response.status_code == 200, response.text
assert hits(client, {"query": "钥匙"}) == [CAR]
assert (
hits(client, {"query": "钥匙", "filters": {"as_of": datetime.now(UTC).isoformat()}}) == []
)
def test_an_archived_memory_still_answers_for_the_time_before_it_was_archived(
client: TestClient, settings: Settings
) -> None:
recorded = datetime.now(UTC) - timedelta(days=30)
response = client.post(
"/api/memory",
json={"text": DRAWER, "event_time": recorded.isoformat(), "event_time_precision": "day"},
)
assert response.status_code == 200, response.text
archived_at = datetime.now(UTC)
moved = lifecycle_store.archive_atoms(
Database(settings), [response.json()["atom_id"]], archived_at
)
assert moved == 1, "the archive moved nothing, so the test proves nothing"
assert hits(client, {"query": "钥匙"}) == [], "an archived memory is out of the working set"
asked_then = hits(
client,
{"query": "钥匙", "filters": {"as_of": (archived_at - timedelta(hours=1)).isoformat()}},
)
assert asked_then == [DRAWER], (
"archival moves a memory between tiers, it does not make it untrue"
)
def test_asking_as_of_another_moment_does_not_borrow_another_holders_memory(
client: TestClient, settings: Settings
) -> None:
atom_id, week_ago = store_the_pair(client)
client.post("/api/memory/supersede", json={"atom_id": atom_id, "text": CAR})
stranger = hits(
client,
{"query": "钥匙", "filters": {"as_holder": "stranger", "as_of": week_ago.isoformat()}},
)
assert stranger == [], "a moment in time does not grant access to somebody else's memory"
as_wife = hits(
client, {"query": "钥匙", "filters": {"as_holder": "wife", "as_of": week_ago.isoformat()}}
)
assert as_wife == [DRAWER]
def test_the_dense_leg_reads_the_same_window_as_the_words_leg(
tmp_path: Path, fake_embeddings: Any
) -> None:
"""Both legs answer an as-of question from one rule, or the fusion would mix two moments.
The dense leg is enabled here with a stand-in model: with the default off, this test would
pass while the vector side of `as_of` had no coverage at all.
"""
from ely_eye.retrieval import RetrievalService
from ely_eye.vector_index import VectorIndex, expected_vectors
settings = Settings(home=tmp_path, default_holder="wife", dense_embeddings_enabled=True)
database = Database(settings)
index = VectorIndex(settings, database, fake_embeddings())
service = MemoryService(settings, database, vectors=index)
week_ago = datetime.now(UTC) - WEEK
drawer = service.remember(
DRAWER,
holder_id="wife",
origin=MemoryOrigin.self_,
event_time=week_ago,
event_time_precision=TimePrecision.day,
)
service.supersede(drawer.atom_id, text=CAR, holder_id="wife")
assert expected_vectors(database) == 2, (
"a retired memory keeps its vector, or no question can reach it"
)
query = index.embeddings.embed_query("钥匙")
now = index.search(query, 10, as_holder="wife")
assert list(now) == [f"mem-{_sha(CAR, 'wife')}:text"], (
"asked now, the vector side offers the current memory"
)
then = index.search(
query, 10, as_holder="wife", as_of=(week_ago + timedelta(days=1)).isoformat()
)
assert f"mem-{_sha(DRAWER, 'wife')}:text" in then, (
"the retired memory is still reachable through the vectors"
)
# Asked through the retrieval service, so the plumbing that hands `as_of` to the engine is pinned too.
criteria = SearchFilters(as_holder="wife", as_of=week_ago + timedelta(days=1)).model_dump(
exclude_none=True
)
criteria["cartridge_ids"] = None
open_now = SearchFilters(as_holder="wife").model_dump(exclude_none=True)
open_now["cartridge_ids"] = None
retrieval = RetrievalService(settings, database)
retrieval.embeddings = fake_embeddings() # type: ignore[assignment]
retrieval.vectors = index # type: ignore[assignment]
assert f"mem-{_sha(DRAWER, 'wife')}:text" in retrieval._dense_search("钥匙", 10, criteria)
assert f"mem-{_sha(DRAWER, 'wife')}:text" not in retrieval._dense_search("钥匙", 10, open_now)
now_page = retrieval.search("钥匙", filters=SearchFilters(as_holder="wife"))
assert [hit.atom.text for hit in now_page.hits] == [CAR]
then_page = retrieval.search(
"钥匙", filters=SearchFilters(as_holder="wife", as_of=week_ago + timedelta(days=1))
)
assert [hit.atom.text for hit in then_page.hits] == [DRAWER]
def test_a_memory_retired_at_a_future_date_still_answers_now(
client: TestClient, settings: Settings
) -> None:
"""A change booked for next year has not happened, so the old fact is still the current one."""
recorded = datetime.now(UTC) - timedelta(days=3)
stored = client.post(
"/api/memory",
json={"text": DRAWER, "event_time": recorded.isoformat(), "event_time_precision": "day"},
)
assert stored.status_code == 200, stored.text
atom_id = stored.json()["atom_id"]
future = datetime.now(UTC) + timedelta(days=365)
booked = client.post(
"/api/memory/supersede",
json={
"atom_id": atom_id,
"text": CAR,
"event_time": future.isoformat(),
"event_time_precision": "day",
},
)
assert booked.status_code == 200, booked.text
assert hits(client, {"query": "钥匙"}) == [DRAWER], (
"a retirement dated next year has not taken effect"
)
listed = client.get("/api/atoms", params={"source_id": f"mem-{_sha(DRAWER, 'wife')}"})
assert listed.status_code == 200, listed.text
assert listed.json()["total"] == 1, "the listing and the search read the same moment"
later = hits(
client, {"query": "钥匙", "filters": {"as_of": (future + timedelta(days=1)).isoformat()}}
)
assert later == [CAR], "asked about the future, the booked change has taken effect"
def _sha(statement: str, holder: str) -> str:
return hashlib.sha256(f"{holder}\n{statement}".encode()).hexdigest()[:16]