Skip to content
Skip to lesson
Back to Topics
LearnPortfolio CapstonesCapstone: Fine-Tuned Classifier
⚡HardFine-Tuning & Training

Capstone: Fine-Tuned Classifier

Train an intake classifier that sends a freeze exception to a person, then hold the release when a required escalation is missed.

47 min read
Learning path
Step 93 of 199 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Consider this ticket:

text
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:

QuestionEvidence 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.

Two hand-authored ticket scores meet the same 0.35 cutoff. An incident-freeze exception scores 0.42 and routes to human_review_now, where a person decides any restore. A key-rotation question scores 0.23 and routes to guarded_agent for permitted evidence, a draft, and an approval request. Routing leaves access changes behind human approval.
The 0.42 freeze fixture crosses the cutoff into review. The 0.23 routine question can enter the guarded agent, where permitted evidence and human approval still control any restore.

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 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 . 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:

LabelRouteMeaning
1human_review_nowAn access-recovery risk, suspected takeover, or policy exception requires a person before the agent workflow.
0guarded_agentA 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 artifactContract it contributesWhat this classifier needs from it
Document QA productAn answer must cite permitted policy evidence.Routine questions still need evidence checks after routing.
Evaluation dashboardHard failures block a release even when averages improve.Missed urgent slices must block a classifier release.
Fine-tuned classifierTicket becomes a risk route under a pinned threshold.Export the intake contract for the production agent.

Write labeled before training. They make the route contract concrete and give support operations something to review before a model can hide an ambiguous case.

label-contract.py
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']})")
Output
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:

FieldTeaching dataset choice
SourceSynthetic access tickets modeled after access, incident-freeze, and account-recovery requests
Label versionescalation-policy-v1
ExclusionsSpam, internal QA, tickets without requester text
High-risk slicesaccess_override_exception, incident_freeze_exception, account_takeover
Split policyEarlier accounts train; later, unseen accounts validate and test
Demonstration gateNo 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 , consent and retention policy, labeling instructions, class balance, disagreement rate, and known coverage gaps.

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]

grouped-time-split.py
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}")
Output
train: 4 tickets, accounts=acme,north
validation: 2 tickets, accounts=summit
test: 2 tickets, accounts=harbor
An eight-row teaching manifest keeps each account inside one split as months move forward. Train holds acme tickets t01 and t02 in months one and two and north tickets t03 and t04 in month two. Validation holds summit tickets v01 and v02 in month three. Test holds harbor tickets x01 and x02 in month four. Later score fixtures are separate.
Each account stays in one partition while months advance from train to validation to test. The later score exercise uses separate fixtures.

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.

Compare a cheap baseline first

Before paying for a contextual , 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 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.

keyword-baseline.py
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")
Output
positive recall: 1/2
missed positive: indirect_incident_freeze_exception
next experiment: fit a text-feature baseline

This 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.

fitted-text-baseline.py
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())
Output
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 for each class. turns the pair into class scores that sum to one. A separate 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.

stable-softmax.py
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}")
Output
incident_freeze: human_review_score=0.95, routine_score=0.05
key_rotation: human_review_score=0.12, routine_score=0.88

Call 0.95 a score until held-out reliability evidence supports interpreting it as an estimated probability; numerical bounds alone do not establish .[7] The final linear layer has the form z=Wh+b∈R2z = W h + b \in \mathbb{R}^2, but how the model constructs hh 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 , attaches a classification head, and trains embeddings and head with . 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.

Training row 6/6, “show access policy,” becomes six token positions with mask 1, 1, 1, 0, 0, 0. A six-by-eight schematic embedding grid sends only the three live rows into the masked mean h. The 8-D vector passes through W in R^(2 by 8) and b to symbolic logits z0 and z1; cross-entropy compares them with gold y=0. Gradients flow back to W, b, and the three live embedding rows, while the padding row stays fixed.
Three live token vectors form h. The two-logit head compares its output with gold y=0, and the resulting gradient updates W, b, and the active embedding rows.
tiny-encoder-training-loop.py
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))
Output
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: True

The 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 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 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 , a of 2×10−52 \times 10^{-5}, 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:

create-classifier-project.sh
uv init --bare --python 3.12 ticket-classifier
cd ticket-classifier
uv add torch 'transformers==5.17.0' datasets accelerate numpy

The 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.

train_distilbert.py
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.

score_export.py
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 pp is the calibrated probability of label 1, mistake costs are constant, and correct decisions have zero cost. Reviewing a routine ticket has cost CFPC_{\text{FP}}; missing an escalation has cost CFNC_{\text{FN}}. The expected losses are CFP(1−p)C_{\text{FP}}(1-p) for review and CFNpC_{\text{FN}}p for the agent lane. Comparing those losses gives:

τ∗=CFPCFP+CFN\tau^* = \frac{C_{\text{FP}}}{C_{\text{FP}} + C_{\text{FN}}}

When CFN≫CFPC_{\text{FN}} \gg C_{\text{FP}}, 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?

Six hand-authored selection scores share a zero-to-one axis. Three positives score 0.89, 0.42, and 0.64; three routine tickets score 0.71, 0.23, and 0.18. Lowering the threshold from 0.50 to 0.35 moves the freeze score 0.42 into human review and reduces false negatives from one to zero; the routine 0.71 score remains one false positive. At 0.75, two positives are missed.
Each ticket has its own row on a common score axis. Freeze at 0.42 falls between the first two cutoffs. Among the shown candidates, only 0.35 retains all required positives, at the cost of reviewing angry key-rotation.
FixtureRoleSliceGoldScore
access_override_exceptionselectionaccess_override_exception10.89
incident_freeze_exceptionselectionincident_freeze_exception10.42
account_takeoverselectionaccount_takeover10.64
angry_key_rotationselectionroutine_key_rotation00.71
routine_key_rotationselectionroutine_key_rotation00.23
access_policy_questionselectionaccess_policy_question00.18
threshold-sweep.py
# 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}")
Output
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.33

The 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.

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 . 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.

exact-receipt-release-gate.py
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}")
Output
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:1

The 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.py
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")
Output
invented scores pass arithmetic: True

They 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. 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; 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:

calibration-diagnostic.py
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)
Output
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: True

The 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:

ComponentVersioned value
Label guideescalation-policy-v1
Gate receiptaccess-intake-gate-v1, six exact fixtures (held out from threshold selection)
Gate policyaccess-intake-gate-policy-v2: exact typed schema, required-slice misses, and routine-review limit
Dataset splitaccess-intake-split-2026-05
Tokenizer and modelencoder_v1 alias plus verified export hashes and base revision
Serving releaseintake_bundle_v2
Input policyaccess-intake-input-v1: trim outer whitespace, preserve punctuation, reject over 256 tokenizer tokens including special tokens
Threshold0.35, chosen on access-intake-selection-v1, then frozen for the gate
Failure fallbackhuman_review_now
Downstream consumeraccess_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.
Check the pinned policy and supplied values before applying 0.35. The handoff then checks route and bundle strings; producer verification is still required. A that restores weights without their tokenizer, threshold, and fallback can change behavior.

The endpoint must . 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:

  1. Validate input text: empty, whitespace-only, or malformed inputs immediately take fallback_route.
  2. Verify token length: inputs exceeding 256 tokens bypass the model and take fallback_route.
  3. Check model availability: timeouts or worker crashes take fallback_route.
  4. Validate the score: non-numeric, None, or non-finite values (NaN, Inf) take fallback_route.
  5. Only finite numeric scores in the inclusive range [0.0,1.0][0.0, 1.0] 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.

serving-fallback.py
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}")
Output
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_long

Admit 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:

  1. Pinned bundle identity: The ticket's bundle_version must match the agent's pinned active configuration (intake_bundle_v2).
  2. Route verification: The assigned route must strictly equal guarded_agent.
  3. 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.

agent-intake-artifact.py
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))
Output
agent accepts: r-104
agent blocked: r-105,r-106
agent authority: draft_with_evidence_and_request_approval
matching fabricated artifact passes checks: True

The artifact passes because it contains matching strings, despite having no authenticated source. The project still needs to connect four boundaries:

  1. Document QA supplies evidence-bound policy answers.
  2. Exact-receipt and gates expose failures before release.
  3. The classifier proposes a route, with fail-closed handling for invalid inputs.
  4. 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:

ArtifactQuestion it answers
label_guide.mdWhat does human_review_now mean, including edge cases?
data_card.mdWhere did rows come from and what traffic is missing?
Frozen split manifestDid account or time contaminate evaluation?
Evaluator-owned fixture manifestWhich exact ticket text, gold route, and required slice define the gate?
Baseline reportDid the encoder solve a measured baseline failure?
Training configurationWhich checkpoint, seed, hyperparameters, and label mapping produced the model?
Versioned scoring outputDid the pinned model return one finite score for every exact fixture ID?
Evaluator reportWhich required-slice failures passed or blocked release under the manifest's gold labels?
Serving bundleAre tokenizer, threshold, fallback, and model restored together?
Shadow-monitor planWhich 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 . 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.

  1. Inspect intake_bundle_incomplete. Why does it hold even though every remaining required positive clears threshold 0.35?
  2. Inspect intake_bundle_padded, intake_bundle_duplicated, and intake_bundle_drifted. Why can't easy extras, duplicate rows, or a newer dataset silently change the release decision?
  3. Re-version the serving policy at threshold 0.50 while freeze_window_ticket still scores 0.41. Which required fixture blocks shadow eligibility?
  4. Inspect intake_bundle_fp_flood. Why does it hold even though required-slice positives still clear a low threshold?
  5. Change fallback_route to guarded_agent. Which serving-fallback.py assertions fail, and why is that unsafe?
  6. 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?
  7. Inspect intake_bundle_tampered_labels and intake_bundle_invalid_score. Which submitted fields does the evaluator trust, and why must the other fields hold before any metric is computed?

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.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A ticket is scored below the pinned threshold and routed to guarded_agent. Which downstream behavior is allowed by that route?
2.A label guide says human_review_now covers access-recovery risk, suspected takeover, or policy exceptions, and that frustrated tone alone isn't enough. Which labeling is consistent with that guide?
3.An access dataset contains several tickets from the same workspace account. Those tickets share service names, incident wording, and prior access details. What is unsafe about randomly placing some of that account's tickets in training and others in validation?
4.A keyword rule catches 'key disabled' and 'can't sign in' but misses 'The production API key was disabled during the incident freeze.' What does that baseline result justify?
5.A tiny PyTorch classifier builds word IDs, averages learned token embeddings, applies a linear head, and trains with cross-entropy on six rows until loss falls. What conclusion is justified?
6.On the selection set, threshold 0.35 has TP=3, FP=1, FN=0, while 0.50 misses freeze at score 0.42. You freeze 0.35 into intake_bundle_v2. The disjoint gate receipt later scores freeze_window_ticket at 0.41. What is allowed?
7.A scoring worker submits the six expected fixture IDs, but changes freeze_window_ticket to gold=0 and slice=routine_key_rotation. What should the evaluator do?
8.The synthetic calibration diagnostic has a high-score bucket with two rows. Their average score is about 0.80, but only one is labeled human_review_now. What is the safest dashboard language?
9.The serving endpoint receives a nonempty ticket, but the model returns NaN for the human-review score. With the pinned bundle's failure fallback, how should it route the ticket?
10.The demonstration receives three intake artifacts: r-104 has bundle intake_bundle_v2 and route guarded_agent; r-105 has bundle intake_bundle_v2 and route human_review_now; r-106 has bundle intake_bundle_v1 and route guarded_agent. Its handoff checks require guarded_agent and bundle intake_bundle_v2. Which ticket passes those checks?

10 questions remaining.

References

BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.

Devlin, J., et al. · 2019 · NAACL 2019

https://arxiv.org/abs/1810.04805

Text Classification.

Hugging Face. · 2026 · Official documentation

https://huggingface.co/docs/transformers/tasks/sequence_classification

DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter

Sanh, V., Debut, L., Chaumond, J., & Wolf, T. · 2019 · NeurIPS 2019 workshop

https://arxiv.org/abs/1910.01108

Cross-validation: evaluating estimator performance

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/cross_validation.html

Common Pitfalls and Recommended Practices: Data Leakage

Scikit-learn Developers · 2026 · Official documentation

https://scikit-learn.org/stable/common_pitfalls.html#data-leakage

LogisticRegression

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html

On Calibration of Modern Neural Networks

Guo, C., Pleiss, G., Sun, Y., & Weinberger, K. Q. · 2017

https://arxiv.org/abs/1706.04599

DistilBERT sequence-classification implementation

Hugging Face Contributors · 2026

https://github.com/huggingface/transformers/blob/v5.17.0/src/transformers/models/distilbert/modeling_distilbert.py

CrossEntropyLoss

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/main/generated/torch.nn.CrossEntropyLoss.html

DistilBERT base uncased configuration

Hugging Face · 2026

https://huggingface.co/distilbert/distilbert-base-uncased/resolve/12040accade4e8a0f71eabdb258fecc2e7e948be/config.json

DeBERTaV3: Improving DeBERTa using ELECTRA-Style Pre-Training with Gradient-Disentangled Embedding Sharing

He, P., Gao, J., & Chen, W. · 2023 · ICLR 2023

https://arxiv.org/abs/2111.09543

Trainer

Hugging Face · 2026

https://huggingface.co/docs/transformers/main_classes/trainer

Locking and Syncing

Astral · 2026

https://docs.astral.sh/uv/concepts/projects/sync/

Tuning the decision threshold for class prediction

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/classification_threshold.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.