arborist/tests/test_directives.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

533 lines
20 KiB
Python

"""Anti-regression tests for the seven-point program.
Each test pins a directive at the substrate level. If a future PR
silently weakens a directive, the test fails by name.
See `docs/seven-point-program.md` for the directive catalogue. Per-row
bench-side compliance lives in `bench/qa_sweep.py`; this file pins
the invariants in the test layer where they can't drift unnoticed.
"""
from __future__ import annotations
import inspect
import re
import pytest
# ---------------------------------------------------------------------------
# D1 — Stop making Hermes prove things
# ---------------------------------------------------------------------------
def test_d1_verify_quotes_signature_has_no_chat_client():
"""The hard verifier never accepts a chat-client / LLM judge.
A future PR that adds a chat_client parameter to verify_quotes
would silently introduce a soft-signal-as-hard-check leak."""
from arborist.qa.verify import verify_quotes
sig = inspect.signature(verify_quotes)
forbidden = {"chat_client", "client", "llm", "judge", "model"}
found = forbidden & set(sig.parameters.keys())
assert not found, (
f"verify_quotes accepts forbidden parameter(s) {found}"
f"directive D1 violation: hard verifier must never receive an "
f"LLM judge."
)
def test_d1_verify_claim_lattice_signature_has_no_chat_client():
from arborist.qa.verify import verify_claim_lattice
sig = inspect.signature(verify_claim_lattice)
forbidden = {"chat_client", "client", "llm", "judge", "model"}
found = forbidden & set(sig.parameters.keys())
assert not found, (
f"verify_claim_lattice accepts forbidden parameter(s) {found} "
f"— directive D1 violation."
)
def test_d1_verify_claim_lattice_json_signature_has_no_chat_client():
from arborist.qa.verify import verify_claim_lattice_json
sig = inspect.signature(verify_claim_lattice_json)
forbidden = {"chat_client", "client", "llm", "judge", "model"}
found = forbidden & set(sig.parameters.keys())
assert not found, (
f"verify_claim_lattice_json accepts forbidden parameter(s) "
f"{found} — directive D1 violation."
)
def test_d1_verifier_method_enum_excludes_llm_judges():
"""The schema CHECK constraint on `verifier_method` must not
include any LLM-as-judge token. If a future migration adds
`llm`, `model`, `judge`, or `nli` to the enum, the verifier
started accepting soft signals into the hard chain."""
from arborist.store import SCHEMA_SQL
# Find the verifier_method CHECK constraint in SCHEMA_SQL.
match = re.search(
r"verifier_method\s+TEXT[^,]+CHECK\s*\(\s*verifier_method\s+IN\s*\(([^)]+)\)\)",
SCHEMA_SQL,
re.IGNORECASE,
)
assert match is not None, (
"verifier_method CHECK constraint not found in SCHEMA_SQL — "
"schema reshape that obscures the enum is itself a directive "
"regression risk."
)
enum_values = [
s.strip().strip("'\"") for s in match.group(1).split(",")
]
forbidden = {"llm", "model", "judge", "nli", "hermes", "ai"}
leaked = forbidden & set(enum_values)
assert not leaked, (
f"verifier_method enum leaked LLM-judge tokens {leaked}"
f"directive D1 violation."
)
# ---------------------------------------------------------------------------
# D2 — Hermes emits pointer clauses
# ---------------------------------------------------------------------------
def test_d2_answer_modes_include_lattice_variants():
"""ANSWER_MODES must contain both lattice variants so an agent
can pick a pointer-clause-emitting mode."""
from arborist.qa.verify import ANSWER_MODES
assert "claim_lattice_pointer" in ANSWER_MODES
assert "claim_lattice" in ANSWER_MODES
def test_d2_default_answer_mode_is_a_known_mode():
from arborist.qa.verify import ANSWER_MODES, DEFAULT_ANSWER_MODE
assert DEFAULT_ANSWER_MODE in ANSWER_MODES
def test_d2_pointer_parser_exists_and_returns_claim_nodes():
"""The pointer-line parser is the surface that turns model
output into internal claim nodes. Its absence would mean
Hermes has nowhere to emit pointer clauses to."""
from arborist.qa.parse_claims import parse_pointer_claims
sample = "Steve Jobs co-founded Apple. [E1]\n"
out = parse_pointer_claims(sample)
assert len(out) == 1
# Each parsed claim carries text + evidence pointer ids.
assert hasattr(out[0], "claim_text")
assert hasattr(out[0], "pointer_ids")
assert "Steve Jobs" in out[0].claim_text
assert out[0].pointer_ids == ["E1"]
# ---------------------------------------------------------------------------
# D3 — Build CTI internally
# ---------------------------------------------------------------------------
def test_d3_runtime_owns_claim_lattice_construction():
"""The runtime parses pointer-line prose into structured claim
nodes. The MODEL never produces structured output directly in
pointer mode — it produces prose that the runtime structures.
A regression that asks the model for structured JSON in pointer
mode would violate this directive."""
from arborist.qa.parse_claims import parse_pointer_claims
multi_line = (
"Steve Jobs co-founded Apple. [E1]\n"
"Steve Wozniak co-founded Apple. [E1,E2]\n"
"Ronald Wayne co-founded Apple. [E1]\n"
)
out = parse_pointer_claims(multi_line)
assert len(out) == 3
# Each node is a tuple-shaped record, not a free-form dict.
# The runtime owns the schema, not the model.
for node in out:
assert isinstance(node.claim_text, str)
assert isinstance(node.pointer_ids, list)
assert all(isinstance(p, str) for p in node.pointer_ids)
def test_d3_evidence_map_built_from_runtime_chunks_not_model_input():
"""The evidence map is constructed by the runtime from retrieved
chunks, not threaded in from the model's prompt-side
invention. Two chunks → two evidence objects with deterministic
pointer ids."""
from arborist.qa.evidence import build_evidence_map
chunks = [
{
"source_root": "a" * 64,
"document_uri": "u://a",
"title": "A",
"chunk_idx": 0,
"chunk_root": "11" * 32,
"span": "alpha",
"source_role": "primary_answer_source",
},
{
"source_root": "b" * 64,
"document_uri": "u://b",
"title": "B",
"chunk_idx": 0,
"chunk_root": "22" * 32,
"span": "beta",
"source_role": "primary_answer_source",
},
]
em = build_evidence_map(chunks)
assert len(em) == 2
# Pointer ids are minted by the runtime, sequentially.
assert em[0].pointer_id == "E1"
assert em[1].pointer_id == "E2"
# Evidence ids are content-addressed, not model-supplied.
assert em[0].evidence_id != em[1].evidence_id
# ---------------------------------------------------------------------------
# D4 — Bind retrieval map AND evidence map
# ---------------------------------------------------------------------------
def test_d4_run_dag_carries_evidence_map_root():
"""The 9-stage CTI run-DAG must commit the evidence_map_root.
Without this, two runs whose retrieval surfaced different chunks
would produce indistinguishable run_dag_roots."""
from arborist.qa.dag import build_run_dag
sig = inspect.signature(build_run_dag)
assert "evidence_map_root" in sig.parameters, (
"build_run_dag missing evidence_map_root parameter — "
"directive D4 (evidence side) violation."
)
def test_d4_retrieval_plan_binding_landed():
"""Retrieval-plan binding landed via ticket #000001. The
`build_run_dag` retrieval stage now hashes both the plan
(operator-influenceable inputs: keywords, top_k, over_fetch,
max_context_chars, shard set) and the result (sources_summary).
Two runs with identical sources but different retrieval keywords
produce different `run_dag_root` values — provenance closes the
'how did retrieval choose these sources' gap."""
from arborist.qa.dag import build_run_dag
sig = inspect.signature(build_run_dag)
assert "retrieval_plan_hash" in sig.parameters, (
"retrieval_plan_hash parameter expected on build_run_dag — "
"ticket #000001 should have landed it."
)
# Confirm the retrieval-stage hash diverges when only the plan
# differs. Hex-only stub values (q, x → not hex; use a-f, 0-9).
base_kwargs = dict(
question_hash="aa" * 32,
sources=[{
"document_root": "dd" * 32,
"source_role": "primary_answer_source",
"score": 1.0,
"chunk_idx": 0,
}],
context_root="cc" * 32,
conversation_hash="ee" * 32,
answer_text="A.",
audit_mode="STRICT",
verifier_method="quote",
n_quotes=1,
n_verified=1,
)
dag_no_plan = build_run_dag(**base_kwargs)
dag_with_plan_a = build_run_dag(**base_kwargs, retrieval_plan_hash="aa" * 32)
dag_with_plan_b = build_run_dag(**base_kwargs, retrieval_plan_hash="bb" * 32)
assert dag_with_plan_a["root"] != dag_no_plan["root"]
assert dag_with_plan_a["root"] != dag_with_plan_b["root"]
def test_d4_retrieval_plan_hash_module_exists():
"""RetrievalPlan dataclass + retrieval_plan_hash function landed
in `arborist.qa.retrieval_plan` per ticket #000001."""
from arborist.qa.retrieval_plan import RetrievalPlan, retrieval_plan_hash
plan = RetrievalPlan(
retrieval_keywords="orwell 1984",
top_k=8,
over_fetch=32,
max_context_chars=60000,
)
h = retrieval_plan_hash(plan)
# 64-char hex string.
assert len(h) == 64
assert all(c in "0123456789abcdef" for c in h)
# Deterministic: same plan → same hash.
assert retrieval_plan_hash(plan) == h
# Differs when input changes.
plan_b = RetrievalPlan(
retrieval_keywords="different",
top_k=8,
over_fetch=32,
max_context_chars=60000,
)
assert retrieval_plan_hash(plan_b) != h
# ---------------------------------------------------------------------------
# D5 — Verify pointers deterministically
# ---------------------------------------------------------------------------
def test_d5_verify_claim_lattice_is_deterministic():
"""Same inputs → same verdict, byte-for-byte. No randomness, no
time-dependent state, no hidden global. Regression here means
the verifier started consulting some non-deterministic source."""
from arborist.qa.evidence import build_evidence_map
from arborist.qa.verify import verify_claim_lattice
chunks = [{
"source_root": "f" * 64,
"document_uri": "u://f",
"title": "F",
"chunk_idx": 0,
"chunk_root": "33" * 32,
"span": "Brachiosaurus appears in the climactic scene.",
"source_role": "primary_answer_source",
}]
em = build_evidence_map(chunks)
answer = "Brachiosaurus appears in the film. [E1]\n"
v1 = verify_claim_lattice(answer, em)
v2 = verify_claim_lattice(answer, em)
assert v1["audit_mode"] == v2["audit_mode"]
assert v1["n_quotes"] == v2["n_quotes"]
assert v1["n_verified"] == v2["n_verified"]
def test_d5_seven_hard_checks_are_pure_functions():
"""Spot check: the per-check helper for citation overlap
(`_claim_textually_overlaps_evidence`) is a pure function — no
network, no chat client, no global state."""
from arborist.qa.verify import _claim_textually_overlaps_evidence
sig = inspect.signature(_claim_textually_overlaps_evidence)
forbidden = {"chat_client", "client", "llm", "judge", "model"}
assert not (forbidden & set(sig.parameters.keys()))
# ---------------------------------------------------------------------------
# D6 — Anchor-class warrant before semantic NLI
# ---------------------------------------------------------------------------
def test_d6_warrant_check_exists_and_is_lexical():
"""warrant_check must exist as a deterministic lexical check.
A future replacement that imports an NLI model into the hard
path would violate D6's 'before NLI' constraint."""
from arborist.qa.warrant import warrant_check
sig = inspect.signature(warrant_check)
forbidden = {"chat_client", "client", "llm", "judge", "model", "nli"}
found = forbidden & set(sig.parameters.keys())
assert not found, (
f"warrant_check accepts forbidden parameter(s) {found}"
f"directive D6 mandates lexical-only warrant check."
)
def test_d6_warrant_fires_on_date_anchor_mismatch():
"""Existing warrant-lite catches the date-anchor lazy-anchor
class. This case is the deterministic anchor (year strings
are case-trivial; ALL-match semantics). Regression here means
the date-anchor extractor or check silently broke."""
from arborist.qa.warrant import warrant_check
# Claim asserts "1985"; cited span has no 1985 anywhere.
ok, missing = warrant_check(
claim_text="Back to the Future was released in theaters in 1985.",
cited_spans=[
"An unrelated chunk about a film released in 1955 "
"discussing trilogy mechanics and pinball adaptations."
],
question="what year was back to the future released?",
)
assert not ok, (
"warrant should fail when claim asserts year 1985 but no "
"cited span contains 1985"
)
assert "1985" in " ".join(missing)
def test_d6_warrant_relation_shape_fires_when_all_anchors_missing():
"""Proper-noun anchor uses ANY-match semantics: at least one
extracted anchor must appear in some cited span. This test
exercises a true failure where the claim's only proper-noun
anchor is missing from every cited span."""
from arborist.qa.warrant import warrant_check
ok, missing = warrant_check(
claim_text="Mr. Burns is the boss.",
cited_spans=[
"Springfield Nuclear Power Plant employs many workers in "
"the maintenance and reactor sections of the facility."
],
question="who is the boss?",
)
assert not ok, (
"warrant should fail when claim's only proper-noun anchor "
"is missing from every cited span"
)
assert any("Burns" in m for m in missing)
def test_d6_warrant_generalization_landed():
"""Per-shape warrant detectors landed via ticket #000003.
Entity-list / count / why-cause shapes now dispatch through
warrant_check alongside the original relation + date classes."""
import arborist.qa.warrant as warrant_mod
expected = (
"_question_is_entity_list_shape",
"_question_is_count_shape",
"_question_is_why_shape",
"extract_count_anchors",
"extract_cause_anchors",
)
missing = [
name for name in expected if not hasattr(warrant_mod, name)
]
assert missing == [], (
f"warrant module missing expected detectors / extractors "
f"{missing} — ticket #000003 marker should have all five."
)
# ---------------------------------------------------------------------------
# D7 — Rename labels honestly
# ---------------------------------------------------------------------------
def test_d7_renderer_relabels_strict_for_claim_lattice():
"""Renderer must map STRICT → ladder rung for claim-lattice
methods. A regression here would put STRICT on synthesis-heavy
claims, overclaiming semantic truth. The four-rung ladder
(POINTER-LINKED / ANCHOR-WARRANTED / EVIDENCE-WARRANTED) replaces
the previous two-rung EVIDENCE-LINKED/EVIDENCE-LINKED-PARTIAL
surface as of #000005."""
from arborist.cli import _render_audit_label
# No violations + STRICT → top rung.
assert "EVIDENCE-WARRANTED" in _render_audit_label("STRICT", "claim_lattice", [])
assert "EVIDENCE-WARRANTED" in _render_audit_label("STRICT", "claim_lattice_pointer", [])
# WARRANT_MISSING → POINTER-LINKED rung.
assert "POINTER-LINKED" in _render_audit_label(
"STRICT", "claim_lattice", [{"kind": "WARRANT_MISSING"}]
)
def test_d7_renderer_keeps_strict_for_pinned_span_methods():
"""Quote / span / entity / paraphrase verify against pinned
spans, not synthesis. STRICT is honest there; renderer must
NOT apply the ladder relabel."""
from arborist.cli import _render_audit_label
for method in ("quote", "span", "entity", "paraphrase"):
label = _render_audit_label("STRICT", method, [])
assert "STRICT" in label, (
f"verifier_method={method} STRICT relabeled — D7 says "
f"only claim-lattice methods get the ladder rename."
)
for rung in ("POINTER-LINKED", "ANCHOR-WARRANTED", "EVIDENCE-WARRANTED", "EVIDENCE-LINKED"):
assert rung not in label
def test_d7_audit_mode_enum_canonical_set():
"""Schema column carries the v9.8 trichotomy plus
CANONICAL_PROJECTION (#000027 — deterministic π* answer rows).
Renderer-level relabels (4-rung ladder, CANONICAL display) do
NOT touch this enum. Adding a new admissibility class here is a
governance event and bumps the schema-version conversation."""
from arborist.store import SCHEMA_SQL
match = re.search(
r"audit_mode\s+TEXT[^,]+CHECK\s*\(\s*audit_mode\s+IN\s*\(([^)]+)\)\)",
SCHEMA_SQL,
re.IGNORECASE,
)
assert match is not None
enum_values = sorted(
s.strip().strip("'\"") for s in match.group(1).split(",")
)
assert enum_values == [
"CANONICAL_PROJECTION", "HYBRID", "STRICT", "UNGROUNDED",
], (
f"audit_mode enum drifted to {enum_values} — schema column "
f"must stay {{STRICT, HYBRID, UNGROUNDED, CANONICAL_PROJECTION}}; "
f"rendered labels are display-layer only."
)
# ---------------------------------------------------------------------------
# D8 — Automate only after the invariants are test-pinned
# ---------------------------------------------------------------------------
def test_d8_seven_point_program_doc_exists():
"""The directive catalogue must exist and be discoverable from
CLAUDE.md. If someone deletes the program doc but leaves the
bench coverage column referencing it, the substrate stops
documenting what it enforces."""
from pathlib import Path
repo_root = Path(__file__).parent.parent
program_doc = repo_root / "docs" / "seven-point-program.md"
assert program_doc.exists(), (
f"docs/seven-point-program.md missing — directive D8 "
f"discipline relies on the program doc as the audit lens. "
f"Restore from git or open a ticket explaining the new "
f"governance scheme."
)
def test_d8_bench_directive_compliance_helper_exists():
"""The bench harness must carry the directive_compliance helper.
Removing it would silently delete the per-run governance signal."""
import importlib.util
from pathlib import Path
repo_root = Path(__file__).parent.parent
spec = importlib.util.spec_from_file_location(
"qa_sweep_dir_check", repo_root / "bench" / "qa_sweep.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
assert hasattr(mod, "_directive_compliance"), (
"bench/qa_sweep.py missing _directive_compliance helper — "
"D8 enforcement substrate gone."
)
def test_d8_tickets_index_exists_and_pins_open_directives():
"""TICKETS.md must list every open ticket that maps to a
partial directive. If a partial directive has no open ticket,
the design log is lying about the work-in-progress state."""
from pathlib import Path
repo_root = Path(__file__).parent.parent
tickets = (repo_root / "docs" / "TICKETS.md").read_text()
program = (repo_root / "docs" / "seven-point-program.md").read_text()
# Every partial directive (½) in the program doc must have at
# least one open ticket referenced. Today's partials: D3, D4, D6.
# Each of those should appear in the tickets index Directive
# column, mapped to an open ticket.
for directive_id in ("D3", "D4", "D6"):
# Program doc lists the directive as ½.
assert f"| {directive_id[1]} |" in program or directive_id in program
# Tickets index references that directive.
assert directive_id in tickets, (
f"partial directive {directive_id} missing from TICKETS.md "
f"index — open ticket should map to it."
)