Train · the recipe, step by step

Train Laya for your own task. Then measure it honestly.

If your app asks a paid model the same kind of small judgment all day, or you have a pile of decisions people already made, you can turn them into a local model that makes those judgments on your own GPU with no fee per decision. This page is the recipe, for your task and your logs: what to log, how to split, the loss, the settings, the code, how to test it without fooling yourself, and how to serve it. One example runs through every step, a support-ticket router. Our own project appears only as the case study, in the notes marked What we saw.

Base model: Laya and the laya library, by Convai Innovations (Apache-2.0, built on ModernBERT-large, about 421M parameters). Our teacher was Jev (jev-1.13.0) by TypeSafe; thank you to both. The code below is shortened from the scripts that trained our fine-tune, layatown-v1. We checked that it compiles and ran its data, batching, loss and metric parts on a CPU; the shortened loop as printed has not been rerun end to end on a GPU, so treat a first run as a test. Not affiliated with TypeSafe or Convai Innovations.

77 minone epoch of training on one RTX 3080 Ti (our case)
0.869same top answer as the teacher on held-out data it never saw
~140/sdecisions served, GPU-bound
0.86 Whper 1,000 decisions; no fee per decision

The fast way: paste this into your coding agent

Fill in the brackets and paste it into Claude Code or any coding agent. It reads this page, follows the recipe one step at a time with your data, and holds itself to the same honesty rules we did. The rest of the page is what the agent will follow, if you want to understand or do it yourself.

Copy-paste prompt for coding agents
You are helping me train a local fine-tune of Laya (Convai Innovations, Apache-2.0) for my own judgment task, following the recipe published at https://jevtown.com/train. Path A: distil a "teacher" model I already use. Path B: train from my own labelled decisions (human choices or outcomes), with no teacher.

First read https://jevtown.com/train in full. Then read https://jevtown.com/evaluation (how the authors pre-registered, amended and reported their own evaluation) and https://jevtown.com/model (how they wrote up results and limits). Use those pages as the method only. The page's running example (a support-ticket router) and its "What we saw" notes (the authors' own project) are illustrations: my task, my data, my questions and my pass marks are my own.

My setup:
- My task: [what the model decides, and where in my product the answer is used]
- Path: [A: teacher = model name and version, called for (what my app asks it) | B: my own labels = who or what labelled them]
- Questions, each with fixed answers: [choice: the options | noul (yes/no): the question]
- Logs: [path], about [N] requests
- The unit that groups correlated rows: [day / customer / session / user]
- Hardware: [GPU and VRAM], Python 3.12, uv
- What the model must do well in my product: [the behaviours that matter]

Work through the recipe one step at a time. After each step, show me what you did and the numbers, and wait for my go-ahead.
0. Terms: for path A, ask me to confirm that the teacher's terms allow training on its outputs; for both paths, that I may use the people's data in the logs for this. Do not continue without that confirmation.
1. Audit the logs against the JSONL format on /train: full probabilities (or exact labels for path B), exact inputs, one subject per question, subjects named rather than addressed by list position, provenance per row. Report what is missing or rebuilt, with counts. Never invent, backfill or guess labels.
2. Split by whole groups, never by row. Hold out one whole real deployment period (for example the most recent full week) as the test set and do not open it. Remove from train any request that also appears in validation or test. Compute the floors with the same code the evaluator will use.
3. Build the sequences with the laya library (512 tokens, 256 for the question head). Count and drop items whose options do not fit.
4. Before training, write EVALUATION.md: systems, metrics with exact formulas, pass marks, cluster bootstrap intervals, the behaviours my product depends on (each with a tolerance), checks that do not come from the teacher, a whole-system comparison, verdict rules and threats to validity. Commit it. After that, change it only by dated amendments appended at the end.
5. Train with the recipe's settings: KL to the soft labels (one-hot labels for path B), AdamW, lr 3e-5 encoder and 1e-4 head, 2% warm-up then linear decay to 5%, about 12,288 padded tokens per length-bucketed batch, one epoch, bf16, gradient checkpointing, clipping 1.0, a fixed seed. No early stopping, no choosing checkpoints. Record the checkpoint's SHA-256 before any scoring.
6. Fit one temperature per question type on validation only.
7. Open the test set once and evaluate at the three levels: fidelity to the teacher or labels (with the floor and the untouched base model), checks that do not come from the teacher, and my whole system running on the student (for example a shadow deployment beside what runs today).
8. Serve it with the same state slicing as training, an exact cache, bearer auth, and a failure mode that skips a decision (falls back to what my product does today) instead of inventing one.

Honesty rules. These override any wish to show a good result:
- Never look at, tune on, or select anything with the test set. If it was looked at before the plan, say so in the plan.
- Never change a pass mark or a metric after seeing a number. Add a dated amendment that says what changed and why.
- Report every failed criterion as prominently as the passes, with its number and interval.
- Say "not measured" rather than estimating, and label every estimate as one.
- Do not route a request type to the student until it has been measured on that type.
- Do not make paid calls to the teacher, or anything that costs money, without asking me first.

At the end, give me a short report: what passed, what failed, the numbers with intervals, what it cost in time and money, and what the model must not be used for.

Plain text, no links to follow except these pages. Works with any agent that can read a web page and run code on your machine.

ExampleOur pre-registered evaluationPlan, pass marks, amendments → ExampleOur model cardData, training, limits, licence → BuildWhat to build with itUses, costs and limits → The studyCan a model teach itself?Round 1: a trade-off →

Why train your own?

Lots of apps ask a paid model small judgments all day: which team gets this ticket, is this message spam or abuse, is this lead worth a call, does this post need a human to look at it, what does a character in your game do next, which model should answer this request. Every call costs money, sends your users' data to someone else and waits on the network. A small local model that gives the same judgments has no fee per decision, keeps the data on your machine and answers in milliseconds.

Path A: you already use a model

  • log its answers (the full probabilities) and distil them into Laya
  • the copy inherits the teacher's strengths and its mistakes

Path B: you have no teacher

  • train from your own labelled decisions: people's choices (which team your agents actually sent the ticket to), or what actually worked (outcomes: was it resolved without being passed on)
  • the same pipeline with one-hot (or outcome-weighted) targets instead of probabilities

Check your teacher's terms before you train on its outputs. Many model providers forbid using their outputs to train another model. TypeSafe's terms permit it for Jev; the project's owner checked on 21 September 2026 before we trained. Your teacher's may not. The same goes for the people in your logs: your customers, users or staff. Train only on data you are allowed to use for this.

Is this for you?

It fits if

  • your app asks typed questions with fixed answers: pick one of these options, or yes/no. From a model that gives a probability for each (a System One model such as Jev, or a classifier), or from people who decide
  • you have, or can start collecting, thousands of logged answers with the exact inputs
  • you want the same judgments locally: no fee per decision, no network, your hardware

It does not, if

  • the teacher writes text (replies, summaries). Laya scores options; it does not generate
  • you want it to be right rather than to copy its teacher: a copy inherits the teacher's mistakes
  • you need it on tasks you did not log. A copy learns what you trained it on and little else

The example: a support-ticket router

Every step below uses one task. A helpdesk gets tickets all day, and for each one the app asks three questions:

State
The ticket's fields: the customer's plan, the channel, the text, and a short customer history (how long they have been a customer, earlier tickets).
Which team?
A choice: billing, technical, sales or account, each with its rule in words.
Is it urgent?
A noul (yes/no): is the customer blocked or losing money right now?
Is the customer angry?
A noul: it decides whether a senior agent answers first.

Swap in your own: a spam or abuse flag on each message, a score for each sales lead, triage for posts waiting for review, what an NPC in your game does next, or which of your models should answer a request. The recipe does not change; only the state, the questions and the options do.

00

Before you start

GPU
An NVIDIA card with 12 GB, such as the RTX 3080 Ti we used. bf16 and gradient checkpointing make it fit.
Python and tools
Python 3.12 and uv. PyTorch with CUDA, Transformers, FastAPI.
Base model
convaiinnovations/laya on Hugging Face, Apache-2.0. We pinned revision 1c5edc17.
Library
The public laya package on PyPI, by Convai Innovations: sequence format, the option-scoring head, batching. Our lockfile pins 0.3.4.
Data
Your teacher's logged answers (path A) or your own labelled decisions (path B), in the format of step 1.
Time
An afternoon to set up. Training time grows with the number of questions: about 77 minutes for 150,000 on our card.
shell · set up a project
uv init my-laya && cd my-laya
uv python pin 3.12
uv add "laya==0.3.4" torch transformers safetensors huggingface_hub numpy fastapi uvicorn
# the base checkpoint downloads on first use (the whole repository snapshot, about 2.3 GB), pinned in load.py

Five files make the whole pipeline: load.py, data.py, train.py, evaluate.py and serve.py. Each is printed in full below, where its step needs it.

01

Log the answers

Do this from the first day, before you know you will train. Everything later depends on it.

  1. Log the full probabilities, not the top choice. A teacher that says billing 0.58, technical 0.34 is telling you the ticket is ambiguous. The student learns that uncertainty from the soft labels. Log them unrounded: rounding puts a floor under how close any copy can get. For path B, log the label exactly as it was decided, and who or what decided it.
  2. Log the exact input, byte for byte, next to the answer: the ticket as the teacher saw it, not as it looks after three edits. Rebuilding inputs later is a liability; logging is cheap.
  3. One subject per question. If your app asks about a batch of 100 tickets in one shared state, keep only that ticket's slice in each training example, because that is how the small model will be asked. Context moves a teacher, so slice in training exactly as you will slice when serving (step 8).
  4. Point at subjects by name, never by list position. Models cannot count. Asked about tickets[37], a teacher may answer about a different ticket and you will never see it. Write `tickets.T-4812`, in backticks, in the question, and key the state by that id.
  5. Record where each example came from: the group it belongs to (day, customer, session), the moment, the model version, whether the input is exact. The group is what makes step 2 possible.
  6. Handle what people typed with care. Ticket text is the input here, so train on it only if your privacy terms and your customers allow it, and strip names, emails and card numbers first. Whatever you leave out, the student never learns to read.

One line per request, in the shape the teacher was asked (state plus named questions), with the answers next to it:

logs.jsonl · one line, formatted here for reading
{
  "id": "3f9c0a17e2b4d851",
  "state": {
    "queue": { "time": "Monday morning", "backlog": "high" },
    "tickets": {
      "T-4812": { "plan": "business", "channel": "email",
                  "text": "Charged twice and the export still fails. Third time I write.",
                  "customer": { "customer_for": "over two years",
                                "history": ["refund in March", "outage report in July"] } }
    }
  },
  "questions": {
    "team": { "type": "choice",
              "instructions": "Which team should handle `tickets.T-4812`? Judge from its `text` and `plan`.",
              "criteria": { "billing": "Charges, refunds, invoices. Comes first when money was taken wrongly.",
                            "technical": "Something in the product does not work.", "sales": "Upgrades, quotes, new seats.", "account": null } },
    "urgent": { "type": "noul", "instructions": "Is `tickets.T-4812` urgent? Yes if the customer is blocked or losing money now." },
    "angry": { "type": "noul", "instructions": "Is the customer who wrote `tickets.T-4812` angry?" }
  },
  "targets": {
    "team": { "type": "choice",
              "probabilities": { "billing": 0.58, "technical": 0.34, "sales": 0.02, "account": 0.06 } },
    "urgent": { "type": "noul", "p": 0.71 },
    "angry": { "type": "noul", "p": 0.83 }
  },
  "source": { "group": "2026-09-14", "moment": "T-4812", "model": "teacher-2.1.0", "inputs": "exact", "duplicates_merged": 1 }
}
questions / targets
Same keys. A choice target has a probability for exactly the options in criteria; a noul (yes/no) target has p, the probability of yes. For path B, write the decided option as 1 and the rest as 0.
criteria
Each option and its rule, in words. null means no description. Decision rules and boundary cases belong here ("Comes first when money was taken wrongly").
source.group
Whatever groups correlated rows: a day, a customer, a session. You split on it.
source.moment
What rows asked together share (here the ticket, whose three questions are correlated). Intervals resample whole moments.
source.inputs
exact, or how the input was rebuilt. Drop rebuilt rows you cannot check.
02

Split by whole groups

Rows that share a day or a customer are near-copies of each other: the same outage floods one morning with the same ticket, and one customer writes the same complaint five times. Split by row and the test set leaks into training; split by whole days or whole customers instead. Group by day if you want to test on a future period (and say that returning customers appear on both sides); group by customer if a few customers write most of your tickets. Then hold out the thing you will actually deploy on.

data.py · logs to training items, split by whole groups
# data.py: logged answers (JSONL) -> training items, split by whole groups
import hashlib
import json

from laya.common import QTYPES, build_sequence, render_options

from load import HEAD_MAX_LEN, MAX_LEN


def split_of(group: str, test_groups: set[str], val_share: float = 0.10) -> str:
    """Whole groups only (a day, a customer). The test groups are a real deployment you chose before training."""
    if group in test_groups:
        return "test"
    h = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
    return "val" if h < val_share else "train"


def target_of(q: dict, t: dict) -> list[float]:
    if q["type"] == "choice":
        return [float(t["probabilities"][k]) for k in q["criteria"]]  # in criteria order
    if q["type"] == "noul":
        return [1.0 - float(t["p"]), float(t["p"])]  # Laya shows a yes/no as [false, true]
    raise ValueError(f"unsupported question type {q['type']}")


def to_items(ex: dict, tok) -> tuple[list[dict], int]:
    items, dropped = [], 0
    for qid, q in ex["questions"].items():
        iq = {"t": q["type"], "ins": q["instructions"], "crit": q.get("criteria")}
        ids, markers = build_sequence(tok, ex["state"], iq, MAX_LEN, HEAD_MAX_LEN)
        if len(markers) != len(render_options(iq)):
            dropped += 1  # an option fell off the end: count it, never train or score on it
            continue
        t = target_of(q, ex["targets"][qid])
        s = sum(t)
        items.append({
            "ids": ids, "markers": markers, "qtype": QTYPES[q["type"]],
            "target": [v / s for v in t],
            "group": ex["source"]["group"], "moment": ex["source"].get("moment"),
        })
    return items, dropped


def build(path: str, tok, test_groups: set[str]) -> dict[str, list[dict]]:
    out, seen, dropped = {"train": [], "val": [], "test": []}, {"val": set(), "test": set()}, 0
    rows = [json.loads(line) for line in open(path)]
    key = lambda ex: hashlib.sha256(json.dumps([ex["state"], ex["questions"]], sort_keys=True).encode()).hexdigest()
    for ex in rows:  # first pass: what val and test contain
        s = split_of(ex["source"]["group"], test_groups)
        if s != "train":
            seen[s].add(key(ex))
    for ex in rows:
        s = split_of(ex["source"]["group"], test_groups)
        if s == "train" and (key(ex) in seen["val"] or key(ex) in seen["test"]):
            continue  # the same request in two splits is a leak: drop it from train
        items, d = to_items(ex, tok)
        out[s] += items
        dropped += d
    print({k: len(v) for k, v in out.items()}, "dropped (did not fit):", dropped)
    return out
03

The sequence Laya reads

Laya is an encoder: it reads the question, the options and the state in one sequence and scores each option at a [MASK] marker placed in front of it. The laya library builds the sequence for you (build_sequence):

the sequence format
[CLS] choice question: <instructions> [SEP] [MASK] billing: <rule> [MASK] technical: <rule> [MASK] sales: <rule> [MASK] account [SEP] <ticket as JSON> [SEP]
└──────────────────────────────────── head: at most 256 tokens ───────────────────────────────────────┘ └── the rest of 512 ──┘
load.py · the base checkpoint, the lengths, loading
# load.py: the base checkpoint, the sequence lengths, and loading
import json
import os

from huggingface_hub import snapshot_download
from laya.agent import _fix_tokenizer_config  # a private helper in laya 0.3.4; it patches older tokenizer files
from laya.common import build_model
from safetensors.torch import load_file
from transformers import AutoTokenizer

BASE_REPO = "convaiinnovations/laya"
BASE_REVISION = "1c5edc17a7acd8701df6fc341c0d179f1c62c982"  # the revision we trained from: pin yours
MAX_LEN = 512       # the whole sequence
HEAD_MAX_LEN = 256  # question + options (Laya's default, 192, can cut long option rules short)


def resolve(path: str) -> str:
    return snapshot_download(BASE_REPO, revision=BASE_REVISION) if path == "base" else path


def load(path: str = "base", device: str = "cuda"):
    d = resolve(path)
    _fix_tokenizer_config(d)
    tok = AutoTokenizer.from_pretrained(os.path.join(d, "tokenizer"))
    with open(os.path.join(d, "rl_agent_config.json")) as f:
        cfg = json.load(f)
    model = build_model(cfg, encoder_dir=os.path.join(d, "encoder"))
    model.load_state_dict(load_file(os.path.join(d, "model.safetensors")), strict=True)
    return model.to(device), tok, cfg
04

Train on the soft labels

The loss is the KL divergence from the teacher's distribution to the student's, over each question's options: cross-entropy against the soft targets minus their own entropy, so 0 means a perfect copy. With path B's one-hot labels the same loss is plain cross-entropy. All question types train together (the team choice and both yes/no questions); Laya's head knows each question's type. If one question type is rare, repeat its items so it is not drowned out.

SettingValueWhy
OptimiserAdamW, weight decay 0.01standard for fine-tuning an encoder
Learning rate3e-5 encoder, 1e-4 headthe head starts further from the task
Schedule2% warm-up, linear decay to 5%fixed in advance
Batchabout 12,288 padded tokens, length-bucketedsimilar lengths together, little padding
Lengthone epochno early stopping, no picking a checkpoint
Precision and memorybf16 autocast, gradient checkpointing, clipping at 1.0fits a 12 GB card
Seed7write it down
train.py · distillation, one epoch, one GPU
# train.py: train Laya on the soft labels (or one-hot labels), one epoch, one GPU
import json
import os
import random
import shutil
import sys

import torch
from laya.common import collate_items
from safetensors.torch import save_file

from data import build
from evaluate import fit_temperature
from load import HEAD_MAX_LEN, MAX_LEN, load, resolve


def batches(items, max_tokens, rng):
    """Length-bucketed batches of about max_tokens padded tokens, in random order."""
    order = sorted(range(len(items)), key=lambda i: (len(items[i]["ids"]), rng.random()))
    out, cur, longest = [], [], 0
    for i in order:
        n = len(items[i]["ids"])
        if cur and max(longest, n) * (len(cur) + 1) > max_tokens:
            out.append(cur)
            cur, longest = [], 0
        cur.append(i)
        longest = max(longest, n)
    if cur:
        out.append(cur)
    rng.shuffle(out)
    return out


def main(logs: str, out_dir: str, test_groups: set[str]):
    torch.manual_seed(7)
    model, tok, cfg = load("base")
    data = build(logs, tok, test_groups)
    items = data["train"]  # repeat a rare question type a few times so it is not drowned out

    model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
    model.train()
    enc = [p for n, p in model.named_parameters() if n.startswith("encoder.")]
    head = [p for n, p in model.named_parameters() if not n.startswith("encoder.")]
    opt = torch.optim.AdamW([{"params": enc, "lr": 3e-5}, {"params": head, "lr": 1e-4}], weight_decay=0.01, fused=True)
    plan = batches(items, 12288, random.Random(7))  # one epoch; fixed schedule, no early stopping
    warmup = max(1, len(plan) // 50)                # 2% warm-up, then linear decay to 5%
    sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / warmup) * max(0.05, 1 - s / len(plan)))

    for step, idx in enumerate(plan):
        b = collate_items([[items[i] for i in idx]], tok.pad_token_id)
        with torch.autocast("cuda", dtype=torch.bfloat16):
            logits, act = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
                                b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
        mask, target = b["marker_mask"].cuda(), b["target"].cuda()
        logp = torch.log_softmax(logits.float().masked_fill(~mask, -1e4), -1)
        # KL(teacher || student): cross-entropy minus the target's own entropy, so 0 means a perfect copy.
        # 0 * act keeps Laya's unused action head in the graph, as our script does.
        loss = (target * (torch.log(target.clamp_min(1e-9)) - logp) * mask).sum(-1).mean() + 0.0 * act.sum()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        sched.step()
        opt.zero_grad(set_to_none=True)
        if step % 100 == 0:
            print(f"step {step}/{len(plan)}  KL {loss.item():.4f}", flush=True)

    model.eval()
    temps = fit_temperature(model, data["val"], tok.pad_token_id)  # validation only, never test
    os.makedirs(out_dir, exist_ok=True)
    save_file({k: v.detach().to(torch.bfloat16).float().contiguous().cpu() for k, v in model.state_dict().items()},
              os.path.join(out_dir, "model.safetensors"))
    for sub in ("encoder", "tokenizer"):
        shutil.copytree(os.path.join(resolve("base"), sub), os.path.join(out_dir, sub), dirs_exist_ok=True)
    cfg.update({"model_name": "my-laya", "max_len": MAX_LEN, "head_max_len": HEAD_MAX_LEN,
                "temperature": temps, "temperature_by_options": {}})  # drop the base model's own temperatures
    with open(os.path.join(out_dir, "rl_agent_config.json"), "w") as f:
        json.dump(cfg, f, indent=2)
    print("saved", out_dir, "temperatures (choice, score, noul):", temps)


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2], set(sys.argv[3].split(",")))
shell · run it
# test_groups.txt: your held-out groups, one per line (for example the seven days of your test week)
uv run python train.py logs.jsonl checkpoints/v1 "$(paste -sd, test_groups.txt)"
sha256sum checkpoints/v1/model.safetensors   # record this before you score anything (step 6)

Validation numbers during training are for watching, never for choosing.

05

Calibrate on validation only

Fit one temperature per question type by minimising cross-entropy against the teacher on the validation split, and store it with the checkpoint. train.py calls fit_temperature at the end. Never fit anything on the test set. Calibration matters when your app acts on the probability, not only the top answer: page a senior agent when p(angry) is over 0.7, and 0.7 has to mean the same thing for the copy as for the teacher.

evaluate.py · predictions, temperatures, metrics with intervals
# evaluate.py: predictions, temperatures (validation only), metrics with intervals over clusters
import numpy as np
import torch
from laya.common import collate_items


@torch.no_grad()
def predict(model, items, pad_id, temperature=None, batch_tokens=16384):
    """Probabilities per item, in input order, batched by length."""
    model.eval()
    order = sorted(range(len(items)), key=lambda i: len(items[i]["ids"]))
    out, i = [None] * len(items), 0
    while i < len(order):
        longest = len(items[order[min(i + 255, len(order) - 1)]]["ids"])
        idx = order[i : i + min(max(1, batch_tokens // longest), 512)]
        b = collate_items([[items[j] for j in idx]], pad_id)
        with torch.autocast("cuda", dtype=torch.bfloat16):
            logits, _ = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
                              b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
        logits = logits.float()
        if temperature is not None:
            logits = logits / temperature[b["qtype"].cuda()][:, None]
        p = torch.softmax(logits, -1).cpu().numpy()
        for r, j in enumerate(idx):
            out[j] = p[r, : len(items[j]["markers"])]
        i += len(idx)
    return out


def fit_temperature(model, val_items, pad_id):
    """One temperature per question type (choice, score, noul), minimising cross-entropy against the teacher."""
    by_type = {0: [], 1: [], 2: []}
    for it, p in zip(val_items, predict(model, val_items, pad_id)):
        by_type[it["qtype"]].append((torch.log(torch.tensor(p).clamp_min(1e-9)), torch.tensor(it["target"])))
    temps = [1.0, 1.0, 1.0]
    for qt, sel in by_type.items():
        if len(sel) < 20:
            continue  # too few to fit: leave 1.0
        log_t = torch.zeros(1, requires_grad=True)
        opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)

        def closure():
            opt.zero_grad()
            loss = sum(-(t * torch.log_softmax(z / log_t.exp(), -1)).sum() for z, t in sel) / len(sel)
            loss.backward()
            return loss

        opt.step(closure)
        temps[qt] = float(log_t.detach().exp().clamp(0.2, 5.0))
    return temps


def per_item(items, preds):
    t = [np.asarray(it["target"]) for it in items]
    rows = {
        "tvd": [0.5 * np.abs(p - q).sum() for p, q in zip(preds, t)],       # distance between the two distributions
        "top1": [float(p.argmax() == q.argmax()) for p, q in zip(preds, t)],  # same top answer as the teacher
        "brier": [float(((p - q) ** 2).sum()) for p, q in zip(preds, t)],
    }
    return {k: np.asarray(v) for k, v in rows.items()}


def bootstrap(values, clusters, n=10_000, seed=0):
    """95% interval for the mean, resampling whole clusters (items from one moment are correlated)."""
    keys, inv = np.unique(np.asarray(clusters).astype(str), return_inverse=True)
    sums = np.bincount(inv, weights=values, minlength=len(keys))
    counts = np.bincount(inv, minlength=len(keys)).astype(float)
    rng = np.random.default_rng(seed)
    means = [sums[pick].sum() / counts[pick].sum() for pick in (rng.integers(0, len(keys), len(keys)) for _ in range(n))]
    return [float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5))]


def report(items, preds):
    clusters = [f"{it['group']}:{it['moment']}" for it in items]
    return {k: (float(v.mean()), bootstrap(v, clusters)) for k, v in per_item(items, preds).items()}
06

Write the pass marks before you open the test set

Write the plan, commit it, then score. After that, change it only with dated amendments, and keep the original text. Our own plan, with its amendments and results, is published as an example: the evaluation. Rules that matter:

EVALUATION.md · a skeleton to fill in and commit
# Evaluation plan: can <student> replace <teacher> for <task>?
Committed <date>, before the test split is opened. Changes below only as dated amendments.

