#000049 §7 #22: speedup (batch + cuda auto-detect + ONNX-int8 export) + the gate-item-4 verdict at proper n

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.
This commit is contained in:
russell@unturf.com 2026-05-12 17:21:32 -04:00
parent 96ff586da1
commit 87d9db15c7
No known key found for this signature in database
8 changed files with 4587 additions and 30 deletions

View file

@ -36,7 +36,7 @@ SEARCH_Q ?= computer
prometheus-trigger-probe bench-5f-threshold-calibration \
bench-5f-selfmodel-snapshot bench-5f-finetuning-shardchain \
bench-5f-falsification-hard bench-fork-baseline-hard bench-5f-formulate-hard \
bootstrap-math bootstrap-nli bench-nli-shadow clean clean-db clean-data help \
bootstrap-math bootstrap-nli bench-nli-shadow export-nli-onnx clean clean-db clean-data help \
textbooks-summary textbooks-urls fetch-textbooks textbooks-stats textbooks-verify \
crawl-textbooks crawl-textbooks-stats textbook textbook-list
@ -787,7 +787,16 @@ bootstrap-math: bootstrap ## install [math] extras (sympy) into the venv
# NOT part of `make bootstrap`, `make test`, or a fresh checkout.
bootstrap-nli: bootstrap ## install [nli] extras + warm the pinned NLI checkpoint
$(PIP) install -e '.[nli]'
$(PY) -c "from arborist.qa.nli import ShadowNLI; r=ShadowNLI(); r._ensure_loaded(); print('nli runtime ·', 'available:', r.available, '·', r._reason)"
$(PY) -c "from arborist.qa.nli import ShadowNLI; r=ShadowNLI(); r._ensure_loaded(); print('nli runtime ·', 'available:', r.available, '· backend:', r.backend, '· device:', r.device, '·', r._reason)"
# #000049 §3 speedup — export the pinned shadow-NLI checkpoint to ONNX
# (+ int8 dynamic quantization), into ~/.arborist/models/nli/<ver>/onnx/.
# ShadowNLI._ensure_loaded auto-prefers the export if present (~2-4x on
# CPU, drops the torch forward path; on cuda uses CUDAExecutionProvider).
# Run once after `make bootstrap-nli`. SHADOW infra — same checkpoint,
# same labels, nothing about audit_mode changes.
export-nli-onnx: bootstrap ## #000049 §3 — export the pinned shadow-NLI checkpoint to ONNX (int8)
PYTHONUNBUFFERED=1 $(PY) bench/scripts/export_nli_onnx.py
# #000049 Phase 2 / §7 #12 gate item 4 — measure the would-demote rate
# of the clause-level shadow check over (answer, context) records.

View file

@ -22,6 +22,7 @@ extra; everything degrades to ``available=False`` when it is missing.
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, asdict
from pathlib import Path
@ -138,7 +139,7 @@ class ShadowNLI:
:meth:`check` returns an unavailable :class:`ShadowResult`.
"""
def __init__(self, manifest: Optional[dict] = None):
def __init__(self, manifest: Optional[dict] = None, device: Optional[str] = None):
self.manifest = manifest or load_manifest()
self.model_version: Optional[str] = self.manifest.get("nli_model_version")
th = self.manifest.get("thresholds", {})
@ -147,12 +148,28 @@ class ShadowNLI:
self.max_length: int = int(self.manifest.get("max_length", 256))
# §7 #5 step-3 / §7 #20 — cap how many source clauses NLI runs on.
self.max_candidate_clauses: int = int(self.manifest.get("max_candidate_clauses", 6))
# device: explicit arg > ARBORIST_NLI_DEVICE env > auto (cuda if available, else cpu).
self.device_pref: str = device or os.environ.get("ARBORIST_NLI_DEVICE") or "auto"
self.device: Optional[str] = None # resolved at load
self.batch_size: int = int(os.environ.get("ARBORIST_NLI_BATCH", "64"))
self.backend: Optional[str] = None # "onnx" | "torch", set at load
self.available = False
self._reason = "uninitialised"
self._tok = None
self._model = None
self._ei = self._ni = self._ci = None
def _onnx_dir(self) -> Optional[Path]:
"""Where an ONNX export of the pinned checkpoint would live, if any.
`ARBORIST_NLI_ONNX_DIR` overrides; default is
`~/.arborist/models/nli/<model_version>/onnx/` (populated by
`bench/scripts/export_nli_onnx.py`)."""
env = os.environ.get("ARBORIST_NLI_ONNX_DIR")
if env:
return Path(env)
mv = self.model_version or "nli"
return Path.home() / ".arborist" / "models" / "nli" / mv / "onnx"
def _ensure_loaded(self) -> None:
if self.available or self._reason.startswith(("deps_missing", "load_failed")):
return
@ -162,28 +179,80 @@ class ShadowNLI:
except ImportError as e:
self._reason = f"deps_missing: {e} (install: pip install 'arborist[nli]')"
return
repo = self.manifest["hf_repo"]
rev = self.manifest.get("pinned_revision")
# resolve device
if self.device_pref == "auto":
self.device = "cuda" if torch.cuda.is_available() else "cpu"
else:
self.device = self.device_pref
# prefer an ONNX export if one exists and `optimum` is importable
# (§3.1 of the speedup plan — ~2-4x on CPU, drops the torch fwd path;
# on cuda uses CUDAExecutionProvider). Falls back to torch silently.
onnx_dir = self._onnx_dir()
try:
if onnx_dir and onnx_dir.exists():
from optimum.onnxruntime import ORTModelForSequenceClassification
provider = ("CUDAExecutionProvider" if self.device == "cuda"
else "CPUExecutionProvider")
self._tok = AutoTokenizer.from_pretrained(str(onnx_dir))
# prefer the int8-quantized graph if the export produced one
# (the §3 CPU speedup); else the fp32 model.onnx.
kw = {}
if (onnx_dir / "model_quantized.onnx").exists():
kw["file_name"] = "model_quantized.onnx"
self._model = ORTModelForSequenceClassification.from_pretrained(
str(onnx_dir), provider=provider, **kw)
self.backend = "onnx-int8" if kw else "onnx"
except Exception: # noqa: BLE001 — ONNX is best-effort; fall through to torch
self._tok = self._model = None
self.backend = None
if self._model is None:
try:
self._tok = AutoTokenizer.from_pretrained(repo, revision=rev)
self._model = AutoModelForSequenceClassification.from_pretrained(repo, revision=rev)
self._model.eval()
if self.device == "cuda":
self._model = self._model.to("cuda")
self.backend = "torch"
except Exception as e: # noqa: BLE001 — any load failure is "unavailable"
self._reason = f"load_failed: {type(e).__name__}: {e}"
self._tok = self._model = None
return
try:
repo = self.manifest["hf_repo"]
rev = self.manifest.get("pinned_revision")
self._tok = AutoTokenizer.from_pretrained(repo, revision=rev)
self._model = AutoModelForSequenceClassification.from_pretrained(repo, revision=rev)
self._model.eval()
id2label = {int(k): v for k, v in self._model.config.id2label.items()}
self._ei, self._ni, self._ci = _resolve_label_indices(id2label)
except Exception as e: # noqa: BLE001 — any load failure is "unavailable"
except Exception as e: # noqa: BLE001
self._reason = f"load_failed: {type(e).__name__}: {e}"
self._tok = self._model = None
return
self.available = True
self._reason = "ok"
def _nli(self, premise: str, hypothesis: str) -> tuple[float, float, float]:
def _nli_batch(self, pairs: list[tuple[str, str]]) -> list[tuple[float, float, float]]:
"""One forward pass over a list of (premise, hypothesis) pairs.
Replaces N batch-1 forwards with N/batch_size batched ones
the §3.1 speedup; on CPU ~3-5x, on cuda far more."""
import torch
enc = self._tok(premise, hypothesis, return_tensors="pt", truncation=True, max_length=self.max_length)
with torch.no_grad():
logits = self._model(**enc).logits[0].tolist()
p = _softmax(logits)
return p[self._ei], p[self._ni], p[self._ci]
out: list[tuple[float, float, float]] = []
for i in range(0, len(pairs), self.batch_size):
chunk = pairs[i:i + self.batch_size]
prem = [p for p, _ in chunk]
hyp = [h for _, h in chunk]
enc = self._tok(prem, hyp, return_tensors="pt", truncation=True,
padding=True, max_length=self.max_length)
if self.device == "cuda" and self.backend == "torch":
enc = {k: v.to("cuda") for k, v in enc.items()}
with torch.no_grad():
logits = self._model(**enc).logits
logits = logits.detach().cpu().tolist()
for row in logits:
p = _softmax(row)
out.append((p[self._ei], p[self._ni], p[self._ci]))
return out
def _nli(self, premise: str, hypothesis: str) -> tuple[float, float, float]:
return self._nli_batch([(premise, hypothesis)])[0]
def check(self, claim: str, source: str) -> ShadowResult:
n_clauses = len(clauses(source))
@ -219,8 +288,7 @@ class ShadowNLI:
max_entailment=0.0, best_clause=None, reason="no_candidate_clauses")
max_e = max_n = max_c = 0.0
best_clause = None
for cl, _ov in cand:
pe, pn, pc = self._nli(cl, claim)
for (cl, _ov), (pe, pn, pc) in zip(cand, self._nli_batch([(cl, claim) for cl, _ in cand])):
max_e = max(max_e, pe)
max_n = max(max_n, pn)
if pc > max_c:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Export the pinned #000049 shadow-NLI checkpoint to ONNX (+ optional
int8 dynamic quantization) the §3 CPU speedup path.
`onnxruntime` on a quantized cross-encoder is typically 24× faster than
torch on CPU and drops the torch forward path; `ShadowNLI._ensure_loaded`
will prefer the export automatically if it finds it at the conventional
location (`~/.arborist/models/nli/<model_version>/onnx/`, or wherever
`ARBORIST_NLI_ONNX_DIR` points). Run once after `make bootstrap-nli`:
python3 bench/scripts/export_nli_onnx.py # export + int8 quantize
python3 bench/scripts/export_nli_onnx.py --no-quantize
python3 bench/scripts/export_nli_onnx.py --out /some/dir
Requires `optimum[onnxruntime]` (in the `[nli]` extra). SHADOW
infrastructure the ONNX model is the same pinned checkpoint, same
labels; nothing about audit_mode changes (cf. ticket #000049 §7).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def main(argv=None) -> int:
sys.path.insert(0, str(REPO))
from arborist.qa.nli.shadow import load_manifest, ShadowNLI
manifest = load_manifest()
repo_id = manifest["hf_repo"]
rev = manifest.get("pinned_revision")
default_out = ShadowNLI(manifest)._onnx_dir()
ap = argparse.ArgumentParser()
ap.add_argument("--out", type=Path, default=default_out,
help=f"output dir (default: {default_out})")
ap.add_argument("--no-quantize", action="store_true", help="skip int8 dynamic quantization")
args = ap.parse_args(argv)
try:
from transformers import AutoTokenizer
from optimum.onnxruntime import ORTModelForSequenceClassification
if not args.no_quantize:
from optimum.onnxruntime import ORTQuantizer
from optimum.onnxruntime.configuration import AutoQuantizationConfig
except ImportError as e:
print(f"[export-nli-onnx] missing dependency: {e}\n"
f" install with: pip install 'arborist[nli]' (pulls optimum[onnxruntime])",
file=sys.stderr)
return 2
out: Path = args.out
out.mkdir(parents=True, exist_ok=True)
print(f"[export-nli-onnx] {repo_id}@{rev or 'main'}{out} (quantize={'no' if args.no_quantize else 'int8-dynamic'})", flush=True)
tok = AutoTokenizer.from_pretrained(repo_id, revision=rev)
model = ORTModelForSequenceClassification.from_pretrained(repo_id, revision=rev, export=True)
model.save_pretrained(out)
tok.save_pretrained(out)
print(f"[export-nli-onnx] fp32 ONNX written ({sum(f.stat().st_size for f in out.glob('*.onnx'))/1e6:.1f} MB of .onnx)", flush=True)
if not args.no_quantize:
quantizer = ORTQuantizer.from_pretrained(out)
qconfig = AutoQuantizationConfig.avx2(is_static=False, per_channel=False)
quantizer.quantize(save_dir=out, quantization_config=qconfig)
print(f"[export-nli-onnx] int8 quantized; dir now {sum(f.stat().st_size for f in out.rglob('*.onnx'))/1e6:.1f} MB of .onnx", flush=True)
# sanity: load via ShadowNLI's ONNX path and run one pair
import os
os.environ["ARBORIST_NLI_ONNX_DIR"] = str(out)
n = ShadowNLI(manifest)
n._ensure_loaded()
if not n.available or not (n.backend or "").startswith("onnx"):
print(f"[export-nli-onnx] WARNING: ShadowNLI did not pick up the ONNX export "
f"(available={n.available}, backend={n.backend}, reason={n._reason})", file=sys.stderr)
return 1
pe, pn, pc = n._nli("Jupiter is the largest planet.", "Mercury is the largest planet.")
print(f"[export-nli-onnx] sanity OK — backend={n.backend} device={n.device}; "
f"NLI(Jupiter-is-largest, Mercury-is-largest) → contradiction={pc:.3f} entail={pe:.3f}")
print(f"[export-nli-onnx] done. ShadowNLI will now auto-prefer {out} (or set ARBORIST_NLI_ONNX_DIR).")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because one or more lines are too long

View file

@ -17,15 +17,20 @@ recombination-risk gating is **load-bearing, not optional** — Phase 3 (and
the next Phase-2 step) must restrict the NLI call to the clauses the
lexical verifier actually matched and/or a deterministic recombination-risk
trigger; do NOT enable runtime NLI demotion on the current scaffold.
Candidate-clause restriction added (§7 #21`ShadowNLI.check` now NLI's
only the top-6 source clauses by overlap, not the whole context): STRICT
would-demote 30% → 20% on the smoke — *helps, not fixed*; the data points
to θc ≈ 0.90 (up from the clean-set 0.5) to zero out the bench-qa-traffic
STRICT FPs at 27/28 synthetic recombination recall, but n=10 is too small
to set it on. Remaining: a fuller `ARBORIST_NLI_SHADOW=1 make bench-qa`
run → sweep θc on hundreds of STRICT cells → confirm → set it. Runtime NLI
demotion stays off. Production verifier unchanged; `falsification-hard`
stays 10/12.
Candidate-clause restriction (§7 #21) + speedup (§7 #22 — batched
forwards, device auto-detect, ONNX-int8 export via `make export-nli-onnx`:
~4× on CPU, seconds on a 4090) landed. **Verdict at proper n** (§7 #22
223-cell `ARBORIST_NLI_SHADOW=1 make bench-qa` sweep, 89 STRICT cells):
NLI-as-runtime-veto on STRICT answers has a ~26% false-positive rate at
θc 0.5, ~8% at θc 0.90, ~0% only at θc 0.99 — but θ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 clauses the lexical
verifier actually matched (13, the right ones), not top-6-by-overlap —
a verifier-side change. Until then: runtime NLI demotion stays off; the
2 fixtures stay permanent boundary markers; shadow telemetry is a
monitoring signal, not a gate. Production verifier unchanged;
`falsification-hard` stays 10/12.
**Opened:** 2026-05-12
**Scope:** Decide whether — and if so how — to add a verifier check
that catches a *recombination*: a claim whose content tokens are all
@ -679,7 +684,7 @@ to `available=False` when `[nli]` absent); `[nli]` extra in
`bench/scripts/nli_shadow_sweep.py` + `make bench-nli-shadow` (the
gate-item-4 instrument — sweeps `(answer, context)` records, reports
the *would-demote* rate bucketed by verifier label; renders even
without `[nli]`, marked `available:false`); <!--AUTOCOUNT:tests:tests/test_nli_shadow.py-->22<!--/AUTOCOUNT--> tests in
without `[nli]`, marked `available:false`); <!--AUTOCOUNT:tests:tests/test_nli_shadow.py-->24<!--/AUTOCOUNT--> tests in
`tests/test_nli_shadow.py` (pure-Python parts + graceful degradation
+ the bench-sweep parser — run in the default suite).
@ -823,3 +828,64 @@ bench-qa-traffic precision — every gate number that matters has to come
from a shadow run on `bench-qa` pipeline output, not from contrived
fixtures. Production verifier unchanged; `falsification-hard` stays
10/12.
**22. Full-ish bench-qa shadow sweep + speedup (2026-05-12).**
*Speedup (the §3 plan).* `ShadowNLI` now (1) **batches** the forward
passes — `_nli_batch(pairs)` runs ⌈N/`batch_size`⌉ batched forwards
instead of N batch-1 ones (`ARBORIST_NLI_BATCH=64` default); (2)
**auto-detects device** (`ARBORIST_NLI_DEVICE` env, else cuda if
available else cpu — `.to("cuda")` + cuda inputs on the torch path,
`CUDAExecutionProvider` on the ONNX path); (3) **prefers an ONNX
export** if one exists — `bench/scripts/export_nli_onnx.py` /
`make export-nli-onnx` exports the pinned checkpoint to ONNX + int8-
dynamic-quantizes it into `~/.arborist/models/nli/<ver>/onnx/` (operator
state, **not** committed — same discipline as the textbook manifest /
`[vec]`), and `_ensure_loaded` loads `model_quantized.onnx` via
`optimum.onnxruntime` (backend `onnx-int8`), falling back silently to
torch when no export / no `optimum`. Measured: torch-cpu-batch1 ≈ 120
ms/pair → **onnx-int8-cpu-batched ≈ 32 ms/pair** (~4×); on a CUDA box
(the 4090) batched inference is ~10⁴10⁵ pairs/s — the whole sweep is
seconds. `optimum[onnxruntime]` added to the `[nli]` extra; 24 tests in
`tests/test_nli_shadow.py`.
*The gate-item-4 number, at proper n.* `ARBORIST_NLI_SHADOW=1 make
bench-qa BENCH_QA_N=1` → 75 q × 3 modes, of which 223 cells completed
(89 STRICT / 90 HYBRID / 44 UNGROUNDED — the run also surfaced a
lone-surrogate bug in real Wikipedia context that `qa_sweep` now
scrubs). Shadow sweep over those 223 (`bench/results/nli-shadow-sweep-benchqa-n1.json`,
candidate-clause restriction on, θe = 0.9):
| audit_mode | n | would_demote @ θc 0.5 | reading |
|---|---|---|---|
| STRICT | 89 | **23 (25.8%)** | the false-positive rate — confirms the smoke at proper n |
| HYBRID | 90 | 36 (40%) | already-demoted; further demotion less harmful |
| UNGROUNDED | 44 | 28 (64%) | already-rejected; NLI agreeing is fine |
θc-sweep on the 89 STRICT cells (FP rate): 0.5→25.8%, 0.7→19.1%,
0.8→12.4%, 0.85→11.2%, **0.90→7.9%**, 0.95→5.6%, 0.97→2.2%, 0.99→0%.
`max_contradiction` on STRICT: p50 0.29, p90 0.88, p95 0.96, max
0.99. So **even with the candidate-clause restriction, NLI-as-runtime-
veto on STRICT answers is *not* gate-passable at any θc that still
catches recombination** — the synthetic recombinations bottom out at
~0.76 on the hard cases (~0.98 on easy), so a θc high enough to get the
STRICT FP rate to ≈0 (0.99) gives up most of the recall the veto exists
for; θc = 0.90 is the least-bad point (~8% STRICT FP, ~96% synthetic
recall) but ~8% false-demote on confidently-grounded answers is well
above any acceptable gate. The recombination-risk split doesn't rescue
it (STRICT FP: 20/65 risk vs 3/24 no-risk — both nonzero).
**Verdict.** This *hardens* §7 #21's conclusion at real n:
NLI-as-runtime-demotion-veto, on the current standalone-lexical
candidate-clause design, **fails the §7 #12 gate** (item 2/4: a
~826% false-positive rate on STRICT). The recombination boundary is
*not* closed by this approach. What remains untried — and the only
path that might pass: a **Phase-3 runtime hook** that runs NLI on the
clauses the *lexical verifier actually matched a span/quote/entity
against* (13 clauses, the right ones), not "top-6 by token overlap"
(6 clauses, several merely lexically-overlapping) — a verifier-side
change, not a standalone proxy. Until that's built and re-measured:
**runtime NLI demotion stays off; the 2 fixtures stay permanent
boundary markers; the shadow telemetry is a monitoring signal, not a
gate.** θc stays 0.5 in the manifest. Production verifier unchanged;
`falsification-hard` stays 10/12.

View file

@ -89,12 +89,19 @@ nli = [
# NLI tests via pytest.importorskip when this extra is absent.
# Install with:
# pip install 'arborist[nli]'
# Phase 3 (if it happens) should ONNX-export the pinned checkpoint
# and switch this to onnxruntime-cpu to drop torch (cf. [vec]).
# `optimum[onnxruntime]` gives the ONNX-export + int8-quantize path
# (`bench/scripts/export_nli_onnx.py`, `make export-nli-onnx`):
# `onnxruntime` on a quantized cross-encoder is ~2-4x faster on CPU
# than the torch forward path; `ShadowNLI._ensure_loaded` auto-prefers
# an export if it finds one. torch is still here because `optimum`'s
# exporter uses it, and it's the fallback when no export exists; a
# Phase-3 runtime could ship an `[nli-onnx]`-only extra (onnxruntime,
# no torch) once the export is committed/distributed (cf. [vec]).
"transformers>=4.40",
"torch>=2.2",
"sentencepiece>=0.2",
"protobuf>=4.0",
"optimum[onnxruntime]>=1.20",
]
dev = [
"pytest>=8",

View file

@ -136,6 +136,36 @@ def test_shadownli_construction_never_raises_and_starts_unavailable():
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():