Skip to content

Commit 169edf8

Browse files
authored
Fix routing correctness and harden updates, tracing, and reports (#87)
1 parent 0c91871 commit 169edf8

31 files changed

Lines changed: 1025 additions & 143 deletions

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
include:
1818
- python-version: "3.12"
1919
dependencies: minimum
20+
- python-version: "3.12"
21+
dependencies: faiss
2022

2123
steps:
2224
- uses: actions/checkout@v4
@@ -31,6 +33,8 @@ jobs:
3133
python -m pip install --upgrade pip
3234
if [ "${{ matrix.dependencies }}" = "minimum" ]; then
3335
python -m pip install -e ".[dev]" "numpy==1.26.4" "scikit-learn==1.4.2" "joblib==1.3.2"
36+
elif [ "${{ matrix.dependencies }}" = "faiss" ]; then
37+
python -m pip install -e ".[dev,faiss]" plotly
3438
else
3539
python -m pip install -e ".[dev]"
3640
fi

‎AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ out = router.predict(embedding_vector) # numpy array
3232
out = router.predict("raw text", embedder=embedder) # or text directly
3333

3434
# 3. UPDATE - continual learning (coverage grows over time)
35-
tracer.update("new_traces.jsonl", embeddings=X_new)
35+
tracer.update("new_traces.jsonl", new_embeddings=X_new)
3636

3737
# 4. AUDIT - structured explanation of what traffic goes where
3838
report = result.qualitative_report # slices, boundary pairs, examples
@@ -148,7 +148,7 @@ out = router.predict(text, fallback=lambda: call_gpt4(text))
148148
### Pattern 3: Continual learning loop
149149
```python
150150
# Collect new traces from production
151-
tracer.update("new_traces.jsonl", embeddings=X_new)
151+
tracer.update("new_traces.jsonl", new_embeddings=X_new)
152152
# Reload router with improved policy
153153
router = tracer.load_router(".tracer", embedder=my_embedder)
154154
```
@@ -190,7 +190,7 @@ tracer.serve(".tracer", port=8000)
190190
| `tracer.load_router(dir, embedder=e)` | Load for inference | No |
191191
| `router.predict(text_or_emb)` | Route one input | No |
192192
| `router.predict_batch(inputs)` | Route a batch | No |
193-
| `tracer.update(new_traces, embeddings=X)` | Refit with new data | No |
193+
| `tracer.update(new_traces, new_embeddings=X)` | Refit with new data | No |
194194
| `tracer.embed(texts)` | Compute embeddings | Needs `pip install tracer-llm[embeddings]` |
195195
| `Embedder.from_endpoint(url)` | External embedding API | Needs URL + auth from human |
196196
| `tracer.generate_html_report(dir)` | Visual audit | No |

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,33 @@ versioning.
55

66
## Unreleased
77

8+
### Fixed
9+
- Preserve the previous artifact generation when an update fails validation,
10+
fitting, or publication. Honor explicit update configuration without mutating
11+
the caller, retain saved configuration by default, and increment the fit count.
12+
- Preserve classifier label IDs in residual stages and select the largest
13+
eligible acceptance set during small-data calibration.
14+
- Keep original embedding scales when building FAISS indexes and reuse the OOD
15+
nearest-neighbor index across predictions. Existing FAISS artifacts trained
16+
with unnormalized inputs need refitting to recover the original scale.
17+
- Apply configured seeds to subsampling, splits, calibration, and model training.
18+
- Record async outputs, errors, cancellation, timing, and parent spans correctly;
19+
isolate tracing storage/export failures from application calls.
20+
- Escape trace and artifact text in reports and reject unsafe watcher filenames
21+
in Python and JavaScript.
22+
- Preserve deferred confidence scores in reports; validate prediction shapes and
23+
finite values; report actual file lines for null teacher labels.
24+
- Handle concurrent HTTP requests and client disconnects, reject malformed or
25+
oversized request bodies, and close the server cleanly on interruption.
26+
- Correct update examples to use `new_embeddings=`.
27+
828
### Changed (breaking)
29+
- The prediction server binds to `127.0.0.1` by default and no longer sends a
30+
wildcard CORS header. Explicitly configure a remote bind address when needed;
31+
browser deployments should configure CORS at their authenticated proxy.
32+
- Watcher filenames must start with a letter or digit and use only ASCII
33+
letters, digits, dots, underscores, or hyphens, up to 128 characters.
34+
- Invalid prediction inputs return HTTP 400 rather than 500.
935
- Require Python 3.12 or newer; drop support for Python 3.9–3.11. CI tests
1036
Python 3.12, 3.13, and 3.14, plus the minimum core dependencies on 3.12.
1137
- Allow NumPy 1.26.4 through 2.x so Python 3.14 can install compatible wheels.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Day 5: 10,000 traces → 92% coverage → 9,200 calls/day saved
142142
```
143143

144144
```python
145-
tracer.update("new_traces.jsonl", embeddings=X_new) # refit with new production traces
145+
tracer.update("new_traces.jsonl", new_embeddings=X_new) # refit with new production traces
146146
```
147147

148148
The parity gate re-calibrates on each update, so coverage only increases when the surrogate actually earns it.

‎benchmarks/README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Routing reliability ablation
2+
3+
`routing_reliability.py` exercises three deterministic failure cases without
4+
external data, model downloads, or API calls. The inputs and seeds are in the
5+
script. These are targeted regression measurements, not general workload claims.
6+
7+
Run the same script against each checkout with identical dependencies:
8+
9+
```bash
10+
pip install -e '.[faiss]'
11+
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 LOKY_MAX_CPU_COUNT=1 \
12+
python benchmarks/routing_reliability.py
13+
```
14+
15+
Measured with Python 3.12.13, NumPy 2.5.3, scikit-learn 1.9.1, and FAISS 1.15.1.
16+
The baseline is commit `0c918712c09a677feae80eede7069442c4e259a9`.
17+
18+
| Case | Baseline | Fixed |
19+
|---|---:|---:|
20+
| Small calibration: accepted fraction | 30.56% | 91.67% |
21+
| Small calibration: agreement on accepted rows | 100% | 90.91% |
22+
| Small calibration: Clopper–Pearson lower bound | 81.11% | 80.86% |
23+
| Model trained on a label subset: label agreement | 0% | 100% |
24+
| FAISS fit/reload: held-out coverage | 0% | 99% |
25+
| FAISS fit/reload: agreement on handled held-out rows | Not applicable (none handled) | 100% |
26+
| FAISS fit: caller input unchanged | No | Yes |
27+
28+
The small-calibration case has 36 rows, a target of 80%, and alpha 0.1.
29+
Both selections satisfy the same lower-bound test; the correction chooses the
30+
larger eligible set. The label-subset case compares six predictions against
31+
known labels `{1, 3}`. The FAISS case uses 240 training rows and **600 separate,
32+
newly generated held-out rows**, target agreement 90%, and the same linear model
33+
candidate in both versions. OOD detection remains Euclidean and retains its
34+
existing threshold rule.
35+
36+
The regression suite also covers staged-update rollback, retained recovery
37+
backups, explicit configuration, reproducible seeds, async tracing and
38+
cancellation, report escaping, path validation, and HTTP error/concurrency paths.

‎benchmarks/routing_reliability.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Small deterministic routing ablation; no model downloads or API calls.
2+
3+
Run with `python benchmarks/routing_reliability.py`. Install the optional faiss
4+
extra to include the fit/reload case. Output is JSON suitable for comparing two
5+
checkouts with the same Python and dependencies.
6+
"""
7+
import json
8+
import tempfile
9+
from pathlib import Path
10+
11+
import numpy as np
12+
from sklearn.linear_model import LogisticRegression
13+
14+
from tracer.api import fit, load_router
15+
from tracer.config import FitConfig
16+
from tracer.fit.pipeline import _calibrate_threshold, _predict
17+
18+
19+
def run():
20+
scores = np.linspace(.50, .99, 36)
21+
teacher = np.zeros(36, dtype=int)
22+
predictions = teacher.copy()
23+
predictions[:6] = 1
24+
chosen = _calibrate_threshold(scores, predictions, teacher, .8)
25+
X = np.array([[-3.], [-2.], [-1.], [1.], [2.], [3.]])
26+
y = np.array([1, 1, 1, 3, 3, 3])
27+
model = LogisticRegression(random_state=42).fit(X, y)
28+
results = {
29+
'small_calibration': {key: chosen[key] for key in
30+
('coverage', 'teacher_agreement', 'teacher_agreement_lower')},
31+
'subset_label_agreement': float((_predict(model, X)[0] == y).mean()),
32+
}
33+
try:
34+
import faiss
35+
except ImportError:
36+
results['fit_reload'] = 'skipped: install tracer-llm[faiss]'
37+
return results
38+
39+
rng = np.random.RandomState(24)
40+
centers = rng.normal(size=(3, 8)) * 10
41+
y_train = np.repeat(np.arange(3), 80)
42+
y_test = np.repeat(np.arange(3), 200)
43+
train = (centers[y_train] + rng.normal(size=(240, 8)) * .2).astype(np.float32)
44+
heldout = (centers[y_test] + rng.normal(size=(600, 8)) * .2).astype(np.float32)
45+
original = train.copy()
46+
skip = ('logreg_c10', 'sgd_log', 'mlp_1h', 'mlp_2h', 'dt', 'rf', 'et', 'gbt', 'xgb')
47+
with tempfile.TemporaryDirectory() as directory:
48+
root = Path(directory)
49+
traces = root / 'traces.jsonl'
50+
traces.write_text(''.join(json.dumps({'input': str(i), 'teacher': str(label)}) + '\n'
51+
for i, label in enumerate(y_train)))
52+
fitted = fit(traces, root / 'model', train,
53+
FitConfig(frontier_targets=(.9,), skip_candidates=skip, verbose=False))
54+
router = load_router(root / 'model')
55+
output = router.predict_batch(heldout)
56+
accepted = output['handled']
57+
labels = np.asarray(output['labels'])
58+
results['fit_reload'] = {
59+
'faiss_version': faiss.__version__,
60+
'training_input_unchanged': bool(np.array_equal(train, original)),
61+
'calibration_coverage': fitted.manifest.coverage_cal,
62+
'heldout_coverage': float(accepted.mean()),
63+
'heldout_teacher_agreement': float((labels[accepted] == y_test[accepted].astype(str)).mean()) if accepted.any() else None,
64+
'heldout_rows': len(y_test),
65+
}
66+
return results
67+
68+
69+
if __name__ == '__main__':
70+
print(json.dumps(run(), indent=2))

‎docs/api.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,17 @@ tracer.update(
118118
| `new_trace_path` | `str \| Path` | required | Path to NEW traces JSONL file |
119119
| `artifact_dir` | `str \| Path` | `".tracer"` | Existing artifact directory to update |
120120
| `new_embeddings` | `np.ndarray \| None` | `None` | Embeddings for the NEW traces only `(n_new, dim)` |
121-
| `config` | `FitConfig \| None` | `None` | If None, re-uses `target_teacher_agreement` from the existing manifest |
121+
| `config` | `FitConfig \| None` | `None` | If None, reuses the saved fit configuration. An explicit configuration takes precedence and is not mutated. |
122122

123123
**Returns:** `FitResult` (same as `fit()`)
124124

125+
The replacement model is fitted in a staging directory. Validation or fitting
126+
failures leave existing artifacts intact; a failed publication restores the
127+
previous directory. If restoration itself fails, the exception identifies the
128+
retained backup. Keep one writer per artifact directory and reload readers only
129+
after `update()` returns: directory publication is not a concurrent-reader or
130+
power-loss transaction. Allow disk space for the staged generation and backup.
131+
125132
**Example:**
126133

127134
```python

‎docs/artifacts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The top-level summary. Human-readable and machine-readable.
4848
| `selected_method` | Which pipeline was deployed: `"global"`, `"l2d"`, `"rsb"`, or `null` (parity gate blocked) |
4949
| `coverage_cal` | Fraction of calibration-set traffic handled by surrogate. Proxy for production coverage. |
5050
| `teacher_agreement_cal` | On calibration-set handled traffic, fraction where surrogate agreed with teacher. Should be ≥ `target_teacher_agreement`. |
51-
| `n_retrains` | How many times this artifact has been updated via `tracer.update()` |
51+
| `n_retrains` | Number of fits in this artifact's history: 1 after initial fitting, incremented by each successful `tracer.update()` |
5252

5353
**Null method:** If `selected_method` is `null`, coverage is 0%. All traffic routes to the teacher. This happens when no pipeline could reach the target teacher agreement on the calibration set. It's safe -- not an error.
5454

‎docs/cold_start.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ As your LLM handles more production traffic, append new traces and re-fit:
9999

100100
```python
101101
# After collecting more traces
102-
result = tracer.update("new_traces.jsonl", ".tracer", embeddings=X_new)
102+
result = tracer.update("new_traces.jsonl", ".tracer", new_embeddings=X_new)
103103
```
104104

105105
Each update round adds deferred inputs (the ones the surrogate wasn't confident about) back into the training pool, automatically expanding coverage over time. This is TRACER's core feedback loop.

‎docs/javascript.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ tracer.serve(".tracer", host="127.0.0.1", port=8000)
9494

9595
Run this as a sidecar next to your Node app, same server, same docker-compose, same machine. This example binds to `127.0.0.1:8000`. For a container sidecar, bind to `0.0.0.0` inside the container and limit network access to your application.
9696

97+
The server defaults to loopback, accepts JSON bodies up to 8 MiB, and uses a
98+
10-second socket timeout. It handles requests concurrently. Invalid input returns
99+
HTTP 400. It provides no authentication or CORS headers; for browser or remote
100+
access, configure authentication, TLS, and CORS at your reverse proxy.
101+
97102
---
98103

99104
## Predicting from your JS app
@@ -168,7 +173,7 @@ Every deferred input that reaches your LLM is a new trace. Accumulate them and r
168173
# Run this script on a schedule; supply matching embeddings or a sibling .npy.
169174
import tracer
170175

171-
tracer.update("new_traces.jsonl", embeddings=X_new)
176+
tracer.update("new_traces.jsonl", new_embeddings=X_new)
172177
```
173178

174179
Then restart the `serve_policy.py` process to pick up the updated policy. Coverage typically grows from ~84% at day 1 to 90%+ within a week of production traffic.

0 commit comments

Comments
 (0)