arborist/tests/test_canonical_projection.py
russell@unturf.com e19aed8da0
#000027 + #000028: canonical projections persist; STRICT-WITNESSED reachable
Closes #000027. Closes #000028 (cache-leg wired).

#000027 — canonical projections persist to providence_cache
============================================================

Math/logic π* answers (arithmetic@v1, logic-kernel@v1,
time-series-quantized@v1, …) are now first-class providence rows.
Pre-fix: question → kernel → answer → return. No cache, no audit
event, no run_dag, no inspect/burn/replay surface.

Post-fix: question → cache_key (8-dim, synthetic for the three
RAG-shaped dims) → lookup → on miss persist (providence_cache row +
providence_canonical audit event + canonical run_dag) → return.

Synthetic cache_key dimensions for canonical rows (per ticket §2.2):

- source_root        = sha256("pi_star_source:" + pi_star_ref)
- model_profile_hash = sha256("pi_star_model:"  + pi_star_ref)
- conversation_hash  = sha256("pi_star_conv:"   + canonical_q + ":" + ref)
- chunking_version   = literal "n/a-canonical" — chunker bumps on
                       wikipedia path don't stale math answers.

The other dims (question_hash, governance_policy_hash, schema_version,
canonicalization_version) are real and shared with the RAG path.

Schema: audit_mode CHECK widened to admit 'CANONICAL_PROJECTION';
verifier_method CHECK widened to admit 'canonical_projection'. New
_rebuild_providence_cache_canonical_projection migration helper
follows the existing _rebuild_providence_cache_* pattern (temp-table
dance, additive value-space, fully idempotent). Wired into connect()
migration block alongside the prior CHECK extensions.

Cache-hit policy: trust the row. Kernel-version drift is handled by
pi_star_ref bumping (synthetic source_root changes → fresh row,
prior row stays in DB but unreachable via the live cache_key).
Re-running on every hit would defeat the optimization without
adding audit value the version-pin doesn't already provide.

Policy gate: canonical_projection_preflight_persist (default True).
Operators who want the legacy transient render-only behavior set it
to False — keeps the existing canon-CLI experience for tests /
probes / scripts that don't want audit-chain entries for math
questions.

CLI render: `CANONICAL · via canonical_projection` for persisted
rows. Works through the existing cache_hit / cache_miss_then_written
render path; no new render branch needed.

`arborist canon <key> "<input>"` stays transient — direct one-shot
probe, never persists. Boundary preserved per ticket §2.6.

#000028 — multi-modality witness cache-leg
==========================================

Pre-#000027 the witness cache-leg closure always returned None;
STRICT-WITNESSED (3-of-3 byte-equal) was structurally unreachable.
Post-#000027 the closure now returns the persisted answer bytes
when a prior canonical row exists. Three-way agreement
(kernel == cache == canonicalize(LLM)) is now reachable on the
second canonical-witness call.

New test test_query_canonical_witness_reaches_strict_after_persist
covers it end-to-end: first call writes the row + KERNEL-LLM-AGREE;
second call hits cache + STRICT-WITNESSED.

Tests
=====

- tests/test_canonical_cache.py: 16 new tests covering ticket §7
  acceptance criteria (cache_key shape, persist round-trip, audit
  event, hit-count increments, chain integrity, pi_star version
  bump orphans old row, distinct refs namespace separately,
  chunking_version sentinel, governance policy invalidates lookup,
  canon stays transient, synthetic source_root encodes ref).
- tests/test_canonical_projection.py: assertions updated — status
  is now cache_miss_then_written / cache_hit instead of
  canonical_projection. Added a transient-mode test pinning the
  policy gate.
- tests/test_witness.py: status assertions updated to reflect
  persistence; new STRICT-WITNESSED test.
- tests/test_directives.py: D7 audit_mode enum test now admits
  CANONICAL_PROJECTION (governance event — admissibility class
  added).

Full suite: 1367 passed, 36 skipped (was 1306; +61 new).

Real-shard smoke
================

  $ make query Q="0.1 + 0.2" BURN=1
  → cache_miss_then_written, ~300ms wall, row written
  $ make query Q="0.1 + 0.2"
  → cache_hit, ~40ms wall, hit_count++

  $ make chain-check-shards
  → 0 breaks per shard
