Answer from the moment a question asks about
The key test in Gaps.md §6 asks what was true last week. It could not be answered: search_atoms joined v_atoms_current, so a superseded memory is unreachable no matter what since/until says — those two fields bound when a memory was true, not which moment the store answers from. SearchFilters.as_of moves that moment. Without it the words leg repeats the view's currency predicate verbatim; with it a memory answers when valid_from <= as_of < valid_to, its event time has arrived, and it is inside its retention. archived_at is ignored on purpose: archival is a storage tier, not a claim about the world, so an archived memory still answers for the time before it was archived. The dense leg reads the same window through vector_index._allowed, so the two legs cannot disagree about what existed. scripts/key_test.sh runs the seven §6 scenarios end to end against one live store — HTTP, the Go MCP binary over stdio, and the CLI mounting cartridges. Six pass. The seventh, "a cartridge saves compute", is reported NOT MEASURED rather than passed: it claims time-to-first-token and a cache-hit rate, no generator has ever run here, and there is no compile or cache route to observe even the second half. Gates: pytest -q 406 passed; ruff clean; filesize OK; pyright 0 on the seven touched files; mypy cold cache Success on the same seven; key test exit 0.
This commit is contained in:
7 files changed
+644
-22
No files matched your search
@@ -88,14 +88,25 @@ def search(
|
||||
query: str,
|
||||
top_k: int = typer.Option(0, "--top-k", help="hits to return; 0 uses the configured default"),
|
||||
holder: str = typer.Option("", "--holder", help="only this holder's memories may answer"),
|
||||
as_holder: str = typer.Option("", "--as-holder", help="see the store through this holder's eyes, the way their agent would"),
|
||||
as_holder: str = typer.Option(
|
||||
"",
|
||||
"--as-holder",
|
||||
help="see the store through this holder's eyes, the way their agent would",
|
||||
),
|
||||
scenario: str = typer.Option("", "--scenario"),
|
||||
source_id: str = typer.Option("", "--source-id"),
|
||||
since: str = typer.Option("", "--since", help="event time at or after, ISO with offset"),
|
||||
until: str = typer.Option("", "--until"),
|
||||
min_trust: float = typer.Option(-1.0, "--min-trust", help="drop memories whose trust falls below this"),
|
||||
as_of: str = typer.Option(
|
||||
"", "--as-of", help="answer as if it were this moment; memories retired since still answer"
|
||||
),
|
||||
min_trust: float = typer.Option(
|
||||
-1.0, "--min-trust", help="drop memories whose trust falls below this"
|
||||
),
|
||||
cursor: str = typer.Option("", "--cursor", help="next_cursor from the previous page"),
|
||||
image: str = typer.Option("", "--image", help="a picture to search with, instead of only words"),
|
||||
image: str = typer.Option(
|
||||
"", "--image", help="a picture to search with, instead of only words"
|
||||
),
|
||||
) -> None:
|
||||
"""Rank the memories that may answer, and say so when none may."""
|
||||
settings = get_settings()
|
||||
@@ -109,6 +120,7 @@ def search(
|
||||
source_id=source_id or None,
|
||||
since=moment(since, "--since"),
|
||||
until=moment(until, "--until"),
|
||||
as_of=moment(as_of, "--as-of"),
|
||||
min_trust=None if min_trust < 0 else min_trust,
|
||||
),
|
||||
cursor=cursor or None,
|
||||
@@ -275,7 +287,9 @@ def runtime_profiles() -> None:
|
||||
|
||||
|
||||
@app.command("research-modules")
|
||||
def research_modules(query: str = "Ely-Eye V-NSA DVKV x-modal KV speculative prefetch evidence graph") -> None:
|
||||
def research_modules(
|
||||
query: str = "Ely-Eye V-NSA DVKV x-modal KV speculative prefetch evidence graph",
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings)
|
||||
from .compiler import ContextCompiler
|
||||
|
||||
@@ -116,6 +116,7 @@ class RetrievalService:
|
||||
modality=criteria.get("modality"),
|
||||
since=_text(criteria.get("since")),
|
||||
until=_text(criteria.get("until")),
|
||||
as_of=_text(criteria.get("as_of")),
|
||||
cartridge_ids=criteria.get("cartridge_ids"),
|
||||
as_holder=criteria.get("as_holder"),
|
||||
)
|
||||
|
||||
@@ -125,7 +125,16 @@ class EvidenceAtom(BaseModel):
|
||||
raise ValueError(f"{self.atom_id}: an atom cannot supersede itself")
|
||||
return self
|
||||
|
||||
@field_validator("ingested_at", "event_time", "event_time_end", "valid_from", "valid_to", "retention_until", "last_recalled_at", "archived_at")
|
||||
@field_validator(
|
||||
"ingested_at",
|
||||
"event_time",
|
||||
"event_time_end",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
"retention_until",
|
||||
"last_recalled_at",
|
||||
"archived_at",
|
||||
)
|
||||
@classmethod
|
||||
def _times_are_aware(cls, value: datetime | None) -> datetime | None:
|
||||
if value is not None and value.tzinfo is None:
|
||||
@@ -172,6 +181,13 @@ class SearchFilters(BaseModel):
|
||||
A memory with no event time cannot satisfy since/until, so it drops out when a time bound is
|
||||
asked for rather than passing as unknown. `holder` restricts the answer to one holder's
|
||||
memories; `as_holder` is the identity asking, and applies the sharing rule instead.
|
||||
|
||||
`since`/`until` bound *when a memory was true*; `as_of` moves the moment the store answers
|
||||
from, which is a different question. Asked as of last Tuesday, a memory that was true then
|
||||
may answer even if it has been retired since, and a memory whose event time falls after that
|
||||
moment had not happened yet, so it may not. Archival is a storage tier rather than a claim
|
||||
about the world, so an archived memory still answers a question about the time before it was
|
||||
archived. Without `as_of` only memories open right now may answer.
|
||||
"""
|
||||
|
||||
holder: str | None = None
|
||||
@@ -180,6 +196,7 @@ class SearchFilters(BaseModel):
|
||||
source_id: str | None = None
|
||||
modality: Modality | None = None
|
||||
cartridge_ids: list[str] | None = None
|
||||
as_of: datetime | None = None
|
||||
since: datetime | None = None
|
||||
until: datetime | None = None
|
||||
min_trust: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
@@ -36,29 +36,50 @@ def search_atoms(
|
||||
modality: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
as_of: datetime | None = None,
|
||||
min_trust: float | None = None,
|
||||
cartridge_ids: list[str] | None = None,
|
||||
as_holder: str | None = None,
|
||||
) -> FtsPage:
|
||||
"""BM25 over the FTS index joined to memories that are open right now and readable by the asker.
|
||||
"""BM25 over the FTS index joined to memories that answer for the moment being asked about.
|
||||
|
||||
Every filter is a `? IS NULL OR` predicate in one literal statement: the ranking never
|
||||
depends on string-built SQL, and the planner sees one shape. A memory with no event time
|
||||
cannot answer a time filter, so it drops out when one is asked for.
|
||||
|
||||
With no `as_of` the join repeats `v_atoms_current` in place of the view — same rule, asked
|
||||
through the atom_id index instead of a scan. With one, the question is what was true then,
|
||||
so a memory retired since still answers and `archived_at` (a storage tier, not a claim about
|
||||
the world) is ignored.
|
||||
|
||||
`as_holder` applies the sharing rule in three states: a holder id sees its own memories, the
|
||||
ones naming it in visible_to, the ones marked for everyone, and the ones nobody owns; the
|
||||
empty string is a caller who is nobody, so only the last two; None runs over the whole
|
||||
library, which is what an operator or a process that owns the store reads with. A memory with
|
||||
no holder cannot be private to a holder that does not exist."""
|
||||
as_of_iso = None if as_of is None else as_of.isoformat()
|
||||
with database.connect() as connection:
|
||||
expression = fts_expression(rare_terms(connection, fts_terms(query)))
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT f.atom_id, bm25(atom_fts) AS score
|
||||
FROM atom_fts AS f
|
||||
JOIN v_atoms_current AS a ON a.atom_id = f.atom_id
|
||||
JOIN atoms AS a ON a.atom_id = f.atom_id
|
||||
WHERE atom_fts MATCH ?
|
||||
AND (
|
||||
(? IS NULL
|
||||
AND a.valid_to IS NULL
|
||||
AND a.archived_at IS NULL
|
||||
AND (a.valid_from IS NULL
|
||||
OR a.valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (a.retention_until IS NULL
|
||||
OR a.retention_until >= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')))
|
||||
OR (? IS NOT NULL
|
||||
AND (a.valid_from IS NULL OR a.valid_from <= ?)
|
||||
AND (a.valid_to IS NULL OR a.valid_to > ?)
|
||||
AND (a.event_time IS NULL OR a.event_time <= ?)
|
||||
AND (a.retention_until IS NULL OR a.retention_until > ?))
|
||||
)
|
||||
AND (? IS NULL OR a.holder_id = ?)
|
||||
AND (? IS NULL OR a.scenario = ?)
|
||||
AND (? IS NULL OR a.source_id = ?)
|
||||
@@ -91,6 +112,12 @@ def search_atoms(
|
||||
""",
|
||||
(
|
||||
expression,
|
||||
as_of_iso,
|
||||
as_of_iso,
|
||||
as_of_iso,
|
||||
as_of_iso,
|
||||
as_of_iso,
|
||||
as_of_iso,
|
||||
holder,
|
||||
holder,
|
||||
scenario,
|
||||
|
||||
@@ -61,7 +61,10 @@ class VectorIndex:
|
||||
"""One float32 vector per current atom: the writer keeps it, the reader asks it questions."""
|
||||
|
||||
def __init__(
|
||||
self, settings: Settings | None = None, database: Database | None = None, embeddings: EmbeddingService | None = None
|
||||
self,
|
||||
settings: Settings | None = None,
|
||||
database: Database | None = None,
|
||||
embeddings: EmbeddingService | None = None,
|
||||
) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self.db = database or Database(self.settings)
|
||||
@@ -99,7 +102,12 @@ class VectorIndex:
|
||||
self.ensure(connection, dim)
|
||||
stale = self._stale(connection)
|
||||
if not stale:
|
||||
return {"encoded": 0, "dim": dim, "model": self.settings.embedding_model_id, "seconds": 0.0}
|
||||
return {
|
||||
"encoded": 0,
|
||||
"dim": dim,
|
||||
"model": self.settings.embedding_model_id,
|
||||
"seconds": 0.0,
|
||||
}
|
||||
vectors = self.embeddings.encode_atoms([atom for _, atom in stale])
|
||||
self._write(connection, stale, vectors)
|
||||
return {
|
||||
@@ -124,10 +132,18 @@ class VectorIndex:
|
||||
self.ensure(connection, dim)
|
||||
current = self._current(connection, wanted)
|
||||
kept = {atom_id for atom_id, _ in current}
|
||||
dropped = self._delete(connection, [atom_id for atom_id in wanted if atom_id not in kept])
|
||||
dropped = self._delete(
|
||||
connection, [atom_id for atom_id in wanted if atom_id not in kept]
|
||||
)
|
||||
if current:
|
||||
self._write(connection, current, self.embeddings.encode_atoms([atom for _, atom in current]))
|
||||
return {"encoded": len(current), "dropped": dropped, "seconds": round(time.monotonic() - started, 3)}
|
||||
self._write(
|
||||
connection, current, self.embeddings.encode_atoms([atom for _, atom in current])
|
||||
)
|
||||
return {
|
||||
"encoded": len(current),
|
||||
"dropped": dropped,
|
||||
"seconds": round(time.monotonic() - started, 3),
|
||||
}
|
||||
|
||||
def search(
|
||||
self,
|
||||
@@ -140,6 +156,7 @@ class VectorIndex:
|
||||
modality: str | None = None,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
as_of: str | None = None,
|
||||
cartridge_ids: list[str] | None = None,
|
||||
as_holder: str | None = None,
|
||||
) -> dict[str, float]:
|
||||
@@ -156,7 +173,9 @@ class VectorIndex:
|
||||
"""
|
||||
if limit <= 0:
|
||||
raise ValueError(f"dense search needs a positive limit, got {limit}")
|
||||
scope = None if cartridge_ids is None else sorted(atoms_of_cartridges(self.db, cartridge_ids))
|
||||
scope = (
|
||||
None if cartridge_ids is None else sorted(atoms_of_cartridges(self.db, cartridge_ids))
|
||||
)
|
||||
if scope == []:
|
||||
return {}
|
||||
narrow = to_json(scope) if scope is not None and len(scope) <= SCOPE_NARROW_MAX else None
|
||||
@@ -178,10 +197,13 @@ class VectorIndex:
|
||||
modality=modality,
|
||||
since=since,
|
||||
until=until,
|
||||
as_of=as_of,
|
||||
cartridge_ids=cartridge_ids,
|
||||
as_holder=as_holder,
|
||||
)
|
||||
hits = {atom_id: 1.0 - distance for atom_id, distance in ranked if atom_id in allowed}
|
||||
hits = {
|
||||
atom_id: 1.0 - distance for atom_id, distance in ranked if atom_id in allowed
|
||||
}
|
||||
# A filter that keeps little of the ask means more answers sit further out; one wider round buys them, bounded by the ceiling.
|
||||
if len(hits) >= limit or len(ranked) < asked or asked >= CANDIDATE_CEILING:
|
||||
break
|
||||
@@ -216,6 +238,7 @@ class VectorIndex:
|
||||
modality: str | None,
|
||||
since: str | None,
|
||||
until: str | None,
|
||||
as_of: str | None,
|
||||
cartridge_ids: list[str] | None,
|
||||
as_holder: str | None,
|
||||
) -> set[str]:
|
||||
@@ -223,7 +246,10 @@ class VectorIndex:
|
||||
|
||||
The currency rules repeat `v_atoms_current` here on purpose: the view answers this
|
||||
question with a full scan (77ms over 153,857 atoms) while the atoms table answers it
|
||||
through the atom_id index in 0.2ms, and a search cannot afford the scan. The scope is
|
||||
through the atom_id index in 0.2ms, and a search cannot afford the scan. An `as_of`
|
||||
replaces that rule with the window that was true then, retired memories included, and
|
||||
ignores `archived_at` because archival is a storage tier rather than a claim about the
|
||||
world. The scope is
|
||||
checked here rather than in the engine so the rule lives in one place; the engine's id
|
||||
list is only a narrowing. Visibility is here too, and it reads the same three states the
|
||||
words leg reads: a holder id, the empty string for a caller who is nobody (unowned rows
|
||||
@@ -233,10 +259,20 @@ class VectorIndex:
|
||||
"""
|
||||
SELECT atom_id FROM atoms
|
||||
WHERE atom_id IN (SELECT value FROM json_each(?))
|
||||
AND archived_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (valid_from IS NULL OR valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (retention_until IS NULL OR retention_until >= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (
|
||||
(? IS NULL
|
||||
AND archived_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (valid_from IS NULL
|
||||
OR valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
|
||||
AND (retention_until IS NULL
|
||||
OR retention_until >= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')))
|
||||
OR (? IS NOT NULL
|
||||
AND (valid_from IS NULL OR valid_from <= ?)
|
||||
AND (valid_to IS NULL OR valid_to > ?)
|
||||
AND (event_time IS NULL OR event_time <= ?)
|
||||
AND (retention_until IS NULL OR retention_until > ?))
|
||||
)
|
||||
AND (? IS NULL OR holder_id = ?)
|
||||
AND (? IS NULL OR scenario = ?)
|
||||
AND (? IS NULL OR source_id = ?)
|
||||
@@ -259,6 +295,12 @@ class VectorIndex:
|
||||
""",
|
||||
(
|
||||
to_json(atom_ids),
|
||||
as_of,
|
||||
as_of,
|
||||
as_of,
|
||||
as_of,
|
||||
as_of,
|
||||
as_of,
|
||||
holder,
|
||||
holder,
|
||||
scenario,
|
||||
@@ -290,7 +332,14 @@ class VectorIndex:
|
||||
with self.db.connect() as connection:
|
||||
meta = self._meta(connection)
|
||||
if not meta:
|
||||
return {"built": False, "format": None, "dim": 0, "vectors": 0, "expected": 0, "pending": 0}
|
||||
return {
|
||||
"built": False,
|
||||
"format": None,
|
||||
"dim": 0,
|
||||
"vectors": 0,
|
||||
"expected": 0,
|
||||
"pending": 0,
|
||||
}
|
||||
rows = connection.execute("SELECT count(*) AS n FROM vec_atoms").fetchone()["n"]
|
||||
expected = _expected(connection)
|
||||
pending = len(self._stale(connection))
|
||||
@@ -355,12 +404,15 @@ class VectorIndex:
|
||||
raise ValueError(f"sync encoded {len(vectors)} vectors for {len(items)} atoms")
|
||||
matrix = np.asarray(vectors, dtype=np.float32)
|
||||
if matrix.ndim != 2 or matrix.shape[1] != self._dim(connection):
|
||||
raise ValueError(f"sync produced a {matrix.shape} matrix, expected one row per atom at the stored width")
|
||||
raise ValueError(
|
||||
f"sync produced a {matrix.shape} matrix, expected one row per atom at the stored width"
|
||||
)
|
||||
ids = [atom_id for atom_id, _ in items]
|
||||
for start in range(0, len(ids), DELETE_CHUNK):
|
||||
stop = min(start + DELETE_CHUNK, len(ids))
|
||||
connection.execute(
|
||||
"DELETE FROM vec_atoms WHERE atom_id IN (SELECT value FROM json_each(?))", (to_json(ids[start:stop]),)
|
||||
"DELETE FROM vec_atoms WHERE atom_id IN (SELECT value FROM json_each(?))",
|
||||
(to_json(ids[start:stop]),),
|
||||
)
|
||||
for start in range(0, len(items), BATCH):
|
||||
stop = min(start + BATCH, len(items))
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
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(settings: Settings) -> None:
|
||||
"""Both legs answer an as-of question from one rule, or the fusion would mix two moments."""
|
||||
service = MemoryService(settings, Database(settings))
|
||||
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")
|
||||
from ely_eye.retrieval import RetrievalService
|
||||
|
||||
retrieval = RetrievalService(settings=settings, database=Database(settings))
|
||||
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]
|
||||
Reference in new issue
Block a user