Jev and an LLM try to roll a fair die

intro

Generated using ChatGPT Images 2.5.

There's been a lot of hype and discussion alongside TypeSafe's new model, Jev.

Jev is interesting:

In other words, Jev is a general-purpose classifier.

This is obviously a different use case from what we typically use LLMs for (even though we do use LLMs as expensive, slow classifiers all the time if we don’t care about – or are usually ignorant of – calibration). Jev is not meant to be a replacement for an LLM, but rather a fast, cheap substitute for taking well-calibrated actions.

But the curious bit is said plainly in the previous sentence: Jev is interesting because its output probabilities are calibrated.

To explain why, let’s take a brief detour into how we can trick LLMs into outputting probabilities.

Because, if you didn’t know, you’ve always been able to directly access the classification “probability” from an LLM (at least when running locally – some API providers like OpenAI do return these for you if you ask for them in the request).On the Responses API you need to pass include=["message.output_text.logprobs"] and a top_logprobs count. You also have to turn reasoning off, which the API requires (but does make intuitive sense too: if the model is generating hidden reasoning tokens first, those vary, and so do the logits that come after. Or maybe there’s a fear that this is a mechanism for extracting the proprietary reasoning tokens). The docs are here: OpenAI docs.

To predict the next token, on the forward pass over the input sequence, an LLM calculates a probability distribution over all possible output tokens. The next token is then sampled from this distribution. This probability distribution is determined entirely from the sequence of tokens provided as input via the forward pass.

If you squint, this is kind of like treating each token as a “class”. We can interpret each token’s probability as the model’s confidence in that token being the next token.

As a result, you can prompt an LLM as follows:

Given the following sentence, classify its sentiment:

"It is sunny today, and man does that make me happy!"

A: positive
B: neutral
C: negative

Respond with only one of the letters corresponding to the sentiment and nothing else.

Then, you can:

Et voilà – you are now using an LLM as a general-purpose classifier. These are “among the option letters” probabilities, not P(the next token is A) over the whole vocab.

Let’s make this concrete with a small Qwen3-0.6B model (one forward pass, softmax over the option-letter tokens):

Classify the following email:

"Congrats! You've been selected for a $10,000 prize. Click here to claim your reward before it expires."

A: spam
B: not spam

Respond with only one of the letters corresponding to the answer and nothing else.
OptionLogit Probability
A: spam0.00
B: not spam1.00
A customer wrote:

"I've been charged twice for the same order and nobody has replied in three days. This is unacceptable."

Where should this go?

A: billing
B: technical support
C: shipping

Respond with only one of the letters corresponding to the answer and nothing else.
OptionLogit Probability
A: billing0.99
B: technical support0.01
C: shipping0.00
A customer is requesting a refund:

"I bought this 11 months ago, used it daily, and now I want my money back because I found it cheaper elsewhere."

A: reject the refund
B: approve the refund

Respond with only one of the letters corresponding to the answer and nothing else.
OptionLogit Probability
A: reject the refund0.00
B: approve the refund1.00

(code is here)

Let’s ignore the accuracy of the logit trick for now (this is coming later). The point I’m trying to illustrate is that we can get classification probabilities directly out of LLMs.

Jev’s claim to fame is that the probabilities are calibrated.

When we say a model is “calibrated”, what we mean is that, if the model classifies something as class A with 90% probability, it will be correct 90% of the time.

A proper check needs lots of predictions, binned against how often they actually happen. A fair die is a looser proxy: if the calibration claim holds any water, asking for the probability of each face should come back close to 1/6 (0.167).

So let’s do that via OpenRouter (useful if, like me, you’re still on the TypeSafe waiting list to get first-party access to Jev).

{
  "model": "~typesafe/jev-latest",
  "state": "A fair six-sided die is about to be rolled. Each face (1–6) is equally likely.",
  "questions": {
    "1": {
      "type": "noul",
      "instructions": "Will the die land on 1?"
    },
    "2": {
      "type": "noul",
      "instructions": "Will the die land on 2?"
    },
    "3": {
      "type": "noul",
      "instructions": "Will the die land on 3?"
    },
    "4": {
      "type": "noul",
      "instructions": "Will the die land on 4?"
    },
    "5": {
      "type": "noul",
      "instructions": "Will the die land on 5?"
    },
    "6": {
      "type": "noul",
      "instructions": "Will the die land on 6?"
    }
  }
}

And here’s what we get back:

OptionReturned Probability
Will the die land on 1?0.150
Will the die land on 2?0.150
Will the die land on 3?0.160
Will the die land on 4?0.160
Will the die land on 5?0.150
Will the die land on 6?0.160

(code is here)

So on this proxy, that’s actually pretty close (especially if you think this is a general-purpose model that can do this across any prompt and number of questions).

If we were to compare this to the logit-calculated probabilities from a small local LLM (again using Qwen3-0.6B):

Read the state and answer with a single option letter.

State:
A fair six-sided die is about to be rolled. Each face (1–6) is equally likely.

Question: Will the die land on 1?

Options:
A. yes
B. no

Same prompt for faces 2–6. I’ll also ask the model to write the probabilities as JSON via next-token prediction, then parse whatever it generates.To guarantee valid JSON as output, we use constrained generation by way of XGrammar: at each step we mask out any token that wouldn’t continue as valid JSON with numeric values in [0, 1). We need to do this because a 0.6B model is a little bad at following instructions — left alone it writes 1 / 6, and a looser schema turns that into 1. Constrained decoding is a topic for another day — here it’s just a trick to keep the output parseable. The logit probability column is softmax of P(A) (which corresponds to the “yes” answer).

The JSON numbers aren’t class probabilities in the logit sense — they’re what the model wrote. That’s still how we’d typically use an LLM as a classifier: assume those outputted probabilities are a reliable proxy for class probabilities. (It’s also usually around 10x slower, since you’re generating token by token, whereas the logit trick is a single parallel forward pass.)

OptionLogit ProbabilityJSON Probability
Will the die land on 1?0.9050.167
Will the die land on 2?0.8900.167
Will the die land on 3?0.8810.167
Will the die land on 4?0.8900.167
Will the die land on 5?0.8810.167
Will the die land on 6?0.9560.167

Six yes/no forwards: 0.225s. Six JSON generations: 2.757s. Jev, by comparison, is 0.460s — though that’s a round trip to OpenRouter, and the Qwen times are local (six separate forwards, no network).

(logits code is here; JSON code is here)

The logits are not calibrated at all! They’re super-biased towards saying YES to every question. If we take the JSON as a proxy for class probabilities, it does look like a fair die: {"A": 0.1667, "B": 0.8333} on every face. So it knows 1/6. That knowledge just doesn’t show up in the A/B logits.

But Jev seemingly isn’t quite calibrated when it comes to multiple choice

So done and dusted? Jev is well-calibrated and in the same ballpark as a forward pass on a tiny LLM?

Well, not quite.

There seems to be an unexpected failure mode.

The die example above was six independent yes/no questions (nouls).

But Jev also has a choice type: one question with several mutually exclusive options.

This is much closer to the A/B/C logits trick from earlier. So let’s ask for the same fair die as a single choice:

{
  "model": "~typesafe/jev-latest",
  "state": "A fair six-sided die is about to be rolled. Each face (1–6) is equally likely.",
  "questions": {
    "face": {
      "type": "choice",
      "instructions": "Which face will the die land on?",
      "criteria": {
        "1": "The die lands on 1",
        "2": "The die lands on 2",
        "3": "The die lands on 3",
        "4": "The die lands on 4",
        "5": "The die lands on 5",
        "6": "The die lands on 6"
      }
    }
  }
}

This time, Jev is not calibrated at all! It returns choice=1 with confidence 0.80:

OptionReturned Probability
1: The die lands on 10.800
2: The die lands on 20.020
3: The die lands on 30.080
4: The die lands on 40.060
5: The die lands on 50.010
6: The die lands on 60.030

(code is here)

A reminder again that a fair probability would be 0.167.

This looks a lot more like our LLM logit probability results from earlier.

For a fair comparison, let’s also run our LLM over a similar multiple-choice prompt (rather than the noul-equivalent six yes/no questions):

A fair six-sided die is about to be rolled. Each face (1–6) is equally likely.

A: The die lands on 1
B: The die lands on 2
C: The die lands on 3
D: The die lands on 4
E: The die lands on 5
F: The die lands on 6

Respond only with the letter corresponding to the answer and nothing else.

Qwen3-0.6B, logits (softmax over A–F) vs asking it to emit JSON:

OptionLogit ProbabilityJSON Probability
A: The die lands on 10.9270.167
B: The die lands on 20.0010.167
C: The die lands on 30.0020.167
D: The die lands on 40.0020.167
E: The die lands on 50.0010.167
F: The die lands on 60.0670.167

