Two enhancements continuing the toy-Hermes design pass: #1 Chain-segment failure localization aborist/qa/dag.py: `localize_failure(audit_mode, n_sources, n_quotes, n_verified)` maps a non-STRICT verdict to the pipeline stage that introduced the failure: retrieval no admitted sources (gate over-rejected, or corpus genuinely lacks the topic) → ingest more / relax breadth context sources retrieved but no quotes extracted (per-source cap dropped relevant content, or model declined to cite) → raise cap / tighten prompt answer quotes extracted but didn't all verify (model fabricated, paraphrased inside quotes, appended citations) → mechanical + re-prompt repair targets exactly this case `failure_stage` lands on the run_dag's verify node payload AND on the result dict so an operator can read the reason at a glance — `failure_stage='answer'` means stop tuning the verifier & fix the model behavior. Debugging becomes typed instead of vague. #2 Re-prompt repair (second tier of the hybrid loop) aborist/qa/repair.py: `reprompt_repair(...)` builds a feedback message naming the failed quotes & asks the model to rewrite using only verbatim citations. Hard rule: only fires when `policy["repair_max_reprompts"] > 0` (default 0); caller enforces the cap by looping at most that many times. aborist/qa/query.py + aborist/qa/runner.py: after mechanical repair, if the answer is still HYBRID/UNGROUNDED with unverified quotes, loop up to `repair_max_reprompts` times. Each iteration: build feedback (assistant turn with current answer + user turn with failed spans), call LLM, verify. Accept the new answer if `n_verified` strictly improved; otherwise break (the model's not converging, don't waste cycles). The mechanical + re-prompt combination handles the cases each tier declines individually: mechanical alone: synthetic_elision split, trailing_artifact trim, no_overlap remove + re-prompt: paraphrase, partial_paraphrase, interior_elision needing semantic judgment, fabrications the model can recognize when shown its own quote `repair_max_reprompts` lives in DEFAULT_QUERY_POLICY + DEFAULT_POLICY so it folds into governance_policy_hash. Default 0 preserves single-shot semantics for callers that don't opt in. Each re-prompt iteration adds a `{action: reprompt_rewrite, diagnosis: model_feedback_loop}` entry to repair_changes; audit chain captures the full transition through the existing providence_repair event. Tests: - dag: localize_failure across all four cases (STRICT, retrieval, context, answer); failure_stage embedded in run_dag verify node. - repair: stub client with sequenced answers (failing first, clean on re-prompt) — assert two LLM calls, STRICT verdict, reprompt_rewrite in the change log. 484 tests pass (dag +5, repair +1). The pre-existing test_burn flake under full-suite ordering remains; passes in isolation.
171 lines
5.6 KiB
Python
171 lines
5.6 KiB
Python
"""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 aborist.qa.dag import build_run_dag, localize_failure, 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
|