---
title: "Choosing between Choice, Score, Noul"
type: guide
tags: [guide, primitives, decision, choice, score, noul]
created: 2026-09-17
updated: 2026-09-17
confidence: high
sources:
  - raw/docs/primitives.md
  - raw/docs/primitives__choice.md
  - raw/docs/primitives__score.md
  - raw/docs/primitives__noul.md
  - raw/docs/model-jaggedness__jev-1.13.md
jev_version: "jev-1.13.0"
sdk_python: "0.6.0"
summary: "Decision table mapping question shape to Choice, Score or Noul, the anti-patterns for each, and how to decompose a complex judgment into several primitives."
---

# 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 | [[concepts/choice]] | `choice` (plus `probabilities`, `confidence`) | `if answer.choice == "returns": ...` |
| A position on a described spectrum | [[concepts/score]] | `score` (plus `legend`, `probabilities`, `confidence`) | `if answer.score / (len(criteria) - 1) > 0.7: ...` |
| True or false, with a probability | [[concepts/noul]] | `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 `other` or `none of the above` option 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" | [[concepts/jaggedness-jev-1-13]] #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 [[cookbooks/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` ([[patterns/confidence-routing]]). |
| A 300-leaf taxonomy in one question | Beyond the 255-option ceiling and hard to describe distinctly. | Chain Choices level by level ([[cookbooks/hierarchical-classification]], [[concepts/advanced-structure]]). |

### 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 ([[concepts/jaggedness-jev-1-13]] #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 ([[concepts/jaggedness-jev-1-13]]).
- **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:

1. **Name the factors.** List the independent things the decision actually depends on. Instead of "rate this startup pitch": market size, technical feasibility, differentiation.
2. **Give each factor its own question, and pick its type by the table above.** Facts → Noul. Magnitudes → Score. Categories → Choice.
3. **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 ([[patterns/fan-out]]).
4. **Normalize before combining Scores.** Divide each `score` by `len(criteria) - 1` so every value is on 0 to 1 and the weights mean what they say.
5. **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 ([[patterns/composite-scoring]]).
6. **Gate on confidence.** Low confidence is a reason to ask a person rather than act ([[patterns/confidence-routing]], [[concepts/confidence]]).

### Worked decomposition: ticket priority

Three Score questions replace one vague "how important is this ticket":

```python
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 [[concepts/score]] 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:

```python
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: [[cookbooks/skill-suggestion]] (re-judge the top 3 of 182 against fetched full text), [[cookbooks/autoformat]] (classify blocks that did not exist before the first answers), [[cookbooks/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

- [[concepts/primitives]] — the overview this guide expands
- [[concepts/choice]], [[concepts/score]], [[concepts/noul]] — full per-type contracts
- [[guides/writing-instructions-and-criteria]] — once you have picked a type
- [[concepts/jaggedness-jev-1-13]] — what the model cannot do at all
- [[concepts/confidence]] — thresholds and escalation
- [[patterns/composite-scoring]], [[patterns/fan-out]], [[patterns/confidence-routing]], [[patterns/intent-routing]]
- [[concepts/how-to-build]] — where these calls sit in a system
- [[reference/http-api]] — 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)
