#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 \
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue