Skip to content

Commit 36abcfb

Browse files
adridaclaude
andauthored
fit: honest, monotonic parity gate + OOD safety gate (#50)
* Gate deployment on a held-out lower bound, not an in-sample point estimate The parity gate previously selected the accept threshold and reported its teacher agreement on the same calibration set, and accepted accept-all configurations on a raw point estimate. Both let a small or lucky set clear the target and then break the contract on real traffic. - build_global now certifies only when the Clopper-Pearson lower bound on cal agreement clears the target (was a raw point estimate). - _calibrate_threshold selects the threshold on a selection split, then verifies it on a held-out split, deploying only if the held-out lower bound clears the target. Falls back to a single-set lower-bound gate when data is too small to split, and refuses rather than guessing. - Guard NaN/inf surrogate probabilities before the acceptor fit (fixes a crash on degenerate datasets). - Expose `alpha` (confidence level) through the builders and fit_frontier. - Add regression tests for low-n refusal, the lower-bound certification, and the NaN guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Update test_fit_and_route for honest gate behaviour With the held-out lower-bound gate, a noisy 300-trace set correctly refuses to certify a deployment (teacher noise caps agreement below target). Give this test cleanly separable data so it still exercises the deploy and route path; the refusal case is covered by test_gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add OOD-robustness test for the routing path Extreme out-of-distribution embeddings must route without crashing and produce finite accept scores. Documents that the OSS library defers via the acceptor threshold and does not ship a separate distance-based OOD gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gate): make held-out parity gate monotonic in target The held-out calibration picked the single highest-coverage threshold that cleared target on the selection split, then returned None if that one threshold failed held-out verification. Because the highest-coverage threshold is also the most aggressive (least likely to generalise), coverage came out non-monotonic in target: banking77 deployed 0% at TA=0.90 but 87% at TA=0.95. Now verify selection-passing thresholds in coverage order and deploy the highest-coverage one that also clears target on the held-out split. Same Clopper-Pearson maths, same held-out split, the gate just no longer quits after one failed candidate. banking77 frontier is now monotonic (97.5% @0.90 -> 87.1% @0.95 -> 65.9% @0.97 -> 50.4% @0.98). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fit(gate): recover coverage at strict targets via hybrid select+verify The 50/50 held-out split was provably honest but too data-hungry: on a few-hundred calibration rows it certified 0% at strict targets even though the confidence ranking clearly supported partial coverage (banking77 0% @0.98 despite an oracle ~80%). Replace it with a hybrid: select the highest-coverage threshold whose Clopper-Pearson LOWER bound clears target on a 70% slice (removes in-sample optimism at selection), then require it to also clear on a held-out 30% by the point estimate (catches selection-bias flukes without demanding CP-tightness on the small verify slice). Falls back to a single-set CP lower-bound gate when n<40. Validated across seeds on banking77/obside at 0.90-0.98 with zero held-out contract violations; recovers banking77 to 67% @0.98 (was 0%) and Ridge to 68% @0.90 (was 0%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ood): distance-based OOD safety gate (defer off-distribution inputs) The parity gate only guarantees agreement on traffic that resembles calibration data; off-distribution inputs (gibberish, off-domain, prompt-injection) still got a confident surrogate answer and slipped through. Add a commodity kNN-distance OOD gate: at fit, calibrate the 99.5th-percentile mean-10-NN distance globally and per predicted label (per-label can only loosen, never tighten below global); at inference, defer any input beyond that, regardless of surrogate confidence. Keyed on input embeddings + predicted label only, NOT the partition cells, so it carries none of the cell-construction IP. On the Obside 156-case battery it lifts OOD-junk deferral from 0% to ~80% while keeping in-distribution coverage (clean 100% / holdout 90% handled). Semantic near-misses (entity-disentanglement) are deliberately out of scope here, those need the advanced embeddings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: neutralize dataset names in gate docstring Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 246973e commit 36abcfb

7 files changed

Lines changed: 415 additions & 36 deletions

File tree

‎src/tracer/api.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ def log_fn(msg: str) -> None: # noqa: E301 — local helper
149149
accept_scores=scores,
150150
trace_ids=[r.trace_id for r in dataset.records])
151151
qual_path = save_qualitative_report(artifact_dir, qual_report)
152+
153+
# Distance-based OOD gate: calibrate kNN-distance thresholds (global +
154+
# per predicted label) so the runtime can defer off-distribution inputs
155+
# the parity gate never saw. Safety net, not the partition.
156+
try:
157+
from tracer.fit.ood import fit_ood_gate
158+
pred_label_strs = [idx_to_label.get(int(p), "?") for p in preds]
159+
ood_gate = fit_ood_gate(X, pred_label_strs)
160+
if ood_gate is not None:
161+
(artifact_dir / "ood.json").write_text(json.dumps(ood_gate))
162+
notes.append(
163+
f"OOD gate calibrated (kNN dist, global thr={ood_gate['global_thr']:.3f}, "
164+
f"{len(ood_gate['per_label_thr'])} per-label)")
165+
except Exception as _e: # never let the safety net break a fit
166+
notes.append(f"OOD gate skipped: {_e}")
152167
else:
153168
notes.append("No deployable pipeline met the target teacher-parity threshold.")
154169

