Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
377
tests/test_sleep.py
Normal file
377
tests/test_sleep.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
"""Tests for iai_mcp.sleep — CLS replay scheduler + light/heavy consolidation (MEM-07, D-16, D-19, D-29).
|
||||
|
||||
D-16 scheduler: ACTIVITY / TIME / MANUAL modes; 48h force-run; TZ-aware quiet window.
|
||||
D-19 FSRS decay sweep: `_decay_edges` on hebbian edges only; invariant edges spared.
|
||||
D-29 unified: light at session_exit, heavy in quiet window.
|
||||
D-GUARD: `should_call_llm` ladder consulted before any Tier-1 path.
|
||||
|
||||
Test constructors use vectors sized to `store.embed_dim` so they work under
|
||||
the bge-m3 1024d default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from iai_mcp.types import EMBED_DIM, MemoryRecord
|
||||
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
|
||||
def _record(
|
||||
*,
|
||||
text: str = "hi",
|
||||
vec: list[float] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tier: str = "episodic",
|
||||
detail_level: int = 2,
|
||||
language: str = "en",
|
||||
never_decay: bool = False,
|
||||
) -> MemoryRecord:
|
||||
if vec is None:
|
||||
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=detail_level,
|
||||
pinned=False,
|
||||
stability=0.0,
|
||||
difficulty=0.0,
|
||||
last_reviewed=None,
|
||||
never_decay=never_decay,
|
||||
never_merge=False,
|
||||
provenance=[],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
tags=list(tags or []),
|
||||
language=language,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================== SleepMode + SleepConfig
|
||||
|
||||
|
||||
def test_sleep_mode_enum_has_three_values():
|
||||
from iai_mcp.sleep import SleepMode
|
||||
|
||||
assert SleepMode.ACTIVITY.value == "activity"
|
||||
assert SleepMode.TIME.value == "time"
|
||||
assert SleepMode.MANUAL.value == "manual"
|
||||
|
||||
|
||||
def test_sleep_config_defaults():
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode
|
||||
|
||||
cfg = SleepConfig()
|
||||
assert cfg.mode == SleepMode.ACTIVITY
|
||||
assert cfg.quiet_window == (22, 6)
|
||||
assert cfg.require_idle_minutes == 30
|
||||
assert cfg.max_defer_hours == 48
|
||||
assert cfg.llm_enabled is False
|
||||
assert cfg.light_on_exit is True
|
||||
|
||||
|
||||
# ================================================================ should_run_heavy
|
||||
|
||||
|
||||
def test_should_run_heavy_activity_mode_inside_window():
|
||||
"""ACTIVITY mode + 40min idle + 23:30 user-local -> (True, "")."""
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("UTC")
|
||||
# 23:30 UTC
|
||||
now = datetime(2026, 1, 1, 23, 30, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=40)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is True
|
||||
assert reason == ""
|
||||
|
||||
|
||||
def test_should_run_heavy_activity_mode_outside_window():
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("UTC")
|
||||
# 15:00 is outside (22, 6) quiet window
|
||||
now = datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=40)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is False
|
||||
assert "quiet window" in reason.lower() or "outside" in reason.lower()
|
||||
|
||||
|
||||
def test_should_run_heavy_activity_mode_too_recent():
|
||||
"""Idle < 30min -> blocked."""
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("UTC")
|
||||
now = datetime(2026, 1, 1, 23, 30, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=5)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is False
|
||||
assert "idle" in reason.lower()
|
||||
|
||||
|
||||
def test_should_run_heavy_time_mode_only_at_3am():
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.TIME)
|
||||
tz = ZoneInfo("UTC")
|
||||
# Hour != 3 -> False
|
||||
now_2am = datetime(2026, 1, 1, 2, 30, tzinfo=timezone.utc)
|
||||
ok_2, _ = should_run_heavy(now_2am, now_2am - timedelta(hours=1), cfg, tz)
|
||||
assert ok_2 is False
|
||||
|
||||
now_3am = datetime(2026, 1, 1, 3, 30, tzinfo=timezone.utc)
|
||||
ok_3, _ = should_run_heavy(now_3am, now_3am - timedelta(hours=1), cfg, tz)
|
||||
assert ok_3 is True
|
||||
|
||||
|
||||
def test_should_run_heavy_manual_mode_never_auto():
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.MANUAL)
|
||||
tz = ZoneInfo("UTC")
|
||||
# Even with 80h idle and in quiet window, MANUAL returns False.
|
||||
now = datetime(2026, 1, 1, 23, 30, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=40)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is False
|
||||
assert "manual" in reason.lower()
|
||||
|
||||
|
||||
def test_should_run_heavy_48h_force():
|
||||
"""idle > 48h -> force-run regardless of window."""
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("UTC")
|
||||
# 15:00 local (outside window) but 50h idle -> force run
|
||||
now = datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc)
|
||||
last = now - timedelta(hours=50)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is True
|
||||
assert "defer" in reason.lower() or "48" in reason
|
||||
|
||||
|
||||
def test_should_run_heavy_respects_user_tz_tokyo():
|
||||
"""quiet_window(22,6) with Asia/Tokyo; UTC 13:00 = JST 22:00 -> inside window."""
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("Asia/Tokyo")
|
||||
# UTC 13:00 = JST 22:00 (inside window)
|
||||
now = datetime(2026, 1, 1, 13, 0, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=40)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_should_run_heavy_respects_user_tz_utc():
|
||||
"""Same UTC 13:00 with UTC tz -> 13:00 is OUT of (22,6)."""
|
||||
from iai_mcp.sleep import SleepConfig, SleepMode, should_run_heavy
|
||||
|
||||
cfg = SleepConfig(mode=SleepMode.ACTIVITY)
|
||||
tz = ZoneInfo("UTC")
|
||||
now = datetime(2026, 1, 1, 13, 0, tzinfo=timezone.utc)
|
||||
last = now - timedelta(minutes=40)
|
||||
ok, reason = should_run_heavy(now, last, cfg, tz)
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ============================================================== light consolidation
|
||||
|
||||
|
||||
def test_run_light_consolidation_returns_expected_shape(tmp_path):
|
||||
from iai_mcp.sleep import run_light_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
result = run_light_consolidation(store, session_id="s-light")
|
||||
assert isinstance(result, dict)
|
||||
assert "fsrs_ticked" in result
|
||||
assert "cooccurrence_updates" in result
|
||||
assert result["mode"] == "light"
|
||||
|
||||
|
||||
def test_run_light_consolidation_no_llm_call(tmp_path, monkeypatch):
|
||||
"""Light phase must NOT touch should_call_llm -- pure local."""
|
||||
from iai_mcp import sleep as sleep_mod
|
||||
from iai_mcp.sleep import run_light_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
call_count = {"n": 0}
|
||||
original_should = sleep_mod.should_call_llm
|
||||
|
||||
def _counting(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
return original_should(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(sleep_mod, "should_call_llm", _counting)
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
# Seed a record
|
||||
store.insert(_record())
|
||||
|
||||
run_light_consolidation(store, session_id="s-light")
|
||||
assert call_count["n"] == 0
|
||||
|
||||
|
||||
def test_run_light_consolidation_emits_event(tmp_path):
|
||||
from iai_mcp.events import query_events
|
||||
from iai_mcp.sleep import run_light_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
run_light_consolidation(store, session_id="s-x")
|
||||
events = query_events(store, kind="cls_consolidation_run")
|
||||
assert len(events) >= 1
|
||||
ev = events[0]
|
||||
assert ev["data"]["mode"] == "light"
|
||||
assert ev["session_id"] == "s-x"
|
||||
|
||||
|
||||
# ============================================================== heavy consolidation
|
||||
|
||||
|
||||
def test_run_heavy_consolidation_uses_d_guard(tmp_path, monkeypatch):
|
||||
"""When should_call_llm returns False (no api key), heavy completes via Tier 0."""
|
||||
from iai_mcp.events import query_events
|
||||
from iai_mcp.guard import BudgetLedger, RateLimitLedger
|
||||
from iai_mcp.sleep import SleepConfig, run_heavy_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
# Seed with 3 records so a trivial cluster is possible
|
||||
recs = [_record(text=f"rec {i}") for i in range(3)]
|
||||
for r in recs:
|
||||
store.insert(r)
|
||||
# Boost a Hebbian triangle among them
|
||||
store.boost_edges([(recs[0].id, recs[1].id)], edge_type="hebbian", delta=0.5)
|
||||
store.boost_edges([(recs[1].id, recs[2].id)], edge_type="hebbian", delta=0.5)
|
||||
store.boost_edges([(recs[0].id, recs[2].id)], edge_type="hebbian", delta=0.5)
|
||||
|
||||
cfg = SleepConfig(llm_enabled=False)
|
||||
budget = BudgetLedger(store)
|
||||
rate = RateLimitLedger(store)
|
||||
|
||||
result = run_heavy_consolidation(
|
||||
store, session_id="s-heavy", config=cfg, budget=budget, rate=rate,
|
||||
has_api_key=False,
|
||||
)
|
||||
assert result["mode"] == "heavy"
|
||||
assert result["tier"] == "tier0"
|
||||
|
||||
events = query_events(store, kind="cls_consolidation_run")
|
||||
heavy_events = [e for e in events if e["data"].get("mode") == "heavy"]
|
||||
assert len(heavy_events) >= 1
|
||||
assert heavy_events[0]["data"]["tier"] == "tier0"
|
||||
|
||||
|
||||
def test_run_heavy_consolidation_creates_consolidated_from_edges(tmp_path):
|
||||
"""3+ cohesive records produce one summary record + consolidated_from edges."""
|
||||
from iai_mcp.guard import BudgetLedger, RateLimitLedger
|
||||
from iai_mcp.sleep import SleepConfig, run_heavy_consolidation
|
||||
from iai_mcp.store import EDGES_TABLE, MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
# Seed 3 cohesive records
|
||||
recs = [_record(text=f"fact {i}") for i in range(3)]
|
||||
for r in recs:
|
||||
store.insert(r)
|
||||
# All three linked by hebbian triangle -> clusters as one component
|
||||
store.boost_edges([(recs[0].id, recs[1].id)], edge_type="hebbian", delta=0.5)
|
||||
store.boost_edges([(recs[1].id, recs[2].id)], edge_type="hebbian", delta=0.5)
|
||||
store.boost_edges([(recs[0].id, recs[2].id)], edge_type="hebbian", delta=0.5)
|
||||
|
||||
cfg = SleepConfig(llm_enabled=False)
|
||||
budget = BudgetLedger(store)
|
||||
rate = RateLimitLedger(store)
|
||||
result = run_heavy_consolidation(
|
||||
store, session_id="s-cons", config=cfg, budget=budget, rate=rate,
|
||||
has_api_key=False,
|
||||
)
|
||||
assert result["summaries_created"] >= 1
|
||||
|
||||
# consolidated_from edges exist
|
||||
edges_df = store.db.open_table(EDGES_TABLE).to_pandas()
|
||||
cf = edges_df[edges_df["edge_type"] == "consolidated_from"]
|
||||
assert len(cf) >= 3 # summary -> each of 3 sources
|
||||
|
||||
|
||||
def test_run_heavy_consolidation_mem01_preserves_sources(tmp_path):
|
||||
"""MEM-01 verbatim: source literal_surfaces untouched after consolidation."""
|
||||
from iai_mcp.guard import BudgetLedger, RateLimitLedger
|
||||
from iai_mcp.sleep import SleepConfig, run_heavy_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
literals = ["fact alpha", "fact beta", "fact gamma"]
|
||||
recs = [_record(text=t) for t in literals]
|
||||
for r in recs:
|
||||
store.insert(r)
|
||||
store.boost_edges(
|
||||
[(recs[0].id, recs[1].id), (recs[1].id, recs[2].id), (recs[0].id, recs[2].id)],
|
||||
edge_type="hebbian", delta=0.5,
|
||||
)
|
||||
|
||||
run_heavy_consolidation(
|
||||
store, session_id="s", config=SleepConfig(llm_enabled=False),
|
||||
budget=BudgetLedger(store), rate=RateLimitLedger(store),
|
||||
has_api_key=False,
|
||||
)
|
||||
|
||||
# Re-read each source and assert literal_surface unchanged.
|
||||
for rec, expected in zip(recs, literals):
|
||||
reloaded = store.get(rec.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.literal_surface == expected
|
||||
|
||||
|
||||
def test_run_heavy_consolidation_empty_store(tmp_path):
|
||||
"""Empty store -> no summaries, no failures."""
|
||||
from iai_mcp.guard import BudgetLedger, RateLimitLedger
|
||||
from iai_mcp.sleep import SleepConfig, run_heavy_consolidation
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
result = run_heavy_consolidation(
|
||||
store, session_id="s", config=SleepConfig(llm_enabled=False),
|
||||
budget=BudgetLedger(store), rate=RateLimitLedger(store),
|
||||
has_api_key=False,
|
||||
)
|
||||
assert result["summaries_created"] == 0
|
||||
|
||||
|
||||
def test_run_heavy_consolidation_no_cluster_below_threshold(tmp_path):
|
||||
"""A pair of connected records (<3) does NOT produce a cluster."""
|
||||
from iai_mcp.guard import BudgetLedger, RateLimitLedger
|
||||
from iai_mcp.sleep import SleepConfig, run_heavy_consolidation
|
||||
from iai_mcp.store import EDGES_TABLE, MemoryStore
|
||||
|
||||
store = MemoryStore(path=tmp_path)
|
||||
r1, r2 = _record(text="a"), _record(text="b")
|
||||
store.insert(r1)
|
||||
store.insert(r2)
|
||||
store.boost_edges([(r1.id, r2.id)], edge_type="hebbian", delta=0.5)
|
||||
|
||||
run_heavy_consolidation(
|
||||
store, session_id="s", config=SleepConfig(llm_enabled=False),
|
||||
budget=BudgetLedger(store), rate=RateLimitLedger(store),
|
||||
has_api_key=False,
|
||||
)
|
||||
|
||||
edges_df = store.db.open_table(EDGES_TABLE).to_pandas()
|
||||
cf = edges_df[edges_df["edge_type"] == "consolidated_from"]
|
||||
assert len(cf) == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue