candidate_clauses() — NLI now runs only on the top-N source clauses by content-token overlap with the answer claim (max_candidate_clauses=6), not the whole context; records n_candidate_clauses / best_clause_overlap / recombination_risk. Synthetic sweep unchanged (28/28 recombination, 0/26 legit FP, mean 1.45 candidate clauses/record). Real-traffic smoke re-run: STRICT would-demote 30% → 20%, overall 47% → 33% — better, not fixed; recombination-risk split doesn't separate either. Residual STRICT false-contras at ~0.83-0.92 → θc would need ≈ 0.90 (vs the clean-set 0.5); at θc=0.90 the data in hand gives 27/28 synthetic recall, 0/26 legit FP, 0/10 smoke STRICT FP — but n=10 is too small to set on. Next: a fuller ARBORIST_NLI_SHADOW=1 bench-qa run → sweep θc on hundreds of STRICT cells → confirm → set it. θc stays 0.5; runtime NLI demotion stays off. Production verifier unchanged; falsification-hard stays 10/12.
205 lines
8.9 KiB
Python
205 lines
8.9 KiB
Python
"""Tests for the #000049 Phase-2 NLI shadow scaffold.
|
|
|
|
These run in the default suite — i.e. WITHOUT the ``[nli]`` extra
|
|
installed — so they exercise the pure-Python parts (clause splitter,
|
|
manifest, label-index resolution, the dataclass contract) and the
|
|
graceful-degradation path (deps missing → ``available=False``, never an
|
|
exception). When ``[nli]`` *is* installed they also cover the real
|
|
model path; we don't assert specific probabilities (those belong in the
|
|
``arborist-nli-bench`` scorecard, not the unit suite).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from arborist.qa.nli import ShadowNLI, ShadowResult, shadow_check, load_manifest
|
|
from arborist.qa.nli.shadow import clauses, candidate_clauses, _content_tokens, _resolve_label_indices
|
|
|
|
|
|
# --- clause splitter -------------------------------------------------------
|
|
|
|
def test_clauses_splits_on_sentence_punctuation():
|
|
assert clauses("Jupiter is the largest. Mercury is the smallest.") == [
|
|
"Jupiter is the largest.",
|
|
"Mercury is the smallest.",
|
|
]
|
|
|
|
|
|
def test_clauses_handles_semicolons():
|
|
assert clauses("A is true; B is false") == ["A is true;", "B is false"]
|
|
|
|
|
|
def test_clauses_single_sentence_is_one_clause():
|
|
assert clauses("Just one sentence with no breaks") == ["Just one sentence with no breaks"]
|
|
|
|
|
|
def test_clauses_empty_or_blank():
|
|
assert clauses("") == []
|
|
assert clauses(" ") == []
|
|
assert clauses(None) == [] # type: ignore[arg-type]
|
|
|
|
|
|
# --- candidate-clause restriction (§7 #5 step 3 / §7 #20) ------------------
|
|
|
|
def test_content_tokens_filters_short_and_stopwords():
|
|
toks = _content_tokens("The Mercury is the largest planet in the Solar System.")
|
|
assert "mercury" in toks and "largest" in toks and "planet" in toks and "solar" in toks
|
|
assert "the" not in toks and "is" not in toks and "in" not in toks # stopword / too short
|
|
|
|
|
|
def test_candidate_clauses_ranks_by_overlap_and_caps():
|
|
claim = "Mercury is the largest planet in the Solar System."
|
|
source = ("Jupiter is the largest planet in the Solar System. "
|
|
"Mercury is the smallest planet in the Solar System. "
|
|
"The Roman god Mercury was the messenger of the gods. "
|
|
"Bananas are yellow.")
|
|
cand = candidate_clauses(claim, source, k=6)
|
|
# the banana clause shares no content tokens → dropped
|
|
assert all("Bananas" not in c for c, _ in cand)
|
|
# the two planet clauses are the highest-overlap ones
|
|
assert "Jupiter is the largest planet" in cand[0][0] or "Mercury is the smallest planet" in cand[0][0]
|
|
assert cand[0][1] > cand[-1][1] or len(cand) == 1
|
|
# cap respected
|
|
assert len(candidate_clauses(claim, source, k=1)) == 1
|
|
|
|
|
|
def test_candidate_clauses_empty_when_no_overlap():
|
|
assert candidate_clauses("Bananas are yellow.", "Mercury is the smallest planet.", k=6) == []
|
|
assert candidate_clauses("", "anything at all here", k=6) == []
|
|
|
|
|
|
def test_check_restricts_to_candidate_clauses_not_whole_haystack():
|
|
# a long source where only 2 clauses touch the claim — the result must
|
|
# report n_candidate_clauses << n_clauses (this is the §7 #20 fix).
|
|
haystack = " ".join(f"Unrelated fact number {i} about pottery." for i in range(40))
|
|
source = "Jupiter is the largest planet. " + haystack + " Mercury is the smallest planet."
|
|
res = shadow_check("Mercury is the largest planet.", source)
|
|
assert res.n_clauses >= 40
|
|
assert res.n_candidate_clauses <= 6
|
|
assert res.n_candidate_clauses < res.n_clauses
|
|
|
|
|
|
def test_check_no_candidate_clauses_is_no_demote():
|
|
res = shadow_check("Mercury is the largest planet.",
|
|
"Bananas are yellow. Pottery is ancient. The weather is fine.")
|
|
assert res.would_demote is False
|
|
if res.available:
|
|
assert res.reason == "no_candidate_clauses" and res.n_candidate_clauses == 0
|
|
|
|
|
|
def test_manifest_has_candidate_clause_cap():
|
|
assert load_manifest()["max_candidate_clauses"] == 6
|
|
|
|
|
|
# --- manifest --------------------------------------------------------------
|
|
|
|
def test_manifest_has_required_fields():
|
|
m = load_manifest()
|
|
for key in ("nli_model_version", "hf_repo", "pinned_revision", "license", "thresholds"):
|
|
assert key in m, key
|
|
assert "contradiction_veto" in m["thresholds"]
|
|
assert "entailment_block_veto" in m["thresholds"]
|
|
# shadow-mode thresholds are the bench-validated point
|
|
assert m["thresholds"]["contradiction_veto"] == 0.5
|
|
assert m["thresholds"]["entailment_block_veto"] == 0.9
|
|
|
|
|
|
def test_manifest_is_valid_json_file():
|
|
p = Path(__file__).resolve().parents[1] / "arborist" / "qa" / "nli" / "manifest.json"
|
|
json.loads(p.read_text()) # raises on malformed
|
|
|
|
|
|
# --- label-index resolution (the cross-encoder vs MNLI vs BART orderings) --
|
|
|
|
@pytest.mark.parametrize("id2label,expect", [
|
|
({0: "entailment", 1: "neutral", 2: "contradiction"}, (0, 1, 2)), # MNLI/MoritzLaurer order
|
|
({0: "contradiction", 1: "entailment", 2: "neutral"}, (1, 2, 0)), # cross-encoder order
|
|
({0: "contradiction", 1: "neutral", 2: "entailment"}, (2, 1, 0)), # BART order
|
|
({0: "ENTAILMENT", 1: "NEUTRAL", 2: "CONTRADICTION"}, (0, 1, 2)), # case-insensitive
|
|
])
|
|
def test_resolve_label_indices(id2label, expect):
|
|
assert _resolve_label_indices(id2label) == expect
|
|
|
|
|
|
def test_resolve_label_indices_rejects_incomplete():
|
|
with pytest.raises(RuntimeError):
|
|
_resolve_label_indices({0: "entailment", 1: "neutral"})
|
|
|
|
|
|
# --- ShadowNLI / ShadowResult contract -------------------------------------
|
|
|
|
def test_shadownli_construction_never_raises_and_starts_unavailable():
|
|
nli = ShadowNLI()
|
|
assert nli.available is False # not loaded until first check
|
|
assert nli.model_version == load_manifest()["nli_model_version"]
|
|
assert nli.theta_contra == 0.5 and nli.theta_entail == 0.9
|
|
|
|
|
|
def test_check_returns_shadowresult_and_degrades_gracefully():
|
|
res = shadow_check("Mercury is the largest planet.",
|
|
"Jupiter is the largest planet. Mercury is the smallest planet.")
|
|
assert isinstance(res, ShadowResult)
|
|
assert res.would_demote in (True, False)
|
|
assert 0.0 <= res.max_contradiction <= 1.0
|
|
assert 0.0 <= res.max_entailment <= 1.0
|
|
assert res.n_clauses == 2
|
|
d = res.as_dict()
|
|
assert set(d) >= {"available", "would_demote", "max_contradiction", "max_entailment",
|
|
"best_clause", "n_clauses", "model_version", "theta_contra",
|
|
"theta_entail", "reason"}
|
|
if not res.available:
|
|
# deps-missing path: a clean, non-raising, no-signal result
|
|
assert res.would_demote is False
|
|
assert res.max_contradiction == 0.0 and res.max_entailment == 0.0
|
|
assert "deps_missing" in res.reason or "load_failed" in res.reason
|
|
|
|
|
|
def test_check_empty_input_when_available_is_no_demote():
|
|
nli = ShadowNLI()
|
|
nli._ensure_loaded()
|
|
if not nli.available:
|
|
pytest.skip("[nli] extra not installed — empty-input branch only reachable when loaded")
|
|
res = nli.check("", "Some source text. Another clause.")
|
|
assert res.available is True and res.would_demote is False and res.reason == "empty_input"
|
|
|
|
|
|
# --- bench sweep script ----------------------------------------------------
|
|
|
|
def test_shadow_sweep_parses_both_record_shapes(tmp_path):
|
|
from bench.scripts.nli_shadow_sweep import _records
|
|
p = tmp_path / "mixed.jsonl"
|
|
p.write_text("\n".join([
|
|
json.dumps({"_meta": {"battery": "5f"}}),
|
|
json.dumps({"id": "a", "answer_text": "X is Y.", "context": "X is Z. Q is Y.", "expected_reason": "UNGROUNDED"}),
|
|
json.dumps({"id": "b", "claim": "P is Q.", "source": "P is R.", "want": "not_contradiction"}),
|
|
json.dumps({"id": "c", "answer_text": "only answer, no context"}), # incomplete → skipped
|
|
]))
|
|
recs = list(_records(p))
|
|
assert [r["id"] for r in recs] == ["a", "b"]
|
|
assert recs[0]["bucket"] == "UNGROUNDED" and recs[0]["is_fp_probe"] is False
|
|
assert recs[1]["bucket"] == "not_contradiction" and recs[1]["is_fp_probe"] is True
|
|
|
|
|
|
def test_shadow_sweep_main_smoke(tmp_path):
|
|
from bench.scripts.nli_shadow_sweep import main
|
|
inp = tmp_path / "in.jsonl"
|
|
inp.write_text("\n".join([
|
|
json.dumps({"id": "r1", "claim": "Mercury is the largest planet.",
|
|
"source": "Jupiter is the largest planet. Mercury is the smallest planet.",
|
|
"want": "contradiction"}),
|
|
json.dumps({"id": "r2", "claim": "Batman, the alias of Bruce Wayne, lives in Gotham.",
|
|
"source": "Batman is the alias of Bruce Wayne. Batman lives in Gotham.",
|
|
"want": "not_contradiction"}),
|
|
]))
|
|
out = tmp_path / "report.json"
|
|
rc = main(["--input", str(inp), "--out", str(out)])
|
|
assert rc == 0
|
|
rep = json.loads(out.read_text())
|
|
assert rep["n_records"] == 2
|
|
assert "by_bucket" in rep and "false_positive_probe" in rep
|
|
assert set(rep["by_bucket"]) == {"contradiction", "not_contradiction"}
|
|
# when [nli] absent the report still renders, just unmeasured
|
|
assert rep["available"] in (True, False)
|