Phase 2 — bench instrumentation + measurement run
bench/qa_sweep.py picks up the answerability sidecar projection per row
(answerability_fired, answerability_confidence, answerability_denial_
pattern, answerability_answer_type, answerability_candidate_count) and
aggregates per-mode (answerability_fires + S/M/W confidence breakdown)
into a new column in the markdown summary table.
Measurement run on bench/qa_results/phase2-sidecar-on/2026-05-27T14-
16-22Z (76 questions × n=3 × claim_lattice × Hermes-3-8B × tail layout,
228 runs). Headline:
sidecar fires 2/228 (0.88%)
confidence dist 2 strong / 0 medium / 0 weak
precision 100% (2/2 fires were the Ballestrini fixture)
recall on Ballestrini 2/3 across n=3 (third run model extracted
correctly -> sidecar silent,
correct behavior)
false positives 0/226 non-Ballestrini runs
verifier verdict both fires labeled STRICT by the binary
verifier (the verifier-blind class, exactly
as predicted)
Detection rule's three-clause conjunction (denial + extraction-shape +
candidate proximity near cleaned subject tokens) is operating at the
precision floor. The strong-confidence-only firing pattern is what
calibrates Phase 3's demote threshold.
Phase 3 — opt-in demote flag (default OFF per Dav1d Phase 4 NO-GO)
arborist/qa/keys.py: answerability_demote_enabled added to
_VERIFIER_POLICY_FIELDS so flipping the flag partitions cache via
verifier_policy_hash. Justification: when on, the rendered audit_mode
changes (EVIDENCE-WARRANTED -> EVIDENCE-MISSED-PARTIAL), which IS a
verifier-output property; verifier hash must move accordingly. The
other answerability_* fields stay governance-only (sidecar
diagnostic, no audit_mode mutation).
arborist/cli.py:_render_audit_label extended with answerability +
demote_enabled kwargs. Logic:
demote_triggers = (
demote_enabled
and answerability["answerability_warning"] is True
and answerability["confidence_class"] in ("strong", "medium")
)
lattice modes:
EVIDENCE-WARRANTED -> EVIDENCE-MISSED-PARTIAL (rung transition)
POINTER-LINKED / ANCHOR-WARRANTED -> "rung · missed-answer"
(tail tag; rung itself already
signals degradation)
non-lattice modes (quote/span/entity/paraphrase):
audit_mode token unchanged + "· missed-answer" tail tag
weak confidence: NEVER demotes (Phase 2 saw zero weak fires on real
failures; reserved for future expanded detection ladder)
CLI flag --demote-on-missed-answer on both `arborist query` and
`arborist ask`, default OFF. Flows into call_policy[
"answerability_demote_enabled"] and through to result[
"answerability_demote_enabled"] so the renderer reads it without
needing the policy dict.
End-to-end verified live: 4 fresh Hermes-3-8B runs with --demote-on-
missed-answer on `songs by veronica ballestrini`, all 4 rendered
EVIDENCE-MISSED-PARTIAL · via claim_lattice (Hermes hit the failure
mode in all 4, sidecar fired strong, demote logic transformed the
label).
Phase 4 (default flip to demote-on) — NO-GO per Dav1d 2026-05-27 §3.4:
"a false sidecar warning is tolerable; a false audit-label demotion
can damage trust in correct abstentions." Phase 2 precision is 100%
but n=2 fires is too few samples to claim precision floor empirically.
Default flip blocks on wider bench + human spot-check of the warnings.
Tests: 47 total (36 Phase 1 + 11 new Phase 3 covering hash partitioning
discipline + render-label projection across all four rung/confidence
matrices). Full suite 2794 passed (delta +22 from prior 2772).
Bench output (bench/qa_results/phase2-sidecar-on/) intentionally not
committed — bench/qa_results/ is gitignored per existing convention;
the ticket carries the headline numbers + path for re-inspection.
656 lines
23 KiB
Python
656 lines
23 KiB
Python
"""Tests for the Ticket #000068 Phase 1 missed-answer falsification guard.
|
|
|
|
The guard is a deterministic read-only sidecar that fires on the three-clause
|
|
conjunction: denial pattern + extraction-shaped question + candidate spans
|
|
near cleaned subject tokens. These tests pin each clause in isolation, the
|
|
positive Ballestrini regression case, the negative John-Smith control, and
|
|
the discipline invariants (no model call, no audit write, no claim promotion).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from arborist.qa.inspect import (
|
|
_DENIAL_PATTERNS_V1,
|
|
_extract_subject_tokens,
|
|
_match_denial_pattern,
|
|
_classify_extraction_shape,
|
|
_extract_candidate_spans,
|
|
_score_answerability_candidates,
|
|
diagnose_missed_answer,
|
|
ANSWERABILITY_DIAGNOSTIC_VERSION,
|
|
DENIAL_PATTERNS_VERSION,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class FakeEv:
|
|
pointer_id: str
|
|
evidence_id: str
|
|
title: str
|
|
document_uri: str
|
|
span: str
|
|
|
|
|
|
def _ballestrini_evidence() -> list[FakeEv]:
|
|
return [
|
|
FakeEv(
|
|
pointer_id="E1",
|
|
evidence_id="E652b125b",
|
|
title="Veronica Ballestrini",
|
|
document_uri="https://en.wikipedia.org/wiki/Veronica_Ballestrini",
|
|
span=(
|
|
"Veronica Jean Ballestrini (born October 29, 1991) is an "
|
|
"Italian-American country music singer and songwriter from "
|
|
"Waterford, Connecticut."
|
|
),
|
|
),
|
|
FakeEv(
|
|
pointer_id="E2",
|
|
evidence_id="E19912984",
|
|
title="Veronica Ballestrini",
|
|
document_uri="https://en.wikipedia.org/wiki/Veronica_Ballestrini",
|
|
span=(
|
|
"Ballestrini went on to record her debut album "
|
|
"\"What I'm All About\" which was released August 21, 2009. "
|
|
"You can buy her CD and hear amazing 11 songs and 6 were "
|
|
"written by her. Her first single \"Amazing\" charted on "
|
|
"the Music Row Country chart and the music video debuted at "
|
|
"#3 on CMT Pure. In January 2010 Timbob records partnered "
|
|
"with Lofton Creek Records president Mike Borchetta for the "
|
|
"promotions of Veronica's single \"Out There Somewhere\"."
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Clause A — denial pattern
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_denial_pattern_positive():
|
|
assert _match_denial_pattern("the specific songs are not mentioned in evidence") == "not mentioned"
|
|
|
|
|
|
def test_denial_pattern_negative():
|
|
assert _match_denial_pattern("Veronica has released several songs including Amazing.") is None
|
|
|
|
|
|
def test_denial_pattern_casefold():
|
|
assert _match_denial_pattern("THIS INFORMATION IS NOT PROVIDED IN THE TEXT") == "not provided"
|
|
|
|
|
|
def test_denial_pattern_whitespace_normalized():
|
|
assert _match_denial_pattern("specific songs are\n not mentioned\nhere") == "not mentioned"
|
|
|
|
|
|
def test_denial_pattern_sealed_list_intact():
|
|
"""Adding a new phrase requires bumping DENIAL_PATTERNS_VERSION.
|
|
This test pins the v1 set so silent additions can't slip through."""
|
|
expected = {
|
|
"not mentioned",
|
|
"not provided",
|
|
"the evidence does not say",
|
|
"does not mention",
|
|
"no specific",
|
|
"no evidence",
|
|
"cannot determine from the provided evidence",
|
|
"is not stated",
|
|
"is not specified",
|
|
}
|
|
assert set(_DENIAL_PATTERNS_V1) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Clause B — extraction shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extraction_cue_songs_by():
|
|
info = _classify_extraction_shape("songs by veronica ballestrini", None)
|
|
assert info is not None
|
|
assert info["cue"] == "songs by"
|
|
assert info["answer_type"] == "title_like"
|
|
assert info["shape"] == "list"
|
|
|
|
|
|
def test_extraction_cue_who_wrote():
|
|
info = _classify_extraction_shape("who wrote ulysses?", None)
|
|
assert info is not None
|
|
assert info["cue"] == "who wrote"
|
|
assert info["answer_type"] == "person"
|
|
|
|
|
|
def test_extraction_cue_what_year():
|
|
info = _classify_extraction_shape("what year did the war end?", None)
|
|
assert info is not None
|
|
assert info["cue"] == "what year"
|
|
assert info["answer_type"] == "date"
|
|
|
|
|
|
def test_extraction_shape_quantifier_fallback():
|
|
"""No surface cue but broad quantifier intensity → still extraction shape."""
|
|
info = _classify_extraction_shape("tell me about beatles members", "OPEN_REQUEST")
|
|
assert info is not None
|
|
assert info["cue"].startswith("quantifier:")
|
|
assert info["answer_type"] == "title_like"
|
|
|
|
|
|
def test_extraction_shape_narrow_question():
|
|
"""Narrow factoid: no cue, no broad quantifier → not extraction-shaped."""
|
|
info = _classify_extraction_shape("who is veronica ballestrini?", "SINGULAR")
|
|
assert info is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subject-token extraction (Dav1d §7 — non-negotiable)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_subject_tokens_strip_cue_words():
|
|
"""`songs by veronica ballestrini` → just the proper-noun run.
|
|
Without this hardening the guard false-triggers on generic spans."""
|
|
tokens = _extract_subject_tokens("songs by veronica ballestrini")
|
|
low = [t.lower() for t in tokens]
|
|
assert "songs" not in low
|
|
assert "by" not in low
|
|
assert "veronica" in low
|
|
assert "ballestrini" in low
|
|
|
|
|
|
def test_subject_tokens_strip_who_wrote():
|
|
tokens = _extract_subject_tokens("who wrote ulysses?")
|
|
low = [t.lower() for t in tokens]
|
|
assert "who" not in low
|
|
assert "wrote" not in low
|
|
assert "ulysses" in low
|
|
|
|
|
|
def test_subject_tokens_preserve_proper_noun_runs():
|
|
"""Multi-word proper nouns survive as separate tokens (matched by either)."""
|
|
tokens = _extract_subject_tokens("books by f scott fitzgerald")
|
|
low = [t.lower() for t in tokens]
|
|
assert "books" not in low
|
|
assert "by" not in low
|
|
assert "fitzgerald" in low
|
|
# f and scott are short — `f` drops (single letter), `scott` survives
|
|
assert "scott" in low
|
|
|
|
|
|
def test_subject_tokens_preserve_hyphenated():
|
|
tokens = _extract_subject_tokens("songs by jean-luc picard")
|
|
low = [t.lower() for t in tokens]
|
|
assert "jean-luc" in low or "jean" in low # implementation may split or preserve
|
|
assert "picard" in low
|
|
|
|
|
|
def test_subject_tokens_dedupe():
|
|
tokens = _extract_subject_tokens("ballestrini ballestrini songs")
|
|
low = [t.lower() for t in tokens]
|
|
assert low.count("ballestrini") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate span extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_candidate_quoted_string():
|
|
text = "She wrote her debut single \"Amazing\" in 2009."
|
|
spans = _extract_candidate_spans(text, "title_like")
|
|
quoted = [s for s in spans if s["kind"] == "quoted_string"]
|
|
assert any(s["text"] == "Amazing" for s in quoted)
|
|
|
|
|
|
def test_candidate_title_case_span():
|
|
text = "Veronica Jean Ballestrini was born in Connecticut."
|
|
spans = _extract_candidate_spans(text, "title_like")
|
|
title_case = [s for s in spans if s["kind"] == "title_case_span"]
|
|
assert any("Veronica Jean Ballestrini" in s["text"] for s in title_case)
|
|
|
|
|
|
def test_candidate_year_for_date_query():
|
|
text = "She was born in 1991 in Connecticut."
|
|
spans = _extract_candidate_spans(text, "date")
|
|
years = [s for s in spans if s["kind"] == "year"]
|
|
assert any(s["text"] == "1991" for s in years)
|
|
|
|
|
|
def test_candidate_no_year_for_title_query():
|
|
"""Year extraction only happens for date queries."""
|
|
text = "She was born in 1991."
|
|
spans = _extract_candidate_spans(text, "title_like")
|
|
assert not any(s["kind"] == "year" for s in spans)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full guard — positive case
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ballestrini_tail_failure_triggers():
|
|
"""The motivating regression: Hermes-3-8B under tail layout said
|
|
'specific songs are not mentioned' when E2 contained 'Amazing',
|
|
'Out There Somewhere', etc. Guard must catch this."""
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer=(
|
|
"Veronica Ballestrini is a country music singer and songwriter "
|
|
"who has released several songs. However, the specific songs by "
|
|
"her are not mentioned in the provided evidence blocks."
|
|
),
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result is not None
|
|
assert result["answerability_warning"] is True
|
|
assert result["confidence_class"] in ("medium", "strong")
|
|
assert result["denial_pattern_matched"] == "not mentioned"
|
|
assert result["extraction_cue_matched"] == "songs by"
|
|
assert "ballestrini" in [t.lower() for t in result["subject_tokens"]]
|
|
assert "songs" not in [t.lower() for t in result["subject_tokens"]]
|
|
# At least one quoted-string candidate (Amazing / What I'm All About / Out There Somewhere)
|
|
kinds = {c["candidate_kind"] for c in result["missed_answer_candidate_spans"]}
|
|
assert "quoted_string" in kinds
|
|
|
|
|
|
def test_ballestrini_diagnostic_version_pinned():
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="The evidence does not say what songs she released.",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result["diagnostic_version"] == ANSWERABILITY_DIAGNOSTIC_VERSION
|
|
assert result["denial_patterns_version"] == DENIAL_PATTERNS_VERSION
|
|
|
|
|
|
def test_offsets_are_start_end_basis():
|
|
"""Dav1d §10: offsets must be precise — start + end + basis, not ambiguous."""
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="Songs are not mentioned in the evidence.",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
for c in result["missed_answer_candidate_spans"]:
|
|
assert "offset_start" in c
|
|
assert "offset_end" in c
|
|
assert c["offset_basis"] == "evidence_object_text"
|
|
assert c["offset_end"] > c["offset_start"]
|
|
|
|
|
|
def test_candidate_cap_at_10():
|
|
"""Phase 1 must cap output at 10 candidates (the per_chunk lesson)."""
|
|
long_evidence = FakeEv(
|
|
pointer_id="E1",
|
|
evidence_id="EBIG",
|
|
title="Veronica Ballestrini",
|
|
document_uri="https://x",
|
|
# 12 quoted titles
|
|
span=" ".join(f'Her single "Song{i}" charted.' for i in range(12))
|
|
+ " Ballestrini wrote them all.",
|
|
)
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="The evidence does not mention specific songs.",
|
|
evidence=[long_evidence],
|
|
)
|
|
assert result is not None
|
|
assert len(result["missed_answer_candidate_spans"]) <= 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Negative controls (Dav1d §14)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_genuine_absence_no_strong_trigger():
|
|
"""Dav1d §14 negative control: 'songs by John Smith' + evidence
|
|
about Harvard/NY → must NOT strong-trigger."""
|
|
ev = FakeEv(
|
|
pointer_id="E1",
|
|
evidence_id="EJS",
|
|
title="John Smith",
|
|
document_uri="https://x",
|
|
span="John Smith studied at Harvard University and lived in New York.",
|
|
)
|
|
result = diagnose_missed_answer(
|
|
question="songs by john smith",
|
|
answer="The evidence does not mention songs by John Smith.",
|
|
evidence=[ev],
|
|
)
|
|
# Per Dav1d: 'no strong warning; ideally no warning; if warning
|
|
# exists, confidence_class = weak.' We accept weak or None.
|
|
if result is not None:
|
|
assert result["confidence_class"] != "strong", (
|
|
"false positive at strong confidence — Harvard/NY are not song titles"
|
|
)
|
|
|
|
|
|
def test_no_denial_does_not_trigger():
|
|
"""Clause A failure: extraction question + candidates, but no denial."""
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="Veronica released Amazing and Out There Somewhere.",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_not_extraction_shape_does_not_trigger():
|
|
"""Clause B failure: SINGULAR question + denial + candidates."""
|
|
result = diagnose_missed_answer(
|
|
question="who is veronica ballestrini?",
|
|
answer="The evidence does not say who she is.",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_no_evidence_does_not_trigger():
|
|
"""Clause C failure: empty evidence list."""
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="Not mentioned.",
|
|
evidence=[],
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_no_candidate_spans_does_not_trigger():
|
|
"""Clause C failure: evidence exists but contains no candidate spans
|
|
matching the answer_type (plain prose with no titles)."""
|
|
ev = FakeEv(
|
|
pointer_id="E1",
|
|
evidence_id="X",
|
|
title="V B",
|
|
document_uri="https://x",
|
|
span="she is a singer-songwriter from connecticut.", # lowercase — no title-case
|
|
)
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="Not mentioned.",
|
|
evidence=[ev],
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discipline invariants — no model, no audit, no promotion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sidecar_disabled_returns_none():
|
|
"""answerability_sidecar_enabled=False short-circuits to None."""
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="not mentioned",
|
|
evidence=_ballestrini_evidence(),
|
|
policy={"answerability_sidecar_enabled": False},
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_evidence_dict_form_supported():
|
|
"""Phase 1 accepts both EvidenceObject and dict-shaped evidence."""
|
|
dict_ev = [
|
|
{
|
|
"pointer_id": "E2",
|
|
"evidence_id": "EX",
|
|
"title": "Veronica Ballestrini",
|
|
"document_uri": "https://x",
|
|
"span": 'Her first single "Amazing" by Veronica Ballestrini.',
|
|
},
|
|
]
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="The evidence does not mention specific songs.",
|
|
evidence=dict_ev,
|
|
)
|
|
assert result is not None
|
|
assert result["answerability_warning"] is True
|
|
|
|
|
|
def test_deterministic_byte_for_byte():
|
|
"""Same inputs → same output. Byte-deterministic per Dav1d §13."""
|
|
inputs = dict(
|
|
question="songs by veronica ballestrini",
|
|
answer="not mentioned",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
r1 = diagnose_missed_answer(**inputs)
|
|
r2 = diagnose_missed_answer(**inputs)
|
|
assert r1 == r2
|
|
|
|
|
|
def test_empty_question_returns_none():
|
|
result = diagnose_missed_answer(
|
|
question="",
|
|
answer="not mentioned",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_empty_answer_returns_none():
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output schema integrity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_output_schema_keys():
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="The specific songs are not mentioned in evidence.",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
required_keys = {
|
|
"diagnostic_version",
|
|
"denial_patterns_version",
|
|
"extraction_cues_version",
|
|
"answerability_warning",
|
|
"confidence_class",
|
|
"triggered_clauses",
|
|
"denial_pattern_matched",
|
|
"extraction_cue_matched",
|
|
"extraction_shape",
|
|
"answer_type",
|
|
"subject_tokens",
|
|
"candidate_count",
|
|
"threshold",
|
|
"threshold_report",
|
|
"missed_answer_candidate_spans",
|
|
}
|
|
assert required_keys.issubset(result.keys()), (
|
|
f"missing keys: {required_keys - set(result.keys())}"
|
|
)
|
|
|
|
|
|
def test_triggered_clauses_all_true_when_firing():
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="not mentioned",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result["triggered_clauses"]["denial"] is True
|
|
assert result["triggered_clauses"]["extraction_shape"] is True
|
|
assert result["triggered_clauses"]["candidate_proximity"] is True
|
|
|
|
|
|
def test_confidence_class_in_valid_set():
|
|
result = diagnose_missed_answer(
|
|
question="songs by veronica ballestrini",
|
|
answer="not mentioned",
|
|
evidence=_ballestrini_evidence(),
|
|
)
|
|
assert result["confidence_class"] in ("weak", "medium", "strong")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3 — opt-in demote flag + render-label projection + hash discipline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_demote_flag_in_verifier_policy_fields():
|
|
"""Phase 3: answerability_demote_enabled must be in the verifier
|
|
set so flipping it changes verifier_policy_hash."""
|
|
from arborist.qa.keys import _VERIFIER_POLICY_FIELDS
|
|
assert "answerability_demote_enabled" in _VERIFIER_POLICY_FIELDS
|
|
|
|
|
|
def test_demote_flag_changes_verifier_hash():
|
|
"""Flipping the demote flag must partition verifier_policy_hash."""
|
|
from arborist.qa.keys import verifier_policy_hash
|
|
base = {"answerability_demote_enabled": False}
|
|
flipped = {"answerability_demote_enabled": True}
|
|
assert verifier_policy_hash(base) != verifier_policy_hash(flipped)
|
|
|
|
|
|
def test_sidecar_fields_do_NOT_change_verifier_hash():
|
|
"""The other answerability fields (governance only) must NOT
|
|
change verifier_policy_hash."""
|
|
from arborist.qa.keys import verifier_policy_hash
|
|
a = {"answerability_demote_enabled": False, "denial_patterns_version": "v1"}
|
|
b = {"answerability_demote_enabled": False, "denial_patterns_version": "v2"}
|
|
assert verifier_policy_hash(a) == verifier_policy_hash(b)
|
|
|
|
|
|
def test_sidecar_fields_DO_change_governance_hash():
|
|
"""The sidecar enable / threshold fields fold into governance."""
|
|
from arborist.qa.keys import governance_policy_hash
|
|
a = {"answerability_sidecar_enabled": True}
|
|
b = {"answerability_sidecar_enabled": False}
|
|
assert governance_policy_hash(a) != governance_policy_hash(b)
|
|
|
|
|
|
def test_render_label_demote_strong_confidence():
|
|
"""When demote is enabled AND sidecar fired strong, label demotes."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "strong",
|
|
}
|
|
label = _render_audit_label(
|
|
"STRICT", "claim_lattice",
|
|
violations=None,
|
|
answerability=answerability,
|
|
demote_enabled=True,
|
|
)
|
|
assert "EVIDENCE-MISSED-PARTIAL" in label
|
|
|
|
|
|
def test_render_label_demote_medium_confidence():
|
|
"""Medium confidence also demotes (per Phase 2 calibration)."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "medium",
|
|
}
|
|
label = _render_audit_label(
|
|
"STRICT", "claim_lattice",
|
|
violations=None,
|
|
answerability=answerability,
|
|
demote_enabled=True,
|
|
)
|
|
assert "EVIDENCE-MISSED-PARTIAL" in label
|
|
|
|
|
|
def test_render_label_weak_does_NOT_demote():
|
|
"""Weak confidence never demotes the rung (Phase 2 saw zero weak
|
|
fires on real failures; reserved for future expanded detection)."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "weak",
|
|
}
|
|
label = _render_audit_label(
|
|
"STRICT", "claim_lattice",
|
|
violations=None,
|
|
answerability=answerability,
|
|
demote_enabled=True,
|
|
)
|
|
assert "EVIDENCE-MISSED-PARTIAL" not in label
|
|
assert "missed-answer" not in label
|
|
|
|
|
|
def test_render_label_demote_off_keeps_original():
|
|
"""When demote flag is False, label stays as-is even on strong fire."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "strong",
|
|
}
|
|
label = _render_audit_label(
|
|
"STRICT", "claim_lattice",
|
|
violations=None,
|
|
answerability=answerability,
|
|
demote_enabled=False,
|
|
)
|
|
assert "EVIDENCE-MISSED-PARTIAL" not in label
|
|
assert "missed-answer" not in label
|
|
|
|
|
|
def test_render_label_no_answerability_unchanged():
|
|
"""No sidecar fire → label unchanged regardless of flag."""
|
|
from arborist.cli import _render_audit_label
|
|
label_off = _render_audit_label(
|
|
"STRICT", "claim_lattice", violations=None,
|
|
answerability=None, demote_enabled=False,
|
|
)
|
|
label_on = _render_audit_label(
|
|
"STRICT", "claim_lattice", violations=None,
|
|
answerability=None, demote_enabled=True,
|
|
)
|
|
assert label_off == label_on
|
|
|
|
|
|
def test_render_label_quote_mode_demote_appends_tail():
|
|
"""Non-lattice mode: demote appends '· missed-answer' tail rather
|
|
than transitioning to MISSED-PARTIAL."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "strong",
|
|
}
|
|
label = _render_audit_label(
|
|
"STRICT", "quote",
|
|
violations=None,
|
|
answerability=answerability,
|
|
demote_enabled=True,
|
|
)
|
|
assert "missed-answer" in label
|
|
|
|
|
|
def test_render_label_lower_rung_demote_appends_tail():
|
|
"""Lattice mode on a non-EVIDENCE-WARRANTED rung (e.g. ANCHOR-WARRANTED
|
|
or POINTER-LINKED) gets a '· missed-answer' tail rather than a rung
|
|
transition — the rung itself already signals degradation."""
|
|
from arborist.cli import _render_audit_label
|
|
answerability = {
|
|
"answerability_warning": True,
|
|
"confidence_class": "strong",
|
|
}
|
|
# HYBRID + WARRANT_MISSING → POINTER-LINKED rung
|
|
violations = [{"kind": "WARRANT_MISSING"}]
|
|
label = _render_audit_label(
|
|
"HYBRID", "claim_lattice",
|
|
violations=violations,
|
|
answerability=answerability,
|
|
demote_enabled=True,
|
|
)
|
|
assert "missed-answer" in label
|
|
assert "EVIDENCE-MISSED-PARTIAL" not in label
|