HALT gates H01–H12
A HALT is the engine refusing to compute. Eleven of the twelve gates exit with
code 3, print HALT Hxx: <message> on stderr — followed, when the gate
carries detail, by a detail: line of aggregates (counts and declared values;
never a header, a cell value or a free-text field) — then
No document was written., and write nothing to --out. There is no partial
pack and no silent number. The twelfth, H10, is listed in
FLAG_ONLY_CODES: it never halts, and a run in which it fires exits 2 with the
finding printed as [W10].
The codes below are the engine’s HALT_CODES table in errors.py; the bold
line under each heading is its one-line meaning, verbatim. “What the gate
inspects” is what gates.py, io/mapping.py and io/declare.py actually
test. Each code’s address is stable — /docs/halt-gates#h01 through #h12,
#s01 through #s04, #w06, #w10, #w12 and #w13 — the one convention
D5 §3.7 gives the CLI line, the browser demo and the onboarding emails. Today
the lifecycle day1_halt email (lane A) links the H- and S-code addresses;
the CLI’s HALT line does not yet end with its address, and the browser demo is
not built.
Order of evaluation in proofpack run (cli.py cmd_run, then gates.py
ingest()):
criteria.yamlis loaded and validated first — a missing block, an empty authored field, a schema violation or a duplicate id is H08 here, and an unknown metric or operating point is H09 here — before the table is opened.- The table is read into memory (
load_table): a file that cannot be parsed, an empty file, duplicate headers or a ragged row is S04. - H11, then H07, on the header list.
- Mapping, then schema typing: S01, S02, S03.
- H02, H03, H05, H06, H01, H04 on the typed table.
- H09 for attributes and levels, and H08 for a numeric
agecolumn with no bands, against the typed table. - The H10 flag.
So a criteria file that lacks a justification halts with H08 before a
date-like column is ever looked at (measured: the same table halts H11 with a
clean criteria file and H08 with one criterion’s justification emptied), and
a ragged row halts S04 before H11 in both run and map. H12 runs only in
compare.
AUROC < 0.5 on declared orientation
What the gate inspects. After exclusions, the engine computes the
Mann–Whitney AUROC of score against y_true with the score oriented as
declared (score.orientation). A value below 0.5 means the declared direction
ranks negatives above positives.
Message. declared orientation contradicts data; check score.orientation / classes.positive, with the AUROC under the declared orientation as detail.
What to change. One of two declarations is the wrong way round: either
score.orientation (higher_is_positive / lower_is_positive) or
classes.positive. Check which label means condition present and which way
your model’s output runs, then correct criteria.yaml. The engine will not
flip either for you.
y_true values not a subset of declared classes and indeterminate values
What the gate inspects. The set of non-missing y_true values against the
union of classes.positive, classes.negative and indeterminates.values.
Detail: how many unknown values, and how many were declared.
Message. y_true contains value(s) outside declared classes and indeterminate values.
What to change. Either your label column carries a value you have not
declared (a third class, a typo, a code for indeterminate), or your
declarations use a different spelling from the data ("1" versus 1 is
compared as text, so that pair is fine; Yes versus yes is not). Add the
value to indeterminates.values if that is what it means, otherwise fix the
data or the declaration.
score.type is probability and a value lies outside [0, 1]
What the gate inspects. With score.type: probability, every non-missing
score value against the closed interval [0, 1]. Detail: the count outside.
Message. score.type is probability but values lie outside [0, 1].
What to change. If the column is a logit, a raw margin or a scaled score,
declare score.type: logit or other (calibration is then not computed). If
it is a probability, the out-of-range values are a data defect to fix at
source.
y_pred not reproducible from score at a declared operating point
What the gate inspects. For every declared operating point, every analysed
row where y_pred is present and not an indeterminate value: the declared
rule applied to score at that threshold must yield exactly y_pred.
Detail: the operating point id and the count of mismatching rows.
Message. y_pred not reproducible from score at operating point <id>.
What to change. The predicted label in your table was not produced by the
threshold and rule you declared: a different threshold, a different comparison
(>= versus >), a later post-processing step, or a y_pred column from
another model version. Declare the operating point that actually produced
y_pred, or drop the y_pred column and let the engine derive predictions.
duplicate row_id, or duplicate case_id rows with conflicting y_true
What the gate inspects. Two things. Any repeated non-missing row_id
(detail: the duplicate count). And, with clustering.unit: none, any
case_id that appears on rows with different y_true values (detail: the
conflicting-row count).
Messages. duplicate row_id values; duplicate case_id rows with conflicting y_true while clustering.unit is none.
What to change. For duplicate row_id, de-duplicate or re-key the table. For
conflicting case_id, decide what a case is: if several rows per case are
expected (several studies per patient), declare clustering.unit: case_id and
the run takes the clustered path; if not, the conflicting rows are a data
defect.
single-class dataset
What the gate inspects. After exclusions, the number of analysed rows and
the count of each declared class. Zero analysable rows, or a class with zero
rows, halts. When a site column is present and a site has only one class,
the run continues with warning W06 (single-class site(s) present; site-level metrics limited) instead.
Messages. no analysable rows after exclusions; single-class dataset.
What to change. A test set with no positives (or no negatives) cannot yield
sensitivity (or specificity), so there is nothing for the engine to report.
Check that classes.positive is the label that actually appears, that the
missing-value tokens have not swallowed a class, and that the file is the
test set rather than a dev slice.
header-set hash differs from mapping.json in non-interactive mode
What the gate inspects. In non-interactive mode (--yes), whether a prior
mapping.json exists, whether its order-independent header-set SHA-256 equals
the current table’s, and whether every mapped role in it is at high
confidence. Also, in any mode, whether two headers map to the same canonical
role, and whether mapping.json can be read at all.
Messages. non-interactive mode requires an existing mapping.json;
header-set hash differs from mapping.json in non-interactive mode (detail:
the two hash prefixes); non-interactive mode requires every mapped role at high confidence (detail: how many are below); two columns map to the same canonical role; mapping.json could not be read.
What to change. Run proofpack map interactively once against this exact
header set, review the roles, and keep the mapping.json it writes with the
table; pass it with --mapping. If a header changed, the table is not the
table the mapping was reviewed for, which is the point of the gate. See
non-interactive runs.
mandatory declaration missing or criterion lacking author/date/justification
What the gate inspects. criteria.yaml against its schema and the engine’s
own rules: the mandatory blocks (classes, score, operating_points,
prevalence, subgroups) and the declared-never-inferred blocks
(reference_standard, indeterminates, clustering) must be present and
non-empty; every criterion and the fairness block must carry non-empty
author, date and justification; operating point ids and subgroup
attributes must be unique; classes.positive and classes.negative must
differ and no indeterminate value may equal either; a numeric age column
needs declared bands; and the file must satisfy criteria_schema.json
(a schema violation is reported as H08 with the path that failed).
Messages. mandatory declaration block(s) missing: <names>;
declared-never-inferred block(s) missing: <names>; criterion <id> lacks <fields>; fairness block lacks <fields>; numeric age column present but no age bands declared; operating point ids are not unique; classes.positive and classes.negative are identical; an indeterminate value coincides with a declared class; subgroup attributes are not unique; for a file that is not
YAML or not a mapping, criteria file could not be read: <exception name>,
criteria file must be a YAML mapping, criteria must be a mapping,
criteria block must be a list, criteria[<i>] is not a mapping, fairness block is not a mapping; and, for a schema violation, declaration invalid at <path>: <validator> — the path and the rule, never the offending value.
What to change. Author the missing declaration. There is no default the
engine can fill in: each of these is a manufacturer’s decision, and the pack
echoes who made it and when. The criteria.yaml page lists
every block and field.
criterion references unknown metric/operating point/attribute/level
What the gate inspects. Every criterion’s metric against the metric-id
enum, its operating_point against the declared ids, and its scope
attribute and level against the attributes and levels the table actually
carries; every subgroups entry and the fairness.attribute against the
table’s attributes.
Messages. criterion <id> references unknown metric; criterion <id> references unknown operating point; criterion <id> references unknown attribute; criterion <id> references unknown level; subgroup declaration references attribute absent from the table; subgroup reference_level is not an observed level; fairness block references attribute absent from the table. The detail: line names the criterion id and the field that failed
({"criterion": "C1", "field": "metric"} for an unknown metric, measured on
15 September 2026); an attribute name appears in it only for the attribute and
level messages.
What to change. Spell the metric as the metric-id list does, reference an operating point you declared, and name only attributes and levels that exist in the mapped table — a level that is in your protocol but not in this test set cannot be a criterion’s scope.
observed vs declared intended-use prevalence differ by > 0.10 absolute (flag only)
Flag only — this code never halts. errors.py lists H10 in
FLAG_ONLY_CODES; the run continues, exits with code 2 (warnings only) and
the finding is recorded as W10.
What the gate inspects. The observed prevalence of classes.positive among
analysed rows against each entry in the declared prevalence list. Detail:
the label, the observed value and the declared value.
Message (as finding W10). observed prevalence differs from declared intended-use prevalence by > 0.10 absolute; PPV/NPV will also be computed at declared prevalence.
What to do. Nothing is required. The flag records that the test set’s
prevalence is not the intended-use prevalence, and the pack reports PPV and
NPV at the declared prevalence as well as the observed one. If the declared
value is wrong, correct its source and value.
date-like column present with no period declaration
What the gate inspects. The header list, before mapping and before any
column is typed: date-like column names. Any such column with no period
declaration halts; so does a date-like column the declaration does not name.
Detail: how many date-like columns are uncovered. The gate exists for privacy
— dates are identifiers. load_table has already read the file into memory
when it runs (step 2 above); H11 is the first gate that runs on that table,
and no value has been typed, mapped, coarsened or written.
Messages. date-like column present with no period declaration (privacy);
date-like column(s) present that the period declaration does not cover (privacy).
What to change. Declare period: {column: <name>, granularity: quarter | month | year} for the date column you want as the time axis, and remove any
other date column from the table before the run. Dates are coarsened at
ingest and never appear in an output.
compare paired mode with unmatched row_ids
What the gate inspects. In proofpack compare, that both tables carry
row_id, and that the two row_id sets are identical. Detail: the count of
ids present in one table only.
Messages. paired compare requires row_id in both tables; paired compare with unmatched row_ids.
What to change. If the two versions were scored on the same test set, the
ids should match — check the join key. If they were not, pass
--allow-unpaired: the comparison then uses unpaired methods and is labelled
“not like-for-like” in the pack (finding W12), which is the honest reading
of a comparison across two different samples. See
version comparison.
S-codes: structural failures that also HALT
Section titled “S-codes: structural failures that also HALT”Four failures halt with exit code 3 but are not one of the twelve gates; they
carry an S-code (SCHEMA_CODES in errors.py) so the H-code table stays
exact. Each has its own address here — #s01 to #s04 — because the
day1_halt email links them the same way it links the H-codes. The bold line
is the engine’s one-line meaning, verbatim.
required column missing
Raised by io/schema.py after mapping when no column maps to y_true, or
when neither score nor y_pred is present. Messages: required column role 'y_true' is missing; one of 'score' or 'y_pred' is required.
column value cannot be coerced to its declared type
Raised during typing (io/schema.py validate): a score or age value
that is not numeric (column role '<role>' has <n> value(s) that are not numeric), an infinite score (score contains infinite values), an
indeterminate value that is not 0/1 (column role 'indeterminate' must be 0/1), a declared period column value that is not an ISO date or has an
invalid month, or a dataset value that is not dev/test. Detail, as
each raise site in io/schema.py sets it: role and count for the
not-numeric and 0/1 cases; role alone for the infinite-score case; count
alone for the dataset case; no detail at all for the two period cases (the
CLI prints no detail: line for those). None of those four detail dicts
carries a cell value; tests/day5-repair2.test.mjs reads the S02 raise sites
and asserts their keys are drawn from role and count only.
declared period column not present in the table
Raised during typing when period.column names a column the mapped table
does not carry. Message: declared period column is not present in the table.
table could not be read
Raised by load_table before any gate: an empty file (table is empty (no header row), table is empty (no rows)), duplicate header names, a row whose
width differs from the header’s (row width differs from header width, detail
expected/observed widths), JSON columns of differing lengths, a JSON shape
that is not a list of objects or {columns: {...}}, or an OS, encoding, CSV or
JSON parse error (table could not be read: <exception name>).
W-codes: findings that do not halt
Section titled “W-codes: findings that do not halt”A finding is written to the report the command leaves on your disk —
ingest_report.json for run (code, message and detail),
compare_ingest_report.json for compare (code and message only) — and a
command with any finding exits 2. Without --quiet, run also prints each
finding as [Wxx] <message> under the ingest ok line (cli.py
cmd_run), and compare prints compare ingest ok (paired=…) and
written: … and no [Wxx] line (cmd_compare builds no such line; measured
15 Sep 2026:
compare --allow-unpaired on two 60-row tables with disjoint row_id sets
exited 2 with W12 in the report and nothing but those two lines on stdout);
under --json-log the whole report, findings included, is the one JSON
object on stdout. The T8 manifest that is specified to carry findings is
not in engine 0.1.0.dev1. Each finding has its own address here — #w06,
#w10, #w12, #w13 — under the same convention as the H-codes. The bold
line is the engine’s WARN_CODES meaning, verbatim.
single-class site(s) present; site-level metrics limited (D1 section 5)
Emitted by gate H06 when a site column is present and at least one site has
only one class, while the dataset as a whole has both.
observed prevalence differs from declared intended-use prevalence by > 0.10
Emitted by the H10 flag, see H10 above.
unmatched row_ids in a paired compare; unpaired methods used
Emitted by proofpack compare --allow-unpaired when the two row_id sets
differ; without the flag the same condition is HALT H12. It is in
compare_ingest_report.json (its unmatched count is not written there)
and, without --json-log, not on the terminal: see the note above.
clustering detected from repeated case_id although clustering.unit is 'none'
(Shown in a code span so the page’s typography does not curl the quotes.)
Emitted by stats/bootstrap.py (ClusterPlan.finding()) when the interval
code detects repeated case_id values that were not declared as clustering.
run in engine 0.1.0.dev1 stops after ingest and the gates, so nothing on
the current command line reaches it; conflicting y_true within a repeated
case_id is HALT H05 at ingest.
Reading a HALT in --json-log mode
Section titled “Reading a HALT in --json-log mode”With --json-log the same information is one JSON object on stderr:
{"halt_code": "H03", "message": "...", "detail": {...}}. The detail object
carries only aggregates — counts, role names, declared values — so it is safe
to paste into a support ticket. The docs anchor for every H-, S- and W-code on
this page is https://proofpack.globalphoenix.co.uk/docs/halt-gates# followed
by the code in lower case; src/data/anchors.json in the site repository
lists all twenty, and tests/day5-docs.test.mjs resolves each against the built
page.