"""Tests for #000027 — canonical projection persistence. Covers the acceptance criteria from the ticket §7: 1. First call writes one providence_cache row + one ``providence_canonical`` audit event. 2. Second call: cache hit; ``hit_count`` increments; kernel not re-run. 3. Audit-chain integrity intact after mixed canonical/RAG writes. 4. Bumping pi_star_ref (``@v1`` → ``@v2``) routes new questions to a fresh row; old rows remain in DB but unreachable via the live cache_key. 5. Bumping CHUNKING_VERSION does NOT stale canonical rows (they pin ``"n/a-canonical"``). 6. Distinct pi_star_refs namespace separately. 7. Strict vs equivalence_class dedup behavior for canonical rows. """ from __future__ import annotations import json from pathlib import Path from arborist.qa.canonical_cache import ( CANONICAL_AUDIT_EVENT_TYPE, canonical_cache_key, canonical_synthetic_source_root, lookup_canonical, lookup_or_persist, persist_canonical, ) from arborist.qa.client import StubClient from arborist.qa.query import query from arborist.store import connect # ----- low-level: cache_key shape ------------------------------------------ def test_canonical_cache_key_is_deterministic(): a = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) b = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) assert a == b def test_canonical_cache_key_distinct_pi_star_refs(): """Same question + different pi_star_ref → different cache_key. The synthetic source_root encodes pi_star_ref.""" a = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) b = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v2", policy={} ) assert a != b def test_canonical_cache_key_distinct_questions(): a = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) b = canonical_cache_key( question="1 + 1", pi_star_ref="arithmetic@v1", policy={} ) assert a != b def test_canonical_cache_key_strict_vs_equivalence_class(): """Strict mode keeps "0.1+0.2" and "0.1 + 0.2" distinct; equivalence_class mode collapses them via the trailing-strip / article-strip / case rules in question_hash.""" strict_a = canonical_cache_key( question="0.1 + 0.2?", pi_star_ref="arithmetic@v1", policy={}, mode="strict", ) strict_b = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={}, mode="strict", ) eq_a = canonical_cache_key( question="0.1 + 0.2?", pi_star_ref="arithmetic@v1", policy={}, mode="equivalence_class", ) eq_b = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={}, mode="equivalence_class", ) assert strict_a != strict_b # "?" matters in strict mode assert eq_a == eq_b # collapses in equivalence_class # ----- persistence round-trip --------------------------------------------- def test_persist_then_lookup(tmp_path: Path): db = tmp_path / "qa.db" conn = connect(db) try: ckey = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) event_hash, run_dag_root, run_dag = persist_canonical( conn, cache_key_value=ckey, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_input_bytes=b"0.1 + 0.2", canonical_output_bytes=b"3/10", policy={}, ) assert len(event_hash) == 64 assert len(run_dag_root) == 64 assert run_dag["nodes"][1]["stage"] == "canonical_projection" row = lookup_canonical(conn, ckey) assert row is not None assert row["audit_mode"] == "CANONICAL_PROJECTION" assert row["verifier_method"] == "canonical_projection" assert row["answer_text"] == "3/10" assert row["audit_event_hash"] == event_hash assert row["run_dag_root"] == run_dag_root finally: conn.close() def test_audit_event_appended(tmp_path: Path): db = tmp_path / "qa.db" conn = connect(db) try: before = conn.execute( "SELECT COUNT(*) FROM audit_events" ).fetchone()[0] ckey = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) persist_canonical( conn, cache_key_value=ckey, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_input_bytes=b"0.1 + 0.2", canonical_output_bytes=b"3/10", policy={}, ) after = conn.execute( "SELECT COUNT(*) FROM audit_events" ).fetchone()[0] assert after == before + 1 latest = conn.execute( "SELECT event_type, body FROM audit_events " "ORDER BY seq DESC LIMIT 1" ).fetchone() assert latest["event_type"] == CANONICAL_AUDIT_EVENT_TYPE body = json.loads(latest["body"]) assert body["pi_star_ref"] == "arithmetic@v1" assert body["canonical_output_text"] == "3/10" assert body["kernel_audit_mode"] == "CANONICAL_PROJECTION" finally: conn.close() def test_lookup_or_persist_first_call_misses_second_hits(tmp_path: Path): db = tmp_path / "qa.db" conn = connect(db) try: ckey1, row1, was_hit1, dag1 = lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_output_bytes=b"3/10", policy={}, ) assert was_hit1 is False assert row1 is None assert dag1["root"] ckey2, row2, was_hit2, dag2 = lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_output_bytes=b"3/10", policy={}, ) assert ckey1 == ckey2 assert was_hit2 is True assert row2 is not None assert row2["answer_text"] == "3/10" finally: conn.close() # ----- end-to-end via query() --------------------------------------------- def test_query_writes_then_hits(tmp_path: Path): qa_db = tmp_path / "qa.db" no_shards = tmp_path / "shards" first = query( question="0.1 + 0.2", qa_db=qa_db, chat_client=StubClient(""), model_id="m", shards_dir=no_shards, ) assert first["status"] == "cache_miss_then_written" assert first["lookup_path"] == "canonical_cache_miss" ckey1 = first["cache_key"] assert ckey1 is not None second = query( question="0.1 + 0.2", qa_db=qa_db, chat_client=StubClient(""), model_id="m", shards_dir=no_shards, ) assert second["status"] == "cache_hit" assert second["lookup_path"] == "canonical_cache_hit" assert second["cache_key"] == ckey1 assert second["answer_text"] == "3/10" def test_hit_count_increments_on_repeated_query(tmp_path: Path): qa_db = tmp_path / "qa.db" no_shards = tmp_path / "shards" for _ in range(3): query( question="0.1 + 0.2", qa_db=qa_db, chat_client=StubClient(""), model_id="m", shards_dir=no_shards, ) conn = connect(qa_db) try: row = conn.execute( "SELECT hit_count FROM providence_cache " "WHERE audit_mode = 'CANONICAL_PROJECTION'" ).fetchone() # 1 write + 2 hits; hit_count counts hits only. assert row["hit_count"] == 2 finally: conn.close() def test_audit_chain_intact_after_canonical_writes(tmp_path: Path): """audit_events chain (event_hash = sha256(prev || canonical_body)) stays unbroken when canonical rows are interleaved.""" qa_db = tmp_path / "qa.db" no_shards = tmp_path / "shards" for q in ("0.1 + 0.2", "1 + 1", "A AND B", "0.1 + 0.2"): query( question=q, qa_db=qa_db, chat_client=StubClient(""), model_id="m", shards_dir=no_shards, ) conn = connect(qa_db) try: rows = conn.execute( "SELECT seq, event_hash, prev_event_hash, body " "FROM audit_events ORDER BY seq" ).fetchall() import hashlib prev = None for r in rows: h = hashlib.sha256() if r["prev_event_hash"]: h.update(bytes.fromhex(r["prev_event_hash"])) h.update(r["body"].encode("utf-8")) assert h.hexdigest() == r["event_hash"] assert r["prev_event_hash"] == prev prev = r["event_hash"] finally: conn.close() # ----- pi_star_ref version semantics -------------------------------------- def test_distinct_pi_star_refs_namespace_separately(tmp_path: Path): """Same question through two different kernels → two distinct rows.""" db = tmp_path / "qa.db" conn = connect(db) try: ckey_a, _, miss_a, _ = lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_output_bytes=b"3/10", policy={}, ) ckey_b, _, miss_b, _ = lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v2", # hypothetical bump canonical_output_bytes=b"3/10", policy={}, ) assert miss_a is False assert miss_b is False assert ckey_a != ckey_b rows = conn.execute( "SELECT cache_key FROM providence_cache " "WHERE audit_mode = 'CANONICAL_PROJECTION'" ).fetchall() assert len({r["cache_key"] for r in rows}) == 2 finally: conn.close() def test_pi_star_version_bump_orphans_old_row(tmp_path: Path): """Write under @v1; lookup under @v2 (different synthetic source_root) misses; old @v1 row remains in DB.""" db = tmp_path / "qa.db" conn = connect(db) try: # Write under @v1. lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_output_bytes=b"3/10", policy={}, ) # Lookup under @v2 — different cache_key → miss. ckey_v2 = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v2", policy={}, ) assert lookup_canonical(conn, ckey_v2) is None # Old @v1 row still present; just unreachable via @v2 lookup. ckey_v1 = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={}, ) assert lookup_canonical(conn, ckey_v1) is not None finally: conn.close() # ----- governance + chunking_version --------------------------------------- def test_chunking_version_bump_does_not_stale_canonical(tmp_path: Path): """Canonical rows pin chunking_version='n/a-canonical' so a chunker bump on the wikipedia path doesn't mass-stale math answers. Verify by inspecting the persisted column directly.""" db = tmp_path / "qa.db" conn = connect(db) try: ckey = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={} ) persist_canonical( conn, cache_key_value=ckey, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_input_bytes=b"0.1 + 0.2", canonical_output_bytes=b"3/10", policy={}, ) row = lookup_canonical(conn, ckey) assert row is not None # The persisted chunking_version is the canonical sentinel, # NOT the live CHUNKING_VERSION constant. cv = conn.execute( "SELECT chunking_version FROM providence_cache " "WHERE cache_key = ?", (ckey,), ).fetchone()[0] assert cv == "n/a-canonical" finally: conn.close() def test_governance_policy_change_invalidates_lookup(tmp_path: Path): """Different policy → different governance_policy_hash → different cache_key. The old row stays but the lookup misses.""" db = tmp_path / "qa.db" conn = connect(db) try: # Write under empty policy. lookup_or_persist( conn, question="0.1 + 0.2", pi_star_ref="arithmetic@v1", canonical_output_bytes=b"3/10", policy={}, ) # Lookup under different policy — different ghash → miss. ckey_alt = canonical_cache_key( question="0.1 + 0.2", pi_star_ref="arithmetic@v1", policy={"answer_mode": "claim_lattice"}, ) assert lookup_canonical(conn, ckey_alt) is None finally: conn.close() # ----- arborist canon stays transient ------------------------------------- def test_arborist_canon_does_not_persist(tmp_path: Path): """`arborist canon ""` is a one-shot probe; it bypasses query() entirely and writes nothing to providence_cache. Confirms the boundary the ticket §2.6 promises.""" import subprocess import sys qa_db = tmp_path / "qa.db" # Make sure no rows exist before the canon call. conn = connect(qa_db) try: before = conn.execute( "SELECT COUNT(*) FROM providence_cache" ).fetchone()[0] finally: conn.close() r = subprocess.run( [sys.executable, "-m", "arborist.cli", "canon", "arithmetic@v1", "0.1 + 0.2"], capture_output=True, text=True, check=False, ) assert r.returncode == 0 assert r.stdout.strip() == "3/10" # No row should have been written by `canon` (it's transient by # design — the canon CLI doesn't take a qa_db). Confirm the # qa_db count is unchanged. We verify by re-checking the same # tmp_path qa_db (which is unrelated to the default qa.db that # `canon` doesn't write to anyway). conn = connect(qa_db) try: after = conn.execute( "SELECT COUNT(*) FROM providence_cache" ).fetchone()[0] finally: conn.close() assert after == before # ----- synthetic dimension shape ------------------------------------------ def test_synthetic_source_root_encodes_pi_star_ref(): a = canonical_synthetic_source_root("arithmetic@v1") b = canonical_synthetic_source_root("arithmetic@v2") c = canonical_synthetic_source_root("logic-kernel@v1") assert len({a, b, c}) == 3 assert len(a) == 64 # sha256 hex