Skip to content

Informative errors. They're important. - #37

Merged
handrews merged 11 commits into
mainfrom
informative-errors
Oct 4, 2026
Merged

handrews merged 11 commits into
mainfrom
informative-errors

Conversation

@handrews

@handrews handrews commented Oct 3, 2026

Copy link
Copy Markdown
Owner

What it says on the tin. Hopefully these errors are sufficiently informative, but as I'm using this package in another project I'm sure I'll notice soon enough.

handrews and others added 11 commits October 3, 2026 11:45
Every keyword spelled its error message twice: an f-string in `evaluate`
and `str()`-joined parts in `lower`. That was workable while messages
held only schema constants, but messages are about to carry instance
values, where JSON and Python rendering differ.

`core/messages.py` adds `realize`, which interprets the IR subset a
message may use against a concrete instance, so a keyword can describe
an error once and hand the same description to both tiers. It also adds
the formatting helpers messages will call, wired into the closed
`HelperName` set and the compiled runtime:

- `preview`: compact JSON, cut at 64 characters with an ellipsis. Lazy,
  so a huge instance costs no more than a short one; errors are often
  recorded only to be dropped.
- `apparent_type`, `index_ranges`, `name_list`, `duplicate_groups` and
  `ranges`.

A property test checks over generated instances that `realize` and the
compiled evaluator produce identical results. No keyword uses any of
this yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The numeric bounds, `multipleOf`, the length and count bounds, `enum`
and `const` named their limit but never the value that broke it. Each
now shows both:

- `must be >= 5, got 3`
- `must be at least 5 characters, got "abc" (3)`
- `must have at least 3 items, got 1`
- `must be one of [1, 2, 3], got 4`

Values render as compact JSON, cut at 64 characters. Params gain the
same facts in full: `value`, plus `length` or `count`.

`assertion()` now takes one builder, `describe(value, instance)`, which
`lower` emits and `evaluate` realizes (P18), so the two tiers share a
single source for each message. The IR-shape tests compare conditions
and treat the `Fail` as opaque; each keyword's message test realizes the
lowered `Fail` and compares text and params with `evaluate`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `type`: `expected string, got 3 (integer)`. A scalar shows its value and
  apparent type, with `integer` for a mathematical integer; a container
  shows only its type. `params.actual` is now the apparent type, and
  `value` is added.
- `pattern`: `must match pattern "^a", got "b"`. Asserting `format`:
  `must match format "ipv4", got "999.1.1.1"`. Both add `value`.
- `required`: one error naming every missing property,
  `missing required properties "a", "c"`, with params `{"missing": [...]}`
  replacing one error per name.
- `dependentRequired` and draft-07 `dependencies` arrays: one error per
  keyword, `"a" requires "b", "c"; "d" requires "e"`, with params
  `{"missing": {"a": ["b", "c"], "d": ["e"]}}`. For `dependencies` it comes
  after any schema members' errors in both tiers.

New message helpers (`typed_preview`, `labeled_names`, `missing_names`,
`missing_dependencies`, `dependency_list`) keep each message to one call
in emitted code. The flag tier's `required` check becomes one
short-circuit `or`, equivalent to the separate guards it replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `uniqueItems`: `items are not unique: 0 = 2 = 5; 1 = 3`, every group of
  equal items rather than the first pair. Params `duplicates` becomes the
  list of groups. Indexes only, never the items.
- `contains`: `the contains subschema matched 2 items (0, 3), expected at
  most 1`, adding the matching indexes as params `matched`. The compiled
  evaluator now always collects them; the flag tier still does not.
- `oneOf`: `matched 2 branches (0, 1), expected exactly 1 of 3`, or
  `matched none, ...`. An empty `oneOf` lowers to a constant failure,
  fixing an evaluator path that referenced bindings no apply had set.
- `anyOf`: `does not match any of the 3 anyOf branches`. Naming the
  failing branches would add nothing, since all of them failed and each
  says why.

`index_ranges` keeps a pair as `0, 1`, collapsing only runs of three or
more. Message helpers are now wired through one table
(`emit.MESSAGE_HELPERS`) instead of a hand-written case each. A new
compiled-tier test checks every runtime-data message shape against the
interpreter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `false` subschema fails everything and explains nothing.
`additionalProperties: false` reported `schema is false` once per extra
property, at each property, while the useful fact -- which properties
were extra -- was the applicator's and never got said.

An applicator now never applies a `false` subschema of its own. It names
the keys it would have applied it to, in one error at its own location:

- `additional properties "b", "c" not allowed`
- `items not allowed from index 1: 1-4`
- `unevaluated items not allowed, first at index 2: 2, 3, 6`
- `allOf branch 1 is false`

