Skip to content

feat: an expressions entry built in code has its keys checked - #577

Closed
FBumann wants to merge 4 commits into
mainfrom
claude/elegant-pasteur-r0p6ui
Closed

FBumann wants to merge 4 commits into
mainfrom
claude/elegant-pasteur-r0p6ui

Conversation

@FBumann

@FBumann FBumann commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Do the pr now in mathspec!" — following "Would a typed dict actually drift? Or would it be caught by ci?"

Note

The following content was generated by AI.

WrittenExpression and WrittenCase are what one expressions: entry takes as a mapping. A caller that builds it in code gets a misspelled key as a type error where it writes it, instead of a SchemaError at load.

What this changes

  • Two TypedDicts in model.py, beside the blocks they mirror, exported and pinned in tests/test_public_surface.py. WrittenExpression is total=False; WrittenCase requires when and expression, as ExpressionCase does. The asymmetry is the models': a case has one shape, a block has two.
  • expression and otherwise take str | float, not str. That is the input type the before-validator admits — a bare number is an expression — and it is what the published schema already says.
  • One test holds them: test_the_written_form_takes_the_keys_its_block_takes compares the key set and the required keys against $defs.ExpressionBlock and $defs.ExpressionCase in the committed schema, which test_the_checked_in_json_schema_has_not_drifted already holds against the models.

Why

This is the answer to whether a copy of an upstream shape drifts: it drifts only if nothing holds it, and here something does. The consumer for it is lpspec, which types the same payload str | Mapping[str, object] and forwards it without reading it — so the checking had nowhere to happen. Publishing the shape from the repository that owns the model puts it in one home rather than two.

The union that was considered, measured, and not taken

An earlier version of this body said the block's one-form-or-the-other rule is one no TypedDict can carry. That was false. A union of _OneExpression = {expression} and _Cases = {dims, cases, otherwise} carries it, on this repo's own pyrefly settings:

written union of two the shape in this PR
{'expression': 'sum(p, over=generator)'} accepted accepted
{'dims': [...], 'cases': {...}, 'otherwise': 0} accepted accepted
both together rejected accepted, fails at load
{} rejected accepted, fails at load

It is not taken, for three reasons that only appeared once the cost was priced:

  1. The case it adds already has the better error. _one_form_or_the_other says a named expression is one expression: or a set of cases:, and this has both — it names the rewrite. A checker says the dict is not assignable to _Cases | _OneExpression, which names nothing. Moving that diagnosis earlier makes it worse.
  2. It costs the caller the ability to name the form they are building. The variants are private, so someone assembling the cases form across several statements has nothing to annotate the variable with — and that caller is the whole audience for this.
  3. It strengthens the half the test cannot hold. The schema says ExpressionBlock requires nothing, because JSON Schema expresses both forms through the same all-optional properties. A union's requiredness therefore has no upstream counterpart, so the new claim would be the one hand-maintained thing here — the drift risk reappearing exactly where it is unheld.

The trigger for revisiting is a consumer that builds these literals often enough to hit the both-or-neither mistake in practice.

The guard, deleted three ways

The test was written against the drift it exists to catch, and each mutation run with the tree restored after:

mutation suite
a key the block takes dropped from WrittenExpression 1 failed, 13 passed
a key the block does not take (unit: str) added 1 failed, 13 passed
WrittenCase made total=False 1 failed, 13 passed

So a field added to ExpressionBlock and not here fails the suite, rather than leaving a caller annotating a key that does not load.

What it is worth

It catches a key that does not exist. It catches nothing for a caller reading YAML from a file, which is most of them. I said as much before building it, and it is why I would not rank this high.

Gates

pixi.sh is refused by this environment's egress proxy, so the gates ran from a uv environment on Python 3.12.3 with pydantic 2.13.5 against the lock's 2.13.4, on the pinned pyrefly 1.2.0 and ruff 0.16.1.