‎src/tracer/fit/ood.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Distance-based out-of-distribution (OOD) gate.
2+
3+
The parity gate certifies how often the surrogate agrees with the teacher *on
4+
traffic that looks like the calibration data*. It says nothing about inputs that
5+
fall outside that distribution: an off-topic query, gibberish, a prompt-injection
6+
string, or a different-domain item can still get a confident surrogate prediction
7+
and slip through. This gate is the safety net: at fit time it measures how far
8+
in-distribution training points sit from their neighbours, and at inference it
9+
defers any query that lands further out than that, regardless of surrogate
10+
confidence.
11+
12+
Mechanism (deliberately commodity): mean distance to the k nearest training
13+
neighbours, with a 95th-percentile threshold taken globally and per predicted
14+
label (global fallback for sparse labels). This is standard kNN-distance OOD
15+
detection. It is intentionally NOT keyed on the partition cells, the cell
16+
construction is out of scope here; this gate only needs the input embeddings and
17+
the surrogate's predicted label.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Optional
23+
24+
import numpy as np
25+
26+
27+
def fit_ood_gate(X_train: np.ndarray, pred_labels, k: int = 10,
28+
quantile: float = 0.995) -> Optional[dict]:
29+
"""Calibrate the OOD thresholds on the training embeddings.
30+
31+
Returns a serialisable dict, or None if there is too little data to calibrate.
32+
pred_labels: the surrogate's predicted label (string) for each training row,
33+
used to key per-label thresholds (with a global fallback).
34+
"""
35+
X_train = np.asarray(X_train, dtype=np.float32)
36+
n = len(X_train)
37+
if n < 20:
38+
return None
39+
from sklearn.neighbors import NearestNeighbors
40+
k_eff = int(min(k, n - 1))
41+
nn = NearestNeighbors(n_neighbors=k_eff + 1).fit(X_train)
42+
d, _ = nn.kneighbors(X_train)
43+
mean_d = d[:, 1:].mean(axis=1) # drop self (col 0)
44+
global_thr = float(np.quantile(mean_d, quantile))
45+
labels = np.asarray([str(l) for l in pred_labels])
46+
per_label: dict = {}
47+
for lab in np.unique(labels):
48+
m = labels == lab
49+
if int(m.sum()) >= 10:
50+
per_label[str(lab)] = float(np.quantile(mean_d[m], quantile))
51+
return {"k": k_eff, "quantile": float(quantile),
52+
"global_thr": global_thr, "per_label_thr": per_label}
53+
54+
55+
def ood_mask(X_query: np.ndarray, X_train: np.ndarray, query_labels, gate) -> np.ndarray:
56+
"""Boolean array, True where a query is out-of-distribution (should defer).
57+
58+
Uses the same mean-k-NN-distance rule the gate was calibrated with, comparing
59+
against the per-predicted-label threshold (global fallback)."""
60+
X_query = np.asarray(X_query, dtype=np.float32)
61+
if gate is None or X_train is None or len(X_train) == 0 or len(X_query) == 0:
62+
return np.zeros(len(X_query), dtype=bool)
63+
from sklearn.neighbors import NearestNeighbors
64+
k = int(min(gate.get("k", 10), len(X_train)))
65+
if k < 1:
66+
return np.zeros(len(X_query), dtype=bool)
67+
nn = NearestNeighbors(n_neighbors=k).fit(np.asarray(X_train, dtype=np.float32))
68+
d, _ = nn.kneighbors(X_query)
69+
mean_d = d.mean(axis=1)
70+
per = gate.get("per_label_thr", {})
71+
g = float(gate.get("global_thr", np.inf))
72+
# Use the looser of the per-label and global thresholds: a safety net should
73+
# only defer clearly-far inputs, so per-label may loosen the bar for labels
74+
# with a naturally wide neighbourhood but never tighten it below global
75+
# (which would over-defer in-distribution traffic that merely phrases things
76+
# differently from the training sample).
77+
thr = np.array([max(float(per.get(str(l), g)), g) for l in query_labels], dtype=float)
78+
return mean_d > thr

0 commit comments

Comments
 (0)