Skip to main content
One YAML file defines one judgment: what rows to read, what to ask, and how to check the answers. A folder of specs is a project. Start with the small spec below, then use this page to look up a key. hunch lint checks every key on this page; unknown keys are warnings, not errors. For completion and checks while you type, put this line at the top of a spec (the recipes start with it):
VS Code and Cursor read it through the YAML extension (redhat.vscode-yaml). The schema is also in the installed package, for working offline: python -c "import hunch, pathlib; print(pathlib.Path(hunch.__file__).parent / 'spec.schema.json')". It covers keys and allowed values; lint also checks what it cannot, such as whether a column reaches the spec.

A minimal spec

This reads a CSV with id, message, and gold_urgent columns (yes or no). It asks one yes/no question and checks its answers against the known labels:
urgent.yml
noul is the YAML type name for a yes/no question. The engine returns both an answer and its probability. The gold column is optional when you have no answer key; you can build one with review.

A representative spec

This example shows several question types, routing thresholds, sampling weights and tests together. It is not an exhaustive spec: project keys such as where, chain and union appear below. It passes hunch lint against a CSV with columns id, request, cwd, description, command, sample, gold_purpose, gold_destroys, gold_side_effects:
command_guard.yml

Top-level keys

judgment

Unique within a project; also the table name in the store. answers, traffic, and names starting with _ or sqlite_ are rejected.

model

The engine, pinned to an exact version. Names ending in latest or containing :~ are refused: different versions would share cache keys.
--model <engine> overrides it for one run. See Engines.

source

Where the rows come from. Relative paths resolve from the spec’s folder.
--source other.csv replaces a root spec’s source for one run; --traffic replaces it with rows logged by judge(...).

view

How traces(...) turns sessions into rows. Ignored for other sources.
  • next_message: the developer’s next message.
  • Injected text (IDE context, ! shell output, reminders, messages from other agent sessions) is removed from user messages. A message that is only injected text, a slash command, a context-continuation summary or an interruption is not a turn.
  • A session’s last turn has no next message, so it is not a row.
  • ran_after_edit: yes if the agent ran a command after its last edit, no if not, empty if it made no edits.
  • final_messages (runs): the agent’s last three messages.
  • commands: request is the last message the person typed before the command; cwd the folder it ran in; description the agent’s own note on it; rejected is person if the person refused the call, classifier if Claude Code’s auto-mode safety classifier did, no otherwise; failed is yes if it returned an error. After a context compaction, a background notification or a slash command (none of which count as requests), request is the last message the person typed before it. Ids are <session>#<turn>.<n>, the n-th command in that turn.

key

The column that identifies a row; diff, review and on_change pair rows by it. Must be unique (run warns on repeats). A ref(...) spec inherits its upstream’s key.

state

The columns the model sees, sent as a JSON object. Nothing else is sent. The cache key is computed from exactly this, after redact and clip; \r\n and \n give the same key.
One column name on its own is sent bare, as a string: the form a Pydantic AI agent sends its prompt in. The two forms are different inputs, so they have different answers and keys. Limit: about 32,000 tokens for state plus the longest question. run and compile warn above 80% and name the largest column; use clip.

redact

Rules applied to every state value before it is hashed, sent or logged. Named rule sets: Anything else is a regular expression; matches become [REDACTED].
Quote regular expressions with single quotes. In YAML double quotes, a backslash starts an escape, so "\d{6}" fails to load. A regex without backslashes, such as "[0-9]{1,3}([.][0-9]{1,3}){3}" for IPv4 addresses, works either way.

clip

N keeps a state column’s first N characters, -N its last N. The column must be in state; N is a nonzero integer.

where

Keep only rows where a condition holds, before asking. Can use source columns and, on a ref(...) spec, the upstream’s answer columns. Allowed: column names, constants, ==, !=, <, <=, >, >=, in, not in, and, or, not. Compared with a number, text is read as a number (' 12 ' and 1e3 count); a cell that isn’t one (empty, n/a, nan) matches no numeric condition, != included, so not year > 2008 keeps it. -year reads the cell as a number the same way. Compared with True or False, true and false (any case) count too, as do 1 and 0; for yes write flag == 'yes'. in holds when == holds for some item of the list, not in when != holds for every one, so year in [2008, 2009] reads year as a number, and an empty cell is in neither. Against text, in looks for a substring and needs text on both sides: 2.5 in text matches nothing, not in included. A column on its own (where: flag) holds unless the cell is blank or flag == False would hold (false, 0); no is text, so it holds, and lint warns when a yes/no answer stands alone in a where, a metric rule or a baseline (where: recent: write recent == 'yes'). A where that keeps none of a run’s rows prints a warning. lint rejects a column that does not reach the spec.

chain

Needs where. Confidence (<question>_p, and what act compares) becomes the judgment’s own confidence times the probability that where holds, computed from the upstream answers’ distributions across every hop.

union and question

Combines one question’s answers from several judgments into one table, so a tree of judgments is tested as one classifier. Every branch asks question with the same type; a branch may itself be a union that combines the same question. A row may reach only one branch (overlapping wheres stop the run). Rows gain _branch, the branch they came through. key defaults to the first branch’s and must be a column every branch has. A rule or a downstream where can read only the columns every branch has, even behind _branch == ...: a rule about one branch’s own column goes on that branch. A union has no model, source or questions: it takes question, key, reviews, metrics, tests, description, exposures and targets; lint says so for any other key.

reviews

The CSV where hunch review saves verdicts, which become gold for test and diff. Default: <judgment>.reviews.csv next to the spec. Specs asking the same question of the same rows can share one file.
A verdict applies while the row’s state is unchanged. Keep the file in git; it is not in the store.

weights

Corrects for a sample whose mix differs from production. Each row gets _w = population share ÷ sample share of its by value; test weights accuracy, calibration and estimates by it. Every by value needs a share above 0; shares sum to 1. Set it on the spec that reads the file; downstream specs carry _w.

tests

Per-question thresholds for hunch test. A failure exits 1, unless the entry has severity: warn.
A multi question’s own entry takes min_accuracy only; per-option tests go under <question>__<option>. A metric takes its own tests under its name. Limits are shares between 0 and 1 (0.9, not 90). severity: warn in an entry makes its failures print WARN and leaves test’s exit code alone: for a check you want to see on every run but not block on. The default is error.

metrics

A metric is a rule over one row: a condition on the judgment’s answers and its columns, in the same language as where. In a rule, a question’s name stands for that row’s answer ('yes'/'no', an option, a level), with <question>_p and <question>_pyes beside it; source columns work too. On a union, a rule reads its question, the columns every branch has and _branch. hunch test counts the rule twice, and compares the two. This example is from the command guard, whose three yes/no questions are named destroys, reaches_outside and sends_out:
Why only random spot checks, and how gold stands in for an answer: How the numbers work. A rule that reads only columns, no answers, gets the first line only. With weights, every line is weighted to the population. Counts stay the rows seen; each rate is weighted, with a 95% interval on the effective sample size, which the line shows:
min_rate and max_rate check the weighted rate. test writes the same numbers to the results file under metrics.

Per group

by: <column> reports the metric once per value of that column, such as a party or a period. The column can be a source column or an answer of this judgment. The overall lines stay as they are.
Each group gets its rate on answers with a 95% interval, and on gold when the metric has gold (the same rows as the overall on gold line). Unlike the unweighted overall line, a group’s rate on answers always has an interval: comparing groups is a claim about what each group produces, not only about these rows. With weights, group rates are weighted like the overall lines, and each line shows the effective sample size its interval uses. Rows with no value in the column are left out and counted. A group under 10 rows is still listed, with a note that its interval is too wide to say much. higher: [a, b] passes when the 95% interval of the difference, a’s rate minus b’s, lies above 0. It can pass while the two groups’ intervals overlap; test says so. It fails when either group has no rows. The check is on the model’s answers. When both groups have gold, test prints the same difference on gold. In the example it does not hold there: the model misses more health bills before 2008, which widens the gap. A finding that passes on answers but not on gold is a finding about the model, until more gold says otherwise. One higher per metric: each extra comparison you run is another chance of a false pass, so pick the pair before you look. Details: How the numbers work.

examples

Statistical tests say how often a judgment is right; they cannot pin a case that must never go wrong. An example is a row you write in the spec, with the answers it must get. hunch test asks it like any other row and turns each example into one check.
The second example has severity: warn, so it is reported without failing test; without it, the line reads FAIL and test exits 1. An example is asked once and then comes from the store like any answer, so it costs nothing in later runs. It tests the spec’s own model: escalate is not applied, and where does not filter it. Results are in the results file under examples.

on_change

What happens to past answers when the spec changes:

description

A sentence saying what the judgment decides, shown by hunch docs. Never sent to the model, and not part of any answer’s key or the spec hash, so editing it re-asks nothing and doesn’t make a test stale.

exposures

What uses the judgment’s answers. hunch never calls them: hunch docs shows them, and hunch diff names the ones a change reaches. Like description, they are outside every key and the spec hash.

Answers an exposure relies on

uses as a mapping names, per question, the answers the exposure’s code compares against:
Lint, which every command runs first, fails while a listed value is not one the question can answer. hunch diff counts, per exposure, the rows whose answer moved into or out of a listed value. Change a spec safely shows both.

targets

Named ways to run the same spec. --target NAME picks one; without --target, the spec runs as written.
A flag given on the command line wins over the target: --target dev --model X answers with X. In an app, hunch.judge(spec, target="dev", ...) answers with the target’s model and store. HUNCH_TARGET sets the default for the CLI and for judge(), so a server runs as dev without a code change. In a folder of specs, each judgment answers with its own targets.NAME.model, and a judgment without one uses its model. sample, store and max_cost apply to the whole run, so specs that set them must agree. Lint fails on an unknown key inside a target, and on a --target that no spec defines. targets is not part of the spec hash, so adding one makes no test stale. test records the target in its results and hunch docs shows it. test --receipt refuses a target: a receipt records the spec as written.

Question keys

Keys not sent are not in the cache key; changing them asks nothing.

Question types

One option. criteria maps each label to a description. Describe all options or none (lint warns on a mix). At most 255 options.

act

Confidence at or above which an answer is acted on; below it, the row goes to the review queue. A number in (0, 1], or for noul one threshold per answer:
With chain: true, the confidence compared is the chained one.

gold

The source column with the right answer. Empty cell: no gold for that row. Reviews override this column for the rows they cover and can accept two labels.

escalate

Re-asks answers below act on another engine. The new answer is used only if it clears act itself (behind a distilled: model, always); both are stored and <question>_by names the engine used. Needs act and a different model.

baseline

A rule that answers a yes/no question without a model: a keyword search, or a condition on columns. test scores it on the same gold rows as the model and prints both; with weights, accuracy, recall and precision are weighted, AUROC is not. When the rule is as accurate as the model, test says so. A regex, found in any of the columns (yes when it is):
Case-sensitive unless it starts with (?i). An empty cell matches nothing. Or a where expression, yes when it holds:
The model’s side is its own label: yes at p(yes) ≥ 0.5. AUROC is p(yes) for the model, yes or no for the rule. The rule can read any column that reaches the judgment except its questions’ gold columns. A non-numeric cell compared to a number says no. A union reports the rule when every branch has the same one; each branch is tested with its own. test writes the same numbers to the results file under baseline.

none

Adds an option none_of_these with this description, so declining is an answer rather than low confidence. test reports how many rows declined.

Columns a judgment adds

A judgment’s output rows (what ref(...) and where read) have their input columns plus: lint rejects a question named like an input column (often its gold column). The store also adds <question>_key and _hunch_run_id; see Store.

YAML details

  • Only true and false are booleans. yes, no, on and off stay text, so options and act: {yes: …, no: …} keep their names.
  • Relative paths (source, reviews, files in traces(...) and py(...)) resolve from the spec’s folder.