This covers `additionalProperties`, `unevaluatedProperties`,
`properties`, `patternProperties`, `propertyNames`, `dependentSchemas`,
`prefixItems`, both forms of `items`, `additionalItems`,
`unevaluatedItems`, `allOf`, `contains` and draft-07 `dependencies`.

The IR gains `Reject`/`RejectCheck` and `Collect(errors=True)`. The
evaluator collects the rejected keys and reports once; a verdict-only
artifact treats `Reject` as a `Fail`, so `compile_validator` still stops
at the first offending key and its flag goldens are unchanged. A `$ref`
to `false` keeps `schema is false`. `then`/`else` are left for now: they
lower as `if`'s siblings and would need a sibling error site.

Also amends D13 with the message rulings, adds a per-keyword params table
to the reference, and a "Reading error messages" guide section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building and realizing a message description for every interpreter
error cost up to a fifth of the interpreter's time on error-heavy
corpora: most records are never rendered -- a verdict-only evaluation
renders none, and an error under a losing `anyOf` branch or an `if`
condition is dropped.

`KeywordContext.report(describe)` defers the whole description. The
`ErrorRecord` holds a builder and realizes it on the first read of
`message` or `params`, so readers see no difference. The built-in
keywords report this way; `error(message, params)` is unchanged for
custom keywords. Against `main` the interpreter is back within noise
(`profile` identical; `event`, `user`, `api-payload` within a few
percent). The compiled tiers were already unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…number

- `uniqueItems`: `items are not unique: [0, 2, 5] are equal; [1, 3] are
  equal`, rather than `0 = 2 = 5; 1 = 3`, which read like assignment.
- `contains`: when `minContains` and `maxContains` are equal, the message
  says `expected 1` rather than `expected 1-1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`KeywordContext.report` wrapped each keyword's description in a second
closure plus two cells, all long-lived and tracked, so error-heavy
evaluations paid a garbage-collection tax even in flag mode (0.9s to 1.5s
for 160k errors), and the 3.13 CI leg overran the verbose-level budget.
The record now keeps the description itself and realizes it against its
own cursor's value.

`realize` dispatches the common nodes by exact type ahead of the
structural match, and `preview` goes straight to text for a scalar
instead of through the bounded generator, which a container still uses.
A `Binding` in an evaluate-side description raises `LookupError` naming
the `Const` rule instead of a bare `KeyError`. The unreachable second
ellipsis chunk is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`dependents_rejected` joins the other `false`-subschema builders in
`_rejects`, so `legacy` no longer reaches into `applicator` for it, and
`positions_sweep` hands back a `step` callable like `tail_sweep` does
instead of a sentinel binding. Integer-aware type matching has one home,
`json_model.type_matches`, shared by the `type` keyword and the message
realizer. The helper table is typed loosely once, with one cast at its
only call site, instead of nine suppressions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`EVALUATOR_NAMES` takes the message-helper identifiers from
`MESSAGE_HELPERS` instead of repeating them, and two tests pin the three
tables (`HelperName`, `messages.HELPERS`, `MESSAGE_HELPERS`) to each
other, so a helper added to one cannot go missing from another until a
compile hits the emitter's fallback.

`KeywordContext.report` now states its contract: an evaluate-side
description carries runtime values as `Const` nodes, since the record
realizes it with no bindings, and a `Binding` there raises `LookupError`
when rendered. DESIGN P18 points at `HelperName` for the helper set
rather than naming six of thirteen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 12s

A keyword whose description depends on nothing but its own value (the
bounds, `type`, `pattern`, `format`, `const`, lengths, counts) rebuilt
the identical IR for every rejection, and an array of 160k wrong items
rejects 160k times: the short-lived objects kept triggering full
collections over the records already alive. `describe_once` hands back
one prebuilt thunk per distinct value, keyed with the value's type so
`1`, `true` and `1.0` stay apart; an unhashable value (an `enum` list)
is built per report as before. Verbose rendering of that case drops
from 2.45s to 2.1s locally, and flag mode no longer allocates a closure
per error at all.

The budget on that test goes from 10s to 12s: each of its 160k messages
now names the item and its type by design, and the slowest CI runner
seen is four times slower than a laptop.

The changelog gains an Added section for what this branch exposes to
keyword authors: `KeywordContext.report`, the `Reject`/`RejectCheck`
lowering statements with `Collect(errors=True)`, and the thirteen
message-formatting `HelperName` members.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@handrews
handrews merged commit 123894b into main Oct 4, 2026
10 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant