619 lines
24 KiB
Python
619 lines
24 KiB
Python
|
|
"""Plan 07.7-04 D-26-C — schema.py induce_schemas_tier0 + persist_schema migrate
|
|||
|
|
to ``store.iter_record_columns(...)`` projection.
|
|||
|
|
|
|||
|
|
CONTEXT.md amendment (added 2026-04-29 mid-execution): the original Plan 04
|
|||
|
|
W4 scope (sleep.py invariant + comment marker) is REPLACED-AND-EXTENDED by
|
|||
|
|
migrating two `all_records()` callers in `schema.py` so that the W4 ≤1
|
|||
|
|
all_records() invariant on `run_heavy_consolidation` becomes achievable.
|
|||
|
|
|
|||
|
|
Pre-D-26 architecture:
|
|||
|
|
|
|||
|
|
run_heavy_consolidation
|
|||
|
|
├── all_records() at sleep.py:513 (records_by_id — kept by W4)
|
|||
|
|
├── _tier0_schema_surfacing (W3 — projection-only via Plan 03)
|
|||
|
|
└── induce_schemas_tier1
|
|||
|
|
└── induce_schemas_tier0
|
|||
|
|
├── all_records() at schema.py:89 ← D-26-A target
|
|||
|
|
└── (downstream) persist_schema
|
|||
|
|
└── all_records() at schema.py:267 ← D-26-B target
|
|||
|
|
|
|||
|
|
Total: 3 all_records() calls per heavy invocation (when auto-status candidates
|
|||
|
|
fire).
|
|||
|
|
|
|||
|
|
Post-D-26 architecture:
|
|||
|
|
|
|||
|
|
run_heavy_consolidation
|
|||
|
|
├── all_records() at sleep.py:513 (records_by_id — kept by W4)
|
|||
|
|
├── _tier0_schema_surfacing (W3 — projection-only via Plan 03)
|
|||
|
|
└── induce_schemas_tier1
|
|||
|
|
└── induce_schemas_tier0
|
|||
|
|
├── iter_record_columns(["id", "tags_json"]) ← D-26-A
|
|||
|
|
└── persist_schema
|
|||
|
|
└── iter_record_columns(["id", "tier", "tags_json"])
|
|||
|
|
← D-26-B (early-exit via break on first match)
|
|||
|
|
|
|||
|
|
Total: 1 all_records() call per heavy invocation. W4 invariant becomes
|
|||
|
|
achievable; the W4 invariant test in tests/test_sleep_consolidation_streaming.py
|
|||
|
|
asserts ``count_all.call_count <= 1``.
|
|||
|
|
|
|||
|
|
Covered contracts (D-26-C):
|
|||
|
|
|
|||
|
|
D-26-A — induce_schemas_tier0 migration:
|
|||
|
|
1. Calls iter_record_columns, NOT all_records (spy via monkeypatch)
|
|||
|
|
2. _decrypt_for_record fires zero times (proof of zero-AES-GCM W3-style)
|
|||
|
|
3. SchemaCandidate output is byte-identical to pre-W4-ext implementation
|
|||
|
|
on a deterministic synthetic store (same patterns, same evidence_count,
|
|||
|
|
same confidence, same status)
|
|||
|
|
|
|||
|
|
D-26-B — persist_schema migration:
|
|||
|
|
4. Calls iter_record_columns, NOT all_records (spy via monkeypatch)
|
|||
|
|
5. Early-exit via break on first matching pattern row works (the keeper
|
|||
|
|
scan must NOT iterate every record after a hit)
|
|||
|
|
6. Correct schema_id returned when keeper is mid-stream (the keeper's
|
|||
|
|
UUID is preserved across the iter_record_columns→str→UUID round-trip)
|
|||
|
|
|
|||
|
|
Cross-cutting:
|
|||
|
|
7. existing_keeper_id remains a UUID (not a string from row["id"])
|
|||
|
|
8. The pattern_tag check is preserved byte-for-byte: tier == "semantic"
|
|||
|
|
AND f"pattern:{candidate.pattern}" in tags
|
|||
|
|
|
|||
|
|
Phase 07.6 plan-checker B-1 lesson: every test uses a real ``MemoryRecord``
|
|||
|
|
dataclass via ``_rec()`` — never a plain dict against attribute-access code.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from uuid import UUID, uuid4
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from iai_mcp.schema import (
|
|||
|
|
SchemaCandidate,
|
|||
|
|
induce_schemas_tier0,
|
|||
|
|
persist_schema,
|
|||
|
|
)
|
|||
|
|
from iai_mcp.store import MemoryStore
|
|||
|
|
from iai_mcp.types import EMBED_DIM, MemoryRecord
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- fixtures
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture(autouse=True)
|
|||
|
|
def _isolated_keyring(monkeypatch: pytest.MonkeyPatch):
|
|||
|
|
"""Mirror tests/test_store_iter_records.py — process-isolated keyring so
|
|||
|
|
AES-256-GCM key generation does not poke the OS keychain inside CI."""
|
|||
|
|
import keyring as _keyring
|
|||
|
|
|
|||
|
|
fake: dict[tuple[str, str], str] = {}
|
|||
|
|
monkeypatch.setattr(_keyring, "get_password", lambda s, u: fake.get((s, u)))
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
_keyring, "set_password", lambda s, u, p: fake.__setitem__((s, u), p)
|
|||
|
|
)
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
_keyring, "delete_password", lambda s, u: fake.pop((s, u), None)
|
|||
|
|
)
|
|||
|
|
yield fake
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture(autouse=True)
|
|||
|
|
def _patch_embedder(monkeypatch: pytest.MonkeyPatch):
|
|||
|
|
"""Avoid loading bge-m3 — persist_schema's insert path embeds the schema
|
|||
|
|
summary; without this fixture each test pays ~5s embedder load."""
|
|||
|
|
from iai_mcp import embed as embed_mod
|
|||
|
|
|
|||
|
|
class _FakeEmbedder:
|
|||
|
|
DIM = EMBED_DIM
|
|||
|
|
DEFAULT_DIM = EMBED_DIM
|
|||
|
|
DEFAULT_MODEL_KEY = "fake"
|
|||
|
|
|
|||
|
|
def __init__(self, *args, **kwargs): # noqa: ANN001
|
|||
|
|
self.DIM = EMBED_DIM
|
|||
|
|
|
|||
|
|
def embed(self, text: str) -> list[float]:
|
|||
|
|
return [1.0] + [0.0] * (EMBED_DIM - 1)
|
|||
|
|
|
|||
|
|
def embed_batch(self, texts): # noqa: ANN001
|
|||
|
|
return [self.embed(t) for t in texts]
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(embed_mod, "Embedder", _FakeEmbedder)
|
|||
|
|
yield
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rec(
|
|||
|
|
*,
|
|||
|
|
text: str = "t",
|
|||
|
|
tags: list[str] | None = None,
|
|||
|
|
tier: str = "episodic",
|
|||
|
|
detail_level: int = 2,
|
|||
|
|
language: str = "en",
|
|||
|
|
) -> MemoryRecord:
|
|||
|
|
"""Real-dataclass fixture (NEVER a plain dict — plan-checker B-1)."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
return MemoryRecord(
|
|||
|
|
id=uuid4(),
|
|||
|
|
tier=tier,
|
|||
|
|
literal_surface=text,
|
|||
|
|
aaak_index="",
|
|||
|
|
embedding=[1.0] + [0.0] * (EMBED_DIM - 1),
|
|||
|
|
community_id=None,
|
|||
|
|
centrality=0.0,
|
|||
|
|
detail_level=detail_level,
|
|||
|
|
pinned=False,
|
|||
|
|
stability=0.0,
|
|||
|
|
difficulty=0.0,
|
|||
|
|
last_reviewed=None,
|
|||
|
|
never_decay=(detail_level >= 3),
|
|||
|
|
never_merge=False,
|
|||
|
|
provenance=[],
|
|||
|
|
created_at=now,
|
|||
|
|
updated_at=now,
|
|||
|
|
tags=list(tags or []),
|
|||
|
|
language=language,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def store(tmp_path: Path) -> MemoryStore:
|
|||
|
|
"""Fresh MemoryStore in tmp_path/lancedb (one per test, no cross-test bleed)."""
|
|||
|
|
return MemoryStore(path=tmp_path / "lancedb")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- D-26-A: induce_schemas_tier0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_induce_schemas_tier0_uses_iter_record_columns_not_all_records(
|
|||
|
|
store: MemoryStore, monkeypatch: pytest.MonkeyPatch
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-A architecture flip: rewritten function uses
|
|||
|
|
``iter_record_columns(["id", "tags_json"], ...)`` and never calls
|
|||
|
|
``all_records()``.
|
|||
|
|
|
|||
|
|
Pre-D-26-A (current main): ``induce_schemas_tier0`` calls
|
|||
|
|
``store.all_records()`` at schema.py:89 — spy on ``all_records`` fires
|
|||
|
|
once and spy on ``iter_record_columns`` fires zero times → assertion
|
|||
|
|
fails RED.
|
|||
|
|
|
|||
|
|
Post-D-26-A: spy on ``iter_record_columns`` fires once and spy on
|
|||
|
|
``all_records`` fires zero times → assertion passes GREEN.
|
|||
|
|
"""
|
|||
|
|
# 5 records with the same tag pair (above CLUSTER_MIN_SIZE=3).
|
|||
|
|
for i in range(5):
|
|||
|
|
store.insert(_rec(text=f"r{i}", tags=["meeting", "notes"]))
|
|||
|
|
|
|||
|
|
spy_all = MagicMock(wraps=store.all_records)
|
|||
|
|
spy_iter = MagicMock(wraps=store.iter_record_columns)
|
|||
|
|
monkeypatch.setattr(store, "all_records", spy_all)
|
|||
|
|
monkeypatch.setattr(store, "iter_record_columns", spy_iter)
|
|||
|
|
|
|||
|
|
induce_schemas_tier0(store)
|
|||
|
|
|
|||
|
|
assert spy_all.call_count == 0, (
|
|||
|
|
f"induce_schemas_tier0 must NOT call store.all_records() post-D-26-A; "
|
|||
|
|
f"got {spy_all.call_count} call(s)"
|
|||
|
|
)
|
|||
|
|
assert spy_iter.call_count >= 1, (
|
|||
|
|
f"induce_schemas_tier0 must call store.iter_record_columns() at least "
|
|||
|
|
f"once post-D-26-A; got {spy_iter.call_count} call(s)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_induce_schemas_tier0_zero_decrypt_calls(
|
|||
|
|
store: MemoryStore, monkeypatch: pytest.MonkeyPatch
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-A zero-decrypt contract: ``_decrypt_for_record`` fires zero times
|
|||
|
|
during the migrated path.
|
|||
|
|
|
|||
|
|
Projection is ``["id", "tags_json"]`` — neither column is encrypted
|
|||
|
|
(``id`` is plain string UUID; ``tags_json`` is plain JSON string per
|
|||
|
|
store.py:273). Therefore the W5 cipher cache is short-circuited entirely
|
|||
|
|
on this path, mirroring the W3 ``_tier0_schema_surfacing`` win.
|
|||
|
|
|
|||
|
|
Pre-D-26-A (current main): ``store.all_records()`` round-trips every row
|
|||
|
|
through ``_from_row``, which calls ``_decrypt_for_record`` on each of
|
|||
|
|
literal_surface + provenance_json + profile_modulation_gain_json
|
|||
|
|
(encrypted columns). For a 5-record store: up to 15 calls. Assertion
|
|||
|
|
``call_count == 0`` fails RED.
|
|||
|
|
|
|||
|
|
Post-D-26-A: zero calls — assertion passes GREEN.
|
|||
|
|
"""
|
|||
|
|
for i in range(5):
|
|||
|
|
store.insert(_rec(text=f"r{i}", tags=["meeting", "notes"]))
|
|||
|
|
|
|||
|
|
decrypt_spy = MagicMock(wraps=store._decrypt_for_record)
|
|||
|
|
monkeypatch.setattr(store, "_decrypt_for_record", decrypt_spy)
|
|||
|
|
|
|||
|
|
induce_schemas_tier0(store)
|
|||
|
|
|
|||
|
|
assert decrypt_spy.call_count == 0, (
|
|||
|
|
f"induce_schemas_tier0 must NOT trigger ANY _decrypt_for_record "
|
|||
|
|
f"calls post-D-26-A; got {decrypt_spy.call_count} call(s)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_induce_schemas_tier0_byte_identical_to_pre_d26_implementation(
|
|||
|
|
store: MemoryStore,
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-C contract: rewritten function produces identical SchemaCandidate
|
|||
|
|
output to the pre-D-26-A implementation on a deterministic synthetic
|
|||
|
|
store.
|
|||
|
|
|
|||
|
|
Compute the expected output inline using the pre-D-26-A algorithm
|
|||
|
|
(``store.all_records()`` + ``_tag_cooccurrence``) and assert
|
|||
|
|
order-independent equality (sort by pattern) against the migrated
|
|||
|
|
function's output.
|
|||
|
|
|
|||
|
|
Fixture (deterministic, 8 records):
|
|||
|
|
- 5 records tagged ["meeting", "notes"] → pair count = 5
|
|||
|
|
- 3 records tagged ["report", "deadline"] → pair count = 3
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
- "tags:meeting+notes" — count=5, confidence=0.5, status="auto"
|
|||
|
|
(5 >= AUTO_INDUCT_COOCCURRENCE=5 BUT confidence < AUTO_INDUCT_CONFIDENCE
|
|||
|
|
=0.85, so it falls into pending_user_approval branch instead)
|
|||
|
|
- Wait — actually count=5 falls into the ``elif`` guard
|
|||
|
|
``USER_APPROVAL_COOCCURRENCE <= count < AUTO_INDUCT_COOCCURRENCE``
|
|||
|
|
which is ``3 <= 5 < 5`` → False. So count=5 needs auto path
|
|||
|
|
``count >= 5 AND confidence >= 0.85``; confidence 0.5 fails the
|
|||
|
|
confidence floor. Result: SKIPPED.
|
|||
|
|
- count=3 → ``elif 3 <= 3 < 5`` AND confidence=0.3 < 0.65 → SKIPPED.
|
|||
|
|
|
|||
|
|
To get measurable output, raise count to clear the floors:
|
|||
|
|
- 9 records tagged ["meeting", "notes"]: count=9, conf=0.9 → "auto"
|
|||
|
|
- 4 records tagged ["report", "deadline"]: count=4, conf=0.4 →
|
|||
|
|
elif 3 <= 4 < 5 → True; conf 0.4 < 0.65 → SKIPPED
|
|||
|
|
- Add 4 records tagged ["alpha", "beta"]: count=4, conf=0.4 → SKIPPED
|
|||
|
|
same as above
|
|||
|
|
|
|||
|
|
To exercise the user-approval path, we need conf >= 0.65. Confidence
|
|||
|
|
saturates at count/10, so count >= 7 with count < 5 is impossible.
|
|||
|
|
We accept that on this fixture only the auto path emits a candidate.
|
|||
|
|
"""
|
|||
|
|
# 9 records with the same tag pair → count=9, confidence=0.9, status="auto"
|
|||
|
|
auto_recs: list[MemoryRecord] = []
|
|||
|
|
for i in range(9):
|
|||
|
|
r = _rec(text=f"auto-{i}", tags=["meeting", "notes"])
|
|||
|
|
auto_recs.append(r)
|
|||
|
|
store.insert(r)
|
|||
|
|
# 4 records with a different tag pair — below auto threshold (count<5),
|
|||
|
|
# below confidence threshold for user-approval (conf=0.4 < 0.65), so
|
|||
|
|
# contributes nothing to the candidate list.
|
|||
|
|
for i in range(4):
|
|||
|
|
store.insert(_rec(text=f"low-{i}", tags=["report", "deadline"]))
|
|||
|
|
|
|||
|
|
# Compute expected via the pre-D-26-A algorithm inline. We re-implement
|
|||
|
|
# the contract directly so the test does not depend on the prior
|
|||
|
|
# implementation surviving the migration unchanged.
|
|||
|
|
from iai_mcp.schema import (
|
|||
|
|
AUTO_INDUCT_CONFIDENCE,
|
|||
|
|
AUTO_INDUCT_COOCCURRENCE,
|
|||
|
|
MAX_EVIDENCE_PER_SCHEMA,
|
|||
|
|
USER_APPROVAL_CONFIDENCE,
|
|||
|
|
USER_APPROVAL_COOCCURRENCE,
|
|||
|
|
_tag_cooccurrence,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
expected_records = store.all_records()
|
|||
|
|
pair_counts = _tag_cooccurrence(expected_records)
|
|||
|
|
expected: list[dict] = []
|
|||
|
|
for pair, evidence in pair_counts.items():
|
|||
|
|
count = len(evidence)
|
|||
|
|
confidence = min(1.0, count / 10.0)
|
|||
|
|
if count >= AUTO_INDUCT_COOCCURRENCE and confidence >= AUTO_INDUCT_CONFIDENCE:
|
|||
|
|
status = "auto"
|
|||
|
|
elif (
|
|||
|
|
USER_APPROVAL_COOCCURRENCE <= count < AUTO_INDUCT_COOCCURRENCE
|
|||
|
|
and confidence >= USER_APPROVAL_CONFIDENCE
|
|||
|
|
):
|
|||
|
|
status = "pending_user_approval"
|
|||
|
|
else:
|
|||
|
|
continue
|
|||
|
|
expected.append({
|
|||
|
|
"pattern": f"tags:{'+'.join(sorted(pair))}",
|
|||
|
|
"confidence": confidence,
|
|||
|
|
"evidence_count": count,
|
|||
|
|
"status": status,
|
|||
|
|
"evidence_ids_set": set(evidence[:MAX_EVIDENCE_PER_SCHEMA]),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
actual = induce_schemas_tier0(store)
|
|||
|
|
|
|||
|
|
expected_sorted = sorted(expected, key=lambda d: d["pattern"])
|
|||
|
|
actual_sorted = sorted(actual, key=lambda c: c.pattern)
|
|||
|
|
|
|||
|
|
assert len(actual_sorted) == len(expected_sorted), (
|
|||
|
|
f"candidate count mismatch — expected={len(expected_sorted)} "
|
|||
|
|
f"actual={len(actual_sorted)}; expected={expected_sorted!r}; "
|
|||
|
|
f"actual={[(c.pattern, c.evidence_count, c.confidence, c.status) for c in actual_sorted]!r}"
|
|||
|
|
)
|
|||
|
|
for e, a in zip(expected_sorted, actual_sorted, strict=True):
|
|||
|
|
assert a.pattern == e["pattern"]
|
|||
|
|
assert a.evidence_count == e["evidence_count"]
|
|||
|
|
assert a.confidence == pytest.approx(e["confidence"])
|
|||
|
|
assert a.status == e["status"]
|
|||
|
|
# evidence_ids must round-trip back to the same UUIDs (set equality —
|
|||
|
|
# iter_record_columns batch order may differ from all_records pandas
|
|||
|
|
# iter order, but the underlying set must match).
|
|||
|
|
assert set(a.evidence_ids) == e["evidence_ids_set"]
|
|||
|
|
|
|||
|
|
# Sanity: at least one auto candidate surfaced (the 9-records pair).
|
|||
|
|
assert any(c.status == "auto" for c in actual_sorted), (
|
|||
|
|
f"expected at least one status='auto' candidate on the 9-record "
|
|||
|
|
f"meeting+notes pair; got {[(c.pattern, c.evidence_count, c.status) for c in actual_sorted]!r}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_induce_schemas_tier0_evidence_ids_are_uuids(
|
|||
|
|
store: MemoryStore,
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-A boundary contract: ``iter_record_columns`` returns ``id`` as a
|
|||
|
|
string (per tests/test_store_iter_records.py:250) but
|
|||
|
|
``SchemaCandidate.evidence_ids`` is typed ``list[UUID]``. The migration
|
|||
|
|
must convert at the boundary; without conversion, downstream code (e.g.
|
|||
|
|
``store.boost_edges([(ev_id, schema_id) for ev_id in evidence_ids])``)
|
|||
|
|
would break.
|
|||
|
|
"""
|
|||
|
|
inserted = []
|
|||
|
|
for i in range(9):
|
|||
|
|
r = _rec(text=f"r{i}", tags=["meeting", "notes"])
|
|||
|
|
store.insert(r)
|
|||
|
|
inserted.append(r.id)
|
|||
|
|
|
|||
|
|
candidates = induce_schemas_tier0(store)
|
|||
|
|
auto = [c for c in candidates if c.status == "auto"]
|
|||
|
|
assert len(auto) >= 1, "expected at least one auto candidate"
|
|||
|
|
|
|||
|
|
for c in auto:
|
|||
|
|
for ev_id in c.evidence_ids:
|
|||
|
|
assert isinstance(ev_id, UUID), (
|
|||
|
|
f"evidence_ids must be list[UUID]; got {type(ev_id).__name__} "
|
|||
|
|
f"for {ev_id!r}"
|
|||
|
|
)
|
|||
|
|
# Set equality with inserted ids — every evidence id must trace back
|
|||
|
|
# to a real record we inserted.
|
|||
|
|
assert set(c.evidence_ids).issubset(set(inserted))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- D-26-B: persist_schema
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_persist_schema_uses_iter_record_columns_not_all_records_for_keeper_scan(
|
|||
|
|
store: MemoryStore, monkeypatch: pytest.MonkeyPatch
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-B architecture flip: the keeper-pattern scan in persist_schema
|
|||
|
|
uses ``iter_record_columns(["id", "tier", "tags_json"], ...)``, NOT
|
|||
|
|
``store.all_records()``.
|
|||
|
|
|
|||
|
|
Fixture: empty store (no existing keeper); we are exercising the
|
|||
|
|
no-keeper-found branch, which still must execute the scan.
|
|||
|
|
|
|||
|
|
Pre-D-26-B (current main): ``persist_schema`` calls ``store.all_records()``
|
|||
|
|
at schema.py:267 — spy on ``all_records`` fires once. Assertion fails RED.
|
|||
|
|
|
|||
|
|
Post-D-26-B: spy on ``iter_record_columns`` fires (with at minimum
|
|||
|
|
``["id", "tier", "tags_json"]`` projection); spy on ``all_records``
|
|||
|
|
fires zero times.
|
|||
|
|
|
|||
|
|
Note: the fallback insert path at schema.py:371 calls ``store.insert(...)``
|
|||
|
|
which internally uses ``boost_edges``/``merge_insert`` and may touch other
|
|||
|
|
tables — but it does NOT call ``store.all_records()`` (verified by reading
|
|||
|
|
store.py). So the spy on ``all_records`` cleanly captures only the
|
|||
|
|
keeper-scan path's calls.
|
|||
|
|
"""
|
|||
|
|
# Seed 3 evidence records — minimum CLUSTER_MIN_SIZE.
|
|||
|
|
ev_recs = [_rec(text=f"ev{i}", tags=["meeting", "notes"]) for i in range(3)]
|
|||
|
|
for r in ev_recs:
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
spy_all = MagicMock(wraps=store.all_records)
|
|||
|
|
spy_iter = MagicMock(wraps=store.iter_record_columns)
|
|||
|
|
monkeypatch.setattr(store, "all_records", spy_all)
|
|||
|
|
monkeypatch.setattr(store, "iter_record_columns", spy_iter)
|
|||
|
|
|
|||
|
|
cand = SchemaCandidate(
|
|||
|
|
pattern="tags:meeting+notes",
|
|||
|
|
confidence=0.9,
|
|||
|
|
evidence_count=3,
|
|||
|
|
evidence_ids=[r.id for r in ev_recs],
|
|||
|
|
status="auto",
|
|||
|
|
)
|
|||
|
|
persist_schema(store, cand)
|
|||
|
|
|
|||
|
|
assert spy_all.call_count == 0, (
|
|||
|
|
f"persist_schema must NOT call store.all_records() post-D-26-B; "
|
|||
|
|
f"got {spy_all.call_count} call(s)"
|
|||
|
|
)
|
|||
|
|
assert spy_iter.call_count >= 1, (
|
|||
|
|
f"persist_schema must call store.iter_record_columns() at least once "
|
|||
|
|
f"post-D-26-B (keeper scan); got {spy_iter.call_count} call(s)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_persist_schema_early_exit_on_first_match(
|
|||
|
|
store: MemoryStore, monkeypatch: pytest.MonkeyPatch
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-B: the keeper scan must break on the FIRST matching pattern row,
|
|||
|
|
matching the existing schema.py:268-272 ``break`` semantics.
|
|||
|
|
|
|||
|
|
Fixture: 50 schema-tier records, ALL carrying the keeper pattern tag.
|
|||
|
|
The migrated code must stop iterating after the first match — proven by
|
|||
|
|
counting how many rows the iterator yields before persist_schema returns.
|
|||
|
|
|
|||
|
|
Strategy: monkeypatch-wrap ``iter_record_columns`` with a row counter.
|
|||
|
|
"""
|
|||
|
|
# Insert 50 schema-tier records, all carrying the same pattern tag.
|
|||
|
|
pattern = "tags:meeting+notes"
|
|||
|
|
pattern_tag = f"pattern:{pattern}"
|
|||
|
|
keeper_ids: list[UUID] = []
|
|||
|
|
for i in range(50):
|
|||
|
|
r = _rec(
|
|||
|
|
text=f"schema-{i}",
|
|||
|
|
tags=["schema", "auto", pattern_tag],
|
|||
|
|
tier="semantic",
|
|||
|
|
detail_level=3,
|
|||
|
|
)
|
|||
|
|
store.insert(r)
|
|||
|
|
keeper_ids.append(r.id)
|
|||
|
|
|
|||
|
|
# Wrap iter_record_columns with a row counter.
|
|||
|
|
real_iter = store.iter_record_columns
|
|||
|
|
yielded = {"count": 0}
|
|||
|
|
|
|||
|
|
def counting_iter(columns, **kwargs): # noqa: ANN001
|
|||
|
|
for row in real_iter(columns, **kwargs):
|
|||
|
|
yielded["count"] += 1
|
|||
|
|
yield row
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(store, "iter_record_columns", counting_iter)
|
|||
|
|
|
|||
|
|
# Seed evidence records.
|
|||
|
|
ev_recs = [_rec(text=f"ev{i}", tags=["meeting", "notes"]) for i in range(3)]
|
|||
|
|
for r in ev_recs:
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
cand = SchemaCandidate(
|
|||
|
|
pattern=pattern,
|
|||
|
|
confidence=0.9,
|
|||
|
|
evidence_count=3,
|
|||
|
|
evidence_ids=[r.id for r in ev_recs],
|
|||
|
|
status="auto",
|
|||
|
|
)
|
|||
|
|
schema_id = persist_schema(store, cand)
|
|||
|
|
|
|||
|
|
# Returned id must be one of the existing keepers (the first matching row).
|
|||
|
|
assert schema_id in keeper_ids, (
|
|||
|
|
f"persist_schema must return an existing keeper id when a match exists; "
|
|||
|
|
f"got {schema_id} not in {keeper_ids[:3]}..."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Early-exit invariant: substantially fewer than 50 rows iterated. Without
|
|||
|
|
# a `break` after first match, the wrap counter would see all 50 records.
|
|||
|
|
# Allow up to 2× CLUSTER_MIN_SIZE to absorb LanceDB batch boundaries —
|
|||
|
|
# iter_record_columns yields per row but the scanner reads in batches of
|
|||
|
|
# 1024, so the in-process generator stops cleanly on `break` from the
|
|||
|
|
# consuming code.
|
|||
|
|
assert yielded["count"] <= 50 // 2, (
|
|||
|
|
f"persist_schema must early-exit on first match; iterator yielded "
|
|||
|
|
f"{yielded['count']} rows on a 50-keeper-row store (expected break "
|
|||
|
|
f"after the first match — strictly < 50)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_persist_schema_returns_correct_id_when_keeper_is_mid_stream(
|
|||
|
|
store: MemoryStore,
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-B: when the keeper is the Nth row of the scan (not the first),
|
|||
|
|
the returned UUID must match the keeper's id, not a string from
|
|||
|
|
row["id"] or a different match-but-not-the-first-one row.
|
|||
|
|
|
|||
|
|
Fixture: 5 schema records, only ONE of which carries the matching
|
|||
|
|
pattern tag. The migrated code must:
|
|||
|
|
1. Iterate through non-matching rows without misfiring.
|
|||
|
|
2. Find the matching row and capture its id (with str→UUID conversion).
|
|||
|
|
3. Break out of the loop.
|
|||
|
|
4. Return that captured UUID.
|
|||
|
|
"""
|
|||
|
|
pattern = "tags:meeting+notes"
|
|||
|
|
pattern_tag = f"pattern:{pattern}"
|
|||
|
|
|
|||
|
|
# Insert 5 schema-tier records, only ONE carries the matching tag.
|
|||
|
|
for i in range(2):
|
|||
|
|
store.insert(_rec(
|
|||
|
|
text=f"unrelated-{i}",
|
|||
|
|
tags=["schema", "auto", "pattern:other"],
|
|||
|
|
tier="semantic",
|
|||
|
|
detail_level=3,
|
|||
|
|
))
|
|||
|
|
keeper = _rec(
|
|||
|
|
text="the-keeper",
|
|||
|
|
tags=["schema", "auto", pattern_tag],
|
|||
|
|
tier="semantic",
|
|||
|
|
detail_level=3,
|
|||
|
|
)
|
|||
|
|
store.insert(keeper)
|
|||
|
|
keeper_id = keeper.id
|
|||
|
|
for i in range(2):
|
|||
|
|
store.insert(_rec(
|
|||
|
|
text=f"trailing-{i}",
|
|||
|
|
tags=["schema", "auto", "pattern:something-else"],
|
|||
|
|
tier="semantic",
|
|||
|
|
detail_level=3,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Seed evidence records.
|
|||
|
|
ev_recs = [_rec(text=f"ev{i}", tags=["meeting", "notes"]) for i in range(3)]
|
|||
|
|
for r in ev_recs:
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
cand = SchemaCandidate(
|
|||
|
|
pattern=pattern,
|
|||
|
|
confidence=0.9,
|
|||
|
|
evidence_count=3,
|
|||
|
|
evidence_ids=[r.id for r in ev_recs],
|
|||
|
|
status="auto",
|
|||
|
|
)
|
|||
|
|
returned_id = persist_schema(store, cand)
|
|||
|
|
|
|||
|
|
assert returned_id == keeper_id, (
|
|||
|
|
f"persist_schema must return the matching keeper's UUID; "
|
|||
|
|
f"got {returned_id} expected {keeper_id}"
|
|||
|
|
)
|
|||
|
|
assert isinstance(returned_id, UUID), (
|
|||
|
|
f"persist_schema must return a UUID, not a string from row['id']; "
|
|||
|
|
f"got {type(returned_id).__name__}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_persist_schema_falls_through_to_insert_when_no_keeper(
|
|||
|
|
store: MemoryStore,
|
|||
|
|
) -> None:
|
|||
|
|
"""D-26-B byte-identical contract: when no existing schema carries the
|
|||
|
|
pattern tag, persist_schema falls through to the original insert path
|
|||
|
|
(line 371 ``store.insert(schema_rec)``) and returns a NEW UUID — not
|
|||
|
|
one of the existing-but-non-matching record ids.
|
|||
|
|
|
|||
|
|
Fixture: 5 schema-tier records carrying DIFFERENT pattern tags. None
|
|||
|
|
matches our candidate; the function must insert a new schema record.
|
|||
|
|
"""
|
|||
|
|
# Insert 5 schema-tier records, none matching the candidate pattern.
|
|||
|
|
other_ids: list[UUID] = []
|
|||
|
|
for i in range(5):
|
|||
|
|
r = _rec(
|
|||
|
|
text=f"other-{i}",
|
|||
|
|
tags=["schema", "auto", f"pattern:other-{i}"],
|
|||
|
|
tier="semantic",
|
|||
|
|
detail_level=3,
|
|||
|
|
)
|
|||
|
|
store.insert(r)
|
|||
|
|
other_ids.append(r.id)
|
|||
|
|
|
|||
|
|
# Seed evidence.
|
|||
|
|
ev_recs = [_rec(text=f"ev{i}", tags=["meeting", "notes"]) for i in range(3)]
|
|||
|
|
for r in ev_recs:
|
|||
|
|
store.insert(r)
|
|||
|
|
|
|||
|
|
cand = SchemaCandidate(
|
|||
|
|
pattern="tags:meeting+notes",
|
|||
|
|
confidence=0.9,
|
|||
|
|
evidence_count=3,
|
|||
|
|
evidence_ids=[r.id for r in ev_recs],
|
|||
|
|
status="auto",
|
|||
|
|
)
|
|||
|
|
schema_id = persist_schema(store, cand)
|
|||
|
|
|
|||
|
|
# Must be a fresh UUID, not one of the non-matching keepers.
|
|||
|
|
assert schema_id not in other_ids, (
|
|||
|
|
f"persist_schema must insert a new schema when no keeper matches; "
|
|||
|
|
f"got returned id {schema_id} which equals one of the existing "
|
|||
|
|
f"non-matching schema ids ({other_ids!r})"
|
|||
|
|
)
|
|||
|
|
# The new schema record exists in the store.
|
|||
|
|
new_rec = store.get(schema_id)
|
|||
|
|
assert new_rec is not None
|
|||
|
|
assert new_rec.tier == "semantic"
|
|||
|
|
assert new_rec.detail_level == 3
|
|||
|
|
assert "schema" in (new_rec.tags or [])
|
|||
|
|
assert f"pattern:{cand.pattern}" in (new_rec.tags or [])
|