#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 \ prometheus-trigger-probe bench-5f-threshold-calibration \
bench-5f-selfmodel-snapshot bench-5f-finetuning-shardchain \ bench-5f-selfmodel-snapshot bench-5f-finetuning-shardchain \
bench-5f-falsification-hard bench-fork-baseline-hard bench-5f-formulate-hard \ 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 \ textbooks-summary textbooks-urls fetch-textbooks textbooks-stats textbooks-verify \
crawl-textbooks crawl-textbooks-stats textbook textbook-list 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. # NOT part of `make bootstrap`, `make test`, or a fresh checkout.
bootstrap-nli: bootstrap ## install [nli] extras + warm the pinned NLI checkpoint bootstrap-nli: bootstrap ## install [nli] extras + warm the pinned NLI checkpoint
$(PIP) install -e '.[nli]' $(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 # #000049 Phase 2 / §7 #12 gate item 4 — measure the would-demote rate
# of the clause-level shadow check over (answer, context) records. # 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 from __future__ import annotations
import json import json
import os
import re import re
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from pathlib import Path from pathlib import Path
@ -138,7 +139,7 @@ class ShadowNLI:
:meth:`check` returns an unavailable :class:`ShadowResult`. :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.manifest = manifest or load_manifest()
self.model_version: Optional[str] = self.manifest.get("nli_model_version") self.model_version: Optional[str] = self.manifest.get("nli_model_version")
th = self.manifest.get("thresholds", {}) th = self.manifest.get("thresholds", {})
@ -147,12 +148,28 @@ class ShadowNLI:
self.max_length: int = int(self.manifest.get("max_length", 256)) 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. # §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)) 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.available = False
self._reason = "uninitialised" self._reason = "uninitialised"
self._tok = None self._tok = None
self._model = None self._model = None
self._ei = self._ni = self._ci = 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: def _ensure_loaded(self) -> None:
if self.available or self._reason.startswith(("deps_missing", "load_failed")): if self.available or self._reason.startswith(("deps_missing", "load_failed")):
return return
@ -162,28 +179,80 @@ class ShadowNLI:
except ImportError as e: except ImportError as e:
self._reason = f"deps_missing: {e} (install: pip install 'arborist[nli]')" self._reason = f"deps_missing: {e} (install: pip install 'arborist[nli]')"
return 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: 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()} id2label = {int(k): v for k, v in self._model.config.id2label.items()}
self._ei, self._ni, self._ci = _resolve_label_indices(id2label) 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._reason = f"load_failed: {type(e).__name__}: {e}"
self._tok = self._model = None self._tok = self._model = None
return return
self.available = True self.available = True
self._reason = "ok" 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 import torch
enc = self._tok(premise, hypothesis, return_tensors="pt", truncation=True, max_length=self.max_length) out: list[tuple[float, float, float]] = []
with torch.no_grad(): for i in range(0, len(pairs), self.batch_size):
logits = self._model(**enc).logits[0].tolist() chunk = pairs[i:i + self.batch_size]
p = _softmax(logits) prem = [p for p, _ in chunk]
return p[self._ei], p[self._ni], p[self._ci] 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: def check(self, claim: str, source: str) -> ShadowResult:
n_clauses = len(clauses(source)) n_clauses = len(clauses(source))
@ -219,8 +288,7 @@ class ShadowNLI:
max_entailment=0.0, best_clause=None, reason="no_candidate_clauses") max_entailment=0.0, best_clause=None, reason="no_candidate_clauses")
max_e = max_n = max_c = 0.0 max_e = max_n = max_c = 0.0
best_clause = None best_clause = None
for cl, _ov in cand: for (cl, _ov), (pe, pn, pc) in zip(cand, self._nli_batch([(cl, claim) for cl, _ in cand])):
pe, pn, pc = self._nli(cl, claim)
max_e = max(max_e, pe) max_e = max(max_e, pe)
max_n = max(max_n, pn) max_n = max(max_n, pn)
if pc > max_c: 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 2–4× 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 the next Phase-2 step) must restrict the NLI call to the clauses the
lexical verifier actually matched and/or a deterministic recombination-risk lexical verifier actually matched and/or a deterministic recombination-risk
trigger; do NOT enable runtime NLI demotion on the current scaffold. trigger; do NOT enable runtime NLI demotion on the current scaffold.
Candidate-clause restriction added (§7 #21 — `ShadowNLI.check` now NLI's Candidate-clause restriction (§7 #21) + speedup (§7 #22 — batched
only the top-6 source clauses by overlap, not the whole context): STRICT forwards, device auto-detect, ONNX-int8 export via `make export-nli-onnx`:
would-demote 30% → 20% on the smoke — *helps, not fixed*; the data points ~4× on CPU, seconds on a 4090) landed. **Verdict at proper n** (§7 #22 —
to θc ≈ 0.90 (up from the clean-set 0.5) to zero out the bench-qa-traffic 223-cell `ARBORIST_NLI_SHADOW=1 make bench-qa` sweep, 89 STRICT cells):
STRICT FPs at 27/28 synthetic recombination recall, but n=10 is too small NLI-as-runtime-veto on STRICT answers has a ~26% false-positive rate at
to set it on. Remaining: a fuller `ARBORIST_NLI_SHADOW=1 make bench-qa` θc 0.5, ~8% at θc 0.90, ~0% only at θc 0.99 — but θc 0.99 gives up most
run → sweep θc on hundreds of STRICT cells → confirm → set it. Runtime NLI recombination recall (hard synthetic recombinations bottom out ~0.76).
demotion stays off. Production verifier unchanged; `falsification-hard` **Fails the §7 #12 gate on this design.** Only untried path that might
stays 10/12. pass: a Phase-3 runtime hook running NLI on the clauses the lexical
verifier actually matched (1–3, 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 **Opened:** 2026-05-12
**Scope:** Decide whether — and if so how — to add a verifier check **Scope:** Decide whether — and if so how — to add a verifier check
that catches a *recombination*: a claim whose content tokens are all 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 `bench/scripts/nli_shadow_sweep.py` + `make bench-nli-shadow` (the
gate-item-4 instrument — sweeps `(answer, context)` records, reports gate-item-4 instrument — sweeps `(answer, context)` records, reports
the *would-demote* rate bucketed by verifier label; renders even 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 `tests/test_nli_shadow.py` (pure-Python parts + graceful degradation
+ the bench-sweep parser — run in the default suite). + 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 from a shadow run on `bench-qa` pipeline output, not from contrived
fixtures. Production verifier unchanged; `falsification-hard` stays fixtures. Production verifier unchanged; `falsification-hard` stays
10/12. 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
~8–26% 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* (1–3 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. # NLI tests via pytest.importorskip when this extra is absent.
# Install with: # Install with:
# pip install 'arborist[nli]' # pip install 'arborist[nli]'
# Phase 3 (if it happens) should ONNX-export the pinned checkpoint # `optimum[onnxruntime]` gives the ONNX-export + int8-quantize path
# and switch this to onnxruntime-cpu to drop torch (cf. [vec]). # (`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", "transformers>=4.40",
"torch>=2.2", "torch>=2.2",
"sentencepiece>=0.2", "sentencepiece>=0.2",
"protobuf>=4.0", "protobuf>=4.0",
"optimum[onnxruntime]>=1.20",
] ]
dev = [ dev = [
"pytest>=8", "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.available is False # not loaded until first check
assert nli.model_version == load_manifest()["nli_model_version"] assert nli.model_version == load_manifest()["nli_model_version"]
assert nli.theta_contra == 0.5 and nli.theta_entail == 0.9 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(): def test_check_returns_shadowresult_and_degrades_gracefully():