gate result
pyrefly check 0 errors, 8 suppressed
pytest -n auto 1303 passed, 6 skipped (base: 1301, 6 — the two new parametrized cases)
ruff check, ruff format --check clean
typos clean
mkdocs build --strict clean, with the docs.python.org inventory dropped for the run, which the proxy refuses with a 403
compile-tex not run here, for want of a TeX distribution — CI has since run it green

The committed schema did not move: no model field changed, only what the package publishes about them.

Branch, and what is deliberately not here
  • The branch carries a merge of its own already-merged tip. refactor: no signature says Any, and a symbol table section that is not a mapping is refused #572 was squash-merged, so this branch's old commits are content-identical to 394599b but not its ancestors. A force-push to restart the branch was refused by this environment, so the stale tip is merged in instead — one import-line conflict, resolved to keep TypedDict. The merge changes nothing: the tree is byte-identical to the commit before it, and the diff against main is this change alone.
  • Nothing else got a written form. The rest of the YAML surface — variables:, constraints:, piecewise: — has the same case and is not here. expressions: is the one a consumer builds in code today, and thirteen more TypedDicts on speculation is what YAGNI refuses.
  • No value-type check in the test. Keys and requiredness are held; the types are not, because the TypedDict names the input shorthands that a before-validator admits and the model's own annotations name the validated types, so no mechanical equality exists between them. The schema is the only rendering where the two meet, and it already has its own test.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nY2qq2hQzyMFApWuD5zFW

`explicit-any` is on for `src/math_spec`. The eleven files that still
write `Any` name themselves in a sub-config list, and the rule is on
everywhere else, so a file leaves that list and cannot come back. One
line that must say `Any` says so with `# pyrefly: ignore[explicit-any]`
and a reason; `unused-ignore` is already an error, so the pragma fails
the gate the day the line stops needing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nY2qq2hQzyMFApWuD5zFW
…ot a mapping is refused

`explicit-any` is an error for `src/math_spec` with nothing exempt, so
the eleven-file list the gate landed with is gone and `src/` says `Any`
nowhere.

The 73 sites in the way are taken from #567, which did the work on top
of the parser stack: the public doors take `Mapping[str, object]`, a raw
value is `object` until pydantic has read it, the grammars hand back the
node type their walk names, and the exclusivity proof compares a cell
with a literal of its own kind. `_where_parser` keeps main's grammar and
takes only the fold's return type.

Two sites that PR leaves are narrowed rather than excused, so no pragma
is needed: `ValidationInfo` is `Protocol[ContextT]` and takes its
argument, and `JsonSchemaValue` is pydantic's `dict[str, Any]` where
those signatures mean `dict[str, object]`.

One refusal comes with the rewrite: a symbol table whose `dimensions:`
or `names:` is a list or a string raised `AttributeError`, and now
raises `SchemaError` naming the rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nY2qq2hQzyMFApWuD5zFW
`WrittenExpression` and `WrittenCase` are what one `expressions:` entry
takes as a mapping, published for a caller that builds it in code rather
than reading it from YAML. A misspelled key is then a type error where
the caller writes it, instead of a `SchemaError` at load.

The TypedDict says which keys exist and what each takes. It does not say
which combination is a model — one `expression:`, or `cases:` with the
`dims:` and `otherwise:` they need — because that is a rule no TypedDict
can carry. Loading still decides it, and still refuses a key that is not
here.

`test_the_written_form_takes_the_keys_its_block_takes` holds both against
the committed schema, which `test_the_checked_in_json_schema_has_not_drifted`
holds against the models. So a field added to the block and not here fails
the suite rather than leaving a caller annotating a key that does not load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nY2qq2hQzyMFApWuD5zFW
…nto claude/elegant-pasteur-r0p6ui

# Conflicts:
#	src/math_spec/model.py
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 math-spec | 🛠️ Build #34654243 | 📁 Comparing 52886e5 against latest (20ee1f1)

  🔍 Preview build  

1 file changed
± reference/math_spec/model/index.html

@FBumann

FBumann commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Not a good change

@FBumann FBumann closed this Sep 19, 2026
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.

2 participants