## Systems      teacher <id> · student (checkpoint SHA-256 recorded before scoring) · floor · untouched base model
## Data         train / val / test by whole <days / customers>; test = <deployment period>; earlier looks at test: <none, or what>
## Metrics      top-1 agreement, TVD, MAE, Brier: exact formulas; 95% intervals by bootstrap over <clusters>
## Pass marks   A1 TVD <= __ · A2 top-1 >= __ · behaviours the product depends on, each with a tolerance
## Beyond the teacher   written rules · invariance to option order · human-labelled sets
## Whole system run the app on the student (shadow or A/B); a period unseen by every system; criteria C1..Cn
## Verdicts     what counts as a replacement, and what does not
## Threats to validity
## Amendments   (dated, appended, never edited)
07

Evaluate at three levels

Agreement with the teacher cannot tell you whether the copy is better or worse than the teacher, and agreement per question cannot tell you whether a system built on those answers behaves the same. Measure all three.

Level 1 · fidelity on the held-out period

Against the teacher (or your labels), on the test week, next to the floor and untouched Laya:

Level 2 · checks that do not come from the teacher

Build them by code from your own written rules, on test tickets only, and record the probes' hashes before any system is asked:

Level 3 · the whole system

Run your product on the student and compare it with the teacher and a cheap baseline (keyword rules, say), on a period none of them has seen. For the router, a shadow deployment works: for a week or two the teacher still routes every ticket and the student answers beside it, logged but unused. Then compare routing accuracy (how often a ticket was reassigned from the team each system chose), urgent flags against the tickets your agents actually escalated, and, if you then route a slice of traffic to the student, time to resolution.

08

Serve it

Answer in the teacher's own wire shape, so your app switches with a URL. Four things matter more than speed:

serve.py · FastAPI, per-subject slices, exact cache, bearer auth
# serve.py: the teacher's wire shape, answered by your checkpoint on the GPU
import hashlib
import hmac
import json
import os
import re
from collections import OrderedDict

import torch
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from laya.common import QTYPES, build_sequence, collate_items, render_options

from load import HEAD_MAX_LEN, MAX_LEN, load

MODEL_DIR = os.environ.get("MODEL_DIR", "checkpoints/v1")
SECRET = os.environ.get("LAYA_SECRET")
BATCH_TOKENS = 24576
PATH = re.compile(r"`([A-Za-z_]\w*)\.([^`.\s]+)")  # `collection.key` in a question's instructions, e.g. `tickets.T-4812`

model, tok, cfg = load(MODEL_DIR)
model.eval()
temps = torch.tensor(cfg.get("temperature", [1.0, 1.0, 1.0]), device="cuda")
cache: OrderedDict[str, list[float]] = OrderedDict()  # exact: same (slice, question) -> same answer
app = FastAPI()


def slice_state(state, instructions: str):
    """Cut each collection a question names down to that one entry: the same slicing as the training data."""
    if not isinstance(state, dict):
        return state
    named: dict[str, set[str]] = {}
    for coll, key in PATH.findall(instructions):
        if isinstance(state.get(coll), dict) and key in state[coll]:
            named.setdefault(coll, set()).add(key)
    return {k: ({n: v[n] for n in v if n in named[k]} if k in named else v) for k, v in state.items()}


@torch.no_grad()
def run(batch: list[dict]) -> list[list[float]]:
    b = collate_items([batch], tok.pad_token_id)
    with torch.autocast("cuda", dtype=torch.bfloat16):
        logits, _ = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
                          b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
    p = torch.softmax(logits.float() / temps[b["qtype"].cuda()][:, None], -1).cpu()
    return [p[r, : len(it["markers"])].tolist() for r, it in enumerate(batch)]


@app.post("/v1/systemone")
async def systemone(req: Request):
    if SECRET and not hmac.compare_digest(req.headers.get("authorization", "").encode(), f"Bearer {SECRET}".encode()):
        raise HTTPException(401, "unauthorized")
    body = await req.json()
    state, questions = body.get("state"), body.get("questions")
    if not isinstance(questions, dict) or not 0 < len(questions) <= 1000:
        raise HTTPException(400, "questions must be an object with 1 to 1000 entries")
    asked, todo = {}, []
    for qid, q in questions.items():
        crit = q.get("criteria")
        if q.get("type") == "choice" and isinstance(crit, list):
            crit = {c: None for c in crit}
        if q.get("type") not in ("choice", "noul") or (q["type"] == "choice" and not crit):
            raise HTTPException(400, f"{qid}: a choice with criteria, or a noul")
        iq = {"t": q["type"], "ins": str(q["instructions"]), "crit": crit}
        sliced = slice_state(state, iq["ins"])
        key = hashlib.sha256(json.dumps([sliced, iq]).encode()).hexdigest()
        if key not in cache:
            ids, markers = build_sequence(tok, sliced, iq, MAX_LEN, HEAD_MAX_LEN)
            if len(markers) != len(render_options(iq)):
                raise HTTPException(400, f"{qid}: the options do not fit in {HEAD_MAX_LEN} tokens")
            todo.append((key, {"ids": ids, "markers": markers, "qtype": QTYPES[iq["t"]]}))
        asked[qid] = (key, iq)
    todo.sort(key=lambda kv: len(kv[1]["ids"]))  # shortest first, batches of about BATCH_TOKENS padded tokens
    batch: list = []
    for key, it in todo + [(None, None)]:
        if batch and (it is None or len(it["ids"]) * (len(batch) + 1) > BATCH_TOKENS):
            for (k, _), p in zip(batch, run([x for _, x in batch])):
                cache[k] = p
            batch = []
        if it is not None:
            batch.append((key, it))
    answers = {}
    for qid, (key, iq) in asked.items():
        p = cache[key]
        cache.move_to_end(key)
        if iq["t"] == "choice":
            opts = list(iq["crit"])
            answers[qid] = {"type": "choice", "choice": opts[p.index(max(p))], "probabilities": dict(zip(opts, p))}
        else:
            answers[qid] = {"type": "noul", "noul": p[1]}
    while len(cache) > 200_000:
        cache.popitem(last=False)
    return {"model": cfg.get("model_name", "my-laya"), "answers": answers}


if __name__ == "__main__":
    host = os.environ.get("HOST", "127.0.0.1")
    if host not in ("127.0.0.1", "localhost") and not SECRET:
        raise SystemExit("refusing to listen beyond localhost without LAYA_SECRET")
    uvicorn.run(app, host=host, port=int(os.environ.get("PORT", "8765")))
shell · serve and ask
MODEL_DIR=checkpoints/v1 uv run python serve.py                     # localhost only, no secret needed
LAYA_SECRET=change-me HOST=0.0.0.0 MODEL_DIR=checkpoints/v1 uv run python serve.py

curl -s localhost:8765/v1/systemone -H 'content-type: application/json' -d '{
  "state": {"tickets": {"T-4812": {"plan": "business", "text": "Charged twice this month."}}},
  "questions": {
    "team": {"type": "choice", "instructions": "Which team should handle `tickets.T-4812`?",
             "criteria": {"billing": "Comes first when money was taken wrongly.", "technical": null, "sales": null, "account": null}},
    "urgent": {"type": "noul", "instructions": "Is `tickets.T-4812` urgent?"}}}'

This server runs the GPU inside the request handler, so it answers one request at a time, which is what one GPU does anyway. Ours adds a lock, gzip, a health route with live GPU readings, and 1,000-question limits.

09

Mistakes to avoid

We made every one of these on our own project.

  1. Pointing at subjects by list position. A teacher that cannot count labels the wrong ticket, silently. Key subjects by name everywhere.
  2. A time limit sized for an API skips decisions on a GPU. Size it from the serial GPU time of your largest burst and watch the slowest request, not the median. Then measure with the limit you actually deploy: a looser limit in testing makes the copy look better than it is.
  3. The evaluator does not yet implement the plan. Review the scoring code line by line before the test set is opened.
  4. Tasks it never trained on are far worse. The default for an unmeasured request type is "not the copy": keep sending it to the teacher, or remove it.
  5. Average agreement hides a behaviour that overshoots. Test the specific behaviours your product depends on, with tolerances written first.
  6. A criterion without enough cases fails for the teacher too. Check power, or run the teacher first.
  7. A bug in your inputs is copied from teacher to student. If your app sends the wrong plan name, both models learn from it. Audit the inputs, not only the labels.
  8. The teacher is not deterministic. When you test invariance, also send identical requests several times and report that as the noise floor.
  9. "In-sample" hides in whole-system tests. Check which periods the student has seen before choosing the one you compare on.
  10. Documents drift behind result files. Generate the numbers people read from the committed results.

What it cost us

For scale, our own numbers. Your costs depend on how many questions you log and train on.