(logits code is here; JSON code is here)

One forward pass: 0.042s. Generating the JSON object: 1.157s. Jev: 1.111s.

Same shape as Jev on the logits side, but still very uncalibrated. This is the “latching” we see with LLMs — not the independent-yes mess from the noul-style prompts.

This does make me curious about the underlying model that Jev is using. Is it trained on top of a small LLM? Maybe? It seems to suffer from the same “latching” behaviour. Or is it an entirely new architecture? Open questions for now.

What happens if we change the order of the statements?

Let’s reverse them: 6 first, 1 last.

{
  "model": "~typesafe/jev-latest",
  "state": "A fair six-sided die is about to be rolled. Each face (1–6) is equally likely.",
  "questions": {
    "face": {
      "type": "choice",
      "instructions": "Which face will the die land on?",
      "criteria": {
        "6": "The die lands on 6",
        "5": "The die lands on 5",
        "4": "The die lands on 4",
        "3": "The die lands on 3",
        "2": "The die lands on 2",
        "1": "The die lands on 1"
      }
    }
  }
}

Nope. Still choice=1, still ~82% on face 1:

OptionReturned Probability
6: The die lands on 60.030
5: The die lands on 50.010
4: The die lands on 40.060
3: The die lands on 30.070
2: The die lands on 20.010
1: The die lands on 10.820

(code is here)

So it’s not (just) picking the first option. It really wants 1. That’s quite weird! The shuffle seems to make no difference.

This implies that Jev computes nouls independently, but falls back to LLM-esque behaviour across choices.

What about for an LLM?

Same shuffle: A is now 6, F is now 1.

A: The die lands on 6
B: The die lands on 5
C: The die lands on 4
D: The die lands on 3
E: The die lands on 2
F: The die lands on 1

Qwen3-0.6B, logits vs JSON:

OptionLogit ProbabilityJSON Probability
A: The die lands on 60.7780.167
B: The die lands on 50.0020.167
C: The die lands on 40.0020.167
D: The die lands on 30.0010.167
E: The die lands on 20.0020.167
F: The die lands on 10.2160.167

(logits code is here; JSON code is here)

The logits actually do move a bit here. Most of the mass still jumped to A (now face 6, rather than 1) – which is a common bias I see in LLMs.Usually called position bias: models often prefer the first MCQ option. Zheng et al. (2024) treat the broader pattern as selection bias (a prior on option IDs like A), with position as one piece of it.

So LLMs are no better. But (surprisingly!) Jev is no better either. At least not when using choice. So keep this in mind when building with Jev!

Aside: are bigger LLMs more calibrated?

Maybe Qwen3-0.6B is just too small to output accurate logits, and a larger model’s logit probabilities would actually look like a fair die? Let’s get the logits for OpenAI’s gpt-5.6 with reasoning={"effort": "none"} (logprobs over A–F) class=“sidenote-num”>OpenAI doesn’t return logprobs for many of its latest models. Astra won’t even take the parameter, and you can’t turn the reasoning off.:

A: The die lands on 1
B: The die lands on 2
C: The die lands on 3
D: The die lands on 4
E: The die lands on 5
F: The die lands on 6

On the 1–6 prompt, it actually does look a little more balanced (but still far from being calibrated)A 0.00 here can just mean the letter wasn’t in top_logprobs. The API lets you ask for up to 20; I asked for 20, and gpt-5.6 still only returned 4 tokens on 1–6 and 1 after the shuffle. Missing letters get treated as 0.:

OptionReturned Probability
A: The die lands on 10.044
B: The die lands on 20.000
C: The die lands on 30.367
D: The die lands on 40.223
E: The die lands on 50.000
F: The die lands on 60.367

(code is here)

Still not a fair die, but it’s not “always A” either. If we shuffle, though, we’re back to the same biases we saw earlier:

OptionReturned Probability
A: The die lands on 60.900
B: The die lands on 50.000
C: The die lands on 40.069
D: The die lands on 30.016
E: The die lands on 20.000
F: The die lands on 10.015

(code is here)

So, anyway

On noul, Jev’s pitch seems to hold water. Six independent “will it land on N?” questions about a fair die come back with a probability of ~0.15–0.16 each, in 0.460s, all in one go. That’s what you expect a calibrated classifier to output.

