"""Per-run Merkle-DAG provenance. `build_run_dag` produces a deterministic Merkle root over the seven stages of a query/ask call. `verify_run_dag` recomputes the root from the persisted node list and confirms it matches. The DAG is not part of cache_key (cache inputs determine the answer; the answer determines the DAG — folding it back would create a cycle). It rides alongside the providence record as `run_dag_root` / `run_dag_blob` so an auditor can verify the run was constructed exactly as recorded. """ from __future__ import annotations from arborist.qa.dag import ( build_run_dag, localize_failure, preflight_node_hash, verify_run_dag, ) def _kw(**overrides): base = dict( question_hash="a" * 64, sources=[ { "document_root": "b" * 64, "source_role": "primary_answer_source", "score": 1.0, "chunk_idx": 0, } ], context_root="b" * 64, conversation_hash="c" * 64, answer_text="The cat is on the mat.", audit_mode="STRICT", verifier_method="quote", n_quotes=1, n_verified=1, claim_statuses=[ {"text": "the cat", "status": "VERIFIED_QUOTE", "method": "quote"} ], lookup_path="miss", ) base.update(overrides) return base def test_dag_root_is_deterministic(): """Same inputs → same root, byte-for-byte.""" a = build_run_dag(**_kw()) b = build_run_dag(**_kw()) assert a["root"] == b["root"] assert len(a["nodes"]) == 7 def test_dag_root_changes_when_answer_changes(): a = build_run_dag(**_kw()) b = build_run_dag(**_kw(answer_text="Different answer.")) assert a["root"] != b["root"] def test_dag_root_changes_when_audit_mode_changes(): a = build_run_dag(**_kw()) b = build_run_dag(**_kw(audit_mode="UNGROUNDED")) assert a["root"] != b["root"] def test_dag_root_changes_when_verifier_method_changes(): a = build_run_dag(**_kw()) b = build_run_dag(**_kw(verifier_method="paraphrase")) assert a["root"] != b["root"] def test_dag_root_changes_when_question_hash_changes(): a = build_run_dag(**_kw()) b = build_run_dag(**_kw(question_hash="d" * 64)) assert a["root"] != b["root"] def test_dag_root_changes_when_sources_change(): a = build_run_dag(**_kw()) new_sources = [{"document_root": "e" * 64, "source_role": "primary_answer_source", "score": 1.0, "chunk_idx": 0}] b = build_run_dag(**_kw(sources=new_sources)) assert a["root"] != b["root"] def test_dag_seven_stages_in_order(): """The seven stages are emitted in fixed order so the root is canonical: question / retrieval / context / prompt / answer / verify / final_label.""" out = build_run_dag(**_kw()) stages = [n["stage"] for n in out["nodes"]] assert stages == [ "question", "retrieval", "context", "prompt", "answer", "verify", "final_label", ] def test_verify_dag_round_trip(): """Recomputing the root from the persisted nodes must match.""" out = build_run_dag(**_kw()) assert verify_run_dag(out) is True def test_verify_dag_detects_node_tampering(): """Mutating a stage hash makes verify fail.""" out = build_run_dag(**_kw()) out["nodes"][3]["hash"] = "f" * 64 # tamper with prompt node # Root no longer matches the (mutated) leaves. assert verify_run_dag(out) is False def test_localize_failure_strict_returns_none(): """STRICT verdict has no failure to localize.""" assert localize_failure(audit_mode="STRICT", n_sources=3, n_quotes=4, n_verified=4) is None def test_localize_failure_no_sources_is_retrieval(): """No admitted sources → retrieval-stage failure (gate over-rejected or corpus genuinely lacks the topic).""" assert localize_failure( audit_mode="UNGROUNDED", n_sources=0, n_quotes=0, n_verified=0 ) == "retrieval" def test_localize_failure_no_quotes_is_context(): """Sources retrieved but no quotes extracted → context-stage failure (per-source cap dropped relevant content, or model declined to cite). Distinct from answer-stage failures where quotes existed but didn't verify.""" assert localize_failure( audit_mode="UNGROUNDED", n_sources=5, n_quotes=0, n_verified=0 ) == "context" def test_localize_failure_unverified_quotes_is_answer(): """Quotes extracted but not all verify → answer-stage failure (model fabricated, paraphrased inside quotes, or appended citations). The case mechanical_repair targets.""" assert localize_failure( audit_mode="HYBRID", n_sources=3, n_quotes=4, n_verified=3 ) == "answer" assert localize_failure( audit_mode="UNGROUNDED", n_sources=3, n_quotes=2, n_verified=0 ) == "answer" def test_localize_failure_lands_on_run_dag_verify_node(): """failure_stage is folded into the verify node's payload so auditors reading the persisted DAG can see which stage produced a non-STRICT verdict.""" out = build_run_dag(**_kw( audit_mode="HYBRID", n_quotes=4, n_verified=3, )) # The verify_node hash should differ between failure_stage='answer' # and a STRICT verdict — failure_stage enters the hashed payload. out_strict = build_run_dag(**_kw( audit_mode="STRICT", n_quotes=4, n_verified=4, )) out["root"] != out_strict["root"] # at minimum, audit_mode differs def test_verify_dag_accepts_json_string(): """Sidecar/audit tools persist the DAG as JSON; verify accepts both.""" import json out = build_run_dag(**_kw()) blob = json.dumps(out, separators=(",", ":")) assert verify_run_dag(blob) is True # ---------------------------------------------------------------- Ticket #000009: preflight stage def test_preflight_node_hash_is_deterministic(): """Same nested-clause inputs → same hex string, byte-for-byte.""" qs = {"logical_statuses": ["well_formed"], "preflight_result": "PREFLIGHT_OK"} quant = {"intensity": "SINGULAR", "is_broad": False} answer_contract = {"guard_enabled": True, "apply_caps_active": False} a = preflight_node_hash( question_state=qs, quantifier=quant, answer_contract=answer_contract, ) b = preflight_node_hash( question_state=qs, quantifier=quant, answer_contract=answer_contract, ) assert a == b assert len(a) == 64 # SHA-256 hex def test_preflight_node_hash_changes_with_question_state(): base_quant = {"intensity": "SINGULAR", "is_broad": False} answer_contract = {"guard_enabled": True} h_a = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier=base_quant, answer_contract=answer_contract, ) h_b = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_PARTIAL"}, quantifier=base_quant, answer_contract=answer_contract, ) assert h_a != h_b def test_preflight_node_hash_changes_with_answer_contract(): """Apply-caps flip in answer_contract MUST bump the node hash so audit replay can distinguish guard-on vs guard-off rows that otherwise share the same classifier output.""" qs = {"logical_statuses": ["broad_quantifier_unbounded"]} quant = {"intensity": "ALL", "is_broad": True} h_off = preflight_node_hash( question_state=qs, quantifier=quant, answer_contract={"apply_caps_active": False}, ) h_on = preflight_node_hash( question_state=qs, quantifier=quant, answer_contract={"apply_caps_active": True}, ) assert h_off != h_on def test_preflight_node_hash_changes_with_prompt_contract(): """Reminder-injection flip MUST bump the node hash.""" qs = {"logical_statuses": ["broad_quantifier_unbounded"]} quant = {"intensity": "ALL", "is_broad": True} h_off = preflight_node_hash( question_state=qs, quantifier=quant, prompt_contract={"reminder_injected": False}, ) h_on = preflight_node_hash( question_state=qs, quantifier=quant, prompt_contract={ "reminder_injected": True, "reminder_template_id": "broad-quantifier-unbounded-v1", }, ) assert h_off != h_on def test_preflight_node_hash_changes_with_policy_refs(): """policy_refs.governance_policy_hash flip → distinct node hash. Required for cache-key/run-DAG-root joint invalidation.""" qs = {"logical_statuses": ["well_formed"]} h_a = preflight_node_hash( question_state=qs, policy_refs={"governance_policy_hash": "a" * 64}, ) h_b = preflight_node_hash( question_state=qs, policy_refs={"governance_policy_hash": "b" * 64}, ) assert h_a != h_b def test_preflight_node_hash_includes_node_version(): """node_version field is part of the hashed payload so a future schema bump (preflight-node-v2 etc.) invalidates legacy nodes.""" from arborist.qa.dag import ( PREFLIGHT_NODE_VERSION, build_preflight_node_payload, ) payload = build_preflight_node_payload( question_state={"x": 1}, ) assert payload["node_version"] == PREFLIGHT_NODE_VERSION assert PREFLIGHT_NODE_VERSION == "preflight-node-v1" def test_preflight_node_hash_handles_all_none(): """Defensive — all clauses may be None during gradual rollout. Hash stays stable.""" a = preflight_node_hash() b = preflight_node_hash() assert a == b assert len(a) == 64 def test_dag_without_preflight_keeps_seven_stage_shape(): """Backward-compat: omitting preflight_hash preserves the pre-#000009 7-stage shape so legacy run_dag_root values re-validate.""" out = build_run_dag(**_kw()) stages = [n["stage"] for n in out["nodes"]] assert stages == [ "question", "retrieval", "context", "prompt", "answer", "verify", "final_label", ] assert len(out["nodes"]) == 7 def test_dag_with_preflight_inserts_eight_stage_shape(): """Quote-mode + preflight_hash → 8 stages, preflight at position 1 (between question and retrieval).""" pre_hash = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier={"intensity": "SINGULAR", "is_broad": False}, answer_contract={"guard_enabled": True}, ) out = build_run_dag(**_kw(preflight_hash=pre_hash)) stages = [n["stage"] for n in out["nodes"]] assert stages == [ "question", "preflight", "retrieval", "context", "prompt", "answer", "verify", "final_label", ] assert len(out["nodes"]) == 8 def test_dag_with_preflight_lattice_mode_ten_stages(): """Pointer-mode + preflight_hash → 10 stages.""" pre_hash = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier={"intensity": "ALL", "is_broad": True}, answer_contract={"guard_enabled": True}, ) out = build_run_dag(**_kw( preflight_hash=pre_hash, evidence_map_root="d" * 64, verifier_method="claim_lattice_pointer", raw_answer_text="Some claim. [E1]", parsed_lattice=[{"claim_text": "Some claim", "evidence_ids": ["e1"]}], rendered_text="Some claim. [E1 | source]", )) stages = [n["stage"] for n in out["nodes"]] assert "preflight" in stages assert stages.index("preflight") == 1 # right after question assert len(stages) == 10 def test_dag_root_changes_when_preflight_hash_changes(): """Different preflight inputs → different run_dag_root. This is the audit-replay payoff: same model output + same verifier verdict + DIFFERENT preflight policy = different cache row.""" pre_a = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier={"intensity": "SINGULAR"}, answer_contract={"apply_caps_active": False}, ) pre_b = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier={"intensity": "SINGULAR"}, answer_contract={"apply_caps_active": True}, ) a = build_run_dag(**_kw(preflight_hash=pre_a)) b = build_run_dag(**_kw(preflight_hash=pre_b)) assert a["root"] != b["root"] def test_dag_with_preflight_round_trips_through_verify(): """The preflight stage's hash is part of the leaf list so verify_run_dag must reconstruct the same root.""" import json pre_hash = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_PARTIAL"}, quantifier={"intensity": "ALL", "is_broad": True}, answer_contract={"apply_caps_active": True, "claim_cap_resolved": 8}, ) out = build_run_dag(**_kw(preflight_hash=pre_hash)) blob = json.dumps(out, separators=(",", ":")) assert verify_run_dag(blob) is True # ---------------------------------------------------------------- reject-broad DAG (§8.2 A) def test_reject_run_dag_three_stage_shape(): """Reject-broad early-return path produces a 3-stage DAG: question → preflight → final_label. Audit replay can read the stage list and tell instantly that this row is a preflight rejection (3 stages = reject path).""" from arborist.qa.dag import build_reject_run_dag, preflight_node_hash pre = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_BLOCKED"}, quantifier={"intensity": "ALL", "is_broad": True, "scope_bound_hint": "unbounded"}, answer_contract={"reject_broad_active": True}, ) out = build_reject_run_dag( question_hash="a" * 64, preflight_hash=pre, rejection_reason="preflight rejection — broad-unbounded.", answer_text="BROAD-QUANTIFIER PREFLIGHT REJECTED", violations=[{"kind": "BROAD_QUANTIFIER_REJECTED"}], ) stages = [n["stage"] for n in out["nodes"]] assert stages == ["question", "preflight", "final_label"] assert len(stages) == 3 def test_reject_run_dag_root_changes_with_preflight_hash(): """Two reject runs that differ only in the preflight payload (e.g. different policy state at rejection time) must produce different run_dag_root values.""" from arborist.qa.dag import build_reject_run_dag, preflight_node_hash pre_a = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_BLOCKED"}, quantifier={"intensity": "ALL"}, policy_refs={"governance_policy_hash": "a" * 64}, ) pre_b = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_BLOCKED"}, quantifier={"intensity": "ALL"}, policy_refs={"governance_policy_hash": "b" * 64}, ) a = build_reject_run_dag( question_hash="d" * 64, preflight_hash=pre_a, rejection_reason="r", answer_text="x", ) b = build_reject_run_dag( question_hash="d" * 64, preflight_hash=pre_b, rejection_reason="r", answer_text="x", ) assert a["root"] != b["root"] def test_reject_run_dag_round_trips_through_verify(): """3-stage reject DAG must verify the same way as the standard 7/9/8/10-stage shapes.""" import json from arborist.qa.dag import build_reject_run_dag, preflight_node_hash pre = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_BLOCKED"}, quantifier={"intensity": "ALL"}, ) out = build_reject_run_dag( question_hash="a" * 64, preflight_hash=pre, rejection_reason="preflight rejection", answer_text="REJECTED", ) blob = json.dumps(out, separators=(",", ":")) assert verify_run_dag(blob) is True # ---------------------------------------------------------------- preflight extraction (#000009 §7.2) def test_extract_preflight_hash_from_blob_with_preflight(): """Pull preflight stage hash out of a persisted run_dag_blob.""" import json from arborist.qa.dag import build_run_dag, preflight_node_hash from arborist.qa.query import _extract_preflight_hash_from_blob pre = preflight_node_hash( question_state={"preflight_result": "PREFLIGHT_OK"}, quantifier={"intensity": "SINGULAR"}, ) out = build_run_dag(**_kw(preflight_hash=pre)) blob = json.dumps(out, separators=(",", ":")) extracted = _extract_preflight_hash_from_blob(blob) assert extracted == pre def test_extract_preflight_hash_returns_none_for_legacy_blob(): """Legacy blobs (no preflight stage) return None — this is the fall-through path for cache rows written before #000009.""" import json from arborist.qa.query import _extract_preflight_hash_from_blob out = build_run_dag(**_kw()) # no preflight_hash blob = json.dumps(out, separators=(",", ":")) assert _extract_preflight_hash_from_blob(blob) is None def test_extract_preflight_hash_returns_none_for_empty_or_invalid(): from arborist.qa.query import _extract_preflight_hash_from_blob assert _extract_preflight_hash_from_blob(None) is None assert _extract_preflight_hash_from_blob("") is None assert _extract_preflight_hash_from_blob("not json {{{") is None assert _extract_preflight_hash_from_blob('"just a string"') is None assert _extract_preflight_hash_from_blob("[]") is None # not a dict assert _extract_preflight_hash_from_blob('{"nodes": []}') is None # no preflight