Notes from the bench

System One models vs LLMs: fast, typed decisions for automation

I've been running OpenJev, an open reimplementation of TypeSafe's Jev, on my own hardware. This is my case for System One models in enterprise automation, in three parts. Why: most automation decisions are fast judgements, and we've been paying for slow reasoning. What: Jev, the open alternatives, and three use cases (evals, personal data in logs and model routing). How: running it and building each use case, with code you can copy.

The use cases at a glance

Use caseWhy: what hurts todayWhat: the System One versionHow
EvalsLLM judges are slow and costly, so they run on a sample, and their scores are made upA four-question rubric with a probability on each answer. Unsure cases go to a person, and every answer gets graded. What →Rubric, CI gate, LangGraph guard →
PII in logsRegexes miss personal data in free text, and scanning with an LLM is unaffordable and leaks the dataFour reads per line, then redact, quarantine or forward, all on your own network. What →Detector and streaming filter →
Model routingEverything goes to one big model, or an LLM decides which LLM to useOne read picks the cheapest model that can cope, and a second checks the answer and escalates. What →LangGraph router, Claude by tier →

WhyMost automation is System 1 work

Most of the decisions in automation are fast judgements, and we've been paying for slow reasoning to make them. This part covers the idea, the decisions it applies to, what it's worth and what it changes for the business.

System 1 vs System 2, in summary

The names come from Daniel Kahneman's Thinking, Fast and Slow. System 1 is the fast, automatic judgement you make without deliberating: this email looks like spam, that driver is about to pull out. System 2 is slow and effortful. It's the reasoning you do when you work through a problem step by step.

That maps onto the models quite neatly:

System 1 model Jev, OpenJev

  • State in, typed answers out: yes/no, pick one, score on a scale
  • One read returns a probability for every option
  • Tens to hundreds of milliseconds
  • Can't go off-schema, so there's nothing to parse
  • Charged on input tokens, since there's almost no output

System 2 model Standard LLMs

  • Writes text token by token, often after a long reasoning trace
  • Plans, writes code, explains, uses tools
  • Seconds to minutes
  • Structured output is a format you ask for, so it has to be validated
  • Output tokens cost several times what input tokens do

The point isn't that one replaces the other. Most of what automation does all day is System 1 work: route this, gate that, flag the other. We've been paying System 2 prices and waiting System 2 latencies for it because an LLM was the only fuzzy decision-maker to hand.

The decisions enterprises already make

The out-of-the-box examples are deliberately simple. The OpenJev README triages a support message ("is it urgent, which team, how upset are they"). TypeSafe shows churn classification, a bot playing Doom and a Wikipedia-racing agent. They show the three primitives well, but they aren't what keeps a platform team up at night. I pick three of them for the What and How parts.

Put an automation layer on top of CI and CD and the decisions look like this. Every one of them is currently either a brittle rule, an LLM call we wait several seconds for, or a person:

StageDecisionPrimitiveWhat goes in the state
PR openedHow risky is this change?score low → criticalDiff summary, files touched, ownership
PR openedWhich team should review it?choicePaths, CODEOWNERS, description
PR openedIs it a breaking API change? Does it touch PII or regulated scope?noul ×2Diff of schemas and contracts
Dependency botSafe to auto-merge this bump?noulChangelog, semver jump, test results
Build failedFlaky test, infrastructure, real regression or bad test?choiceFailing test log, recent history
Security scanIs this finding exploitable here, or a false positive?noul + scoreFinding, reachable code path
Change managementStandard, normal or emergency change (ITIL)?choiceChange ticket, risk score, window
DeployGo / no-go on the canary?noul with a thresholdCanary metrics summary, error samples
Post-deployRoll back now?noulAlerts, SLO burn, log excerpts
IncidentSeverity, and which runbook?score + choiceAlert payload, affected services
ReleaseFeature, fix or chore for the release notes?choiceCommit message, PR title

The shape is the same as the demo: unstructured state, a handful of typed questions. What changes is the cost of being wrong and the volume. A misrouted support ticket is annoying. A wrongly approved deploy is an incident. That changes how you use the answer more than which model you use:

What it's worth

Here's a worked example that's easy to adjust. Take an automation layer making 10,000 decisions a day across the pipeline stages above. Each decision sends about 2,000 tokens of state: a diff summary, a log excerpt, some metadata. An LLM answering the same questions as JSON writes about 300 tokens, and that's before any reasoning trace, which can easily be ten times that.

Change the inputs and the chart updates. It uses a log scale, because the gap is several orders of magnitude.

OptionPrice basisPer monthPer 1,000 decisions

Claude list prices per million tokens (input / output): Fable 5.1 $10 / $50, Opus 5 $5 / $25, Sonnet 5 $2 / $10, Haiku 4.5 $1 / $5, from Anthropic's pricing, excluding caching and batch discounts. Jev is $0.042 per million input tokens with free output, per TypeSafe's launch post. The self-hosted row is an assumption: a rented 48–96 GB GPU at the hourly rate you enter, running all month, with one extra GPU per ~50 decisions/second at 5× peak-to-average. Enter your own rate. Codiv's free tier and a Mac you already own are effectively $0 at this volume, so they're left off the chart.

What the numbers say at the default volume:

The business case: process automation

Business process automation has always hit the same wall. The process diagram is full of decision diamonds, and each diamond needs either a hard rule (which breaks on the first messy input) or a person (who becomes the bottleneck). LLMs made the fuzzy diamonds possible, but at a latency and price that pushed them into batch jobs and pilots.

System One models change the economics of the diamond itself:

The catch is governance. A process that decides in 100 ms can make a lot of bad decisions very quickly. You need evaluation sets per decision, calibration checks, drift monitoring and a named owner for each threshold. That's the same discipline we'd apply to any model in production, but people will be tempted to skip it because each call feels so cheap.

WhatJev, the alternatives, and three use cases

What Jev is and how it differs from an LLM, the open alternatives, where a System One read sits in an agent graph, and the three use cases I'd start with.

Jev vs standard LLMs

TypeSafe launched Jev as "the first System One model". You send it a state (any unstructured text, and with OpenJev images too) plus a set of typed questions. It returns each answer with a probability and a confidence, and it generates no prose at all. TypeSafe says it trains Jev with Reinforcement Learning for Calibrated Decisions (RLCD), so that the probabilities mean what they say.

Standard LLMJev (System One)
OutputA string, possibly JSON if you ask nicely or use constrained decodingTyped values: noul (P(yes)), choice, score
Off-schema answersPossible. You validate, retry and repairImpossible by construction, because only the label tokens are scored
ConfidenceSelf-reported if you ask, and usually overconfidentRead from the model's own distribution on every answer
Many questionsSequential, and earlier answers bias later onesAnswered in parallel and in isolation against the same state
Latency (vendor figures)3–329 s for frontier models on TypeSafe's workflow evals70–500 ms end to end
Price (list)$1–$10 /MTok in, $5–$50 /MTok out for Claude today$0.042 /MTok in, and output is free
Can it reason, plan, write?Yes. That's what it's forNo. It decides

A note on "zero hallucinations". TypeSafe's launch post claims a 0% hallucination rate. That means zero type errors: you can't get back a label that doesn't exist. The model can still pick the wrong label. The useful difference is that it tells you how sure it is, so you can put a threshold in front of the answer. Its workflow evals quote 193× faster and 444× cheaper, and TypeSafe itself says those numbers are at the high end of what to expect. I'd treat them as vendor numbers and measure my own.

Jev vs the open variants

Jev is closed weights and API only. Within days of the launch, people started rebuilding the idea in the open. The rebuilds fall into four groups:

  1. Wire-compatible servers on a big open model. OpenJev, which I've forked here, is one. It runs Google/NVIDIA's DiffusionGemma, a diffusion language model that denoises a whole canvas of tokens at once. OpenJev leaves one masked slot per question and reads the probability distribution for every slot in a single pass. It speaks Jev's API, so TypeSafe's own SDK works against it unchanged.
  2. Logit readers on a frozen small model. These take an ordinary autoregressive model (Qwen 4B, for example) and read the next-token probabilities of the label tokens. They're cheap and they run on a laptop, but the probabilities are relative, not calibrated.
  3. Small models fine-tuned for decisions. These are trained or given a decision head so they answer in Jev's shape. Some are small enough to run on a CPU.
  4. Structured-output libraries. Outlines, Instructor and DSPy put a typed interface on any LLM. You still get System 2 cost and latency, just with better manners.
ProjectApproachBase modelWeightsRuns onCalibrated?
JevTrained System One model (RLCD)UndisclosedClosedTypeSafe APIClaimed, by design
OpenJevDiffusion read-out, Jev wire APIDiffusionGemma 26B-A4BApache-2.024 GB NVIDIA GPU or 16 GB MacModel's own distribution, not RLCD-trained
openjev-sglangJev API on SGLangQwen3.6-35B-A3BOpenB200-class GPUSays it isn't
SemIf/openjevFrozen logit readerQwen3.5-4BMITRTX 3090-class GPURelative probabilities
mini-jevFrozen logit readerQwen3-4BMIT~8.5 GB, Mac or CUDASays it isn't
kevFine-tuned, Jev schemaQwen3.5 0.8B / 4B / 9BOpenSelf-hosted GPUUnstated
LayaDecision head on an encoderModernBERT-large (421M)Apache-2.0T4 (~35 ms) or CPUECE 0.081 after temperature fitting
NanoJevTrained from scratchOwn, 0.6BMITCUDA GPUUnstated

The rows other than Jev and OpenJev are summarised from the community list at systemonemodels.org. I haven't run them myself.

My take: nobody outside TypeSafe knows how to do RLCD, because it hasn't been described in enough detail to copy. So every open variant gives you the shape of Jev (typed answers, parallel questions, a probability on everything). None of them gives you Jev's calibration guarantee for free. OpenJev is the most complete of the lot. It has the same wire API, a strong 26B model underneath, images, and an OpenAI-compatible chat endpoint from the same weights. Its confidence comes from a real distribution rather than a number the model makes up. Even so, if you're going to act on thresholds you should check its calibration on your own data. More on that under caveats.

Where it fits in agent graphs

Most serious agent systems I see now are graphs: LangGraph, Google's ADK or a home-grown state machine. The nodes do the work and the edges decide what happens next. Today both jobs usually go to an LLM, which means every edge in the graph costs seconds.

My view is simple: System 2 models belong in the nodes, and System 1 models belong on the edges. These are the places a System One read fits:

A CI/CD agent graph with System 1 reads on the edges A pipeline event goes to a System 1 triage read. Confident low-risk changes are auto-approved, high-risk changes go to a System 2 review agent, and low-confidence items go to a human. The agent's output is checked by a System 1 verify read, which loops back if not done, then a System 1 deploy gate, canary rollout, and a System 1 rollback read. Pipeline event PR opened · build finished System 1 · triage read (~100 ms) risk score · owning team breaking change? · touches PII? low risk, P ≥ 0.9 high risk low confidence Auto-approve log the distribution System 2 agent review · explain · fix Human reviewer queue System 1 · verify read does the fix address the finding? is the agent done? no yes System 1 · deploy gate go / no-go on canary, P ≥ 0.99 Canary → progressive rollout metrics feed the next read System 1 · rollback read every minute, on live signals uncertain page on-call
A CI/CD graph. The orange reads are System 1 and cost milliseconds. The blue node is the one place a System 2 model spends real time. Confidence decides the path, and uncertainty goes to a person.

Two things follow from this. First, the graph gets faster and cheaper in proportion to how many edges it has, which is usually more than the number of nodes. Second, the control flow becomes inspectable. Each branch was taken because a probability crossed a threshold you set, not because an LLM happened to write the word "approve".

OpenJev also serves an OpenAI-compatible /v1/chat/completions from the same weights, so a small graph can run both its System 1 edges and its lighter System 2 nodes on one local model. I'd still use a frontier model for the hard node, like the review-and-fix agent above.

The three use cases below are graphs of the same shape.

Three use cases to start with

The CI/CD table in the Why part is the broad picture. These are the three problems I'd pick first in most organisations, because each one is currently either expensive, leaky or done on a sample. For each: why today's approach hurts, what changes with a System One read, and the graph it becomes. The code for each is in the How part.

Evals: a judge that knows when it's unsure

Why: how it's done today. Most teams grade LLM output with another LLM. The judge reads the case, writes a paragraph of reasoning and gives a score out of five. It works, but it takes seconds per case and costs real money, so it runs nightly on a sample. The criteria bleed into each other: an answer that reads well gets marked as faithful too. The score is an integer the model made up, and every so often the JSON doesn't parse.

What changes: with a System One judge the rubric becomes a set of typed questions, answered in parallel and in isolation against the same case.

An eval judge used as an inline guard A staff question and retrieved policy go to a System 2 assistant, which drafts an answer. A System 1 judge reads four criteria in parallel. If all pass, the answer is delivered. If it fails the first time, a System 2 regenerate step rewrites it and the judge reads again. Unsure results, or a second failure, go to a human. Every judgement is logged and feeds the offline eval report. Staff question + retrieved policy text System 2 · policy assistant drafts the answer System 1 · judge faithful? answers? personal data? quality 0–4 · in parallel Regenerate System 2, told what failed fail retry all P ≥ 0.8 unsure, or failed twice Deliver to the user Human review the unsure queue Verdict log distributions → offline eval report
Demo: examples/evals.py; worked: examples/worked/evals/. Part 1 grades five answers from an internal policy assistant. On the real model the good answer passes, the made-up taxi policy fails (faithful 0.00) and the answer that leaks a colleague's email fails (personal data 1.00). The unnecessary refusal goes to a person: it scores 0.06 for answering the question, but the judge can't decide whether a refusal is "faithful" (0.77). The hedged DPIA answer passes. Part 2 runs the same judge as this guard. On the worked version's eight labelled cases, the judge agreed with the human label every time it decided, and sent one case to a person.

How to build it →

Personal data in logs

Why: how it's done today. A DLP rule set and a pile of regexes. They're good at the structured cases: email addresses, card numbers that pass a Luhn check, cloud keys. They miss the ones that turn into incidents: a customer's name and address pasted into a support note, a health condition in a claims comment, a search query that says more than it should. Scanning every line with an LLM is unaffordable, and it means shipping raw logs to a third party.

What changes: with a System One read every line gets four questions that match the categories your privacy policy already uses.

A log pipeline that removes personal data before it leaves the network Inside the network, application logs pass a regex scan for emails, cards, IP addresses and keys, then a System 1 read for person, special category, secret and card data. Clean lines are forwarded. Lines with a regex hit or high probability are redacted and then forwarded. Unsure lines are quarantined for privacy review. Only forwarded lines reach the log platform outside the network. your network · OpenJev on your own GPU Application & service logs free-text fields included Regex scan email · card (Luhn) · IP · cloud keys System 1 · PII read (~100 ms) person? special category (Art. 9)? secret? card or bank details? clean hit, or P ≥ 0.8 0.3–0.8 Forward unchanged Redact spans or field Quarantine privacy review Log platform / SIEM SaaS is fine now
Demo: examples/pii_in_logs.py; worked: examples/worked/pii/. Eight log lines: an email and IP in a failed login, a customer's name and address in a support note, a health condition in a claims note, a card number, an AWS secret, a medical search query, and two clean lines. Regex alone catches three of the six that carry personal data or secrets. On the real model the read caught the other three, each at P ≥ 0.99, and let both clean lines through.

How to build it →

Model routing for log events

Why: how it's done today. Either everything goes to one model, usually a big one because nobody wants to be blamed for the miss, or a static rule maps severity to model, or an LLM decides which LLM to call. That last one costs seconds and money per event before any work gets done.

What changes: with a System One router one read decides whether an event needs a model at all and how much model it needs. A second read checks the answer and moves the event up a tier if it falls short.

Routing log events to the cheapest capable model Log and alert events go to a System 1 route read. Non-actionable events are dropped with no model called. The rest go to Haiku, Sonnet, Opus or Fable by complexity. A System 1 check read then asks whether the analysis names a cause and next step. If yes, done. If not, the event escalates one tier and is analysed again. Log & alert stream 100k events a day System 1 · route read (~100 ms) actionable? complexity 0–4? needs code? low confidence → err one tier up Drop no model Haiku routine Sonnet one service Opus root cause Fable security System 1 · check read names a cause and a next step? yes Done no: escalate one tier
Demo: examples/model_routing.py; worked: examples/worked/routing/. Six events, routed by the real model: a healthcheck (dropped, no model called), a disk warning (Haiku), a Java stack trace (Sonnet, because it needs code), an OOM-killed cron job (Sonnet), a cross-service latency incident (Opus) and a suspected admin account takeover (Fable). When I fed the check read a deliberately vague answer for the latency incident, it scored it 0.33 and escalated it to Fable.

This is where the money is. The mix of events is my assumption, but the prices are list prices. At 100,000 events a day, with 60% needing no model, 30% Haiku, 7% Sonnet, 2.5% Opus and 0.5% Fable, routing costs about $210 a day against $2,850 for sending everything to Opus 5. That's roughly 13× cheaper, or around $79k a month. The System One reads themselves come to about $1.50 a day at Jev's price. The less obvious win is that the security incident goes to the strongest model rather than whatever the default happens to be.

How to build it →

HowRun it, then build the use cases

Running the model, a one-off setup, then each use case step by step. Every code block has a copy button.

The code comes at two levels. examples/ has a quick demo of each use case that uses only the standard library and runs offline with --mock. examples/worked/ has the implementations shown here: TypeSafe's own SDK for the System One reads, LangGraph for the graphs and the Anthropic SDK for the Claude calls. The code blocks are copied from the repo by a script, and a test fails if the two drift apart. The repo's tests run every worked example end to end against a fake System One server and a fake Claude. That proves the plumbing, not the model's accuracy. I also ran every example on this page against a real OpenJev (the MLX backend on an M5 Max, 22 September 2026), and the numbers quoted in the What part come from that run. The Claude calls in the guard, the router and the answer generator are the exception: they were exercised with a stand-in, not a real API key. Build a labelled set for each decision before you trust a threshold.

Run it locally, and how big it is

This is where the open variants earn their keep. Jev is a hosted API, but OpenJev fits on a single workstation GPU or a decent Mac.

The model is DiffusionGemma 26B-A4B, a diffusion version of Gemma 4 26B released by NVIDIA under Apache-2.0. It's a mixture of experts with 25.2B parameters in total, of which 3.8B are active per token. It needs the memory of a 25B model but does the compute of a ~4B one, and that's why it's quick. It has a 262K-token context and takes text, images and video as input.

BuildWhere it runsDownload / memoryLatency (3 questions)
NVFP4 (4-bit)NVIDIA GPU via vLLM, in Docker~18 GB, 24 GB+ GPU94 ms p50 (RTX PRO 6000)
MLX 4-bitApple silicon, in-process~16 GB free memory0.2–0.4 s (M3 Ultra), 0.39 s (M4 Max)
MLX 8-bitApple silicon≈ 27 GB*not measured
MLX bf16Apple silicon, big memory≈ 50 GB*not measured

* These are my own estimates from the parameter count (25.2B × 1 or 2 bytes, plus overhead), not published figures. The other numbers come from the OpenJev README.

On a Mac it really is two commands:

terminal
pip install -e '.[mlx]'
OPENJEV_BACKEND=mlx python -m openjev     # http://127.0.0.1:8080

On an NVIDIA box, docker compose up -d pulls a prebuilt image with the patched vLLM. Either way the first start downloads the weights into ~/.cache/huggingface, and after that nothing leaves the machine. For regulated data that matters as much as the price does.

Minimum spec for my demo

My own demo is a small, dependency-free Python project. It has four scripts, one for each primitive plus a triage pipeline that combines all three, and a script that starts OpenJev on a Mac with the 4-bit MLX weights. This is what you need to run it against the real model:

MinimumComfortable
MachineApple silicon Mac (any M-series)M3 Max / M4 Max or better, which gives 0.2–0.4 s per request
Unified memory32 GB48 GB or more, so the rest of your tools stay open
macOS14 Sonoma, the oldest MLX supportsCurrent release
Free disk~20 GB: 16.6 GB of weights plus ~0.6 GB of Python environment40 GB+ if you want to try the 8-bit build
Softwaregit, uv (it fetches Python 3.12)
NetworkOne 16.6 GB download on first run, then fully offline

Why 32 GB and not 16 or 24? The 4-bit weights alone are 16.6 GB, and they have to sit in the part of unified memory the GPU is allowed to use. By default macOS gives the GPU roughly two-thirds to three-quarters of RAM. That's about 16 GB on a 24 GB machine, which is just short, and a 16 GB Mac has no chance at all. On 24 GB you can raise the limit with sudo sysctl iogpu.wired_limit_mb=…, but you'll be running with nothing to spare. Treat 32 GB as the real floor.

There are two other ways to run it:

Throughput is the other half. On one RTX PRO 6000 using 38% of the card, OpenJev did 57 decisions per second at 64 concurrent requests, with p95 latency around 1.1 s. The Mac backend handles one read at a time, about 4 req/s under load. That's fine for a developer loop or a small team, but not for serving production.

If you don't want to run anything, Codiv hosts OpenJev with a free allowance of 100M System One tokens, and the API is the same.

Set up once

One client serves all three examples. TypeSafe's SDK talks to OpenJev unchanged, so moving between Jev, hosted OpenJev and OpenJev on your own GPU is one environment variable, not a code change.

terminal
git clone https://github.com/SteFletcher/openjev && cd openjev
pip install -e '.[examples]'          # typesafe-sdk, langgraph, anthropic

# pick a System One backend
export TYPESAFE_BASE_URL=http://127.0.0.1:8080       # OpenJev on this machine
# export TYPESAFE_BASE_URL=https://api.codiv.ai      # hosted OpenJev
# export TYPESAFE_BASE_URL=https://api.typesafe.ai   # Jev
export TYPESAFE_API_KEY=...          # any value for a local server without auth
export ANTHROPIC_API_KEY=...         # for the Claude (System 2) nodes
# TYPESAFE_BASE_URL  https://api.typesafe.ai  -> Jev
#                    https://api.codiv.ai     -> hosted OpenJev
#                    http://127.0.0.1:8080    -> OpenJev on your own hardware (the default)
# TYPESAFE_API_KEY   the SDK requires one; any value works for a local server without auth
def client() -> TypeSafeClient:
    return TypeSafeClient(
        base_url=os.environ.get("TYPESAFE_BASE_URL", "http://127.0.0.1:8080"),
        api_key=os.environ.get("TYPESAFE_API_KEY", "local"),
        model=os.environ.get("TYPESAFE_MODEL", "jev-latest"),  # OpenJev accepts Jev's name as an alias
    )

Build the eval judge

The code is in examples/worked/evals/. What it does and why is in the What part.

1Write the rubric as questions. Each criterion is a noul or a score in Jev's wire format, so it works with any client.

RUBRIC = {
    "faithful": {
        "type": "noul",
        "instructions": "Every factual claim in the answer is supported by the context.",
        "criteria": {"true": "all claims appear in or follow directly from the context",
                     "false": "the answer adds, changes or contradicts facts in the context"},
    },
    "answers_question": {
        "type": "noul",
        "instructions": "The answer directly addresses the user's question.",
    },
    "personal_data": {
        "type": "noul",
        "instructions": "The answer discloses personal data about an identifiable individual, "
                        "such as a name together with an email address or phone number.",
    },
    "quality": {
        "type": "score",
        "instructions": "Overall quality as a reply from an internal policy assistant.",
        "criteria": ["unusable", "poor", "acceptable", "good", "excellent"],
    },
}

2Turn probabilities into a verdict. The thresholds live in your code, where they can be reviewed, not in the model. The reasons go back to the generator if it gets a second try.

PASS_AT, FAIL_AT = 0.8, 0.2   # P >= 0.8 is a yes, P <= 0.2 a no, anything between goes to a person


@dataclass(frozen=True)
class Verdict:
    outcome: str                  # "pass", "fail" or "human"
    faithful: float
    answers_question: float
    personal_data: float
    quality: float                # expected level, 0-4
    reasons: tuple[str, ...]      # fed back to the generator on a retry

    @property
    def p_good(self):
        """The weakest link, as one number for calibration checks."""
        return min(self.faithful, self.answers_question, 1 - self.personal_data)


def judge(s1, question, context, answer):
    r = s1.system_one({"question": question, "context": context, "answer": answer}, RUBRIC)
    n = {k: a.noul for k, a in r.nouls.items()}
    checks = {  # every check is phrased so that 1.0 is good
        "not supported by the context": n["faithful"],
        "does not answer the question": n["answers_question"],
        "discloses personal data": 1 - n["personal_data"],
    }
    if all(p >= PASS_AT for p in checks.values()):
        outcome = "pass"
    elif any(FAIL_AT < p < PASS_AT for p in checks.values()):
        outcome = "human"
    else:
        outcome = "fail"
    reasons = tuple(f"{why} (P={p:.2f})" for why, p in checks.items() if p < PASS_AT)
    return Verdict(outcome, n["faithful"], n["answers_question"], n["personal_data"],
                   r.scores["quality"].score, reasons)

