#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:
parent
96ff586da1
commit
87d9db15c7
8 changed files with 4587 additions and 30 deletions
13
Makefile
13
Makefile
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
4289
bench/results/nli-shadow-sweep-benchqa-n1.json
Normal file
4289
bench/results/nli-shadow-sweep-benchqa-n1.json
Normal file
File diff suppressed because it is too large
Load diff
88
bench/scripts/export_nli_onnx.py
Normal file
88
bench/scripts/export_nli_onnx.py
Normal 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
|
|
@ -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 (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
|
||||
**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
|
||||
~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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue