Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Consider this ticket:
The production API key was disabled during an incident freeze. The portal rejected my restore request. Please make an exception.Given a new access ticket, should it enter human_review_now, or may it enter the guarded agent workflow?
That exception isn't the same as a routine key-rotation question. The intended route is immediate human review; routine questions can use a guarded workflow without consuming the same review capacity. The classifier does routing, not authorization. It doesn't decide access eligibility, send a reply, or change permissions. It can misclassify a ticket, so the agent's independent approval controls must remain in place.
Why try a dedicated encoder when a prompted language model could also classify the ticket? Treat that as an experiment. Measure the alternatives under the same label guide and evaluation boundary:
| Question | Evidence to collect |
|---|---|
| Will intake meet its deadline? | End-to-end latency, including tokenization, queues, network calls, and fallback, at realistic ticket lengths and load. A webhook can acknowledge receipt separately from classification. |
| Can the route be inspected? | The encoder exposes class logits and a threshold. A prompted classifier needs validated outputs too; neither design makes malicious or unfamiliar tickets harmless. |
| What will it cost? | Serving utilization, token charges where applicable, labeling, retraining, and human-review load. Combine the per-ticket estimate with expected volume and infrastructure assumptions to budget the service. |
| Which mistakes matter? | Missed escalations by required slice, false positives, and review capacity. An all-human router has perfect positive recall while providing no automated routing benefit. |
For this teaching contract, any observed miss in a required gate slice blocks the candidate. Zero observed misses on a small fixture set cannot establish zero missed escalations in future traffic. A missed escalation also doesn't grant the downstream agent permission to restore a key; independent approval controls still apply.
The evaluation dashboard in the previous capstone made one rule hard to dodge: a release is blocked by the failures that matter, even when the average looks fine. Bring that exact-receipt habit to a learned classifier. Document QA already answers policy questions with permitted citations. A later production-agent capstone will draft an access response and request approval. This intake classifier is the boundary in front of that agent.

One bundle has to carry the route contract and the evidence that it was tested:
- a versioned label guide and split rule
- a fitted baseline to compare with a pretrained candidate
- a small PyTorch encoder training loop that exposes the mechanics
- a practical Hugging Face fine-tuning recipe for a pretrained encoder
- held-out threshold and slice gates
- a serving bundle the production-agent capstone can consume
BERT showed how a pretrained bidirectional encoder can adapt to a downstream classification task with a small output layer.[1] Hugging Face exposes that sequence-classification pattern through models and training utilities.[2] DistilBERT offers a smaller distilled encoder; its paper's speed measurements aren't a latency guarantee for this application.[3] The discipline here isn't choosing a fashionable checkpoint. It's testing a trained classifier against a routing contract.
The executable examples have three evidence levels: a fitted miniature baseline, a six-row neural training demonstration, and hand-authored score receipts for testing routing logic. The separate DistilBERT project script needs your reviewed dataset and a checkpoint download. None of the printed gate scores comes from that pretrained model, and a passing demonstration doesn't authorize deployment or shadow traffic.
Write the routing contract before training
Before training, give the model one narrow decision to make:
| Label | Route | Meaning |
|---|---|---|
1 | human_review_now | An access-recovery risk, suspected takeover, or policy exception requires a person before the agent workflow. |
0 | guarded_agent | A routine request may enter the agent, which still follows retrieval evidence and approval rules. |
Read the second row carefully. Label 0 doesn't mean "approve automatically." It means the label guide permits this intake lane. The learned classifier can still assign the wrong label. A missed takeover or exception delays specialist handling and brings a higher-risk ticket into the agent workflow, so this contract treats missed escalations as critical errors. The downstream agent still needs permitted documentation and human approval before any state change.
The model produces a score. A pinned threshold maps that score to an intake lane, and either lane preserves the agent's downstream evidence and approval boundaries.
Carry the evidence forward
| Capstone artifact | Contract it contributes | What this classifier needs from it |
|---|---|---|
| Document QA product | An answer must cite permitted policy evidence. | Routine questions still need evidence checks after routing. |
| Evaluation dashboard | Hard failures block a release even when averages improve. | Missed urgent slices must block a classifier release. |
| Fine-tuned classifier | Ticket becomes a risk route under a pinned threshold. | Export the intake contract for the production agent. |
Write labeled fixtures before training. They make the route contract concrete and give support operations something to review before a model can hide an ambiguous case.
label_version = "escalation-policy-v1"
fixtures = [
{
"id": "access_override_exception",
"text": "The production API key is disabled, but the incident freeze still blocks restore.",
"gold_route": "human_review_now",
"reason": "access_override_exception",
},
{
"id": "incident_freeze_exception",
"text": "The production API key was disabled during an incident freeze. Please allow an exception.",
"gold_route": "human_review_now",
"reason": "incident_freeze_exception",
},
{
"id": "account_takeover",
"text": "Someone changed my recovery email and I can't sign in.",
"gold_route": "human_review_now",
"reason": "account_takeover",
},
{
"id": "routine_key_rotation",
"text": "Where is the key rotation guide?",
"gold_route": "guarded_agent",
"reason": "routine_key_rotation",
},
{
"id": "access_policy_question",
"text": "What is the access policy for disabled keys?",
"gold_route": "guarded_agent",
"reason": "access_policy_question",
},
]
allowed_routes = {"human_review_now", "guarded_agent"}
assert all(row["gold_route"] in allowed_routes for row in fixtures)
assert {row["reason"] for row in fixtures if row["gold_route"] == "human_review_now"} == {
"access_override_exception",
"incident_freeze_exception",
"account_takeover",
}
print("label guide:", label_version)
for row in fixtures:
print(f"{row['id']}: {row['gold_route']} ({row['reason']})")label guide: escalation-policy-v1
access_override_exception: human_review_now (access_override_exception)
incident_freeze_exception: human_review_now (incident_freeze_exception)
account_takeover: human_review_now (account_takeover)
routine_key_rotation: guarded_agent (routine_key_rotation)
access_policy_question: guarded_agent (access_policy_question)A label guide should name ambiguous cases too. Frustrated tone alone isn't a reason for immediate human routing, while a calm request for an incident-freeze exception may be. Support operations should adjudicate disagreements and store that policy version beside every dataset generated from it.
Now the labels have meaning. The next question is whether future tickets can reach the evaluation split without bringing their account history with them.
Build a dataset you can defend
These rows form a hypothetical teaching dataset modeled on access-ticket scenarios. They make failure modes visible; evaluate production behavior on representative traffic:
| Field | Teaching dataset choice |
|---|---|
| Source | Synthetic access tickets modeled after access, incident-freeze, and account-recovery requests |
| Label version | escalation-policy-v1 |
| Exclusions | Spam, internal QA, tickets without requester text |
| High-risk slices | access_override_exception, incident_freeze_exception, account_takeover |
| Split policy | Earlier accounts train; later, unseen accounts validate and test |
| Demonstration gate | No missed positive in required gate slices, with at most one routine ticket sent to review |
For a real data card, replace each teaching assumption with provenance, consent and retention policy, labeling instructions, class balance, disagreement rate, and known coverage gaps.
Keep related tickets together
Multiple messages from one workspace account can share service names or incident wording. Putting one thread in training and another in validation can inflate an estimate meant to describe new accounts. For this target, use non-overlapping accounts and later time windows. If an account spans windows, exclude its earlier rows from training when reserving it for evaluation. Group splits and time splits solve different problems; neither alone enforces both.[4]
records = [
{"ticket": "t01", "account": "acme", "month": 1, "split": "train"},
{"ticket": "t02", "account": "acme", "month": 2, "split": "train"},
{"ticket": "t03", "account": "north", "month": 2, "split": "train"},
{"ticket": "t04", "account": "north", "month": 2, "split": "train"},
{"ticket": "v01", "account": "summit", "month": 3, "split": "validation"},
{"ticket": "v02", "account": "summit", "month": 3, "split": "validation"},
{"ticket": "x01", "account": "harbor", "month": 4, "split": "test"},
{"ticket": "x02", "account": "harbor", "month": 4, "split": "test"},
]
accounts = {
split: {row["account"] for row in records if row["split"] == split}
for split in ("train", "validation", "test")
}
assert accounts["train"].isdisjoint(accounts["validation"])
assert accounts["train"].isdisjoint(accounts["test"])
assert accounts["validation"].isdisjoint(accounts["test"])
assert len({row["ticket"] for row in records}) == len(records)
months = {
split: [row["month"] for row in records if row["split"] == split]
for split in ("train", "validation", "test")
}
assert max(months["train"]) < min(months["validation"])
assert max(months["validation"]) < min(months["test"])
for split in ("train", "validation", "test"):
count = sum(row["split"] == split for row in records)
names = ",".join(sorted(accounts[split]))
print(f"{split}: {count} tickets, accounts={names}")train: 4 tickets, accounts=acme,north
validation: 2 tickets, accounts=summit
test: 2 tickets, accounts=harbor
Use development data to pick features and checkpoints, then a disjoint selection partition to choose the threshold. Keep the final gate/test partition untouched until those choices are frozen. The eight-row manifest illustrates group and time checks; it isn't the actual split behind the later synthetic score tables. In a real pipeline, ticket IDs, normalized text hashes, account groups, and timestamps must join to the same frozen dataset.
The split output is simple on purpose: each supplied account appears in one partition, and months are ordered. These assertions check this manifest. They don't check historical label availability, prove the account identifiers are correct, or establish that later unseen accounts represent the traffic you will serve.
Two messages from account acme describe the same access incident. Why is putting one in training and one in validation invalid even when the text differs?
Answer
The rows share incident and account context, so the model can learn wording or facts from the training thread that reappear in validation. Keep the whole account or incident group on one side of the split, and reserve the final test groups until model and threshold choices are frozen.
Compare a cheap baseline first
Before paying for a contextual transformer, give a cheap baseline a chance. Start with a deterministic rule or a bag-of-words logistic-regression model. If it satisfies the routing gate on representative held-out data, a neural model may add maintenance without adding safety.
The rule below looks for contiguous phrases such as key disabled. Before reading its output, predict what happens to "Production API key disabled..." and to "The production API key was disabled during the incident freeze." The second sentence contains the same idea, but was sits between key and disabled.
validation = [
("explicit_access_override_exception", "Production API key disabled and the incident freeze blocks restore.", 1),
("indirect_incident_freeze_exception", "The production API key was disabled during the incident freeze.", 1),
("routine_key_rotation", "Where is the key rotation guide?", 0),
("routine_policy", "Where can I read the access policy?", 0),
]
terms = ("key disabled", "can't sign in", "admin override")
predictions = []
for fixture_id, text, label in validation:
predicted = int(any(term in text.lower() for term in terms))
predictions.append((fixture_id, label, predicted))
positives = sum(label for _, label, _ in predictions)
true_positives = sum(label == 1 and predicted == 1 for _, label, predicted in predictions)
missed = [fixture_id for fixture_id, label, predicted in predictions if label == 1 and predicted == 0]
print(f"positive recall: {true_positives}/{positives}")
print("missed positive:", missed[0])
print("next experiment: fit a text-feature baseline")positive recall: 1/2
missed positive: indirect_incident_freeze_exception
next experiment: fit a text-feature baselineThis rule misses the phrase, which motivates a stronger baseline. Fit TF-IDF plus logistic regression next. The vectorizer learns its vocabulary and inverse document frequencies from training rows only; a pipeline keeps that fit boundary explicit.[5][6]
This small comparison uses the same four held-out teaching tickets as the rule. It prints positive recall and false positives instead of rewarding an all-human classifier for perfect recall. The training rows are deliberately similar to the validation wording, so these results test the implementation, not realistic generalization.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
baseline_train = [
("prod key disabled incident freeze", 1),
("prod key disabled during incident freeze", 1),
("recovery email changed cannot sign in", 1),
("where is key rotation guide", 0),
("show key rotation guide", 0),
("show access policy", 0),
]
baseline_validation = [
("Production API key disabled and the incident freeze blocks restore.", 1),
("The production API key was disabled during the incident freeze.", 1),
("Where is the key rotation guide?", 0),
("Where can I read the access policy?", 0),
]
baseline = make_pipeline(
TfidfVectorizer(ngram_range=(1, 2)),
LogisticRegression(C=1.0, max_iter=500, random_state=7),
)
baseline.fit([text for text, _ in baseline_train], [gold for _, gold in baseline_train])
predicted = baseline.predict([text for text, _ in baseline_validation])
tp = sum(gold == 1 and pred == 1 for (_, gold), pred in zip(baseline_validation, predicted))
fn = sum(gold == 1 and pred == 0 for (_, gold), pred in zip(baseline_validation, predicted))
fp = sum(gold == 0 and pred == 1 for (_, gold), pred in zip(baseline_validation, predicted))
print(f"TF-IDF baseline: tp={tp} fn={fn} fp={fp}")
print("validation predictions:", predicted.tolist())TF-IDF baseline: tp=2 fn=0 fp=0
validation predictions: [1, 1, 0, 0]Keep this baseline when it meets the actual acceptance criteria. Fine-tuning is a candidate experiment, not the required winner. Bag-of-words features discard much of word order; bigrams retain some local context, including phrases such as not requesting. Whether this fitted baseline handles a negated request depends on its features and training data. A contextual encoder can represent wider dependencies, but it can still learn shortcuts or miss negation. Test those failure cases instead of assigning a winner by architecture.
Keep the comparison empirical. A fine-tuned encoder isn't cheaper, faster, or more accurate than prompting or this baseline until those candidates run on the same task and hardware.
Turn a score into a route
The model's raw output isn't a route yet. An encoder classifier emits one unnormalized logit for each class. Softmax turns the pair into class scores that sum to one. A separate decision threshold turns the positive-class score into the intake route.
The small implementation detail matters: subtract the largest logit before exponentiating so softmax stays numerically stable. With logits [-1.0, 2.0], predict which class should receive the larger score before running the function.
from math import exp, isfinite
examples = [
("incident_freeze", [-1.0, 2.0]),
("key_rotation", [1.0, -1.0]),
]
def softmax(logits: list[float]) -> list[float]:
def finite_logit(value: object) -> bool:
try:
return type(value) in (int, float) and isfinite(value)
except OverflowError:
return False
if type(logits) is not list or not logits or not all(finite_logit(value) for value in logits):
raise ValueError("Expected nonempty finite logits")
offset = max(logits)
values = [exp(value - offset) for value in logits]
total = sum(values)
return [value / total for value in values]
for fixture_id, logits in examples:
routine, human = softmax(logits)
print(f"{fixture_id}: human_review_score={human:.2f}, routine_score={routine:.2f}")incident_freeze: human_review_score=0.95, routine_score=0.05
key_rotation: human_review_score=0.12, routine_score=0.88Call 0.95 a score until held-out reliability evidence supports interpreting it as an estimated probability; numerical bounds alone do not establish calibration.[7] The final linear layer has the form , but how the model constructs depends on its architecture. DistilBERT's sequence-classification head transforms the first-token representation with a dense layer, ReLU, and dropout before that final projection.[8] Floating-point results can vary across hardware or runtimes even when inference disables dropout.
Make the training mechanics visible
Before downloading a checkpoint, trace one trainable path end to end. The sandbox builds a tiny word-embedding encoder, averages token vectors, attaches a classification head, and trains embeddings and head with cross-entropy loss. Unused vocabulary rows receive no training signal, and the padding row stays fixed.
This classifier trains from random embeddings, making each moving part visible before pretrained representations enter the workflow.

import torch
from torch import nn
from io import BytesIO
torch.manual_seed(7)
torch.set_num_threads(1)
training_rows = [
("prod key disabled incident freeze", 1),
("prod key disabled during incident freeze", 1),
("recovery email changed cannot sign in", 1),
("where is key rotation guide", 0),
("show key rotation guide", 0),
("show access policy", 0),
]
vocabulary = {"<pad>": 0, "<unk>": 1}
for text, _ in training_rows:
for token in text.split():
vocabulary.setdefault(token, len(vocabulary))
def encode(texts: list[str]) -> torch.Tensor:
if type(texts) is not list or not texts or any(type(text) is not str or not text.strip() for text in texts):
raise ValueError("Expected a nonempty list of nonblank ticket strings")
rows = [[vocabulary.get(token, 1) for token in text.lower().split()] for text in texts]
width = max(1, max(len(row) for row in rows))
return torch.tensor([row + [0] * (width - len(row)) for row in rows])
inputs = encode([text for text, _ in training_rows])
labels = torch.tensor([label for _, label in training_rows])
class TinyEncoderClassifier(nn.Module):
def __init__(self, vocab_size: int) -> None:
super().__init__()
self.embedding = nn.Embedding(vocab_size, 8, padding_idx=0)
self.head = nn.Linear(8, 2)
def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
mask = (token_ids != 0).unsqueeze(-1)
embedded = self.embedding(token_ids) * mask
pooled = embedded.sum(dim=1) / mask.sum(dim=1).clamp_min(1)
return self.head(pooled)
model = TinyEncoderClassifier(len(vocabulary))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
initial_loss = criterion(model(inputs), labels).item()
original_head = model.head.weight.detach().clone()
for _ in range(80):
loss = criterion(model(inputs), labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model.eval()
with torch.inference_mode():
final_logits = model(inputs)
final_loss = criterion(final_logits, labels).item()
print("logit shape:", tuple(final_logits.shape))
print("loss decreased:", final_loss < initial_loss)
print("classification head updated:", not torch.equal(original_head, model.head.weight))
# Evaluate after fitting. This set was not used for gradient updates.
held_out = [
("prod key was disabled during incident freeze", 1),
("recovery email changed cannot sign in today", 1),
("where is the key rotation guide", 0),
("show the access policy", 0),
]
with torch.inference_mode():
predictions = model(encode([text for text, _ in held_out])).argmax(dim=1).tolist()
tp = sum(gold == 1 and pred == 1 for (_, gold), pred in zip(held_out, predictions))
fn = sum(gold == 1 and pred == 0 for (_, gold), pred in zip(held_out, predictions))
fp = sum(gold == 0 and pred == 1 for (_, gold), pred in zip(held_out, predictions))
print(f"tiny held-out check: tp={tp} fn={fn} fp={fp}")
# Verify a state-dict round trip without writing a deployment artifact.
buffer = BytesIO()
torch.save(model.state_dict(), buffer)
buffer.seek(0)
restored = TinyEncoderClassifier(len(vocabulary))
restored.load_state_dict(torch.load(buffer, weights_only=True))
restored.eval()
with torch.inference_mode():
print("restored logits match:", torch.equal(final_logits, restored(inputs)))
original = model(encode(["prod key disabled incident freeze"]))
reordered = model(encode(["freeze incident disabled key prod"]))
print("reordered tokens give the same scores:", torch.allclose(original, reordered))logit shape: (6, 2)
loss decreased: True
classification head updated: True
tiny held-out check: tp=2 fn=0 fp=0
restored logits match: True
reordered tokens give the same scores: TrueThe sandbox can memorize six training examples. The four new strings check inference after fitting, but their near-duplicate wording doesn't establish generalization to new accounts. Follow the wiring: text becomes tokens, pooled representations become logits, and gradients update weights. The restore check uses the same vocabulary and architecture; saving weights without those would be incomplete.
One architecture gap is deliberate. This sandbox averages token vectors, so it can't distinguish word order: swapping tokens leaves the pooled representation unchanged. BERT-style sequence classification uses contextual token representations and a leading [CLS] summary.[1] Cross-entropy expects unnormalized logits, so don't apply softmax before passing the output to CrossEntropyLoss.[9]
Fine-tune a pretrained encoder
Now replace random embeddings with a pretrained encoder and fine-tune it for two labels. DistilBERT uses six transformer layers in this checkpoint and bidirectional attention over the admitted input.[10] A causal decoder's final input token can also attend to the preceding ticket text, so its attention mask doesn't make whole-ticket classification impossible. It is a different representation and training design.
DeBERTa-v3 is another candidate: it retains DeBERTa's disentangled attention and changes pretraining to replaced-token detection with gradient-disentangled embedding sharing. Its published benchmark improvements don't establish an access-ticket result.[11] The script below uses DistilBERT, whose head includes the extra transformation described above, through AutoModelForSequenceClassification and Trainer.[8]
The script uses three epochs, a learning rate of , and batch size 16 as starting settings, not tuned values for this dataset. While DistilBERT's default configuration supports 512 positions, the serving policy here enforces a 256-token ceiling including special tokens.[10]
For this input policy, don't silently truncate. A long diagnostic log can place "The key failed during the incident freeze, please bypass" beyond the 256-token ceiling. Right truncation would remove that evidence; it doesn't determine what score the remaining prefix would receive. Left truncation can discard earlier evidence too. Route oversized tickets to people and measure the resulting review burden. This handles one input boundary without guaranteeing correct classification of shorter tickets.
This project script is a runnable fine-tuning recipe; no training result is recorded here. It needs your reviewed JSONL splits, the pinned checkpoint download, and torch, transformers, datasets, accelerate, and numpy. The Trainer API recipe was checked with Transformers 5.17.0 on October 10, 2026; retain the resolved package versions for your run.[12] Each row needs a unique nonblank string fixture_id, nonblank text, and integer label (0 for guarded_agent, 1 for human_review_now). Apply the account/time manifest before export. The script loads only training and checkpoint-development data; threshold-selection and final-gate rows stay outside training.
Create a separate project and save the next code block as train_distilbert.py in its root:
uv init --bare --python 3.12 ticket-classifier
cd ticket-classifier
uv add torch 'transformers==5.17.0' datasets accelerate numpyThe first dependency resolution chooses the other package versions and writes uv.lock. Retain that file with pyproject.toml, the Python version, and the run artifacts. Subsequent uv run --locked commands check that the lock matches the project instead of silently updating it.[13] Put the reviewed files at data/train.jsonl and data/validation.jsonl; each must contain both classes, with distinct IDs and normalized text across the two files. The script checks those local conditions, while the account/time split remains your dataset preparation responsibility.
import hashlib
import json
from importlib.metadata import version
from pathlib import Path
import numpy as np
from datasets import load_dataset
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
DataCollatorWithPadding,
Trainer,
TrainingArguments,
set_seed,
)
checkpoint = "distilbert/distilbert-base-uncased"
revision = "12040accade4e8a0f71eabdb258fecc2e7e948be"
run_root = Path("artifacts/encoder_v1")
if run_root.exists():
raise FileExistsError("Use a new versioned directory for each training run")
set_seed(7) # before initializing the new classification head
tokenizer = AutoTokenizer.from_pretrained(checkpoint, revision=revision)
development_files = {name: f"data/{name}.jsonl" for name in ("train", "validation")}
# Check original JSON values before a dataset library can coerce column types.
development_rows = {
name: [json.loads(line) for line in Path(filename).read_text().splitlines() if line.strip()]
for name, filename in development_files.items()
}
seen_ids = set()
seen_text = set()
for partition in development_rows.values():
for row in partition:
if type(row) is not dict or not {"fixture_id", "text", "label"}.issubset(row):
raise ValueError("Missing development row fields")
if type(row["label"]) is not int or row["label"] not in (0, 1) or type(row["text"]) is not str or not row["text"].strip():
raise ValueError("Invalid label or blank text")
fixture_id = row["fixture_id"]
if type(fixture_id) is not str or not fixture_id.strip() or fixture_id != fixture_id.strip():
raise ValueError("Invalid fixture ID")
if row["fixture_id"] in seen_ids:
raise ValueError("Repeated fixture ID across development data")
seen_ids.add(row["fixture_id"])
normalized = " ".join(row["text"].lower().split())
if normalized in seen_text:
raise ValueError("Repeated normalized text in development data")
seen_text.add(normalized)
if {row["label"] for row in partition} != {0, 1}:
raise ValueError("Each development partition must contain both classes")
dataset = load_dataset("json", data_files=development_files)
def tokenize(batch):
encoded = tokenizer([text.strip() for text in batch["text"]], truncation=False)
if any(len(ids) > 256 for ids in encoded["input_ids"]):
raise ValueError("Oversize ticket requires the manual-input policy")
return encoded
encoded = dataset.map(tokenize, batched=True)
model = AutoModelForSequenceClassification.from_pretrained(
checkpoint,
revision=revision,
num_labels=2,
id2label={0: "guarded_agent", 1: "human_review_now"},
label2id={"guarded_agent": 0, "human_review_now": 1},
)
def compute_metrics(eval_pred):
logits, labels = eval_pred
predicted = np.argmax(logits, axis=-1)
tp = np.sum((predicted == 1) & (labels == 1))
fn = np.sum((predicted == 0) & (labels == 1))
fp = np.sum((predicted == 1) & (labels == 0))
return {"recall_at_argmax": float(tp / (tp + fn)), "false_positives_at_argmax": int(fp)}
arguments = TrainingArguments(
output_dir="artifacts/encoder_v1",
learning_rate=2e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
num_train_epochs=3,
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
seed=7,
data_seed=7,
report_to=[],
push_to_hub=False,
)
trainer = Trainer(
model=model,
args=arguments,
train_dataset=encoded["train"],
eval_dataset=encoded["validation"],
processing_class=tokenizer,
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
compute_metrics=compute_metrics,
)
trainer.train()
print(trainer.evaluate(encoded["validation"]))
export = Path("artifacts/encoder_v1/model")
trainer.save_model(str(export))
tokenizer.save_pretrained(str(export))
files = {
path.name: hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(export.iterdir()) if path.is_file()
}
manifest = {
"base_checkpoint": checkpoint, "base_revision": revision,
"selected_checkpoint": trainer.state.best_model_checkpoint,
"label2id": model.config.label2id, "max_length": 256,
"seed": 7, "sha256": files,
"input_policy_version": "access-intake-input-v1",
"packages": {name: version(name) for name in ("torch", "transformers", "datasets", "accelerate", "numpy")},
"development_files": {
name: hashlib.sha256(Path(f"data/{name}.jsonl").read_bytes()).hexdigest()
for name in ("train", "validation")
},
}
Path("artifacts/encoder_v1/export-manifest.json").write_text(json.dumps(manifest, indent=2))Run uv run --locked train_distilbert.py from the project root. It downloads the pinned base checkpoint, trains, selects a development checkpoint, and exports the model. A rerun deliberately raises FileExistsError if artifacts/encoder_v1 already exists; preserve that run and choose a new versioned path throughout the script for a new experiment. After freezing the model and threshold, save the scorer below as score_export.py, supply gate-fixtures.json, and run uv run --locked score_export.py in the same project.
Minimum development loss chooses a checkpoint here; it doesn't approve a routing policy. load_best_model_at_end restores the checkpoint selected by that metric.[12] The reported argmax recall uses the implicit two-class 0.5 boundary, not the later 0.35 routing threshold (at an exact logit tie, NumPy's argmax chooses class 0). Selecting only maximum recall could favor sending everything to people. Compare candidates using required-slice misses and review capacity after threshold selection, and record exact dependency versions with the export.
The base revision names a repository snapshot, while export digests identify the saved file bytes. Neither proves that a particular training run occurred or protects mutable files. A human-readable name such as encoder_v1 is only a label. The manifest also records development-file hashes and package versions; these don't validate the split or supply a complete environment lock. Reload the export, compare hashes with an independently retained approved manifest, and use model.eval() with torch.inference_mode() when scoring. Take softmax(logits, dim=-1)[:, 1]: a generic pipeline's confidence in its winning label is not always the human-review score.
After completing the project training run, this separate scorer reloads its local export and writes one score per evaluator-owned fixture. gate-fixtures.json is a list of objects containing fixture_id and text; the trusted evaluator retains labels separately. Hash checks detect mismatched files, not a malicious replacement of both the files and their manifest. Store the approved manifest outside the scorer's write boundary.
import hashlib
import json
from importlib.metadata import version
from pathlib import Path
import re
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
export = Path("artifacts/encoder_v1/model")
manifest_path = Path("artifacts/encoder_v1/export-manifest.json")
manifest = json.loads(manifest_path.read_text())
if (
type(manifest) is not dict
or manifest.get("label2id") != {"guarded_agent": 0, "human_review_now": 1}
or type(manifest.get("max_length")) is not int
or manifest["max_length"] != 256
or manifest.get("input_policy_version") != "access-intake-input-v1"
):
raise ValueError("Unsupported scoring policy")
digests = manifest.get("sha256")
if type(digests) is not dict or not {"config.json", "model.safetensors", "tokenizer.json"}.issubset(digests):
raise ValueError("Incomplete export digest inventory")
for name, digest in digests.items():
if (
type(name) is not str or not name or name in (".", "..")
or "/" in name or "\\" in name
or type(digest) is not str or re.fullmatch(r"[0-9a-f]{64}", digest) is None
):
raise ValueError("Invalid export digest entry")
if any(not path.is_file() or path.is_symlink() for path in export.iterdir()):
raise ValueError("Expected a flat export without symbolic links")
if {path.name for path in export.iterdir()} != set(digests):
raise ValueError("Export file inventory changed")
for name, expected in manifest["sha256"].items():
if hashlib.sha256((export / name).read_bytes()).hexdigest() != expected:
raise ValueError(f"Export mismatch: {name}")
tokenizer = AutoTokenizer.from_pretrained(export, local_files_only=True)
classifier = AutoModelForSequenceClassification.from_pretrained(export, local_files_only=True)
if (
classifier.config.label2id != {"guarded_agent": 0, "human_review_now": 1}
or classifier.config.id2label != {0: "guarded_agent", 1: "human_review_now"}
or classifier.config.num_labels != 2
):
raise ValueError("Positive-class mapping changed")
classifier.eval()
fixture_path = Path("gate-fixtures.json")
fixtures = json.loads(fixture_path.read_text())
if type(fixtures) is not list or not fixtures:
raise ValueError("Expected a nonempty gate fixture list")
seen_ids = set()
for fixture in fixtures:
if type(fixture) is not dict or set(fixture) != {"fixture_id", "text"}:
raise ValueError("Expected only fixture ID and ticket text")
fixture_id = fixture["fixture_id"]
if type(fixture_id) is not str or not fixture_id.strip() or fixture_id != fixture_id.strip():
raise ValueError("Invalid gate fixture ID")
if fixture_id in seen_ids:
raise ValueError("Repeated gate fixture ID")
seen_ids.add(fixture_id)
if type(fixture["text"]) is not str or not fixture["text"].strip():
raise ValueError("Invalid gate input text")
scores = []
for fixture in fixtures:
text = fixture["text"].strip()
if not text:
raise ValueError("Blank gate input")
encoded = tokenizer(text, return_tensors="pt", truncation=False)
if encoded["input_ids"].shape[1] > manifest["max_length"]:
raise ValueError("Gate input exceeds serving policy; route manually")
with torch.inference_mode():
score = torch.softmax(classifier(**encoded).logits, dim=-1)[0, 1].item()
scores.append({"fixture_id": fixture["fixture_id"], "score": score})
Path("gate-scores.json").write_text(json.dumps(scores, indent=2, allow_nan=False))
Path("gate-scoring-metadata.json").write_text(json.dumps({
"fixtures_sha256": hashlib.sha256(fixture_path.read_bytes()).hexdigest(),
"manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(),
"input_policy_version": manifest["input_policy_version"],
"max_length": manifest["max_length"],
"packages": {name: version(name) for name in ("torch", "transformers")},
}, indent=2))Run the scorer only after freezing the model and threshold. Its separate metadata file retains fixture and manifest hashes, input policy, and scoring-library versions. It checks a complete flat export inventory for this recipe, not every possible sharded model format. Files must stay fixed during verification and use; these sequential checks provide no protection against a concurrent writer. JSON files and their metadata remain editable, with no signature or protected storage. The next sections exercise the evaluator with synthetic values instead of pretending a pretrained-model scoring run has happened.
Select a threshold on a selection set, not the freeze gate
Audit the default 0.50 cutoff against the routing goal. For two-class softmax it corresponds to choosing the larger class score, apart from tie handling; the library hasn't measured your operational costs. A missed security escalation can matter much more than an extra review, while excessive review load can delay urgent cases too.[14]
To see why lower cutoffs can make sense, assume is the calibrated probability of label 1, mistake costs are constant, and correct decisions have zero cost. Reviewing a routine ticket has cost ; missing an escalation has cost . The expected losses are for review and for the agent lane. Comparing those losses gives:
When , this simplified threshold is below 0.50. It isn't a formula for applying invented dollar amounts directly to uncalibrated scores. Review costs for true escalations, capacity constraints, changing prevalence, and different costs by ticket category require a fuller decision model. For our exercise, choose a cutoff empirically on a dedicated selection set, then evaluate a separate frozen gate receipt. Tuning on the same rows you later present as independent release evidence contaminates the gate.
The next scores are hand-authored numbers for the threshold exercise, not exports from either trained example. Suppose a future, independently evaluated encoder_v1 produced them. Which cutoff retains required positives without flooding the human queue?

| Fixture | Role | Slice | Gold | Score |
|---|---|---|---|---|
access_override_exception | selection | access_override_exception | 1 | 0.89 |
incident_freeze_exception | selection | incident_freeze_exception | 1 | 0.42 |
account_takeover | selection | account_takeover | 1 | 0.64 |
angry_key_rotation | selection | routine_key_rotation | 0 | 0.71 |
routine_key_rotation | selection | routine_key_rotation | 0 | 0.23 |
access_policy_question | selection | access_policy_question | 0 | 0.18 |
# Selection set: used only to propose a threshold. Not the release gate.
selection_rows = [
("access_override_exception", 1, 0.89),
("incident_freeze_exception", 1, 0.42),
("account_takeover", 1, 0.64),
("angry_key_rotation", 0, 0.71),
("routine_key_rotation", 0, 0.23),
("access_policy_question", 0, 0.18),
]
def summarize(threshold: float) -> tuple[int, int, int, float]:
if type(threshold) not in (int, float) or not 0 <= threshold <= 1:
raise ValueError("Expected a numeric threshold in [0, 1]")
tp = fp = fn = 0
for _, gold, score in selection_rows:
predicted = int(score >= threshold)
tp += predicted == 1 and gold == 1
fp += predicted == 1 and gold == 0
fn += predicted == 0 and gold == 1
recall = tp / (tp + fn)
return tp, fp, fn, recall
for threshold in (0.35, 0.50, 0.75):
tp, fp, fn, recall = summarize(threshold)
print(f"threshold={threshold:.2f} tp={tp} fp={fp} fn={fn} recall={recall:.2f}")threshold=0.35 tp=3 fp=1 fn=0 recall=1.00
threshold=0.50 tp=2 fp=1 fn=1 recall=0.67
threshold=0.75 tp=1 fp=0 fn=2 recall=0.33The sweep answers one narrow question. On this selection set, threshold 0.35 is the only candidate shown that avoids a missed escalation. It sends one angry but routine key-rotation ticket to human review, so the tradeoff is acceptable only if queue capacity allows it.
After locking a threshold, score a frozen gate receipt held out from selection. Nested cross-validation can separate model selection from evaluation when data is limited. This application still requires its group, time, and leakage checks.[4]
Don't report 1.00 recall from three positive selection fixtures as proof of production reliability. It's evidence for this selection slice, not for unseen traffic.
Threshold 0.35 has zero false negatives on the selection set, while 0.50 misses one required escalation. What must happen before a real rollout can use 0.35?
Answer
Freeze 0.35 with the model and input policy, then evaluate measured outputs on a disjoint gate with adequate slice coverage. Check misses, review capacity, and data-handling approval. Run an approved shadow evaluation before changing actual routes. Selection-set recall alone can't authorize release.
Freeze the evidence gate
The dashboard capstone compared runs only when dataset, grader, corpus, and fixture set matched. Carry that discipline here. A classifier receipt pins its dataset, label guide, split manifest, model, gate-policy version, and exact gate fixture IDs before scores are aggregated. The evaluator owns the fixture manifest, including each fixture's gold route and required slice. A scoring worker may submit only a fixture ID and the model's score. It can't submit a replacement label or slice. Gate fixtures must not be the set used to choose the threshold.
In the simulation, intake_bundle_v1 uses threshold 0.50 and misses freeze_window_ticket at score 0.41. intake_bundle_v2 uses 0.35 and passes the arithmetic gate. The two comparisons illustrate a policy change, not permission to retune after seeing the final gate. Real gate feedback becomes development data once used for tuning, and the next candidate needs new independent evidence. A threshold change versions the serving bundle, not the model weights.
Here eligible_for_shadow is a demonstration return value. These synthetic scores can't establish eligibility for real shadow traffic. That decision also needs approved data handling, measured model outputs, sufficient slice coverage, and operational review.
The evaluator checks declared model identity, bounded scores, and exact fixture coverage. It owns gold routes, slice membership, and bundle thresholds; submitted replacements are rejected. These checks don't prove which model ran or which text it saw. A real harness must score evaluator-owned inputs and retain authenticated output provenance. Disjoint fixture IDs also don't prove disjoint text, accounts, or incidents; check the split manifest and duplicates before scoring.
from collections import Counter
from dataclasses import dataclass
from types import MappingProxyType
EXPECTED_IDENTITY = {
"dataset_version": "access-intake-gate-v1",
"label_version": "escalation-policy-v1",
"split_manifest_version": "access-intake-split-2026-05",
"model_version": "encoder_v1",
"threshold_selection_set": "access-intake-selection-v1",
"gate_policy_version": "access-intake-gate-policy-v2",
}
# The frozen gate receipt uses fixture IDs that never appeared in the threshold
# selection set above. It is scored only after the threshold is locked.
SELECTION_FIXTURES = {
"access_override_exception",
"incident_freeze_exception",
"account_takeover",
"angry_key_rotation",
"routine_key_rotation",
"access_policy_question",
}
TRUSTED_BUNDLES = MappingProxyType({
"intake_bundle_v1": MappingProxyType({"threshold": 0.50}),
"intake_bundle_v2": MappingProxyType({"threshold": 0.35}),
})
@dataclass(frozen=True)
class FixtureSpec:
text: str
slice: str
gold: int
# This in-process fixture mapping is read-only. That is not authentication:
# the production evaluator and scoring worker need separate trust boundaries.
AUTHORITATIVE_FIXTURES = MappingProxyType({
"after_hours_access_override": FixtureSpec(
"The production API key is disabled outside business hours. Please make an exception.",
"access_override_exception",
1,
),
"freeze_window_ticket": FixtureSpec(
"The production API key was disabled during an incident freeze. Please restore it.",
"incident_freeze_exception",
1,
),
"takeover_recovery_request": FixtureSpec(
"Someone changed the recovery email and I can't sign in.",
"account_takeover",
1,
),
"routine_rotation_followup": FixtureSpec(
"The key rotation guide failed. Can I see it again?",
"routine_key_rotation",
0,
),
"routine_access_question": FixtureSpec(
"Which document explains who may rotate a service key?",
"access_policy_question",
0,
),
"routine_status_request": FixtureSpec(
"Can you tell me whether the key rotation completed?",
"routine_key_rotation",
0,
),
})
EXPECTED_FIXTURES = frozenset(AUTHORITATIVE_FIXTURES)
REQUIRED_SLICES = frozenset(
spec.slice for spec in AUTHORITATIVE_FIXTURES.values() if spec.gold == 1
)
MAX_FALSE_POSITIVES = 1 # queue capacity for this teaching receipt
# Production scores must come from the pinned model runner. The values below
# are synthetic fixtures for the evaluator, not output from a trained model.
SUBMITTED_SCORE_FIELDS = frozenset({"fixture_id", "score"})
RECEIPT_FIELDS = frozenset(EXPECTED_IDENTITY) | {"bundle_version", "threshold", "rows"}
gate_scores = [
{"fixture_id": "after_hours_access_override", "score": 0.87},
{"fixture_id": "freeze_window_ticket", "score": 0.41},
{"fixture_id": "takeover_recovery_request", "score": 0.62},
{"fixture_id": "routine_rotation_followup", "score": 0.69},
{"fixture_id": "routine_access_question", "score": 0.21},
{"fixture_id": "routine_status_request", "score": 0.14},
]
assert REQUIRED_SLICES == {
"access_override_exception",
"incident_freeze_exception",
"account_takeover",
}
def receipt(bundle_version: str, rows: list[dict[str, object]], **overrides: object) -> dict[str, object]:
return {
**EXPECTED_IDENTITY,
"bundle_version": bundle_version,
"threshold": TRUSTED_BUNDLES[bundle_version]["threshold"],
"rows": rows,
**overrides,
}
def release_decision(run: dict[str, object]) -> tuple[str, str]:
if type(run) is not dict:
return "hold", "invalid:receipt"
if set(run) != RECEIPT_FIELDS:
return "hold", "invalid:receipt_fields"
for field, expected in EXPECTED_IDENTITY.items():
actual = run.get(field)
if actual != expected:
return "hold", f"{field}:{actual}"
bundle_version = run.get("bundle_version")
if type(bundle_version) is not str:
return "hold", f"bundle_version:{bundle_version}"
bundle = TRUSTED_BUNDLES.get(bundle_version)
if bundle is None:
return "hold", f"bundle_version:{bundle_version}"
# A submitted threshold is metadata, not policy. Use the evaluator's
# pinned bundle manifest for every comparison and hold a report that disagrees.
if run.get("threshold") != bundle["threshold"]:
return "hold", f"threshold:{run.get('threshold')}"
rows = run.get("rows")
if type(rows) is not list or any(type(row) is not dict for row in rows):
return "hold", "invalid:rows"
scores_by_fixture = {}
fixture_ids = []
for row in rows:
if set(row) != SUBMITTED_SCORE_FIELDS:
fields = sorted(str(field) for field in set(row) ^ SUBMITTED_SCORE_FIELDS)
return "hold", f"invalid:submitted_fields:{','.join(fields)}"
fixture_id = row["fixture_id"]
if type(fixture_id) is not str or not fixture_id.strip():
return "hold", "invalid:fixture_id"
if fixture_id in SELECTION_FIXTURES:
return "hold", f"selection_reuse:{fixture_id}"
if fixture_id not in AUTHORITATIVE_FIXTURES:
return "hold", f"unexpected:{fixture_id}"
raw_score = row["score"]
# JSON numbers only: booleans and numeric-looking strings are not scores.
if type(raw_score) not in (int, float) or not 0.0 <= raw_score <= 1.0:
return "hold", f"invalid:score:{fixture_id}"
score = float(raw_score)
fixture_ids.append(fixture_id)
scores_by_fixture[fixture_id] = score
counts = Counter(fixture_ids)
missing = sorted(EXPECTED_FIXTURES - set(fixture_ids))
unexpected = sorted(set(fixture_ids) - EXPECTED_FIXTURES)
duplicated = sorted(fixture_id for fixture_id, count in counts.items() if count != 1)
if missing:
return "hold", f"missing:{','.join(missing)}"
if unexpected:
return "hold", f"unexpected:{','.join(unexpected)}"
if duplicated:
return "hold", f"duplicate:{','.join(duplicated)}"
threshold = bundle["threshold"]
misses = [
fixture_id
for fixture_id in fixture_ids
if AUTHORITATIVE_FIXTURES[fixture_id].slice in REQUIRED_SLICES
and AUTHORITATIVE_FIXTURES[fixture_id].gold == 1
and scores_by_fixture[fixture_id] < threshold
]
if misses:
return "hold", f"missed:{','.join(misses)}"
false_positives = sum(
1
for fixture_id, spec in AUTHORITATIVE_FIXTURES.items()
if spec.gold == 0 and scores_by_fixture[fixture_id] >= threshold
)
if false_positives > MAX_FALSE_POSITIVES:
return "hold", f"false_positives:{false_positives}>max:{MAX_FALSE_POSITIVES}"
return "eligible_for_shadow", "exact_receipt_pass"
runs = {
"intake_bundle_v1": receipt("intake_bundle_v1", gate_scores),
"intake_bundle_v2": receipt("intake_bundle_v2", gate_scores),
"intake_bundle_incomplete": receipt(
"intake_bundle_v2",
[row for row in gate_scores if row["fixture_id"] != "takeover_recovery_request"],
),
"intake_bundle_padded": receipt(
"intake_bundle_v2",
[*gate_scores, {"fixture_id": "easy_status_extra", "score": 0.02}],
),
"intake_bundle_duplicated": receipt("intake_bundle_v2", [*gate_scores, gate_scores[-1]]),
"intake_bundle_drifted": receipt("intake_bundle_v2", gate_scores, dataset_version="access-intake-gate-v2"),
"intake_bundle_wrong_threshold": receipt("intake_bundle_v2", gate_scores, threshold=0.20),
"intake_bundle_tampered_labels": receipt(
"intake_bundle_v2",
[
{**row, "slice": "routine_key_rotation", "gold": 0}
if row["fixture_id"] == "freeze_window_ticket"
else row
for row in gate_scores
],
),
"intake_bundle_invalid_score": receipt(
"intake_bundle_v2",
[*gate_scores[:-1], {"fixture_id": "routine_status_request", "score": float("nan")}],
),
"intake_bundle_string_score": receipt(
"intake_bundle_v2",
[*gate_scores[:-1], {"fixture_id": "routine_status_request", "score": "0.14"}],
),
"intake_bundle_fp_flood": receipt(
"intake_bundle_v2",
[
{
**row,
"score": (
0.90
if AUTHORITATIVE_FIXTURES[row["fixture_id"]].gold == 0
else row["score"]
),
}
for row in gate_scores
],
),
}
for bundle_version, run in runs.items():
decision, reason = release_decision(run)
print(f"{bundle_version}: decision={decision} reason={reason}")intake_bundle_v1: decision=hold reason=missed:freeze_window_ticket
intake_bundle_v2: decision=eligible_for_shadow reason=exact_receipt_pass
intake_bundle_incomplete: decision=hold reason=missing:takeover_recovery_request
intake_bundle_padded: decision=hold reason=unexpected:easy_status_extra
intake_bundle_duplicated: decision=hold reason=duplicate:routine_status_request
intake_bundle_drifted: decision=hold reason=dataset_version:access-intake-gate-v2
intake_bundle_wrong_threshold: decision=hold reason=threshold:0.2
intake_bundle_tampered_labels: decision=hold reason=invalid:submitted_fields:gold,slice
intake_bundle_invalid_score: decision=hold reason=invalid:score:routine_status_request
intake_bundle_string_score: decision=hold reason=invalid:score:routine_status_request
intake_bundle_fp_flood: decision=hold reason=false_positives:3>max:1The rejected label mutation never reaches the gate calculation: gold and slice aren't part of the scoring-worker schema, so the evaluator keeps its own values. A malformed score also holds before aggregation, and a report that changes the threshold can't replace the evaluator's pinned policy. If false positives exceed the toy capacity of one, the gate holds even when required-slice FNs are zero. The outer receipt schema rejects missing or extra fields too.
Now challenge what this gate actually proves. Could someone invent a perfect set of scores without running a model?
invented_scores = [
{"fixture_id": fixture_id, "score": float(spec.gold)}
for fixture_id, spec in AUTHORITATIVE_FIXTURES.items()
]
invented_decision, _ = release_decision(receipt("intake_bundle_v2", invented_scores))
print("invented scores pass arithmetic:", invented_decision == "eligible_for_shadow")invented scores pass arithmetic: TrueThey can. The evaluator's labels resist replacement in submitted rows, but the function can't authenticate the submitted scores. A trusted harness must own the input text, run the retained export, and bind its outputs to that execution. MappingProxyType blocks item assignment through a mapping; it doesn't make the Python process or its variable bindings immutable.
Audit scores without overclaiming calibration
A threshold can be useful even when the score isn't a well-calibrated probability. Calibration asks whether tickets scored near 0.70, across enough held-out examples, need human review about 70 percent of the time. Modern neural classifiers can be miscalibrated; temperature scaling is one common post-training adjustment, fit on held-out data, that rescales logits without changing the argmax class.[7]
That last detail matters for this product. Guo et al. showed temperature scaling leaves predicted labels unchanged because it doesn't change which logit is largest. This router doesn't use argmax. It uses score >= 0.35 on the positive class, so rescaling logits can move a ticket across the cutoff even when the predicted class stays the same. Fit temperature on held-out data, then re-run the gate receipt before that change enters the serving bundle.
Six fixtures are enough to demonstrate a calculation, not to justify operator-facing percentages:
from math import exp, log
scored_rows = [
(0.89, 1),
(0.64, 1),
(0.42, 1),
(0.71, 0),
(0.23, 0),
(0.18, 0),
]
buckets = {
"low [0.0,0.4)": [(score, gold) for score, gold in scored_rows if score < 0.4],
"middle [0.4,0.7)": [(score, gold) for score, gold in scored_rows if 0.4 <= score < 0.7],
"high [0.7,1.0]": [(score, gold) for score, gold in scored_rows if score >= 0.7],
}
for name, bucket in buckets.items():
if not bucket:
print(f"{name}: no observations")
continue
average_score = sum(score for score, _ in bucket) / len(bucket)
observed_rate = sum(gold for _, gold in bucket) / len(bucket)
print(f"{name}: n={len(bucket)}, score={average_score:.2f}, observed={observed_rate:.2f}")
print("decision: diagnostic only; collect more held-out labels")
# Illustrate a supplied positive temperature, not a fitted calibrator.
before = 0.30
after = 1 / (1 + exp(-log(before / (1 - before)) / 2))
print("argmax stays routine:", before < 0.5 and after < 0.5)
print("route changes at 0.35:", before < 0.35 <= after)low [0.0,0.4): n=2, score=0.21, observed=0.00
middle [0.4,0.7): n=2, score=0.53, observed=1.00
high [0.7,1.0]: n=2, score=0.80, observed=0.50
decision: diagnostic only; collect more held-out labels
argmax stays routine: True
route changes at 0.35: TrueThe high bucket contains two tickets, too few to establish that scores near 0.80 imply an 80 percent escalation rate. It reuses the synthetic selection scores rather than labels from a fresh calibration evaluation. The final lines demonstrate a different issue: dividing the logit difference by a supplied temperature of 2 moves score 0.30 to about 0.40, crossing 0.35 while the argmax label stays routine. This calculation illustrates a route change; assess calibration with a temperature fit on development data and evaluate the transformed scores and threshold together on untouched data.
Ship the serving contract
A model file alone can't reproduce the route. A changed tokenizer configuration, preprocessing rule, runtime, or cutoff can change routes even when the weights hash matches. Merely installing a newer tokenizer library doesn't establish that tokenization changed; retain its files and versions and compare behavior.
Keep the release components together and protect approved versions from modification. This lesson constructs dictionaries and an export manifest; it doesn't implement an immutable artifact store:
| Component | Versioned value |
|---|---|
| Label guide | escalation-policy-v1 |
| Gate receipt | access-intake-gate-v1, six exact fixtures (held out from threshold selection) |
| Gate policy | access-intake-gate-policy-v2: exact typed schema, required-slice misses, and routine-review limit |
| Dataset split | access-intake-split-2026-05 |
| Tokenizer and model | encoder_v1 alias plus verified export hashes and base revision |
| Serving release | intake_bundle_v2 |
| Input policy | access-intake-input-v1: trim outer whitespace, preserve punctuation, reject over 256 tokenizer tokens including special tokens |
| Threshold | 0.35, chosen on access-intake-selection-v1, then frozen for the gate |
| Failure fallback | human_review_now |
| Downstream consumer | access_agent_v2 |
![The intake bundle checks a pinned policy, nonempty text, trusted token count no greater than 256, a successful model call, and a finite score in [0,1] before applying τ=0.35. Any failure routes to human_review_now. Valid scores at or above 0.35 enter human review; lower scores may enter guarded_agent. Handoff checks pass r-104, block r-105 because its route is human review, and block r-106 because it names intake_bundle_v1. Producer identity requires separate authenticated verification.](/cdn/content-image/foundations/capstone-fine-tuned-classifier/illustrations/_generated/serving_contract_bundle_dark.png?v=047cdbed6b7b)
The endpoint must fail closed. In Python, float("nan") >= 0.35 returns False. A threshold check followed by an unconditional agent route would therefore admit a NaN score. A model exception is different: it raises unless the caller catches it. Explicitly handle both invalid returned values and failures to obtain a score.
A defensive intake contract enforces strict verification order:
- Validate input text: empty, whitespace-only, or malformed inputs immediately take
fallback_route. - Verify token length: inputs exceeding 256 tokens bypass the model and take
fallback_route. - Check model availability: timeouts or worker crashes take
fallback_route. - Validate the score: non-numeric,
None, or non-finite values (NaN,Inf) takefallback_route. - Only finite numeric scores in the inclusive range are evaluated against threshold
0.35.
The trusted tokenizer must count the full input before inference; a client-supplied count isn't acceptable. The function below tests the routing layer with supplied scores and counts. None stands in for unavailable scoring; no tokenizer, model call, or timeout runs here. It also checks the editable bundle against the pinned teaching policy before using it. A changed threshold or fallback mustn't silently open the agent lane.
from types import MappingProxyType
SERVING_POLICY = MappingProxyType({
"bundle_version": "intake_bundle_v2",
"label_version": "escalation-policy-v1",
"gate_receipt_version": "access-intake-gate-v1",
"split_manifest_version": "access-intake-split-2026-05",
"tokenizer_version": "encoder_v1",
"model_version": "encoder_v1",
"input_policy_version": "access-intake-input-v1",
"max_length": 256,
"threshold": 0.35,
"fallback_route": "human_review_now",
})
bundle = dict(SERVING_POLICY)
def route_ticket(text: object, score: object, token_count: object) -> tuple[str, str]:
if (
type(bundle) is not dict
or set(bundle) != set(SERVING_POLICY)
or any(type(bundle[key]) is not type(expected) or bundle[key] != expected
for key, expected in SERVING_POLICY.items())
):
return "human_review_now", "invalid_bundle"
if not isinstance(text, str):
return bundle["fallback_route"], "invalid_text"
if not text.strip():
return bundle["fallback_route"], "empty_text"
if type(token_count) is not int or token_count <= 0:
return bundle["fallback_route"], "invalid_token_count"
if token_count > bundle["max_length"]:
return bundle["fallback_route"], "input_too_long"
if score is None:
return bundle["fallback_route"], "model_unavailable"
if type(score) not in (int, float) or not 0.0 <= score <= 1.0:
return bundle["fallback_route"], "invalid_score"
if score >= bundle["threshold"]:
return "human_review_now", "threshold"
return "guarded_agent", "below_threshold"
checks = [
("Production API key disabled during the incident freeze", 0.72, 12, "human_review_now", "threshold"),
("Where is the access policy?", 0.18, 8, "guarded_agent", "below_threshold"),
("", 0.04, 2, "human_review_now", "empty_text"),
("Where is the key rotation guide?", None, 9, "human_review_now", "model_unavailable"),
("Malformed model score", float("nan"), 5, "human_review_now", "invalid_score"),
("Boolean is not a score", False, 8, "human_review_now", "invalid_score"),
("Oversize ticket fixture", 0.01, 257, "human_review_now", "input_too_long"),
]
print("bundle:", bundle["bundle_version"], bundle["gate_receipt_version"])
for text, score, token_count, expected_route, expected_reason in checks:
route, reason = route_ticket(text, score, token_count)
assert (route, reason) == (expected_route, expected_reason)
print(f"{route}: {reason}")bundle: intake_bundle_v2 access-intake-gate-v1
human_review_now: threshold
guarded_agent: below_threshold
human_review_now: empty_text
human_review_now: model_unavailable
human_review_now: invalid_score
human_review_now: invalid_score
human_review_now: input_too_longAdmit only pinned routes
A serving bundle still needs an explicit downstream handoff contract. During canaries and rolling updates, multiple versions may coexist. Here the agent admits only the specified active bundle; accepting a stale version would bypass that chosen intake policy. Independent approval controls remain necessary after admission.
The demonstration checks bundle and route. A production consumer also needs to verify the producer:
- Pinned bundle identity: The ticket's
bundle_versionmust match the agent's pinned active configuration (intake_bundle_v2). - Route verification: The assigned route must strictly equal
guarded_agent. - Producer provenance: The intake artifact must originate from the authenticated classifier service.
Matching a caller-supplied version string doesn't authenticate the producer. Use an authenticated service channel or verify signed artifacts against trusted issuers, binding the decision to the ticket and approved configuration. Signing alone doesn't make an asserted decision correct. The function below implements neither mechanism; test its limit with a fabricated matching artifact.
handoff = {
"producer": "support_ticket_escalation_classifier",
"bundle_version": "intake_bundle_v2",
"model_version": "encoder_v1",
"threshold": 0.35,
"allowed_agent_route": "guarded_agent",
"blocked_or_manual_route": "human_review_now",
"policy_evidence_service": "document_qa_v2",
"evaluation_report": "classifier_dashboard_run_intake_bundle_v2",
}
agent_inputs = [
{"ticket_id": "r-104", "bundle_version": "intake_bundle_v2", "route": "guarded_agent"},
{"ticket_id": "r-105", "bundle_version": "intake_bundle_v2", "route": "human_review_now"},
{"ticket_id": "r-106", "bundle_version": "intake_bundle_v1", "route": "guarded_agent"},
]
def admitted_by_pinned_bundle(row: dict[str, str]) -> bool:
return (
isinstance(row, dict)
and isinstance(row.get("ticket_id"), str)
and bool(row["ticket_id"].strip())
and row.get("bundle_version") == handoff["bundle_version"]
and row.get("route") == handoff["allowed_agent_route"]
)
accepted = [
row["ticket_id"]
for row in agent_inputs
if admitted_by_pinned_bundle(row)
]
blocked = [
row["ticket_id"]
for row in agent_inputs
if not admitted_by_pinned_bundle(row)
]
print("agent accepts:", ",".join(accepted))
print("agent blocked:", ",".join(blocked))
print("agent authority: draft_with_evidence_and_request_approval")
forged = {"ticket_id": "invented-ticket", "bundle_version": "intake_bundle_v2", "route": "guarded_agent"}
print("matching fabricated artifact passes checks:", admitted_by_pinned_bundle(forged))agent accepts: r-104
agent blocked: r-105,r-106
agent authority: draft_with_evidence_and_request_approval
matching fabricated artifact passes checks: TrueThe artifact passes because it contains matching strings, despite having no authenticated source. The project still needs to connect four boundaries:
- Document QA supplies evidence-bound policy answers.
- Exact-receipt evaluation rows and gates expose failures before release.
- The classifier proposes a route, with fail-closed handling for invalid inputs.
- The agent uses only admitted routine intake and still needs approval for consequential actions.
Keep the release evidence together
A notebook screenshot can't answer the release questions. Keep these files or equivalent artifacts together:
| Artifact | Question it answers |
|---|---|
label_guide.md | What does human_review_now mean, including edge cases? |
data_card.md | Where did rows come from and what traffic is missing? |
| Frozen split manifest | Did account or time leakage contaminate evaluation? |
| Evaluator-owned fixture manifest | Which exact ticket text, gold route, and required slice define the gate? |
| Baseline report | Did the encoder solve a measured baseline failure? |
| Training configuration | Which checkpoint, seed, hyperparameters, and label mapping produced the model? |
| Versioned scoring output | Did the pinned model return one finite score for every exact fixture ID? |
| Evaluator report | Which required-slice failures passed or blocked release under the manifest's gold labels? |
| Serving bundle | Are tokenizer, threshold, fallback, and model restored together? |
| Shadow-monitor plan | Which reviewed production sample can trigger rollback? |
Before changing live routing, obtain approval for data handling and run a shadow evaluation that leaves actual routing unchanged. Retain scores, proposed routes, human outcomes, and outcome-availability times. Compare misses and label completeness by required slice. After a guarded rollout, hold or return to manual routing under the agreed monitoring policy, including missing evidence; a healthy average can't clear an unresolved critical slice.
Practice: break the intake contract
Treat each mutation as a controlled failure. Predict which evidence should reject it, run the relevant cells, then revert that mutation before starting the next.
- Inspect
intake_bundle_incomplete. Why does it hold even though every remaining required positive clears threshold0.35? - Inspect
intake_bundle_padded,intake_bundle_duplicated, andintake_bundle_drifted. Why can't easy extras, duplicate rows, or a newer dataset silently change the release decision? - Re-version the serving policy at threshold
0.50whilefreeze_window_ticketstill scores0.41. Which required fixture blocks shadow eligibility? - Inspect
intake_bundle_fp_flood. Why does it hold even though required-slice positives still clear a low threshold? - Change
fallback_routetoguarded_agent. Whichserving-fallback.pyassertions fail, and why is that unsafe? - Remove the bundle-version comparison from
admitted_by_pinned_bundle. Which stale artifact now passes? Why does the fabricated current-version artifact pass even before this mutation? - Inspect
intake_bundle_tampered_labelsandintake_bundle_invalid_score. Which submitted fields does the evaluator trust, and why must the other fields hold before any metric is computed?
What should each mutation prove?
Answer
- Missing fixture evidence must hold; absence isn't a passing result. 2. Unexpected easy rows, duplicates, and dataset drift reject before aggregation. 3.
freeze_window_ticketfalls below 0.50 at score 0.41. 4. The false-positive limit holds excessive review demand even when required FNs are zero. 5. Changing the fallback makes the bundle invalid, so all checks receive the humaninvalid_bundleroute; don't deploy that policy. 6. Removing the version comparison admitsr-106. The fabricated current-version artifact already passes because this function has no producer authentication. 7. Only fixture ID and finite score belong in submitted rows; the evaluator retains its own labels and slices. Accepted score fields still require execution provenance.
What the agent receives next
The intake route stays held when a required escalation is missing, the threshold was chosen on the wrong rows, or the score isn't tied to the evaluator's own labels. This demonstration still hasn't deployed a classifier or validated a pretrained model. Finishing the project means authenticated scores from the hashed export, the same untouched gate for the baseline, and operational approval before any live route changes. The later agent can add retrieval, approval gates, traces, and evaluation only if those intake boundaries stay in place.