Skip to main content
This cookbook takes one borderline user post, runs a moderation rubric over it 15 times, and checks whether each answer holds still across the repeats. Every check is a Choice, so each answer is one label from a fixed set. In a moderation pipeline that label is the routing decision: remove or leave up, escalate or auto-resolve, send to the threat, spam, or general queue. When the label wobbles from one run to the next, the same post routes to different places for no good reason. The rubric is 8 Choices, and each run is one call that answers all 8. We do 15 repeats per condition, where a condition is one model plus one setting, and plot every label that came back. The conditions:
  • Fast LLMs claude-haiku-4-5 and gpt-5.4-mini, at temperature 0 and at the API default.
  • Reasoning LLMs gpt-5.5 and claude-opus-4-8, which have no temperature dial.
  • TypeSafe: one system_one call over the 8 Choices, with a fresh uid field (a throwaway unique value) on each call, matching the noul cookbook setup.
What to look for: the LLM picks flip inside a single condition, and they disagree across providers. TypeSafe repeats the same picked labels in this run. In this run the LLM distribution settings repeat their own routing 88% to 95% of the time over the 15 re-runs, and their per-label probabilities move 2.4x to 5.6x more than TypeSafe’s, which repeats every label on every question. Re-running an LLM hides some flips but does not settle which label is right, so stable routing is what keeps the same post from landing in a different queue on a re-run.

Setup

then set TYPESAFE_API_KEY, ANTHROPIC_API_KEY, and OPENAI_API_KEY.

The state: a borderline user post, as JSON

The post below is built to sit on the fence. The language is heated and insulting, aimed partly at one person and partly at the argument and the community. It carries an off-platform invite (a link pulling people to another site), one prior strike on the account, and four user reports, and the threat-like wording is never cleanly phrased. There is no single obvious answer here, and that is the point: small wording differences should not randomly move the same post between enforcement paths. The LLMs get json.dumps(POST) in the prompt. TypeSafe gets the Python dict directly.

The rubric: 8 Choices

Each question has a key, a line of instructions, and a fixed label set. The labels within a question are mutually exclusive (exactly one applies), and each carries a short description. TypeSafe returns a picked choice plus a probabilities distribution over the labels. The LLMs are asked to use the same label sets, which keeps every row comparable.

How we ask

Each LLM call is one prompt holding json.dumps(POST), all 8 questions, and every allowed label. There are two answer formats. In distribution mode the model returns one JSON object per question with a probability on each label. In single-pick mode it returns one bare label per question, and our analysis puts all the probability mass on that label. The TypeSafe call is one system_one request over the same post and the same 8 Choices, returning one distribution per question. Every query also gets a fresh uid, a throwaway unique value (a nonce, or uid buster) that changes each run. In the LLM prompt it stops the provider from caching the response and hiding run-to-run behavior, while leaving the post and rubric unchanged. In the TypeSafe state it is one extra field, and since it changes on every call the state is never sent twice, so the variation measures sensitivity to an irrelevant field in addition to sampling noise. Each helper returns the answer, an estimated cost, and the round-trip latency.

Conditions

  • Fast models (claude-haiku-4-5, gpt-5.4-mini): distribution mode at temperature 0 and at the API default. Temperature 0 is the usual “make it deterministic” advice, so we test it head-on.
  • Fast models, single-pick (claude-haiku-4-5 single-pick t=0, gpt-5.4-mini single-pick t=0): a single label per question at temperature 0.
  • Reasoning models (gpt-5.5, claude-opus-4-8): one distribution condition each, since they have no temperature dial.
  • TypeSafe (typesafe_choice): one condition.
We draw NUM_SAMPLES = 15 repeats per condition. Each repeat has its own cache key and counts as a distinct draw, and the cache (json_cache.json) ships with the cookbook, so re-rendering reuses it and spends no API calls. Delete the cache to sample live again.

Cost + speed (per rubric query)

One row is one full 8-question rubric call. time/call and cost/call average the 15 calls, and the vs ts_choice columns divide by the TypeSafe figures. The LLMs run in a 16-way pool.
In this run typesafe_choice is the cheapest and fastest condition at about 100ms, several times quicker than the fastest LLM setting. The reasoning LLM calls cost far more, because they spend many more tokens and much more time per rubric.

Plot: every sample’s picked label as a heatmap

How to read it:
  • Outer row group: the question.
  • Inner row: the condition.
  • Column: one full rubric call.
  • Cell text: the picked label plus the probability on that top label.
  • Cell color: the label’s position within that question, so the same color all the way across a row means the same decision every time.
  • Hatched n/a: the reply did not parse into usable labels (a parse failure).
  • Blank rows are just spacers.
output The clearer questions hold steady: target reads Person and severity reads High across the board. The borderline ones split across conditions: category, primary_risk, action, review_path, and link_handling. Some conditions also flip within their own 15 repeats. TypeSafe holds one picked label on every question in this run.

Probability std dev

This looks at the full probability vectors, not just the picked label. For each condition we collect all 15 distributions for every question, take the standard deviation of each label’s probability across the repeats (how much it moves from run to run), then average those std devs over all labels and questions. We also report the single largest label std dev, and count parse failures separately. The table compares every probability-output LLM condition against TypeSafe. The single-pick rows are left out, since they emit hard labels rather than probability distributions.
In this run TypeSafe has a mean probability std dev of 0.0113 and a max single-label std dev of 0.0377. The LLM probability rows range from 0.0273 to 0.0626 mean probability std dev, about 2.4x to 5.6x the TypeSafe mean. TypeSafe moves less at the probability level than every probability-output LLM row here, and still repeats the same picked labels.

Plot: how often does each condition repeat its own decision?

For each condition and question we find the most common valid label (the plurality pick), count how many of the 15 draws chose it, and divide by 15. Parse failures count against the score. A score of 1.0 means the condition landed on its own label every time. The bar averages that score over all 8 questions.
output This chart measures self-agreement, not correctness. Realistic LLM settings score below 1.0, which means the same post can route differently on a re-run. Some temperature=0 or single-pick rows look perfectly consistent, but that does not make them right: the heatmap still shows the conditions disagreeing with each other. Extra LLM repeats can paper over some of the flips, but they do not settle which label is the appropriate one.

Open it in the TypeSafe playground

The link below opens the same post and rubric in the playground: one post, the same 8 Choices, and TypeSafe jev-1.12.
Open this post + rubric in the TypeSafe playground →