Jev and System One models: typed decisions instead of generated text — and how to build the pattern yourself
TypeSafe's Jev answers typed questions with calibrated probabilities instead of writing prose your code has to parse. The product is in early access, but the pattern behind it is not: you can implement closed-set decisions with real probability distributions on top of an ordinary LLM today, and this is how.
Almost every AI feature ends the same way. The model writes something, and your code has to turn that something back into a decision:
raw = llm.chat("Classify this ticket. Reply with JSON.")
data = json.loads(raw) # may throw
queue = data["queue"] # may be "Billing", "billing", or "the billing queue"
You wanted queue = "billing". You got a paragraph generator pointed
at a schema, and a parser holding it together. Structured output modes
make the parsing reliable; they do not change the fact that you asked a
text model for text and then threw most of it away.
Jev, released in September 2026 by TypeSafe AI, is a model built on the opposite premise. It is the first of what they call System One Models — the name borrowed from Kahneman’s fast, automatic thinking, as against the slow deliberate kind that conventional LLMs are optimised for.
What Jev actually does
You send it state — whatever your application knows — and a set of typed questions. It returns typed answers with probabilities.
From TypeSafe’s own documentation, there are three primitives:
| Question | Asks | Returns |
|---|---|---|
| Choice | Pick one option from a list | the choice, the full distribution over options, a confidence |
| Score | Rate the state on a rubric | the score, the distribution over levels, a confidence |
| Noul | Is this statement true? | a number from 0 to 1 |
The API shape looks like this:
response = client.system_one(
state=user_message,
questions={
"action": Choice(
instructions="What is the user trying to do?",
criteria={
"check_balance": "View account balance",
"approve_transfer": "Approve the pending withdrawal request",
"support": "Get help with an issue",
},
),
},
)
action = response.answers["action"]
action.choice # "approve_transfer"
action.probabilities # the distribution across your options
action.confidence # 0..1, collapsed from that distribution
Three properties of that design matter more than the model itself.
The output space is the question. The options you declared are the only values that can come back. This is not a schema the model is asked to respect; it is the set it is choosing from. TypeSafe is careful, and correct, to note that this is structural safety and not correctness — it can still pick the wrong option from your list.
Every question is evaluated independently and in parallel against the same state. Their docs make a point that follows from this: adding questions does not meaningfully add latency, and does not create context rot, because the questions do not share a generation.
Uncertainty is a first-class return value. You get the distribution and a confidence, so your code can decide when the model is allowed to act alone. Their training approach is named for this — Reinforcement Learning for Calibrated Decisions, positioned against RLHF’s optimisation for the answer a human would prefer.
On performance, TypeSafe reports 70–500 ms responses and headline figures of up to 193.6× faster and 444.6× cheaper, with input priced around $0.042 per million tokens and output free, since there is no generated output to pay for. Those are vendor-reported numbers on their own System One workloads. Parameter count, architecture and context window are not publicly disclosed, and at the time of writing it is early access.
Which raises the practical question. If you cannot use it yet, or you need this to run on a model you already have — can you build the same shape yourself?
Mostly, yes.
Building typed decisions on an ordinary LLM
The key realisation: an autoregressive model already computes a probability distribution over its vocabulary at every position. When you ask it to “answer A, B or C” and read back a string, you are throwing that distribution away and keeping the argmax.
Do not throw it away. Ask for one token, read the logprobs, and renormalise over just your options.
Step 1 — a Choice primitive
Give each option a single-token label, ask for exactly one token, and read the top logprobs. Then softmax over only the labels you allowed.
import math
from openai import OpenAI
client = OpenAI()
LABELS = ["A", "B", "C", "D", "E", "F"] # single tokens
def choice(state: str, question: str, options: dict[str, str], model="gpt-5.2"):
keys = list(options)
menu = "\n".join(f"{LABELS[i]}. {k} — {v}" for i, (k, v) in enumerate(options.items()))
prompt = (
f"State:\n{state}\n\n"
f"Question: {question}\n\n{menu}\n\n"
"Reply with exactly one letter and nothing else."
)
r = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=1,
temperature=0,
logprobs=True,
top_logprobs=20, # we need the losers, not just the winner
)
# Raw logprobs for the letters we actually offered.
top = r.choices[0].logprobs.content[0].top_logprobs
raw = {t.token.strip(): t.logprob for t in top}
scores = [raw.get(LABELS[i], -100.0) for i in range(len(keys))]
# Softmax over the allowed set only — this is the renormalisation that
# turns "what the model would have said" into "a distribution over YOUR
# options". Mass the model put on anything else is discarded.
m = max(scores)
exps = [math.exp(s - m) for s in scores]
Z = sum(exps)
probs = {k: e / Z for k, e in zip(keys, exps)}
best = max(probs, key=probs.get)
return {"choice": best, "probabilities": probs, "confidence": probs[best]}
That is the whole trick, and it buys you three things at once: the model cannot return a value outside your set, you get a real distribution rather than a vibe, and it costs one output token.
Step 2 — Score and Boolean fall out of the same idea
A Score is a Choice over ordered levels, with one addition: because the levels are ordered, the expected value is more informative than the argmax. A rating that is split evenly between 4 and 5 should read as 4.5, not as a coin flip.
def score(state, question, levels=(1, 2, 3, 4, 5)):
opts = {str(n): f"level {n}" for n in levels}
out = choice(state, question, opts)
p = out["probabilities"]
expected = sum(int(k) * v for k, v in p.items())
return {"score": expected, "probabilities": p, "confidence": max(p.values())}
def noul(state, statement):
"""Is this statement true? Returns a single probability."""
out = choice(state, statement, {"true": "the statement holds",
"false": "the statement does not hold"})
return out["probabilities"]["true"]
Step 3 — confidence that means something
max(probs) is a starting point, not a confidence. It ignores the
shape of the rest of the distribution: {0.5, 0.49, 0.01} and
{0.5, 0.25, 0.25} both report 0.5 and mean different things.
Normalised entropy uses the whole distribution:
def confidence(probs: dict[str, float]) -> float:
"""1.0 = all mass on one option, 0.0 = perfectly flat."""
n = len(probs)
if n < 2:
return 1.0
H = -sum(p * math.log(p) for p in probs.values() if p > 0)
return 1.0 - H / math.log(n) # log(n) is the flat-distribution entropy
TypeSafe makes the same point in their docs — they return a confidence
for convenience but hand you the full probabilities precisely because
the right statistic depends on what you are measuring.
Step 4 — calibrate, or the number is decorative
Raw model probabilities are usually overconfident: the model says 0.95 and is right 80% of the time. Thresholding on an uncalibrated number gives you a false sense of control.
Temperature scaling is the cheapest fix that works. One parameter, fit on a few hundred labelled examples:
def fit_temperature(logit_sets, correct_idx, grid=None):
"""Pick T minimising negative log-likelihood on a held-out labelled set."""
grid = grid or [0.5 + 0.05 * i for i in range(60)] # 0.5 .. 3.45
best_T, best_nll = 1.0, float("inf")
for T in grid:
nll = 0.0
for logits, y in zip(logit_sets, correct_idx):
m = max(l / T for l in logits)
Z = sum(math.exp(l / T - m) for l in logits)
nll -= (logits[y] / T - m) - math.log(Z)
if nll < best_nll:
best_T, best_nll = T, nll
return best_T
Divide your logprobs by T before the softmax and the numbers start
meaning what they say. Check it with a reliability table: bucket
predictions by confidence, and compare each bucket’s mean confidence
against its actual accuracy. If the 0.9 bucket is right 90% of the
time, you can trust a 0.9 threshold. If it is right 70% of the time,
your threshold is a guess wearing a decimal point.
Step 5 — gate on confidence, and scale the gate to the stakes
This is the part that turns a classifier into a system you can operate. TypeSafe’s docs frame it as three paths, and the framing is right:
def decide(state, question, options, *, risk: str):
out = choice(state, question, options)
c = confidence(out["probabilities"])
# The threshold is not one number. It scales with what it costs to be wrong.
floor = {"low": 0.55, "medium": 0.75, "high": 0.92}[risk]
if c < floor:
return {"action": "escalate", "reason": "below threshold", **out}
return {"action": "auto", **out}
Routing a ticket to the wrong queue costs a minute. Approving the wrong withdrawal costs money and trust. Same model, same call, different floor — and that single idea is most of the value of having a distribution at all.
Step 6 — atomic questions, composed in code
The design rule in TypeSafe’s documentation is the one I would keep even if you never touch their API: ask one thing per question, and combine the answers with a formula in your code.
Instead of “rate this startup pitch”, ask separately about market size, technical feasibility and differentiation — then weight them yourself:
import asyncio
async def evaluate(pitch):
market, feasible, diff = await asyncio.gather(
ascore(pitch, "How large is the addressable market?"),
ascore(pitch, "How technically feasible is this in 18 months?"),
ascore(pitch, "How differentiated is this from incumbents?"),
)
# When priorities change you edit a coefficient, not a prompt.
return 0.5 * market["score"] + 0.3 * feasible["score"] + 0.2 * diff["score"]
Three benefits, all practical. Each question stays small enough to be reliable. The questions are independent, so they run concurrently rather than sequentially. And the weighting lives in code you can version, test and change without re-tuning a paragraph of English.
What you get, and what you do not
Building it yourself gets you the shape: closed output sets, real distributions, calibrated thresholds, parallel independent questions. That is most of the engineering value, on a model you already have.
What it does not get you is the economics. You are still paying an LLM to run a full forward pass over your state for every question, at LLM input prices. A model built for this — trained to produce calibrated decisions rather than text, sampling several questions against one state in a single evaluation — is a different cost structure, which is what TypeSafe’s figures are claiming.
So the decision is roughly:
- Roll your own when volume is modest, you need it on a model you already trust, or you cannot add a vendor. The code above is a day’s work and you own it.
- Reach for a dedicated decision model when you are making thousands of small judgments a minute inside an agent loop — tool selection, retry-or-stop, risk scoring, verification — and the per-decision cost and latency are what is limiting you.
And in either case, hold on to the distinction TypeSafe makes and does not hide: constraining the output space eliminates type errors, not judgement errors. The model can still confidently choose the wrong option from your list. The probability it hands back is how you find out — which is exactly why the calibration step is not optional.