Skip to content
  • FDA AI-DSF lifecycle guidance: DRAFT (Jan 2025) — not for implementation
  • PCCP guidance: final (Dec 2024, updated Aug 2025)
  • FDA Statistical Guidance on diagnostic tests: final (2007)
  • Guidance map v1

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()):

  1. criteria.yaml is 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.
  2. 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.
  3. H11, then H07, on the header list.
  4. Mapping, then schema typing: S01, S02, S03.
  5. H02, H03, H05, H06, H01, H04 on the typed table.
  6. H09 for attributes and levels, and H08 for a numeric age column with no bands, against the typed table.
  7. 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>).

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.

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.