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).
220 lines
8.9 KiB
Python
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]
|