344 lines
11 KiB
Python
344 lines
11 KiB
Python
|
|
"""Tests for iai_mcp.s5 -- identity kernel (MEM-09, D-22).
|
|||
|
|
|
|||
|
|
D-22 constitutional:
|
|||
|
|
- ρ_identity = 0.99 (stricter than write-path ρ=0.95 and S4 ρ=0.97).
|
|||
|
|
- M-of-N = 3-of-5: a proposal becomes an invariant update only after 3
|
|||
|
|
vigilance-passing proposals within the consensus window.
|
|||
|
|
- 48h cooldown on recently-updated invariants.
|
|||
|
|
- trust threshold = 0.9: any record with s5_trust_score >= 0.9 is an
|
|||
|
|
"invariant-tier" record that cannot be written directly.
|
|||
|
|
- Every commit writes `s5_invariant_update` event with full provenance.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from uuid import UUID, uuid4
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from iai_mcp.types import EMBED_DIM, MemoryRecord
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------- helpers
|
|||
|
|
|
|||
|
|
def _anchor(
|
|||
|
|
*,
|
|||
|
|
text: str = "User is Alice",
|
|||
|
|
vec: list[float] | None = None,
|
|||
|
|
s5_trust_score: float = 0.9,
|
|||
|
|
tier: str = "semantic",
|
|||
|
|
tags: list[str] | None = None,
|
|||
|
|
language: str = "en",
|
|||
|
|
) -> MemoryRecord:
|
|||
|
|
if vec is None:
|
|||
|
|
# Normalised primary-axis vector so cosine against a near-identical
|
|||
|
|
# proposal is close to 1.
|
|||
|
|
vec = [1.0] + [0.0] * (EMBED_DIM - 1)
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
return MemoryRecord(
|
|||
|
|
id=uuid4(),
|
|||
|
|
tier=tier,
|
|||
|
|
literal_surface=text,
|
|||
|
|
aaak_index="",
|
|||
|
|
embedding=vec,
|
|||
|
|
community_id=None,
|
|||
|
|
centrality=0.0,
|
|||
|
|
detail_level=5,
|
|||
|
|
pinned=True,
|
|||
|
|
stability=0.5,
|
|||
|
|
difficulty=0.3,
|
|||
|
|
last_reviewed=now,
|
|||
|
|
never_decay=True,
|
|||
|
|
never_merge=True,
|
|||
|
|
provenance=[],
|
|||
|
|
created_at=now,
|
|||
|
|
updated_at=now,
|
|||
|
|
tags=list(tags or ["identity"]),
|
|||
|
|
language=language,
|
|||
|
|
s5_trust_score=s5_trust_score,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeEmbedder:
|
|||
|
|
"""Deterministic embedder that returns a vector aligned with the anchor's
|
|||
|
|
primary axis. Used to guarantee high cosine without hitting bge-m3."""
|
|||
|
|
|
|||
|
|
DIM = EMBED_DIM
|
|||
|
|
|
|||
|
|
def embed(self, text: str) -> list[float]:
|
|||
|
|
return [1.0] + [0.0] * (EMBED_DIM - 1)
|
|||
|
|
|
|||
|
|
def embed_batch(self, texts):
|
|||
|
|
return [self.embed(t) for t in texts]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture(autouse=True)
|
|||
|
|
def _patch_embedder(monkeypatch):
|
|||
|
|
"""Monkeypatch Embedder inside s5.py so propose_invariant_update doesn't
|
|||
|
|
try to load bge-m3 when encoding the proposed fact."""
|
|||
|
|
# We patch at the Embedder class level so any `from iai_mcp.embed import Embedder`
|
|||
|
|
# import inside s5 gets our fake.
|
|||
|
|
from iai_mcp import embed as embed_mod
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(embed_mod, "Embedder", _FakeEmbedder)
|
|||
|
|
yield
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------- constants
|
|||
|
|
|
|||
|
|
def test_s5_constants():
|
|||
|
|
from iai_mcp import s5
|
|||
|
|
|
|||
|
|
assert s5.IDENTITY_VIGILANCE_RHO == 0.99
|
|||
|
|
assert s5.S5_CONSENSUS_M == 3
|
|||
|
|
assert s5.S5_CONSENSUS_N == 5
|
|||
|
|
assert s5.COOLDOWN_HOURS == 48
|
|||
|
|
assert s5.TRUST_THRESHOLD_IDENTITY == 0.9
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_s5_exports_propose_invariant_update():
|
|||
|
|
from iai_mcp import s5
|
|||
|
|
|
|||
|
|
assert callable(getattr(s5, "propose_invariant_update", None))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_s5_exports_check_identity_anchor_on_write():
|
|||
|
|
from iai_mcp import s5
|
|||
|
|
|
|||
|
|
assert callable(getattr(s5, "check_identity_anchor_on_write", None))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------- propose_invariant_update
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_first_proposal_stages(tmp_path):
|
|||
|
|
"""First call on an anchor returns ("staged", proposal_id)."""
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
verdict, pid = propose_invariant_update(
|
|||
|
|
store, anchor.id, "new identity fact", session_id="s1"
|
|||
|
|
)
|
|||
|
|
assert verdict == "staged"
|
|||
|
|
assert isinstance(pid, UUID)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_consensus_commits(tmp_path):
|
|||
|
|
"""3 distinct-session proposals agreeing -> 3rd returns ("committed", ...)."""
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
r1 = propose_invariant_update(store, anchor.id, "fact", "s1")
|
|||
|
|
r2 = propose_invariant_update(store, anchor.id, "fact", "s2")
|
|||
|
|
r3 = propose_invariant_update(store, anchor.id, "fact", "s3")
|
|||
|
|
assert r1[0] == "staged"
|
|||
|
|
assert r2[0] == "staged"
|
|||
|
|
assert r3[0] == "committed"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_insufficient_consensus_rejected(tmp_path, monkeypatch):
|
|||
|
|
"""5 proposals with only 2 vigilance-passing -> ("rejected", None) at N=5."""
|
|||
|
|
# For this test we need proposals that DON'T align with the anchor.
|
|||
|
|
# Patch the embedder to return orthogonal vectors for every 2nd proposal.
|
|||
|
|
from iai_mcp import embed as embed_mod
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
# Cycle: pass, fail, fail, fail, fail -> 1 vigilance pass total (NOT 3).
|
|||
|
|
call_count = {"n": 0}
|
|||
|
|
|
|||
|
|
class _AlternatingEmbedder:
|
|||
|
|
DIM = EMBED_DIM
|
|||
|
|
|
|||
|
|
def embed(self, text):
|
|||
|
|
call_count["n"] += 1
|
|||
|
|
if call_count["n"] == 1:
|
|||
|
|
# First proposal matches anchor exactly (cosine=1.0 passes ρ=0.99).
|
|||
|
|
return [1.0] + [0.0] * (EMBED_DIM - 1)
|
|||
|
|
# All subsequent proposals are orthogonal (cosine=0 < 0.99).
|
|||
|
|
vec = [0.0] * EMBED_DIM
|
|||
|
|
vec[call_count["n"] % EMBED_DIM] = 1.0
|
|||
|
|
return vec
|
|||
|
|
|
|||
|
|
def embed_batch(self, texts):
|
|||
|
|
return [self.embed(t) for t in texts]
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(embed_mod, "Embedder", _AlternatingEmbedder)
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
verdicts = []
|
|||
|
|
for i in range(5):
|
|||
|
|
v, _ = propose_invariant_update(store, anchor.id, f"fact {i}", f"s{i}")
|
|||
|
|
verdicts.append(v)
|
|||
|
|
# 1 pass + 4 fails != 3-of-5 consensus -> final Nth should be "rejected"
|
|||
|
|
assert verdicts[-1] == "rejected"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_cooldown(tmp_path):
|
|||
|
|
"""After a successful update, subsequent proposals return ("cooldown", None)."""
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
# Push through consensus
|
|||
|
|
propose_invariant_update(store, anchor.id, "fact", "s1")
|
|||
|
|
propose_invariant_update(store, anchor.id, "fact", "s2")
|
|||
|
|
verdict_commit, _ = propose_invariant_update(store, anchor.id, "fact", "s3")
|
|||
|
|
assert verdict_commit == "committed"
|
|||
|
|
|
|||
|
|
# Next proposal hits cooldown
|
|||
|
|
verdict_next, pid = propose_invariant_update(
|
|||
|
|
store, anchor.id, "another fact", "s4"
|
|||
|
|
)
|
|||
|
|
assert verdict_next == "cooldown"
|
|||
|
|
assert pid is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_writes_event(tmp_path):
|
|||
|
|
"""On commit, events table has kind=s5_invariant_update with provenance."""
|
|||
|
|
from iai_mcp.events import query_events
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
propose_invariant_update(store, anchor.id, "fact", "s1")
|
|||
|
|
propose_invariant_update(store, anchor.id, "fact", "s2")
|
|||
|
|
propose_invariant_update(store, anchor.id, "fact", "s3")
|
|||
|
|
|
|||
|
|
events = query_events(store, kind="s5_invariant_update")
|
|||
|
|
assert len(events) == 1
|
|||
|
|
ev = events[0]
|
|||
|
|
assert ev["data"]["anchor_id"] == str(anchor.id)
|
|||
|
|
assert "new_record_id" in ev["data"]
|
|||
|
|
assert "session_ids" in ev["data"]
|
|||
|
|
assert "agree_count" in ev["data"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_vigilance_099(tmp_path, monkeypatch):
|
|||
|
|
"""Proposals with cosine < 0.99 (even if textually similar) don't count as consensus votes."""
|
|||
|
|
from iai_mcp import embed as embed_mod
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
# Every proposal is orthogonal to the anchor -> none pass vigilance.
|
|||
|
|
class _LowCosineEmbedder:
|
|||
|
|
DIM = EMBED_DIM
|
|||
|
|
_n = 0
|
|||
|
|
|
|||
|
|
def embed(self, text):
|
|||
|
|
# Return a mostly-orthogonal vector; cosine with anchor [1,0,...,0]
|
|||
|
|
# will be near zero.
|
|||
|
|
vec = [0.0] * EMBED_DIM
|
|||
|
|
vec[1] = 1.0
|
|||
|
|
return vec
|
|||
|
|
|
|||
|
|
def embed_batch(self, texts):
|
|||
|
|
return [self.embed(t) for t in texts]
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(embed_mod, "Embedder", _LowCosineEmbedder)
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
anchor = _anchor()
|
|||
|
|
store.insert(anchor)
|
|||
|
|
|
|||
|
|
# 5 proposals, none passing vigilance -> reject at Nth
|
|||
|
|
verdicts = []
|
|||
|
|
for i in range(5):
|
|||
|
|
v, _ = propose_invariant_update(store, anchor.id, f"fact {i}", f"s{i}")
|
|||
|
|
verdicts.append(v)
|
|||
|
|
# None committed
|
|||
|
|
assert "committed" not in verdicts
|
|||
|
|
# Final verdict is "rejected" once total=N=5
|
|||
|
|
assert verdicts[-1] == "rejected"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_propose_invariant_update_unknown_anchor_rejected(tmp_path):
|
|||
|
|
from iai_mcp.s5 import propose_invariant_update
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
ghost = uuid4()
|
|||
|
|
verdict, pid = propose_invariant_update(store, ghost, "fact", "s")
|
|||
|
|
assert verdict == "rejected"
|
|||
|
|
assert pid is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------- check_identity_anchor_on_write
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_check_identity_anchor_on_write_blocks_direct(tmp_path):
|
|||
|
|
"""Identity-tier record (s5_trust_score>=0.9) without s5_consensus tag: blocked."""
|
|||
|
|
from iai_mcp.s5 import check_identity_anchor_on_write
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
identity_rec = _anchor(s5_trust_score=0.95, tags=["identity"])
|
|||
|
|
# No "s5_consensus" marker
|
|||
|
|
ok, reason = check_identity_anchor_on_write(store, identity_rec, {})
|
|||
|
|
assert ok is False
|
|||
|
|
assert "identity-tier" in reason.lower() or "propose" in reason.lower()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_check_identity_anchor_on_write_allows_low_trust(tmp_path):
|
|||
|
|
"""s5_trust_score < 0.9 -> always allowed."""
|
|||
|
|
from iai_mcp.s5 import check_identity_anchor_on_write
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
rec = _anchor(s5_trust_score=0.5)
|
|||
|
|
ok, reason = check_identity_anchor_on_write(store, rec, {})
|
|||
|
|
assert ok is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_check_identity_anchor_on_write_allows_with_consensus_marker(tmp_path):
|
|||
|
|
"""Identity-tier record carrying s5_consensus tag -> allowed (coming from propose)."""
|
|||
|
|
from iai_mcp.s5 import check_identity_anchor_on_write
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
rec = _anchor(s5_trust_score=0.95, tags=["identity", "s5_consensus"])
|
|||
|
|
ok, reason = check_identity_anchor_on_write(store, rec, {})
|
|||
|
|
assert ok is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------- guarded_insert
|
|||
|
|
|
|||
|
|
def test_guarded_insert_blocks_direct_identity_write(tmp_path):
|
|||
|
|
"""write.guarded_insert rejects direct identity-tier writes; caller
|
|||
|
|
should route via propose_invariant_update."""
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
from iai_mcp.write import guarded_insert
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
rec = _anchor(s5_trust_score=0.95)
|
|||
|
|
ok, reason = guarded_insert(store, rec, {})
|
|||
|
|
assert ok is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_guarded_insert_allows_low_trust_write(tmp_path):
|
|||
|
|
"""Non-identity write passes guarded_insert cleanly."""
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
from iai_mcp.write import guarded_insert
|
|||
|
|
|
|||
|
|
store = MemoryStore(path=tmp_path)
|
|||
|
|
rec = _anchor(s5_trust_score=0.5)
|
|||
|
|
ok, reason = guarded_insert(store, rec, {})
|
|||
|
|
assert ok is True
|