Skip to content

Commit d409709

Browse files
aithal007claude
andcommitted
Fix OOD gate embedding-scale mismatch between calibration and inference
fit_ood_gate() calibrated its kNN-distance thresholds on the raw fit-time embeddings, but EmbeddingIndex.build() then L2-normalized that same array in place for its cosine FAISS index -- so Router loaded a rescaled copy as train_embeddings, out of sync with the thresholds. With faiss installed, this made every training point (the exact data the gate was calibrated on) register as out-of-distribution, verified by reverting the fix and re-running the new regression test: 240/240 false positives. Fix: EmbeddingIndex.build() no longer mutates its input, and the OOD gate's training embeddings are now saved as their own dedicated artifact (ood_train_embeddings.npy), decoupled from whatever the FAISS index does to its own copy, so the two can no longer drift out of scale with each other regardless of call order or faiss availability. Also: Router now builds the OOD gate's k-NN index once at load time (build_ood_index) instead of re-fitting it from scratch on every single predict() call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 093792a commit d409709

8 files changed

Lines changed: 221 additions & 21 deletions

File tree

‎src/tracer/api.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,22 @@ def log_fn(msg: str) -> None: # noqa: E301, local helper
153153
# Distance-based OOD gate: calibrate kNN-distance thresholds (global +
154154
# per predicted label) so the runtime can defer off-distribution inputs
155155
# the parity gate never saw. Safety net, not the partition.
156+
#
157+
# The gate's training embeddings are saved to their own dedicated
158+
# array (ood_train_embeddings.npy) rather than reusing the FAISS
159+
# index's embeddings.npy: the index build below normalizes its own
160+
# copy for cosine similarity, which is a different representation
161+
# than whatever fit_ood_gate calibrated its distance thresholds on.
162+
# Keeping them as two independent artifacts means the two can never
163+
# silently drift out of scale with each other, regardless of what
164+
# normalization the FAISS index does or whether faiss is installed.
156165
try:
157166
from tracer.fit.ood import fit_ood_gate
158167
pred_label_strs = [idx_to_label.get(int(p), "?") for p in preds]
159168
ood_gate = fit_ood_gate(X, pred_label_strs)
160169
if ood_gate is not None:
161170
(artifact_dir / "ood.json").write_text(json.dumps(ood_gate))
171+
np.save(artifact_dir / "ood_train_embeddings.npy", X)
162172
notes.append(
163173
f"OOD gate calibrated (kNN dist, global thr={ood_gate['global_thr']:.3f}, "
164174
f"{len(ood_gate['per_label_thr'])} per-label)")

‎src/tracer/embeddings/index.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,20 @@ def build(cls, embeddings: np.ndarray, metric: str = "cosine") -> "EmbeddingInde
2222
except ImportError:
2323
return cls(embeddings, index=None)
2424

25-
X = embeddings.astype(np.float32, copy=False)
25+
# Always copy: normalize_L2 mutates in place, and callers (e.g.
26+
# api.py, which passes the same array it just used to calibrate the
27+
# OOD gate and train the surrogate) must not have their embeddings
28+
# silently rescaled out from under them as a side effect of building
29+
# this index. Store the exact array that was indexed on `.embeddings`
30+
# so the two stay consistent with each other.
31+
X = np.array(embeddings, dtype=np.float32, copy=True)
2632
if metric == "cosine":
2733
faiss.normalize_L2(X)
2834
idx = faiss.IndexFlatIP(X.shape[1])
2935
else:
3036
idx = faiss.IndexFlatL2(X.shape[1])
3137
idx.add(X)
32-
return cls(embeddings, index=idx)
38+
return cls(X, index=idx)
3339

3440
def search(self, query: np.ndarray, k: int = 5):
3541
if self._index is None:

‎src/tracer/fit/ood.py‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515
detection. It is intentionally NOT keyed on the partition cells, the cell
1616
construction is out of scope here; this gate only needs the input embeddings and
1717
the surrogate's predicted label.
18+
19+
The caller is responsible for passing fit_ood_gate() and ood_mask() embeddings
20+
on the same scale (whatever that is -- raw, L2-normalized, or otherwise). This
21+
module does not rescale its input: doing so would silently change what "far"
22+
means (angular vs. magnitude-sensitive distance), which is a real behavioural
23+
choice, not something to bake in unconditionally. See api.py/router.py: the
24+
training embeddings used to calibrate this gate are saved and reloaded as their
25+
own dedicated artifact, kept independent of whatever the (possibly
26+
cosine-normalizing) FAISS index build does to its own copy, specifically so the
27+
two can never silently drift out of scale with each other.
1828
"""
1929

2030
from __future__ import annotations
@@ -52,20 +62,42 @@ def fit_ood_gate(X_train: np.ndarray, pred_labels, k: int = 10,
5262
"global_thr": global_thr, "per_label_thr": per_label}
5363

5464

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).
65+
def build_ood_index(X_train: np.ndarray, gate: Optional[dict]):
66+
"""Fit a reusable k-NN index over the training embeddings.
5767
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)
68+
A caller that invokes ``ood_mask`` repeatedly against the same X_train --
69+
e.g. a live Router calling it once per prediction -- should build this
70+
once (at load time) and pass it back in via ``ood_mask(..., index=...)``,
71+
instead of paying the index-fit cost fresh on every single call.
72+
"""
73+
if gate is None or X_train is None or len(X_train) == 0:
74+
return None
6375
from sklearn.neighbors import NearestNeighbors
6476
k = int(min(gate.get("k", 10), len(X_train)))
6577
if k < 1:
78+
return None
79+
return NearestNeighbors(n_neighbors=k).fit(np.asarray(X_train, dtype=np.float32))
80+
81+
82+
def ood_mask(X_query: np.ndarray, X_train: np.ndarray, query_labels, gate,
83+
index=None) -> np.ndarray:
84+
"""Boolean array, True where a query is out-of-distribution (should defer).
85+
86+
Uses the same mean-k-NN-distance rule the gate was calibrated with, comparing
87+
against the per-predicted-label threshold (global fallback).
88+
89+
``index``: an optional pre-fit index from ``build_ood_index()``. If omitted,
90+
one is built fresh from X_train on every call (fine for one-off/batch use,
91+
wasteful for a live router serving one prediction at a time).
92+
"""
93+
X_query = np.asarray(X_query, dtype=np.float32)
94+
if gate is None or len(X_query) == 0 or (index is None and (X_train is None or len(X_train) == 0)):
6695
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)
96+
if index is None:
97+
index = build_ood_index(X_train, gate)
98+
if index is None:
99+
return np.zeros(len(X_query), dtype=bool)
100+
d, _ = index.kneighbors(X_query)
69101
mean_d = d.mean(axis=1)
70102
per = gate.get("per_label_thr", {})
71103
g = float(gate.get("global_thr", np.inf))

‎src/tracer/runtime/router.py‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@ class Router:
3535
"""
3636

3737
def __init__(self, stages: list, label_space: list, manifest, embedder=None,
38-
ood_gate=None, train_embeddings=None):
38+
ood_gate=None, train_embeddings=None, ood_index=None):
3939
self._stages = stages
4040
self._label_space = label_space
4141
self._idx_to_label = {i: l for i, l in enumerate(label_space)}
4242
self.manifest = manifest
4343
self.embedder = embedder
4444
self._ood_gate = ood_gate
4545
self._train_embeddings = train_embeddings
46+
# Pre-fit k-NN index over the training embeddings (see fit.ood.build_ood_index):
47+
# built once here rather than re-fit from scratch on every predict() call.
48+
self._ood_index = ood_index
4649

4750
@classmethod
4851
def load(cls, artifact_dir: Union[str, Path], embedder=None) -> "Router":
@@ -67,26 +70,41 @@ def load(cls, artifact_dir: Union[str, Path], embedder=None) -> "Router":
6770
label_space = bundle["label_space"]
6871

6972
# Optional OOD safety net: defer inputs far from the training distribution.
73+
# train_emb comes from its own dedicated array (ood_train_embeddings.npy),
74+
# not the FAISS index's embeddings.npy -- the index build normalizes its
75+
# own copy for cosine similarity, a different representation than what
76+
# fit_ood_gate calibrated its distance thresholds on. Falls back to the
77+
# index's embeddings for artifacts written before this file existed.
7078
ood_gate = None
7179
train_emb = None
80+
ood_index = None
7281
ood_path = artifact_dir / "ood.json"
7382
if ood_path.exists():
7483
try:
84+
import numpy as _np
7585
ood_gate = _json.loads(ood_path.read_text())
76-
from tracer.embeddings.index import EmbeddingIndex
77-
train_emb = EmbeddingIndex.load(artifact_dir / "index").embeddings
86+
ood_emb_path = artifact_dir / "ood_train_embeddings.npy"
87+
if ood_emb_path.exists():
88+
train_emb = _np.load(ood_emb_path)
89+
else:
90+
from tracer.embeddings.index import EmbeddingIndex
91+
train_emb = EmbeddingIndex.load(artifact_dir / "index").embeddings
92+
from tracer.fit.ood import build_ood_index
93+
ood_index = build_ood_index(train_emb, ood_gate)
7894
except Exception:
79-
ood_gate, train_emb = None, None
95+
ood_gate, train_emb, ood_index = None, None, None
8096
return cls(stages=stages, label_space=label_space, manifest=manifest,
81-
embedder=embedder, ood_gate=ood_gate, train_embeddings=train_emb)
97+
embedder=embedder, ood_gate=ood_gate, train_embeddings=train_emb,
98+
ood_index=ood_index)
8299

83100
def _ood_flags(self, X: np.ndarray, preds) -> np.ndarray:
84101
"""True where each row is out-of-distribution (force-deferred)."""
85102
if self._ood_gate is None or self._train_embeddings is None:
86103
return np.zeros(len(X), dtype=bool)
87104
from tracer.fit.ood import ood_mask
88105
labels = [self._idx_to_label.get(int(p), "?") for p in preds]
89-
return ood_mask(X, self._train_embeddings, labels, self._ood_gate)
106+
return ood_mask(X, self._train_embeddings, labels, self._ood_gate,
107+
index=self._ood_index)
90108

91109
def _to_embedding(self, input) -> np.ndarray:
92110
"""Convert input (text or array) to a (dim,) float32 embedding."""

‎tests/test_embedding_index.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Tests for EmbeddingIndex.build (embeddings/index.py).
2+
3+
Regression coverage for a real bug: build() used to normalize its input
4+
array IN PLACE for the cosine metric, silently rescaling whatever array the
5+
caller passed in (e.g. api.py reused the same `X` for OOD-gate calibration
6+
right before building the index). It must not mutate the caller's array, and
7+
`.embeddings` must match exactly what was indexed.
8+
"""
9+
10+
import sys
11+
from pathlib import Path
12+
13+
import numpy as np
14+
import pytest
15+
16+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
17+
18+
from tracer.embeddings.index import EmbeddingIndex
19+
20+
21+
def _unnormalized(n=20, dim=8, seed=0):
22+
rng = np.random.RandomState(seed)
23+
return (rng.normal(0, 1, (n, dim)) * 5.0).astype(np.float32)
24+
25+
26+
def test_build_does_not_mutate_caller_array():
27+
pytest.importorskip("faiss")
28+
X = _unnormalized()
29+
original = X.copy()
30+
EmbeddingIndex.build(X, metric="cosine")
31+
np.testing.assert_array_equal(X, original)
32+
33+
34+
def test_build_embeddings_attr_matches_what_was_indexed():
35+
pytest.importorskip("faiss")
36+
X = _unnormalized()
37+
idx = EmbeddingIndex.build(X, metric="cosine")
38+
# .embeddings should already be L2-normalized (what the FAISS index holds),
39+
# not the raw un-normalized input -- so the two never silently diverge.
40+
norms = np.linalg.norm(idx.embeddings, axis=1)
41+
np.testing.assert_allclose(norms, 1.0, atol=1e-5)
42+
43+
# The nearest neighbour of a training point (searched via the same
44+
# normalization) should be itself.
45+
I, _D = idx.search(X[0], k=1)
46+
assert I[0] == 0
47+
48+
49+
def test_build_no_faiss_falls_back_without_mutating(monkeypatch):
50+
monkeypatch.setitem(sys.modules, "faiss", None) # forces `import faiss` to raise ImportError
51+
52+
X = _unnormalized()
53+
original = X.copy()
54+
idx = EmbeddingIndex.build(X, metric="cosine")
55+
np.testing.assert_array_equal(X, original)
56+
np.testing.assert_array_equal(idx.embeddings, original)

‎tests/test_gate.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ def test_nan_probs_do_not_crash_acceptor():
5151

5252
def test_routing_robust_to_extreme_ood_inputs():
5353
"""Out-of-distribution / extreme embeddings must route without crashing and
54-
produce finite accept scores. (The OSS library defers via the acceptor
55-
threshold; it does not ship a separate distance-based OOD gate, which lives
56-
in the hosted gateway.)"""
54+
produce finite accept scores, even with no OOD gate calibrated (this test
55+
exercises apply_stage/the acceptor threshold directly, not the separate
56+
distance-based OOD gate in fit/ood.py -- see tests/test_ood.py for that)."""
5757
from tracer.fit.pipeline import build_l2d, apply_stage
5858
split = _make_split(800, 3.0)
5959
res = build_l2d(split, target_ta=0.9, alpha=0.1)

‎tests/test_ood.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
1414

15-
from tracer.fit.ood import fit_ood_gate, ood_mask
15+
from tracer.fit.ood import fit_ood_gate, ood_mask, build_ood_index
1616

1717