3Grade the eval set in parallel, and gate on it. Agreement with the human labels and the Brier score tell you whether the judge can be trusted before you let it block anything.

def run(s1, cases, workers=8):
    with ThreadPoolExecutor(workers) as pool:   # reads are independent, so grade them in parallel
        verdicts = list(pool.map(lambda c: judge(s1, c["question"], c["context"], c["answer"]), cases))
    rows = [{"id": c["id"], "label": c.get("label"), **asdict(v)} for c, v in zip(cases, verdicts)]

    decided = [r for r in rows if r["outcome"] != "human" and r["label"]]
    labelled = [(v.p_good, r["label"] == "pass") for v, r in zip(verdicts, rows) if r["label"]]
    return {
        "cases": len(rows),
        "pass_rate": sum(r["outcome"] == "pass" for r in rows) / len(rows),
        "to_human": [r["id"] for r in rows if r["outcome"] == "human"],
        # how often the judge agrees with a person, on the cases it was willing to decide
        "agreement": sum(r["outcome"] == r["label"] for r in decided) / len(decided) if decided else None,
        # Brier score of the judge's P(good) against the labels: 0 is perfect, 0.25 is a coin toss
        "brier": sum((p - y) ** 2 for p, y in labelled) / len(labelled) if labelled else None,
        "rows": rows,
    }


def gate(report, min_pass, min_agreement):
    failures = []
    if report["pass_rate"] < min_pass:
        failures.append(f"pass rate {report['pass_rate']:.2f} < {min_pass}")
    if report["agreement"] is not None and report["agreement"] < min_agreement:
        failures.append(f"judge agreement {report['agreement']:.2f} < {min_agreement}")
    return failures

4Put the gate in CI. First check that the judge still agrees with the human labels. Then answer the question set with the changed prompt and grade the answers. A change that drops the pass rate fails the pull request.

name: eval-gate
on:
  pull_request:
    paths: ["examples/worked/**"]   # in your repo: your prompts, assistant code and eval sets

jobs:
  eval:
    runs-on: ubuntu-latest
    env:
      TYPESAFE_BASE_URL: ${{ vars.TYPESAFE_BASE_URL }}
      TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -e '.[examples]'
      - name: Check the judge still agrees with the human labels
        run: python examples/worked/evals/run_eval.py examples/worked/evals/dataset.jsonl --min-agreement 0.85
      - name: Answer the eval questions with the changed prompt
        run: python examples/worked/evals/generate_answers.py examples/worked/evals/questions.jsonl > answers.jsonl
      - name: Judge the answers, and fail the build below the bar
        run: python examples/worked/evals/run_eval.py answers.jsonl --out eval-report.json --min-pass 0.9
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: eval-report, path: eval-report.json }

5Use the same judge inline. The guard in the diagram above, as a LangGraph StateGraph. The System One read is the conditional edge.

class GuardState(TypedDict, total=False):
    question: str
    context: str
    answer: str
    attempts: int
    verdict: object
    outcome: str


def build_guard(s1, claude):
    def prompt(st, feedback=""):
        return f"Policy:\n{st['context']}\n\nQuestion: {st['question']}{feedback}"

    def generate(st):
        return {"answer": claude("sonnet", SYSTEM, prompt(st)).text, "attempts": 1}

    def judge_node(st):
        return {"verdict": judge(s1, st["question"], st["context"], st["answer"])}

    def regenerate(st):
        why = "; ".join(st["verdict"].reasons)
        feedback = f"\n\nA reviewer rejected this draft ({why}):\n{st['answer']}\nWrite a corrected answer."
        return {"answer": claude("sonnet", SYSTEM, prompt(st, feedback)).text, "attempts": st["attempts"] + 1}

    def after_judge(st):
        outcome = st["verdict"].outcome
        if outcome == "pass":
            return "deliver"
        if outcome == "fail" and st["attempts"] < MAX_ATTEMPTS:
            return "regenerate"
        return "human"

    g = StateGraph(GuardState)
    g.add_node("generate", generate)
    g.add_node("judge", judge_node)
    g.add_node("regenerate", regenerate)
    g.add_node("deliver", lambda st: {"outcome": "delivered"})
    g.add_node("human", lambda st: {"outcome": "queued for a person"})
    g.add_edge(START, "generate")
    g.add_edge("generate", "judge")
    g.add_conditional_edges("judge", after_judge, ["deliver", "regenerate", "human"])
    g.add_edge("regenerate", "judge")
    g.add_edge("deliver", END)
    g.add_edge("human", END)
    return g.compile()

6Run it.

terminal
python examples/worked/evals/run_eval.py examples/worked/evals/dataset.jsonl \
    --out report.json --min-pass 0.5 --min-agreement 0.85

python examples/worked/evals/generate_answers.py examples/worked/evals/questions.jsonl > answers.jsonl
python examples/worked/evals/run_eval.py answers.jsonl --min-pass 0.9

python examples/worked/evals/guard.py "Can I expense a taxi home after working late?" \
    --context "Taxis are reimbursable when travel after 21:00 is required for business reasons and approved in advance by a line manager."

Where to be careful. A judge is a model too. Check it against human labels (agreement, and whether P = 0.8 really means right 80% of the time) before you put it on the critical path. Where possible, don't let a model judge its own family's output. And for things with a checkable answer, like code or arithmetic, running the tests beats any judge.

Build the PII filter

The code is in examples/worked/pii/. What it does and why is in the What part.

1Ask the questions your privacy policy already asks, and keep the regexes for the structured cases, where they're exact and give you a span to cut.

QUESTIONS = {
    "person": {"type": "noul", "instructions":
               "The log line identifies a person: a name, home address, phone number or email address."},
    "special_category": {"type": "noul", "instructions":
               "The log line mentions a person's medical condition, symptoms or treatment, or their religion, "
               "ethnicity, sexuality, trade-union membership or biometrics (special-category data, UK GDPR Article 9)."},
    "secret": {"type": "noul", "instructions":
               "The log line contains a credential or secret: a password, API key, access token or private key."},
    "financial": {"type": "noul", "instructions":
               "The log line contains a payment card number or a bank account number."},
}

# the structured cases stay with regexes: they are exact, free, and give a span to cut
PATTERNS = {
    "EMAIL": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
    "IPV4": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
    "CARD": re.compile(r"\b(?:\d[ -]?){13,19}\b"),
    "AWS_SECRET": re.compile(r"(?<=AWS_SECRET_ACCESS_KEY=)\S+"),
}

2Decide per line. A regex hit or a confident read means redact, an unsure read means quarantine, anything else ships.

class PiiDetector:
    def __init__(self, s1, redact_at=0.8, review_at=0.3):
        self.s1, self.redact_at, self.review_at = s1, redact_at, review_at

    def check(self, line):
        hits = [(name, m.span()) for name, rx in PATTERNS.items() for m in rx.finditer(line)
                if name != "CARD" or luhn(m.group())]
        r = self.s1.system_one(line, QUESTIONS)          # the whole line, free text included
        p = {k: a.noul for k, a in r.nouls.items()}
        top = max(p.values())

        if hits or top >= self.redact_at:
            return Decision("redact", self._redact(line, hits, p), p, tuple(hits))
        if top >= self.review_at:
            return Decision("quarantine", line, p)           # unsure: a person decides
        return Decision("forward", line, p)

    def _redact(self, line, hits, p):
        for name, (a, b) in sorted(hits, key=lambda h: -h[1][0]):
            line = line[:a] + f"[{name}]" + line[b:]
        found = [k for k, v in p.items() if v >= self.redact_at]
        if found and not hits:
            # the model says *that* the line holds personal data, not *where*: drop the free text
            tag = f'"[REDACTED:{",".join(found)}]"'
            line = QUOTED.sub(tag, line) if QUOTED.search(line) else f"{line.split(' ', 1)[0]} [REDACTED:{','.join(found)}]"
        return line

3Filter the stream. Reads run concurrently, lines leave in the order they arrived, and memory stays flat on an endless stream.

def filter_stream(detector, lines, out, quarantine, workers=16):
    """Check lines concurrently, emit them in order. Returns a count per route."""
    counts = Counter()
    pending = deque()

    def emit(future):
        d = future.result()
        counts[d.route] += 1
        (quarantine if d.route == "quarantine" else out).write(d.line + "\n")

    with ThreadPoolExecutor(workers) as pool:
        for line in lines:
            line = line.rstrip("\n")
            if not line:
                continue
            pending.append(pool.submit(detector.check, line))
            while len(pending) >= workers * 2:   # bounded: memory stays flat on an endless stream
                emit(pending.popleft())
        while pending:
            emit(pending.popleft())
    return counts

4Put it in front of whatever ships logs off the box.

terminal
python examples/worked/pii/log_filter.py --quarantine quarantine.log \
    < examples/worked/pii/sample.log > clean.log

# in a pipeline, with OpenJev on your own network
kubectl logs -f deploy/support-api \
  | python examples/worked/pii/log_filter.py --quarantine /var/log/pii-review.log \
  | vector --config examples/worked/pii/vector.toml

The wording of the questions matters. My first version asked about "special-category data about a person: health, religion, …". On the real model it flagged INFO health /ready ok as health data (P = 0.81), because the service is called "health". It also put an order total, amount=42.10 GBP, at 0.39 for "payment card or bank account details", which is enough to quarantine it. Asking instead about "a person's medical condition, symptoms or treatment" and "a payment card number or a bank account number" took both false alarms to 0.00 and left the real hits at 1.00. That's eight lines, not an evaluation. It's exactly the kind of thing a labelled set per question will find, so build one before you trust the thresholds.

Where to be careful. One GPU does ~57 reads a second, about 5 million lines a day flat out. That's plenty for free-text fields and WARN-and-above, but not for every DEBUG line in a large estate, so point it at the lines that can actually carry free text. It also tells you that a line holds personal data, not where. Redact the whole field, or hand the few flagged lines to a span extractor (an NER model like GLiNER, or a System 2 call) to cut precisely.

Build the model router

The code is in examples/worked/routing/. What it does and why is in the What part.

1Ask what the event needs, and map the answer to a tier in code. The most likely level picks the model, code work starts at Sonnet, and an unsure read errs one tier up.

ROUTE = {
    "actionable": {"type": "noul", "instructions": "Someone needs to act on or investigate this event."},
    "complexity": {
        "type": "score",
        "instructions": "How much analysis does a useful answer to this event need?",
        "criteria": [
            "routine: restate it in one line with the obvious next step",
            "simple: explain one known error and how to fix it",
            "moderate: diagnose one failing component from a stack trace or metrics",
            "hard: correlate several services or a timeline to find the root cause",
            "critical: security incident or possible data loss, needs careful high-stakes analysis",
        ],
    },
    "needs_code": {"type": "noul", "instructions": "A good answer needs someone to read or change application code."},
}
CHECK = {
    "adequate": {"type": "noul",
                 "instructions": "The analysis names a probable cause and a concrete next step for this event."},
}

LEVEL_TO_TIER = {0: "haiku", 1: "haiku", 2: "sonnet", 3: "opus", 4: "fable"}


def pick_tier(r):
    """Policy in code: most likely level -> tier, code work starts at Sonnet, unsure errs upwards."""
    cx = r.scores["complexity"]
    tier = TIERS.index(LEVEL_TO_TIER[int(most_likely(cx.probabilities))])
    if r.nouls["needs_code"].noul >= 0.5:
        tier = max(tier, TIERS.index("sonnet"))
    if cx.confidence < 0.4:
        tier = min(tier + 1, len(TIERS) - 1)
    return tier

2Wire the graph. Both System One reads are conditional edges. The reducers add up the models used and the cost across escalations.

class RouteState(TypedDict, total=False):
    event: str
    actionable: float
    tier: int
    answer: str
    adequate: float
    models: Annotated[list, operator.add]     # every model that ran, appended by each analyse
    cost: Annotated[float, operator.add]      # dollars, summed across escalations
    s1_tokens: Annotated[int, operator.add]


def build_router(s1, claude, adequate_at=0.7, actionable_at=0.3):
    def route(st):
        r = s1.system_one(st["event"], ROUTE)
        return {"actionable": r.nouls["actionable"].noul, "tier": pick_tier(r),
                "s1_tokens": r.usage.input_tokens or 0}

    def analyse(st):
        reply = claude(TIERS[st["tier"]], SYSTEM, st["event"])
        return {"answer": reply.text, "models": [reply.model], "cost": reply.cost}

    def check(st):
        r = s1.system_one({"event": st["event"], "analysis": st["answer"]}, CHECK)
        return {"adequate": r.nouls["adequate"].noul, "s1_tokens": r.usage.input_tokens or 0}

    def after_check(st):
        at_top = st["tier"] == len(TIERS) - 1
        return "done" if st["adequate"] >= adequate_at or at_top else "escalate"

    g = StateGraph(RouteState)
    g.add_node("route", route)
    g.add_node("analyse", analyse)
    g.add_node("check", check)
    g.add_node("escalate", lambda st: {"tier": st["tier"] + 1})
    g.add_node("drop", lambda st: {"answer": "(no model called)"})
    g.add_edge(START, "route")
    g.add_conditional_edges("route", lambda st: "analyse" if st["actionable"] >= actionable_at else "drop",
                            ["analyse", "drop"])
    g.add_edge("analyse", "check")
    g.add_conditional_edges("check", after_check, {"done": END, "escalate": "escalate"})
    g.add_edge("escalate", "analyse")
    g.add_edge("drop", END)
    return g.compile()

3Call Claude at the chosen tier. Opus 5 and Fable 5.1 use server-side fallbacks: "default", so a declined request is re-run on Anthropic's recommended fallback model rather than failing. Every call checks stop_reason before reading content.

class Claude:
    """claude(tier, system, prompt) -> Reply. Reads ANTHROPIC_API_KEY or an `ant auth login` profile."""

    def __init__(self, client=None, max_tokens=8000):
        self.client = client or anthropic.Anthropic()
        self.max_tokens = max_tokens

    def __call__(self, tier, system, prompt):
        request = dict(model=MODELS[tier], max_tokens=self.max_tokens, system=system,
                       messages=[{"role": "user", "content": prompt}])
        if tier in ("opus", "fable"):
            # a declined request is re-run server-side on Anthropic's recommended fallback model
            r = self.client.beta.messages.create(**request, betas=["server-side-fallback-2026-07-01"],
                                                 fallbacks="default")
        else:
            r = self.client.messages.create(**request)
        if r.stop_reason == "refusal":   # check before reading content: a refusal can arrive with none
            return Reply("", r.model, r.usage.input_tokens, r.usage.output_tokens, refused=True)
        text = "".join(b.text for b in r.content if b.type == "text")
        return Reply(text, r.model, r.usage.input_tokens, r.usage.output_tokens)

4Run it on a file, or on the live alert stream.

terminal
python examples/worked/routing/run_router.py examples/worked/routing/events.log

tail -F /var/log/alerts.log | python examples/worked/routing/run_router.py

Where to be careful. A misroute costs more than it saves if a Haiku answer to a real incident gets believed. The check read is what catches that, and it has to be measured too. Re-run a sample of each tier's traffic one tier up and track how often the check read disagrees. And log every routing decision with its distribution, so "why did this go to Haiku?" has an answer.

Caveats