266 lines
9.4 KiB
Python
266 lines
9.4 KiB
Python
|
|
"""Tests for provenance append on recall and edge-based contradict."""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
from iai_mcp.core import dispatch
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
from iai_mcp.types import EMBED_DIM
|
|||
|
|
from tests.test_store import _make
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_recall_appends_provenance(tmp_path):
|
|||
|
|
"""every recall creates a new provenance entry on every returned record."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make()
|
|||
|
|
store.insert(r)
|
|||
|
|
before = store.get(r.id).provenance
|
|||
|
|
dispatch(
|
|||
|
|
store,
|
|||
|
|
"memory_recall",
|
|||
|
|
{"cue": "test cue", "session_id": "s1", "cue_embedding": r.embedding},
|
|||
|
|
)
|
|||
|
|
after = store.get(r.id).provenance
|
|||
|
|
assert len(after) == len(before) + 1
|
|||
|
|
assert after[-1]["cue"] == "test cue"
|
|||
|
|
assert after[-1]["session_id"] == "s1"
|
|||
|
|
assert "ts" in after[-1]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_recall_appends_provenance_twice(tmp_path):
|
|||
|
|
"""Two recalls -> two new provenance entries (reconsolidation never idempotent)."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make()
|
|||
|
|
store.insert(r)
|
|||
|
|
dispatch(store, "memory_recall", {"cue": "first", "session_id": "s1", "cue_embedding": r.embedding})
|
|||
|
|
dispatch(store, "memory_recall", {"cue": "second", "session_id": "s2", "cue_embedding": r.embedding})
|
|||
|
|
after = store.get(r.id).provenance
|
|||
|
|
assert len(after) == 2
|
|||
|
|
assert after[0]["cue"] == "first"
|
|||
|
|
assert after[1]["cue"] == "second"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_contradict_creates_linked_record_without_rewrite(tmp_path):
|
|||
|
|
"""MEM-05 edge-based: original preserved, new record linked."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make(text="Original fact")
|
|||
|
|
store.insert(r)
|
|||
|
|
original_text = store.get(r.id).literal_surface
|
|||
|
|
|
|||
|
|
result = dispatch(
|
|||
|
|
store,
|
|||
|
|
"memory_contradict",
|
|||
|
|
{"id": str(r.id), "new_fact": "Contradicting fact", "cue_embedding": r.embedding},
|
|||
|
|
)
|
|||
|
|
assert result["edge_type"] == "contradicts"
|
|||
|
|
assert result["original_id"] == str(r.id)
|
|||
|
|
|
|||
|
|
# original remains unchanged (full rewrite is Phase 2).
|
|||
|
|
assert store.get(r.id).literal_surface == original_text
|
|||
|
|
|
|||
|
|
# New record contains the contradicting fact.
|
|||
|
|
new_rec = store.get(UUID(result["new_record_id"]))
|
|||
|
|
assert new_rec is not None
|
|||
|
|
assert new_rec.literal_surface == "Contradicting fact"
|
|||
|
|
assert "contradict" in new_rec.tags
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_contradict_unknown_record_raises(tmp_path):
|
|||
|
|
"""Tampering resistance (T-01-01): unknown UUID -> ValueError (RPC error -32000)."""
|
|||
|
|
import pytest
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
phantom_id = "11111111-2222-3333-4444-555555555555"
|
|||
|
|
with pytest.raises(ValueError):
|
|||
|
|
dispatch(
|
|||
|
|
store,
|
|||
|
|
"memory_contradict",
|
|||
|
|
{"id": phantom_id, "new_fact": "x", "cue_embedding": [0.0] * EMBED_DIM},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# -------------------------------------------------------- H-02 guard
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_contradict_rejects_cyrillic_new_fact_without_raw_tag(tmp_path):
|
|||
|
|
"""H-02: memory_contradict enforces English-raw on the new record.
|
|||
|
|
|
|||
|
|
Without the constitutional guard a Cyrillic new_fact would store silently
|
|||
|
|
and corrupt the invariant that established.
|
|||
|
|
"""
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make(text="Original fact")
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
with pytest.raises(ValueError, match="constitutional"):
|
|||
|
|
dispatch(
|
|||
|
|
store,
|
|||
|
|
"memory_contradict",
|
|||
|
|
{
|
|||
|
|
"id": str(r.id),
|
|||
|
|
"new_fact": "Не правда, было не так",
|
|||
|
|
"cue_embedding": r.embedding,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_contradict_new_record_has_aaak_index_stamped(tmp_path):
|
|||
|
|
"""H-02: aaak_index is generated on the new record -- not left empty."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make(text="Original English fact")
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
result = dispatch(
|
|||
|
|
store,
|
|||
|
|
"memory_contradict",
|
|||
|
|
{
|
|||
|
|
"id": str(r.id),
|
|||
|
|
"new_fact": "English correction",
|
|||
|
|
"cue_embedding": r.embedding,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
new_rec = store.get(UUID(result["new_record_id"]))
|
|||
|
|
assert new_rec is not None
|
|||
|
|
# generate_aaak_index yields W:<wing>/R:<room>/E:<entities>/T:<tags>.
|
|||
|
|
# It must be non-empty and contain at least the wing segment.
|
|||
|
|
assert new_rec.aaak_index != ""
|
|||
|
|
assert new_rec.aaak_index.startswith("W:")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# -------------------------------------- append_provenance_batch
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_basic(tmp_path):
|
|||
|
|
"""3 records, 3 distinct entries, one batch call: each record gets its own entry."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r1 = _make(text="a")
|
|||
|
|
r2 = _make(text="b")
|
|||
|
|
r3 = _make(text="c")
|
|||
|
|
for r in (r1, r2, r3):
|
|||
|
|
store.insert(r)
|
|||
|
|
e1 = {"ts": "2026-04-17T00:00:00Z", "cue": "one", "session_id": "s1"}
|
|||
|
|
e2 = {"ts": "2026-04-17T00:00:01Z", "cue": "two", "session_id": "s1"}
|
|||
|
|
e3 = {"ts": "2026-04-17T00:00:02Z", "cue": "three", "session_id": "s1"}
|
|||
|
|
store.append_provenance_batch([(r1.id, e1), (r2.id, e2), (r3.id, e3)])
|
|||
|
|
got1 = store.get(r1.id).provenance
|
|||
|
|
got2 = store.get(r2.id).provenance
|
|||
|
|
got3 = store.get(r3.id).provenance
|
|||
|
|
assert got1[-1]["cue"] == "one"
|
|||
|
|
assert got2[-1]["cue"] == "two"
|
|||
|
|
assert got3[-1]["cue"] == "three"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_multiple_entries_same_id(tmp_path):
|
|||
|
|
"""Two entries for the same record: provenance list grows by exactly 2 entries, in order."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make()
|
|||
|
|
store.insert(r)
|
|||
|
|
before = len(store.get(r.id).provenance)
|
|||
|
|
e1 = {"ts": "t1", "cue": "first", "session_id": "s"}
|
|||
|
|
e2 = {"ts": "t2", "cue": "second", "session_id": "s"}
|
|||
|
|
store.append_provenance_batch([(r.id, e1), (r.id, e2)])
|
|||
|
|
after = store.get(r.id).provenance
|
|||
|
|
assert len(after) == before + 2
|
|||
|
|
assert after[-2]["cue"] == "first"
|
|||
|
|
assert after[-1]["cue"] == "second"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_empty_list(tmp_path):
|
|||
|
|
"""Empty input -> no-op, no exception."""
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
store.append_provenance_batch([]) # must not raise
|
|||
|
|
# Sanity: store still functional.
|
|||
|
|
r = _make()
|
|||
|
|
store.insert(r)
|
|||
|
|
assert store.get(r.id) is not None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_unknown_id(tmp_path):
|
|||
|
|
"""Unknown id is silently skipped; known id is appended correctly."""
|
|||
|
|
from uuid import uuid4
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
r = _make()
|
|||
|
|
store.insert(r)
|
|||
|
|
phantom = uuid4()
|
|||
|
|
e_known = {"ts": "t1", "cue": "known", "session_id": "s"}
|
|||
|
|
e_unknown = {"ts": "t0", "cue": "unknown", "session_id": "s"}
|
|||
|
|
# Should NOT raise on the phantom id (matches append_provenance semantics).
|
|||
|
|
store.append_provenance_batch([(phantom, e_unknown), (r.id, e_known)])
|
|||
|
|
after = store.get(r.id).provenance
|
|||
|
|
assert after[-1]["cue"] == "known"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_equivalence_with_single(tmp_path):
|
|||
|
|
"""Byte parity: N single calls on store A == 1 batch call on store B, modulo updated_at."""
|
|||
|
|
path_a = tmp_path / "a"
|
|||
|
|
path_b = tmp_path / "b"
|
|||
|
|
store_a = MemoryStore(path=path_a)
|
|||
|
|
store_b = MemoryStore(path=path_b)
|
|||
|
|
# Seed same 3 records into both stores with SAME uuids so we can compare directly.
|
|||
|
|
import copy
|
|||
|
|
from uuid import uuid4
|
|||
|
|
base_records = [_make(text=f"r{i}") for i in range(3)]
|
|||
|
|
for r in base_records:
|
|||
|
|
store_a.insert(r)
|
|||
|
|
store_b.insert(copy.deepcopy(r)) # avoid shared refs
|
|||
|
|
entries = [
|
|||
|
|
{"ts": "t1", "cue": "e1", "session_id": "sA"},
|
|||
|
|
{"ts": "t2", "cue": "e2", "session_id": "sB"},
|
|||
|
|
{"ts": "t3", "cue": "e3", "session_id": "sC"},
|
|||
|
|
]
|
|||
|
|
# Path A: N single calls.
|
|||
|
|
for r, e in zip(base_records, entries):
|
|||
|
|
store_a.append_provenance(r.id, e)
|
|||
|
|
# Path B: one batch call.
|
|||
|
|
store_b.append_provenance_batch(list(zip([r.id for r in base_records], entries)))
|
|||
|
|
# Compare provenance lists (ignore updated_at since it is clock-based).
|
|||
|
|
for r in base_records:
|
|||
|
|
prov_a = store_a.get(r.id).provenance
|
|||
|
|
prov_b = store_b.get(r.id).provenance
|
|||
|
|
assert prov_a == prov_b, f"provenance diverged for {r.id}: {prov_a} vs {prov_b}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_append_provenance_batch_scan_count(tmp_path, monkeypatch):
|
|||
|
|
"""N+1 collapse: 5 single calls -> 5 to_pandas scans; 1 batch call -> 1 scan."""
|
|||
|
|
from iai_mcp.store import MemoryStore as _MS, RECORDS_TABLE
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
recs = [_make(text=f"r{i}") for i in range(5)]
|
|||
|
|
for r in recs:
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
# Monkey-counter on the *records table*'s to_pandas by shimming open_table.
|
|||
|
|
counter = [0]
|
|||
|
|
original_open_table = store.db.open_table
|
|||
|
|
|
|||
|
|
def _counting_open_table(name, *args, **kwargs):
|
|||
|
|
tbl = original_open_table(name, *args, **kwargs)
|
|||
|
|
if name == RECORDS_TABLE:
|
|||
|
|
original_to_pandas = tbl.to_pandas
|
|||
|
|
|
|||
|
|
def _counting_to_pandas(*a, **k):
|
|||
|
|
counter[0] += 1
|
|||
|
|
return original_to_pandas(*a, **k)
|
|||
|
|
|
|||
|
|
tbl.to_pandas = _counting_to_pandas # type: ignore[assignment]
|
|||
|
|
return tbl
|
|||
|
|
|
|||
|
|
store.db.open_table = _counting_open_table # type: ignore[assignment]
|
|||
|
|
|
|||
|
|
# --- N singles ---
|
|||
|
|
counter[0] = 0
|
|||
|
|
for r in recs:
|
|||
|
|
store.append_provenance(r.id, {"ts": "t", "cue": "c", "session_id": "s"})
|
|||
|
|
single_scans = counter[0]
|
|||
|
|
|
|||
|
|
# --- 1 batch ---
|
|||
|
|
counter[0] = 0
|
|||
|
|
store.append_provenance_batch(
|
|||
|
|
[(r.id, {"ts": "t", "cue": "c2", "session_id": "s"}) for r in recs]
|
|||
|
|
)
|
|||
|
|
batch_scans = counter[0]
|
|||
|
|
|
|||
|
|
assert single_scans == 5, f"append_provenance (single) did {single_scans} records-table scans; expected 5"
|
|||
|
|
assert batch_scans == 1, f"append_provenance_batch did {batch_scans} scans; expected 1 (N+1 collapse)"
|