Choosing between Choice, Score, Noul
TL;DR Unordered set of options →
choice. Ordered spectrum you can describe level by level →score. Single yes/no where the probability is the signal →noul. If two types fit, pick the one whose answer your code can act on directly. If none fit cleanly, the judgment is too big — split it into several questions and combine them in code.
Step 1 — decide the shape of the answer
Work backwards from the line of code that will consume the answer.
| The answer you need | Primitive | Field your code reads | Typical code |
|---|---|---|---|
| One of N named, unordered options | Choice questions | choice (plus probabilities, confidence) |
if answer.choice == "returns": ... |
| A position on a described spectrum | Score questions | score (plus legend, probabilities, confidence) |
if answer.score / (len(criteria) - 1) > 0.7: ... |
| True or false, with a probability | Noul (yes/no) questions | noul |
if answer.noul > 0.5: ... |
| A ranking across many items | Score or Noul, one question per item | score / noul, sorted in code |
sorted(items, key=...) |
| A count | None — count in code | n/a | one Noul per item, sum(...) |
| A free-text value | None — see below | n/a | regex or a generative model proposes options; a Choice picks one |
| A date, duration or comparison | Choice per component, then code | choice per part |
assemble and compare in datetime |
Step 2 — check the question shape
Upstream's phrasing, per raw/docs/primitives.md:
- Choice fits when the answer is one of a known set of options with no order between them: routing a ticket to a department, classifying a document type, detecting a programming language. Give the full list of options, and add an
otherornone of the aboveoption when the list might not cover every input. - Score fits when the answer falls on a spectrum and you can describe what each point on that spectrum means: bug severity, customer frustration, skill level. The levels are yours to define, and the model returns a position along them.
- Noul fits a clean yes/no question where the probability itself is the useful signal: does this message report a bug, is the customer requesting a refund, does the resume mention distributed systems.
Tie-breaker: if two types both seem to fit, prefer the one whose answer your code can act on directly. A Choice between refund, rebook, and information maps straight onto three code paths. A Score of customer frustration maps onto a threshold. A Noul maps onto an if.
Step 3 — sanity-check against the anti-patterns
Noul anti-patterns
| Anti-pattern | Why it fails | Do instead |
|---|---|---|
| "Is this candidate strong in Python?" | "Strong" is undefined, so the probability is uninterpretable. 0.5 means yes/no are equally likely, not medium skill. | Either define the condition literally ("Does the resume state that the candidate has used Python at work?") or use a Score with levels: no experience / some familiarity / daily use / deep expertise. |
Using noul as a magnitude |
It is P(yes), not a quantity. |
Score. |
criteria.true describing "no" |
Jev 1.13 jaggedness: known failure modes #7: contradictory instructions and criteria perform worse. | Keep true = yes. |
| One Noul per option, treating it as a classification | Nouls are absolute and can all be low; the set does not sum to 1. | A Choice settles which. Use both only deliberately, as Cookbook: Skill suggestion does. |
Choice anti-patterns
| Anti-pattern | Why it fails | Do instead |
|---|---|---|
Options that are really a scale ("low", "medium", "high") |
Choice has no ordering; you lose the ability to threshold a position. | Score with described levels. |
No other / none of the above |
Probability mass lands on the least-wrong listed option. | Add a catch-all option. |
| A shortlist of options "to save tokens" | Options cost a few tokens each; up to 255 are allowed. | Send the full list. |
Acting on choice alone |
A 0.60/0.38 split and a 1.00/0.00 split give the same choice. |
Gate on confidence, inspect probabilities (Confidence-gated routing). |
| A 300-leaf taxonomy in one question | Beyond the 255-option ceiling and hard to describe distinctly. | Chain Choices level by level (Cookbook: Hierarchical classification, Structured instructions, options, levels, criteria). |
Score anti-patterns
| Anti-pattern | Why it fails | Do instead |
|---|---|---|
criteria: ["0", "1", "2"] with "rate 0 to 2" in the instructions |
Each level is judged on its own; the model never sees a level's number or its neighbours. On a cosmetic bug this returned score 0.57, confidence 0.35 versus 0.0 / 1.0 with described levels. | Describe a situation per level. |
| "Moderately severe" as a level | A degree word gives nothing to match the state against. | Describe situations, not degrees. |
| One level saying "punctual and smart and experienced" | The question measures three things; an input high on one and low on another can't be placed. | One dimension per Score, combined in code. |
Interpolating score back into a real number |
jev-1.13's score levels are weak in numerical calibration (Jev 1.13 jaggedness: known failure modes #2). |
Use the expectation to check a threshold only. |
| Comparing a 3-level and a 4-level score directly | Ranges differ (0–2 vs 0–3). | Divide by len(criteria) - 1 first. |
| Levels for discrete categories with no in-between | Nothing to place between them. | Choice, or several Nouls. |
Cross-type anti-patterns
- Don't carry a threshold tuned on a Noul over to a Choice, or vice versa — the two answer different questions.
- Don't ask a question code can answer exactly. Counting, arithmetic, date ordering, and string matching belong in your code (Jev 1.13 jaggedness: known failure modes).
- Don't ask for generated text. When the answer space is bounded, turn extraction into a Choice over the options.
Step 4 — if nothing fits, decompose
"Analyze this message and determine the best course of action" is not a System One question. Ask for a judgment a knowledgeable person makes in a second given the right context.
The decomposition recipe:
- Name the factors. List the independent things the decision actually depends on. Instead of "rate this startup pitch": market size, technical feasibility, differentiation.
- Give each factor its own question, and pick its type by the table above. Facts → Noul. Magnitudes → Score. Categories → Choice.
- Send them all in one request. Questions in a request are evaluated in parallel; adding questions barely changes the response time and costs only the extra question tokens. Include speculative questions whose answers only matter for some inputs (Speculative fan-out).
- Normalize before combining Scores. Divide each
scorebylen(criteria) - 1so every value is on 0 to 1 and the weights mean what they say. - Weight and combine in code. The weights are yours; when the combined result doesn't match what your team would decide, change them in code and run again (Composite scoring).
- Gate on confidence. Low confidence is a reason to ask a person rather than act (Confidence-gated routing, Confidence vs probability).
Worked decomposition: ticket priority
Three Score questions replace one vague "how important is this ticket":
from typesafe_sdk import Score, TypeSafeClient
TRIAGE_QUESTIONS = {
"severity": Score(
instructions="How severe is the reported issue?",
criteria=[
"Cosmetic; no impact to functionality",
"Broken or degraded feature, but workaround exists",
"Blocking issue; no workaround exists",
],
),
"frustration": Score(
instructions="How frustrated is the customer?",
criteria=[
"Calm, just stating facts",
"Frustrated but civil",
"Very angry, strong language or threatening to leave",
],
),
"report_quality": Score(
instructions="How much does the report give an engineer to work with?",
criteria=[
"No detail; just says something is broken",
"Names the feature but no steps or environment",
"Steps to reproduce or environment, but not both",
"Steps to reproduce and environment",
],
),
}
def normalized(answers, question_id: str) -> float:
"""Put a score on 0 to 1 by dividing by its top level number."""
top_level = len(TRIAGE_QUESTIONS[question_id].criteria) - 1
return answers[question_id].score / top_level
def priority(ticket: str) -> float:
with TypeSafeClient() as client:
response = client.system_one(
state=ticket,
questions=TRIAGE_QUESTIONS,
)
answers = response.answers
severity = normalized(answers, "severity")
frustration = normalized(answers, "frustration")
report_quality = normalized(answers, "report_quality")
# A detailed report helps an engineer investigate, so it raises priority a little.
return 0.6 * severity + 0.3 * frustration + 0.1 * report_quality
On the worked response in Score questions this yields 0.6 × 0.62 + 0.3 × 0.725 + 0.1 × 1.0 = 0.6895 → 0.69.
Mixing types in one request
You can mix types freely in one request. This is the shape most triage code ends up with:
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
state = {
"ticket_message": "My flight was cancelled. Can I get a refund?",
"refund_policy": "Cancelled flights are eligible for a full refund.",
}
with TypeSafeClient() as client:
response = client.system_one(
state=state,
questions={
"refund_requested": Noul(
instructions="Does `ticket_message` request a refund?",
),
"request_type": Choice(
instructions="What is the main request in `ticket_message`?",
criteria={
"refund": "The customer wants money returned.",
"rebooking": "The customer wants a replacement flight.",
"information": "The customer is asking for information only.",
},
),
"frustration": Score(
instructions="How frustrated does the customer appear in `ticket_message`?",
criteria=[
"Calm and neutral.",
"Concerned but civil.",
"Very angry or using strong language.",
],
),
},
)
print(response.answers["refund_requested"].noul)
print(response.answers["request_type"].choice)
print(response.answers["frustration"].score)
Step 5 — when to make a second request
Questions in one request are independent: one answer does not become context for another. Make a second request only when your code cannot build it until it has the first answer — it needs the answer to fetch more data for the state, to decide what the state is made of, or to pick the next question's options. Two requests are the exception, not the rule. If the second request's questions could have been asked against the original state, ask them in the first request and let the code ignore what it doesn't need.
The three documented legitimate cases: Cookbook: Skill suggestion (re-judge the top 3 of 182 against fetched full text), Cookbook: Structure recovery (autoformat) (classify blocks that did not exist before the first answers), Cookbook: Hierarchical classification (each Choice answer decides the next request's options).
Quick reference
- Noul →
{"type": "noul", "instructions": "..."}→noul - Choice →
{"type": "choice", "instructions": "...", "criteria": {"opt": "desc"|null}}(≤255 options) →choice,probabilities,confidence - Score →
{"type": "score", "instructions": "...", "criteria": ["low", ..., "high"]}(2–10 ordered levels) →score,legend,probabilities,confidence
Related
- Primitives: Choice, Score, Noul — the overview this guide expands
- Choice questions, Score questions, Noul (yes/no) questions — full per-type contracts
- Writing instructions and criteria that Jev reads correctly — once you have picked a type
- Jev 1.13 jaggedness: known failure modes — what the model cannot do at all
- Confidence vs probability — thresholds and escalation
- Composite scoring, Speculative fan-out, Confidence-gated routing, Intent routing
- How to build software with System One — where these calls sit in a system
- HTTP API: POST /v1/systemone and GET /v1/models — wire format
Sources
- raw/docs/primitives.md (https://docs.typesafe.ai/primitives)
- raw/docs/primitives__choice.md (https://docs.typesafe.ai/primitives/choice)
- raw/docs/primitives__score.md (https://docs.typesafe.ai/primitives/score)
- raw/docs/primitives__noul.md (https://docs.typesafe.ai/primitives/noul)
- raw/docs/model-jaggedness__jev-1.13.md (https://docs.typesafe.ai/model-jaggedness/jev-1.13)