2026-05-09 11:37:06 -04:00

272 lines
8.9 KiB
Python

"""Tests for the canonical-projection preflight + ``arborist canon`` CLI.
Two surfaces, one substrate. The π* registry already has unit-level
coverage in ``tests/test_pi_star.py``; these tests exercise the ways
those π*'s become reachable from the user — without RAG, without an
LLM, and without a shard.
- ``_canonical_projection_preflight`` — the sniff + π* dispatch
helper inside ``arborist.qa.query``.
- ``query()`` short-circuit path — math/logic-shaped questions return
``status="canonical_projection"`` before retrieval starts.
- ``arborist canon ...`` CLI subcommand — direct π* call.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from arborist.qa.client import StubClient
from arborist.qa.query import _canonical_projection_preflight, query
# ----- preflight helper ---------------------------------------------------
@pytest.mark.parametrize(
"question,expected_ref,expected_canonical",
[
("0.1 + 0.2", "arithmetic@v1", b"3/10"),
("0.3", "arithmetic@v1", b"3/10"),
("1/3 + 1/6", "arithmetic@v1", b"1/2"),
("6/4", "arithmetic@v1", b"3/2"),
("(2+3)*4", "arithmetic@v1", b"20/1"),
("A IMPL B", "logic-kernel@v1", b"(NOT A OR B)"),
("(NOT B) IMPL (NOT A)", "logic-kernel@v1", b"(NOT A OR B)"),
("A OR NOT A", "logic-kernel@v1", b"TRUE"),
("NOT NOT A", "logic-kernel@v1", b"A"),
],
)
def test_preflight_matches_math_or_logic(question, expected_ref, expected_canonical):
result = _canonical_projection_preflight(question)
assert result is not None, f"expected match, got None for {question!r}"
ref, canonical = result
assert ref == expected_ref
assert canonical == expected_canonical
@pytest.mark.parametrize(
"question",
[
# natural language → no match
"what is 0.1 + 0.2?",
"tell me about the capital of france",
"is A IMPL B the same as B IMPL A",
# mixed case (lowercase letters) → no logic match
"a IMPL b",
# bare uppercase atom without operator → no logic match
"A",
"B",
# empty / whitespace
"",
" ",
],
)
def test_preflight_falls_through_for_natural_language(question):
assert _canonical_projection_preflight(question) is None
def test_preflight_returns_none_on_pi_star_error():
"""Shape-matched but π*-rejected input falls through gracefully.
``arithmetic@v1`` rejects identifiers; a string that LOOKS
arithmetic but contains nothing parseable should not crash query().
"""
# Empty parens — passes the regex shape but arithmetic@v1 rejects.
assert _canonical_projection_preflight("()") is None
# ----- query() short-circuit ----------------------------------------------
def test_query_math_short_circuits_with_no_shards(tmp_path: Path):
"""Math-shaped question persists a canonical row + answers without
needing a shard or hitting the LLM. Status is cache_miss_then_written
(first call writes the row); audit_mode/verifier_method are the
canonical-projection tokens; cache_key is real."""
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards" # absent on purpose
result = query(
question="0.1 + 0.2",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="test/model",
shards_dir=no_shards,
)
assert result["status"] == "cache_miss_then_written"
assert result["audit_mode"] == "CANONICAL_PROJECTION"
assert result["verifier_method"] == "canonical_projection"
assert result["pi_star_ref"] == "arithmetic@v1"
assert result["answer_text"] == "3/10"
assert result["lookup_path"] == "canonical_cache_miss"
assert result["sources"] == []
assert result["cache_key"] is not None
assert result["audit_event_hash"] is not None
assert result["run_dag_root"] is not None
def test_query_logic_short_circuits(tmp_path: Path):
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards"
result = query(
question="A IMPL B",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="test/model",
shards_dir=no_shards,
)
assert result["status"] == "cache_miss_then_written"
assert result["pi_star_ref"] == "logic-kernel@v1"
assert result["answer_text"] == "(NOT A OR B)"
def test_query_canonical_transient_mode(tmp_path: Path):
"""Operators can disable persistence per-call via
policy['canonical_projection_preflight_persist']=False; the legacy
transient render-only behavior comes back (status='canonical_projection',
cache_key=None, no audit-chain entry)."""
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards"
result = query(
question="0.1 + 0.2",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="test/model",
shards_dir=no_shards,
policy={"canonical_projection_preflight_persist": False},
)
assert result["status"] == "canonical_projection"
assert result["lookup_path"] == "preflight_canonical"
assert result["cache_key"] is None
def test_query_contrapositive_collapses_to_same_canonical(tmp_path: Path):
"""A IMPL B and (NOT B) IMPL (NOT A) are the same equivalence class."""
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards"
a = query(
question="A IMPL B",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="m",
shards_dir=no_shards,
)
b = query(
question="(NOT B) IMPL (NOT A)",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="m",
shards_dir=no_shards,
)
assert a["answer_text"] == b["answer_text"] == "(NOT A OR B)"
def test_query_natural_language_falls_through_to_rag(tmp_path: Path):
"""Natural-language question doesn't trigger the preflight; goes
to RAG and (with no corpus) returns no_sources."""
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards"
result = query(
question="what is the capital of france?",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="m",
shards_dir=no_shards,
)
# Falls through — preflight didn't match, RAG runs, finds no shards.
assert result["status"] != "canonical_projection"
assert result.get("audit_mode") != "CANONICAL_PROJECTION"
def test_query_canonical_preflight_disable_via_policy(tmp_path: Path):
"""policy['canonical_projection_preflight']=False disables the
short-circuit even on math-shaped input."""
qa_db = tmp_path / "qa.db"
no_shards = tmp_path / "shards"
result = query(
question="0.1 + 0.2",
qa_db=qa_db,
chat_client=StubClient(""),
model_id="m",
shards_dir=no_shards,
policy={"canonical_projection_preflight": False},
)
assert result["status"] != "canonical_projection"
# ----- `arborist canon` CLI subcommand ------------------------------------
def _run_canon(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "arborist.cli", "canon", *args],
capture_output=True,
text=True,
check=False,
)
def test_cli_canon_arithmetic():
r = _run_canon("arithmetic@v1", "0.1 + 0.2")
assert r.returncode == 0, r.stderr
assert r.stdout.strip() == "3/10"
def test_cli_canon_logic():
r = _run_canon("logic-kernel@v1", "A IMPL B")
assert r.returncode == 0, r.stderr
assert r.stdout.strip() == "(NOT A OR B)"
def test_cli_canon_time_series():
r = _run_canon(
"time-series-quantized@v1",
'{"dt":1,"dv":0.1,"samples":[[0,1.0],[1,2.0]]}',
)
assert r.returncode == 0, r.stderr
assert r.stdout.strip() == "dt=1;dv=0.1;n=2;t0=0:10|20"
def test_cli_canon_list():
r = _run_canon("--list")
assert r.returncode == 0, r.stderr
keys = {line.split("\t")[0] for line in r.stdout.strip().split("\n")}
assert "arithmetic@v1" in keys
assert "logic-kernel@v1" in keys
assert "time-series-quantized@v1" in keys
assert "code-py-ast@v1" in keys
assert "claim-lattice@v1" in keys
assert "wikitext-base@v1" in keys
def test_cli_canon_json_emits_sha256():
r = _run_canon("--json", "arithmetic@v1", "0.1 + 0.2")
assert r.returncode == 0, r.stderr
obj = json.loads(r.stdout)
assert obj["pi_star_ref"] == "arithmetic@v1"
assert obj["input"] == "0.1 + 0.2"
assert obj["canonical"] == "3/10"
assert len(obj["canonical_sha256"]) == 64
def test_cli_canon_unknown_key_returns_2():
r = _run_canon("does-not-exist@v1", "anything")
assert r.returncode == 2
assert "unknown π*" in r.stderr
def test_cli_canon_pi_star_error_returns_1():
r = _run_canon("arithmetic@v1", "x + 1") # identifier rejected
assert r.returncode == 1
assert "error" in r.stderr.lower()
def test_cli_canon_missing_args_returns_2():
r = _run_canon()
assert r.returncode == 2
assert "required" in r.stderr.lower()