The LLM equivalent is the logit trick: one parallel forward pass, softmax over the option-letter tokens. The outputs are completely uncalibrated (unless we go for JSON and take those written numbers as a reliable proxy for class probabilities — which looks more accurate here, sure, but far slower).

Surprisingly (to me anyway), big models fare a little bit better, but still nowhere near as calibrated as Jev.

The catch seems to be everything that isn’t noul. If we reorganize the same request instead as a choice, Jev behaves more like an LLM again. Weird!

Regardless, now that Jev is on OpenRouter, I’ll be playing a lot more with it and seeing if I can find interesting use-cases. The demo on TypeSafe’s website is an excellent showcase, so do go check it out.

Appendix: every run

A fair die is 0.167 on each face. Independent yes/no (noul) probabilities do not have to sum to 1. Multiple-choice distributions do. Parentheses are the error vs 1/6.

Qwen times are wall-clock inference after a warmup pass, excluding model load (Apple MPS, float16). Jev times are warm OpenRouter round trips (network included). JSON runs are greedy generate with XGrammar constraining the output to a JSON object of option-letter keys and numeric values in [0, 1). No numeric example in the prompt. max_new_tokens is 96, except 192 for the questions choice run (it likes very long 0.1666… literals).

Independent yes/no — P(yes) per roll

ModelSetupTimeroll 1roll 2roll 3roll 4roll 5roll 6
Fair die0.1670.1670.1670.1670.1670.167
Jev 1.13six nouls, “Will the die land on N?”0.460s0.150
(-0.017)
0.150
(-0.017)
0.160
(-0.007)
0.160
(-0.007)
0.150
(-0.017)
0.160
(-0.007)
Qwen3-0.6BA/B logits0.225s0.905
(+0.738)
0.890
(+0.723)
0.881
(+0.714)
0.890
(+0.723)
0.881
(+0.714)
0.956
(+0.789)
Qwen3-0.6BJSON output2.757s0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)

Multiple choice — P(roll)

Each column is the die face, not the option letter or its position. roll 1 is P(lands on 1) on every row, including 6–1. JSON rows are uniform 0.167 after requiring decimal numbers in [0, 1).

ModelSetupOrderTimeroll 1roll 2roll 3roll 4roll 5roll 6
Fair die0.1670.1670.1670.1670.1670.167
Jev 1.13choice, questions as criteria1–61.122s0.870
(+0.703)
0.010
(-0.157)
0.050
(-0.117)
0.050
(-0.117)
0.000
(-0.167)
0.020
(-0.147)
Jev 1.13choice, statements as criteria1–61.111s0.800
(+0.633)
0.020
(-0.147)
0.080
(-0.087)
0.060
(-0.107)
0.010
(-0.157)
0.030
(-0.137)
Jev 1.13choice, statements as criteria6–10.438s0.820
(+0.653)
0.010
(-0.157)
0.070
(-0.097)
0.060
(-0.107)
0.010
(-0.157)
0.030
(-0.137)
Qwen3-0.6BA–F logits, questions1–60.040s0.878
(+0.711)
0.009
(-0.158)
0.010
(-0.157)
0.003
(-0.164)
0.005
(-0.162)
0.094
(-0.073)
Qwen3-0.6BJSON output, questions1–62.390s0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
Qwen3-0.6BA–F logits, statements1–60.042s0.927
(+0.760)
0.001
(-0.166)
0.002
(-0.165)
0.002
(-0.165)
0.001
(-0.166)
0.067
(-0.100)
Qwen3-0.6BJSON output, statements1–61.157s0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
Qwen3-0.6BA–F logits, statements6–10.041s0.216
(+0.049)
0.002
(-0.165)
0.002
(-0.165)
0.001
(-0.166)
0.002
(-0.165)
0.778
(+0.611)
Qwen3-0.6BJSON output, statements6–11.305s0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
0.167
(+0.000)
gpt-5.6A–F logprobs, reasoning=none1–60.044
(-0.123)
0.000
(-0.167)
0.367
(+0.200)
0.223
(+0.056)
0.000
(-0.167)
0.367
(+0.200)
gpt-5.6A–F logprobs, reasoning=none6–10.015
(-0.152)
0.000
(-0.167)
0.016
(-0.151)
0.069
(-0.098)
0.000
(-0.167)
0.900
(+0.733)

gpt-6-astra is omitted: it rejects logprobs, so there is nothing to put here.