1818
def _train(n=500, dim=16, seed=0):
@@ -52,3 +52,25 @@ def test_ood_mask_no_gate_is_noop():
5252
X = np.zeros((3, 4), dtype=np.float32)
5353
out = ood_mask(X, X, ["a", "a", "a"], None)
5454
assert out.shape == (3,) and not out.any()
55+
56+
57+
def test_prebuilt_index_matches_fresh_index():
58+
"""build_ood_index() + ood_mask(..., index=...) (the path Router now uses,
59+
built once at load time) must give identical results to the fresh-fit
60+
path, since it's the same computation with the fit step hoisted out."""
61+
X, labels = _train()
62+
gate = fit_ood_gate(X, labels)
63+
rng = np.random.RandomState(3)
64+
X_q = rng.normal(0, 1, (25, 16)).astype(np.float32)
65+
q = ["a"] * 25
66+
67+
fresh = ood_mask(X_q, X, q, gate)
68+
idx = build_ood_index(X, gate)
69+
cached = ood_mask(X_q, X, q, gate, index=idx)
70+
assert (fresh == cached).all()
71+
72+
73+
def test_build_ood_index_none_cases():
74+
assert build_ood_index(None, {"k": 5}) is None
75+
assert build_ood_index(np.zeros((0, 4)), {"k": 5}) is None
76+
assert build_ood_index(np.zeros((5, 4)), None) is None

‎tests/test_router.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,59 @@ def test_fallback_called_on_deferred(fitted):
183183
break
184184
if not deferred_found:
185185
pytest.skip("no deferred prediction found, policy handled everything")
186+
187+
188+
# ── OOD gate: decoupled from the FAISS index (regression) ──────────────────────
189+
190+
def test_ood_train_embeddings_saved_as_dedicated_artifact(fitted):
191+
"""fit() must save the OOD gate's calibration embeddings to their own file,
192+
exactly matching the array it calibrated on -- not reuse the FAISS index's
193+
embeddings.npy, which build() may have separately normalized for cosine
194+
similarity. See fit/ood.py's module docstring for why the two must stay
195+
decoupled."""
196+
artifact_dir, X = fitted
197+
ood_emb_path = artifact_dir / "ood_train_embeddings.npy"
198+
if not ood_emb_path.exists():
199+
pytest.skip("no OOD gate calibrated for this fixture")
200+
saved = np.load(ood_emb_path)
201+
np.testing.assert_array_equal(saved, X)
202+
203+
204+
def test_ood_gate_correct_after_fit_and_load_with_faiss():
205+
"""Regression test for a real bug: with faiss installed, EmbeddingIndex.build
206+
L2-normalizes its own copy of the embeddings for the cosine index. Router
207+
used to load the OOD gate's train_embeddings from that same (normalized)
208+
array, while fit_ood_gate had calibrated its thresholds on the original raw
209+
embeddings -- a scale mismatch between calibration and inference. A full
210+
fit() -> load_router() round trip must still correctly flag clearly
211+
off-distribution inputs and pass through in-distribution ones."""
212+
pytest.importorskip("faiss")
213+
import tempfile
214+
import tracer
215+
216+
with tempfile.TemporaryDirectory() as tmp:
217+
traces_path, X = _make_traces(tmp) # defaults match the `fitted` fixture above (known-deployable)
218+
artifact_dir = Path(tmp) / ".tracer"
219+
result = tracer.fit(traces_path, artifact_dir, embeddings=X,
220+
config=tracer.FitConfig(verbose=False))
221+
if result.manifest.selected_method is None:
222+
pytest.skip("no deployable policy on this synthetic split")
223+
224+
router = tracer.load_router(artifact_dir)
225+
if router._ood_gate is None:
226+
pytest.skip("no OOD gate calibrated for this fixture")
227+
228+
# Check the OOD gate's own flag directly (isolated from the acceptor's
229+
# separate handled/deferred decision, which router.predict() conflates
230+
# it with): a clearly off-distribution point must be flagged, and the
231+
# original training points -- the exact rows the gate calibrated on --
232+
# must not be, since a scale mismatch would corrupt both directions.
233+
from tracer.fit.pipeline import _predict as _stage_predict
234+
rng = np.random.RandomState(7)
235+
dim = X.shape[1]
236+
far = (rng.randn(1, dim) * 1000).astype(np.float32)
237+
far_preds, _ = _stage_predict(router._stages[0]["clf"], far)
238+
assert router._ood_flags(far, far_preds)[0]
239+
240+
train_preds, _ = _stage_predict(router._stages[0]["clf"], X)
241+
assert not router._ood_flags(X, train_preds).any()

0 commit comments

Comments
 (0)