Cookbook: Self-consistency — choices
TL;DR A repeatability experiment. One borderline moderation post, an 8-question
Choicerubric, 15 repeats per condition, Jev (jev-latest→jev-1.13.0) vs. six LLM conditions. Jev's raw label agreement is 90.8% and its mean probability std dev0.0098; addinguncertainwhen the top probability is below0.60raises decision agreement to 99.2% while still acting automatically on 74.2% of answers. Per call: 114ms, $0.000046.
Goal
"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."
What to look for, per TypeSafe: "picked labels can flip inside a single condition, including TypeSafe, and conditions disagree with each other."
Inputs / state shape
One post, "built to sit on the fence": heated insults aimed partly at a person and partly at the community, an off-platform discord.gg invite, one prior strike, four user reports, and threat-like wording that is "never cleanly phrased."
POST = {
"post_id": "P-88213",
"author": {
"user_id": "u/4471",
"account_age_days": 38,
"prior_strikes": 1,
"followers": 210,
},
"context": {
"surface": "public reply",
"in_reply_to": "another user defending a game patch",
"community": "r/gamedebates",
},
"content": {
"text": (
"Are you seriously this dense? Anyone who defends that patch is a complete clown and "
"should be embarrassed to even post here. People like you are what's ruining this "
"community and honestly you need to be dealt with. Come say it to my face, invite's "
"right here. Keep it up and I'll end your whole channel."
),
"has_link": True,
"link_domain": "discord.gg",
"language": "en",
},
"reports": {"user_reports": 4, "report_reasons": ["harassment", "spam", "threat"]},
}
Jev's state is {"uid": f"{rubric_hash}:{sample_index}:{token_hex(4)}", "post": POST}. The LLMs get json.dumps(POST) in a prompt. Same uid confound as the noul cookbook, acknowledged the same way.
Questions asked
Eight Choice questions, each a (instructions, {label: description}) pair, verbatim. "The labels within a question are mutually exclusive (exactly one applies), and each carries a short description."
QUESTIONS = {
"category": (
"What is the single most applicable content-policy category for this post?",
{
"None": "No policy violation of any kind.",
"Harass": "Insults or demeans a person, with no threat of harm and no protected-class attack.",
"Hate": "Attacks a person or group over a protected characteristic (race, religion, gender, ...).",
"Violence": "Makes a credible threat of harm or incites violence against someone.",
"Spam": "Unsolicited promotion or link spam, with no personal attack.",
"Sexual": "Sexual or adult content.",
},
),
"primary_risk": (
"What is the primary moderation risk that should drive triage for this post?",
{
"Harassment": "Personal attack or targeted abuse is the main risk.",
"Violence": "A threat of harm or intimidation is the main risk.",
"LinkAbuse": "External-link or off-platform coordination risk is the main risk.",
"AccountHistory": "Prior account history or repeat behavior is the main risk.",
"LowRisk": "No meaningful moderation risk is present.",
},
),
"target": (
"Who or what is the content primarily directed at?",
{
"None": "Not directed at anyone in particular.",
"Person": "Directed at one specific individual.",
"Group": "Directed at a protected group or class.",
"Platform": "Directed at the community or platform itself, not a person.",
},
),
"action": (
"What enforcement action should be taken on this post?",
{
"Allow": "Leave the post up with no action.",
"Warn": "Leave the post up but attach a warning label.",
"Remove": "Remove the post, but do not penalize the account.",
"Strike": "Remove the post and add a strike to the account.",
"Escalate": "Take no automated action; hold for a human decision.",
},
),
"queue": (
"Which single moderation queue should own this post?",
{
"Auto": "Auto-resolve; no human queue needed.",
"General": "General moderation queue.",
"Threat": "Threat / violence response queue.",
"Spam": "Spam and platform-abuse queue.",
"TSLead": "Trust-and-safety lead / senior queue.",
},
),
"link_handling": (
"How should any external link or off-platform invite in the post be handled?",
{
"Allow": "Leave the link in place.",
"RmLink": "Strip or disable the link but keep the post.",
"Brigade": "Treat the link as coordinated brigading and action it as abuse.",
"Escalate": "Send the link to a specialist to assess before acting.",
},
),
"review_path": (
"Who should make the final call on this post?",
{
"Auto": "Automated action; no human review.",
"Human": "A frontline human moderator makes the call.",
"Senior": "A senior or specialist reviewer is required.",
"Legal": "Route to legal or law-enforcement escalation.",
},
),
"severity": (
"What is the overall severity of this post?",
{
"None": "No violation.",
"Low": "Rude or dismissive, but essentially harmless.",
"Medium": "Personal harassment with no clearly credible threat.",
"High": "Harassment together with a threat that could be read as credible.",
},
),
}
Built into SDK questions with:
questions = {
key: Choice(instructions=instructions, criteria=choices)
for key, (instructions, choices) in QUESTIONS.items()
}
Combining logic in code
import os
from collections import Counter
from secrets import token_hex
from time import perf_counter
import numpy as np
from typesafe_sdk import Choice, TypeSafeClient
TYPESAFE_MODEL = "jev-latest"
NUM_SAMPLES = 15
MIN_CHOICE_PROBABILITY = 0.60 # illustrative automatic-action threshold
TYPESAFE_PRICE = (0.042, 0.00) # Historical TypeSafe rate, as of 2026-08
typesafe_client = TypeSafeClient(
api_key=os.environ["TYPESAFE_API_KEY"],
base_url="https://api.typesafe.ai",
timeout=30.0,
)
def _call_typesafe(sample_index: int, rubric_hash: str, model: str):
questions = {
key: Choice(instructions=instructions, criteria=choices)
for key, (instructions, choices) in QUESTIONS.items()
}
started = perf_counter()
response = typesafe_client.system_one(
model=model,
state={"uid": f"{rubric_hash}:{sample_index}:{token_hex(4)}", "post": POST},
questions=questions,
)
distributions = {}
for key, (_instructions, choices) in QUESTIONS.items():
probabilities = dict(response.answers[key].probabilities)
distributions[key] = [probabilities.get(label, float("nan")) for label in choices]
return (
distributions,
response.usage.input_tokens,
response.usage.output_tokens,
perf_counter() - started,
{"requested_model": model, "response_model": response.model},
)
The decision rule — the shippable part:
def argmax_label(values: list, labels: list[str]) -> str | None:
"""The label with the most probability mass, or ``None`` if any value is missing or
non-numeric -- a partially parsed distribution never yields a confident-looking pick."""
numeric = [_numeric_value(value) for value in values]
if any(value is None for value in numeric):
return None
return labels[int(np.argmax(numeric))]
def choice_decision_with_uncertainty(values: list, labels: list[str]) -> str | None:
"""Abstain below the action threshold; retain invalid results as parse failures."""
label = argmax_label(values, labels)
if label is None:
return None
probabilities = [float(value) for value in values]
if any(value < 0 or value > 1 for value in probabilities):
return None
return label if max(probabilities) >= MIN_CHOICE_PROBABILITY else "uncertain"
An important implementation note from the text: "This uses the returned probabilities, not the API's separate confidence field, and adds no model calls." At exactly 0.60, the top label is selected.
Results the cookbook reports
Production API, sampled 2026-09-11. All 15 Jev calls returned jev-1.13.0.
Cost and speed (per 8-question rubric call, mean of 15; LLMs in a 16-way pool)
speed vs cost vs
condition calls time/call cost/call ts_choice ts_choice
claude-haiku-4-5 t=0 15 3853ms $0.003498 33.8x 76.1x
claude-haiku-4-5 t=default 15 3860ms $0.003494 33.8x 76.0x
claude-haiku-4-5 single-pick t=0 15 992ms $0.001527 8.7x 33.2x
gpt-5.4-mini t=0 15 2293ms $0.002299 20.1x 50.0x
gpt-5.4-mini t=default 15 1986ms $0.002164 17.4x 47.1x
gpt-5.4-mini single-pick t=0 15 826ms $0.000936 7.2x 20.3x
gpt-5.5-reasoning 15 12978ms $0.041255 113.7x 897.4x
claude-opus-4-8-reasoning 15 10376ms $0.028375 90.9x 617.2x
typesafe_choice 15 114ms $0.000046 1.0x 1.0x
Same caveat as the noul cookbook: these use "historical price assumptions… not verified jev-latest prices or current billing amounts." TypeSafe also notes its own calls were drawn sequentially "after the LLM pool has closed, so each call's latency is a clean round trip rather than one measured under the 16-way LLM thread contention."
Probability standard deviation
Method: per condition, std dev of each label's probability across the 15 repeats, averaged over all labels and questions.
condition mean prob std max prob std parse fail x TypeSafe
claude-haiku-4-5 t=0 0.0012 0.0221 0% 0.12x
claude-haiku-4-5 t=default 0.0516 0.3150 1% 5.29x
gpt-5.4-mini t=0 0.0312 0.0905 0% 3.20x
gpt-5.4-mini t=default 0.0543 0.2303 0% 5.56x
gpt-5.5-reasoning 0.0305 0.1047 0% 3.12x
claude-opus-4-8-reasoning 0.0245 0.0693 0% 2.52x
typesafe_choice 0.0098 0.0515 0% 1.00x
TypeSafe reports this honestly: "Haiku at temperature 0 has a lower mean std dev of 0.0012. The other five LLM probability conditions range from 0.0245 to 0.0543, about 2.5x to 5.6x the TypeSafe mean. Small changes can still switch the top label when two labels are close."
Agreement, with and without the uncertain band
condition raw agree policy agree uncertain automatic conflicts
claude-haiku-4-5 t=0 100.0% 100.0% 0.0% 100.0% 0
claude-haiku-4-5 t=default 87.5% 86.7% 0.8% 98.3% 2
gpt-5.4-mini t=0 99.2% 87.5% 12.5% 87.5% 0
gpt-5.4-mini t=default 90.8% 84.2% 22.5% 77.5% 2
gpt-5.5-reasoning 90.0% 93.3% 30.8% 69.2% 1
claude-opus-4-8-reasoning 92.5% 94.2% 33.3% 66.7% 0
typesafe_choice 90.8% 99.2% 25.8% 74.2% 0
Column definitions from the cookbook: "policy agree counts uncertain as a decision; parse failures count against agreement. automatic is the share of all answers that select a label. conflicts counts questions with more than one concrete label across the repeats, ignoring abstentions."
Where Jev moved: "Before abstention, TypeSafe changes its top label on primary_risk (Harassment 11 times, Violence 4 times) and link_handling (RmLink 8 times, Brigade 7 times). Both rows now show uncertain throughout because their top probabilities are below 0.60." Also: "category alternated between Violence and uncertain, crossing the action threshold on some repeats and not others. No question produced two different concrete TypeSafe labels."
Steady questions: "target reads Person and severity reads High across the board." Contested ones: category, primary_risk, action, review_path, link_handling.
The refusal to over-claim, quoted: "None of this shows accuracy or superiority: Haiku at temperature 0 had 100% agreement here, with no abstentions." And on the chart itself: "Haiku t=0: 100% repeatability does not imply correctness. This experiment does not measure accuracy."
Adapting it to a new domain
- Copy the harness: rubric fingerprint as cache key, per-sample
uid, loggingresponse.model, andchoice_decision_with_uncertainty. - Make every question's label set mutually exclusive and give every label a one-line description; that is what makes the abstention rule meaningful.
- Sweep
MIN_CHOICE_PROBABILITYagainst your own labeled examples. The cookbook is explicit: "The threshold is an illustrative application policy, not a calibrated guarantee or a threshold chosen to maximize this run's agreement." - Read the abstention rate as a cost: 0.60 bought 99.2% agreement at the price of routing 25.8% of answers to a human. A lower floor automates more and agrees less.
- Questions that abstain on every repeat (
primary_risk,link_handlinghere) are telling you the rubric is under-specified for this input, not that the model failed. Re-word or decompose them — see Writing instructions and criteria that Jev reads correctly.
Gotchas
cooksafeis not publicly installable. Install line:pip install anthropic openai matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/.cooksafeis a TypeSafe helper on a private index, andpypi.typesafe.aireturned 404 publicly on 2026-09-17. Usepip install typesafe-sdk anthropic openaiand reimplementJsonCache(Path("json_cache.json"))(a decorator memoizing JSON-serializable returns to a file keyed by call arguments, withsample_index+rubric_hashin the key so each repeat is an independent draw) andmake_playground_link(state, questions, models=[...]).- Jev's labels can flip. 2 of 8 questions changed top label within 15 repeats of the same post. Do not treat a
Choiceas deterministic. - The abstention band has its own edges. "a probability near
0.60can still move between a concrete label anduncertain." - Use
probabilities, notconfidence, for this rule — the cookbook says so explicitly, and they are different fields. See Confidence vs probability. probabilities.get(label, float("nan"))— the code does not assume the response contains every label. Preserve that defensiveness.- Repeatability is not accuracy. Stated three separate times in the source, including inside the exported chart.
jev-latestis an alias;response.modelis logged per call so an alias move mid-run stays visible.
Related
- Cookbook: Self-consistency — nouls — the same experiment for
Noulquestions - Cookbooks overview — the cookbook index
- Testing and evaluating a Jev workflow — running this on your own workflow
- Choice questions —
choice,probabilities,confidence - Confidence vs probability — why the two are not interchangeable
- Jev 1.13 jaggedness: known failure modes — known failure modes of this model version
- Intent routing — routing on a Choice label in production
- Jev vs LLM JSON mode / structured outputs — the broader comparison
Sources
- raw/docs/cookbooks__consistency_choice_cookbook.md (https://docs.typesafe.ai/cookbooks/consistency_choice_cookbook)