"""Unified memory search: tiered merge, provenance, freshness."""
from datetime import datetime, timedelta, timezone
from navi.memory.search import UnifiedMemorySearch, format_hits
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
class FakeMemory:
def __init__(self, facts=None, chunks=None, summary_at=None):
self.facts = facts or []
self.chunks = chunks or []
self.summary_at = summary_at
self.calls = []
async def search_facts(self, query, user_id=None, limit=15):
self.calls.append(("facts", user_id, limit))
return self.facts[:limit]
async def search_chunks(self, query, user_id=None, session_id=None, limit=8):
self.calls.append(("raw", user_id, session_id, limit))
return self.chunks[:limit]
async def latest_summary_at(self, session_id):
return self.summary_at
class FakeKv:
def __init__(self, rows=None):
self.rows = rows or []
self.calls = []
async def search_values(self, user_id, scope, terms, limit=10, session_id=None, prefer_session_id=None):
self.calls.append((user_id, scope, tuple(terms), limit, prefer_session_id))
return self.rows[:limit]
def _fact(key, value, updated=NOW, category="technical"):
return {
"id": key, "category": category, "key": key, "value": value,
"updated_at": updated, "source_session_id": "sess-fact",
}
def _chunk(content, session="s1", start=1, end=2, ended=NOW, score=0.9):
return {
"id": f"chunk-{content[:6]}", "session_id": session,
"start_seq": start, "end_seq": end, "started_at": None,
"ended_at": ended, "content": content, "breadcrumbs": ["port"],
"created_at": ended, "score": score, "terms_hit": 0,
}
class TestTiering:
async def test_facts_come_first_then_raw_then_sketches(self):
memory = FakeMemory(facts=[_fact("server_port", "8099")], chunks=[_chunk("port was 8098")])
kv = FakeKv([{"session_id": "s1", "key": "findings", "value": "port 8098", "updated_at": NOW}])
search = UnifiedMemorySearch(memory, kv)
hits = await search.search("port", user_id="u1")
assert [h.source_type for h in hits] == ["fact", "raw", "sketch"]
async def test_each_scope_reads_only_its_store(self):
memory = FakeMemory(facts=[_fact("k", "v")], chunks=[_chunk("c")])
kv = FakeKv([{"session_id": "s1", "key": "k", "value": "v", "updated_at": NOW}])
search = UnifiedMemorySearch(memory, kv)
assert [h.source_type for h in await search.search("q", scope="facts")] == ["fact"]
assert [h.source_type for h in await search.search("q", scope="raw")] == ["raw"]
assert [h.source_type for h in await search.search("q", scope="sketches")] == ["sketch"]
async def test_an_unknown_scope_behaves_like_all(self):
memory = FakeMemory(facts=[_fact("k", "v")])
search = UnifiedMemorySearch(memory, FakeKv())
hits = await search.search("q", scope="nonsense")
assert [h.source_type for h in hits] == ["fact"]
async def test_an_empty_query_returns_nothing_without_touching_stores(self):
memory = FakeMemory(facts=[_fact("k", "v")])
search = UnifiedMemorySearch(memory, FakeKv())
assert await search.search(" ") == []
assert memory.calls == []
async def test_k_is_clamped(self):
memory = FakeMemory(facts=[_fact("k", "v")])
kv = FakeKv()
search = UnifiedMemorySearch(memory, kv)
await search.search("q", k=999)
await search.search("q", k=0) # falsy k falls back to the default
fact_calls = [c for c in memory.calls if c[0] == "facts"]
assert fact_calls[0][2] == 20
assert fact_calls[1][2] == 5
async def test_a_failing_store_does_not_sink_the_others(self):
class BrokenMemory(FakeMemory):
async def search_facts(self, query, user_id=None, limit=15):
raise RuntimeError("db down")
memory = BrokenMemory(chunks=[_chunk("recoverable")])
search = UnifiedMemorySearch(memory, FakeKv())
hits = await search.search("q")
assert [h.source_type for h in hits] == ["raw"]
class TestProvenance:
async def test_a_fact_carries_category_key_and_update_time(self):
search = UnifiedMemorySearch(FakeMemory(facts=[_fact("server_port", "8099")]), FakeKv())
hit = (await search.search("port"))[0]
assert hit.category == "technical"
assert hit.key == "server_port"
assert hit.timestamp == NOW
assert hit.session_id == "sess-fact"
async def test_raw_carries_session_and_turn_range(self):
search = UnifiedMemorySearch(FakeMemory(chunks=[_chunk("body", start=12, end=14)]), FakeKv())
hit = (await search.search("body"))[0]
assert hit.session_id == "s1"
assert (hit.start_seq, hit.end_seq) == (12, 14)
assert hit.breadcrumbs == ("port",)
async def test_a_sketch_carries_its_section(self):
kv = FakeKv([{"session_id": "s9", "key": "findings", "value": "x", "updated_at": NOW}])
hit = (await UnifiedMemorySearch(FakeMemory(), kv).search("x"))[0]
assert (hit.session_id, hit.key) == ("s9", "findings")
class TestFreshness:
async def test_a_newer_fact_supersedes_an_older_raw_excerpt(self):
memory = FakeMemory(
facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
)
hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
assert hit.superseded_by is not None
assert "server_port" in hit.superseded_by
async def test_raw_scope_still_labels_a_newer_fact(self):
# Recall asks for raw only, but an outdated excerpt shown as
# authoritative is worse than the extra facts query. The facts are used
# for the label and are not returned.
memory = FakeMemory(
facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
)
hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port", scope="raw")
assert [h.source_type for h in hits] == ["raw"]
assert hits[0].superseded_by is not None
async def test_an_older_fact_does_not_supersede_a_newer_excerpt(self):
memory = FakeMemory(
facts=[_fact("server_port", "8099 is the server port", updated=NOW - timedelta(days=3))],
chunks=[_chunk("the server port is 8100 now", ended=NOW)],
)
hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
assert hit.superseded_by is None
async def test_unrelated_facts_do_not_supersede(self):
memory = FakeMemory(
facts=[_fact("editor", "neovim", updated=NOW)],
chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=1))],
)
hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
assert hit.superseded_by is None
async def test_raw_older_than_the_current_summary_is_marked(self):
memory = FakeMemory(
chunks=[_chunk("old turn", ended=NOW - timedelta(days=2))],
summary_at=NOW,
)
hit = (await UnifiedMemorySearch(memory, FakeKv()).search(
"old", current_session_id="s1", summary_at=NOW,
))[0]
assert hit.before_summary is True
async def test_iso_string_timestamps_are_normalized(self):
# `search_facts` returns `updated_at` as an ISO string (the facts store
# formats it for the API), while chunks carry real datetimes. Comparing
# the two used to raise TypeError and take the whole search down.
memory = FakeMemory(
facts=[_fact("server_port", "8099 is the server port", updated=NOW.isoformat())],
chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
)
hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port")
fact, raw = hits[0], hits[1]
assert isinstance(fact.timestamp, datetime)
assert raw.superseded_by is not None
async def test_another_sessions_summary_does_not_mark_this_chunk(self):
memory = FakeMemory(chunks=[_chunk("old turn", session="s2", ended=NOW - timedelta(days=2))])
hit = (await UnifiedMemorySearch(memory, FakeKv()).search(
"old", current_session_id="s1", summary_at=NOW,
))[0]
assert hit.before_summary is False
class TestFormatting:
async def test_every_line_names_its_source(self):
memory = FakeMemory(
facts=[_fact("server_port", "8099")],
chunks=[_chunk("verbatim body", start=4, end=4)],
)
kv = FakeKv([{"session_id": "s3", "key": "findings", "value": "sketch body", "updated_at": NOW}])
hits = await UnifiedMemorySearch(memory, kv).search("body port")
text = format_hits(hits, "body port", "all")
assert 'memory_search "body port" (scope=all): 3 hit(s)' in text
assert "fact [technical]" in text
assert "raw · session s1 · turn 4" in text
assert "sketch · session s3 · findings" in text
assert "keys: port" in text
async def test_an_outdated_raw_excerpt_says_so(self):
memory = FakeMemory(
facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
)
hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port")
text = format_hits(hits, "server port", "all")
assert "outdated: a newer curated fact overrides this" in text
def test_no_hits_says_so(self):
text = format_hits([], "nothing", "all")
assert "0 hit(s)" in text
assert "No matches" in text