Speedup (§3 plan): ShadowNLI._nli_batch batches forwards (ARBORIST_NLI_BATCH=64); device auto-detect (ARBORIST_NLI_DEVICE, else cuda-if-available); auto-prefer an ONNX export — bench/scripts/export_nli_onnx.py / make export-nli-onnx exports + int8-dynamic-quantizes the pinned checkpoint into ~/.arborist/models/nli/<ver>/onnx/ (operator state, NOT committed), _ensure_loaded loads model_quantized.onnx via optimum.onnxruntime (backend onnx-int8), falls back to torch silently. torch-cpu-batch1 ~120ms/pair → onnx-int8-cpu-batched ~32ms/pair (~4x); seconds on a 4090. optimum[onnxruntime] added to the [nli] extra; 24 tests. Gate-item-4 verdict at proper n: ARBORIST_NLI_SHADOW=1 make bench-qa BENCH_QA_N=1 → 223 cells (89 STRICT / 90 HYBRID / 44 UNGROUNDED; also surfaced + fixed a lone-surrogate bug). Shadow sweep over those: NLI-as- runtime-veto on STRICT has ~26% FP at θc 0.5, ~8% at θc 0.90, ~0% only at θc 0.99 — and θc 0.99 gives up most recombination recall (hard synthetic recombinations bottom out ~0.76). FAILS the §7 #12 gate on this design. Only untried path that might pass: a Phase-3 runtime hook running NLI on the verifier's actual matched clauses (1-3), not top-6-by-overlap. Until then: runtime NLI demotion stays off; the 2 fixtures stay permanent boundary markers; θc stays 0.5. Production verifier unchanged; falsification-hard stays 10/12.
235 lines
10 KiB
Python
235 lines
10 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
|
|
assert nli.device_pref in ("auto", "cpu", "cuda") # default 'auto'
|
|
assert nli.batch_size >= 1
|
|
assert nli.backend is None # set at load → "torch" / "onnx" / "onnx-int8"
|
|
assert nli._onnx_dir().name == "onnx" # conventional export location
|
|
|
|
|
|
def test_shadownli_device_and_onnx_dir_env_overrides(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("ARBORIST_NLI_DEVICE", "cpu")
|
|
monkeypatch.setenv("ARBORIST_NLI_ONNX_DIR", str(tmp_path / "x"))
|
|
monkeypatch.setenv("ARBORIST_NLI_BATCH", "8")
|
|
nli = ShadowNLI()
|
|
assert nli.device_pref == "cpu" and nli.batch_size == 8
|
|
assert nli._onnx_dir() == tmp_path / "x"
|
|
|
|
|
|
def test_nli_batch_matches_single_when_available():
|
|
nli = ShadowNLI()
|
|
nli._ensure_loaded()
|
|
if not nli.available:
|
|
pytest.skip("[nli] extra not installed")
|
|
pairs = [("Jupiter is the largest planet.", "Mercury is the largest planet."),
|
|
("Paris is the capital of France.", "Paris is the capital of France.")]
|
|
batched = nli._nli_batch(pairs)
|
|
singles = [nli._nli(p, h) for p, h in pairs]
|
|
for b, s in zip(batched, singles):
|
|
# batching pads the shorter sequence; with attention masking that's
|
|
# ~a no-op for fp32, and within quantization noise for int8 ONNX —
|
|
# the operative thing (argmax label, gate decision) must not move.
|
|
assert max(range(3), key=lambda i: b[i]) == max(range(3), key=lambda i: s[i])
|
|
assert all(abs(x - y) < 0.05 for x, y in zip(b, s))
|
|
|
|
|
|
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)
|