Skip to content

refactor: no signature in the package says Any, and a symbol table section that is not a mapping is refused - #567

Closed
FBumann wants to merge 7 commits into
claude/expression-parser-language-split-kdqegz-arithmetic-wherefrom
claude/expression-parser-language-split-kdqegz-typing
Closed

FBumann wants to merge 7 commits into
claude/expression-parser-language-split-kdqegz-arithmetic-wherefrom
claude/expression-parser-language-split-kdqegz-typing

Conversation

@FBumann

@FBumann FBumann commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Improve the typehinting. We should be able to get rid of any in all places" — "I feel like some more advanced patterns aren't needed. Or they don't earn their keep"

Note

The following content was generated by AI.

Stacked on #566. src/math_spec has no Any left, pyrefly check is at 0 errors with one pragma fewer, and one crash became a refusal along the way: a symbol table whose dimensions: or names: is a list or a string raised AttributeError and now raises SchemaError naming the rewrite.

What each Any became

  • The public doors take Mapping[str, object] in place of dict[str, Any]: to_spec, to_program, advice, typeset, typeset_declaration, to_latex/to_typst/to_markdown and SymbolTable.load. A mapping is covariant in its values, so every dict a caller has today still passes; to_spec tests isinstance(model, Mapping) accordingly. The three per-format doors forward **options: Unpack[_Options], a TypedDict of typeset's own keywords, so a misspelled option is a type error rather than a TypeError at the call.
  • A raw value is object until pydantic has read it: every mode='before' validator, _without_absence, _is_absent, the YAML reader's return, to_dict, and the raw model the piecewise expansion writes into. Spec.model_validate spells pydantic's own signature (extra, context and the rest) instead of *args: Any, **kwargs: Any; the override stays because its docstring and test_an_expansion_will_not_be_built_around_a_curve hold it to raising the package's errors. The piecewise expansion reads its sections through _section, which asserts the mapping a validated model guarantees, and takes the mask parameter's dims off the schema rather than off the raw dict.
  • The grammars hand back the node type their child walk names: parse_text[T] is generic over the walk, as depth[T] beside it already was, so parse_expression needs no cast; the where side names _ParsedWhere, the union the depth measurement walks; the two parse actions return ArithmeticNode and the connective folder returns a where node.
  • The exclusivity proof compares a cell with a literal of its own kind. _Literal is what a where comparison is written against. The one gap-walk over Any with its _step/_between pair is two typed walks, _numeric_cells and _dated_cells, and the pragma that excused _between's return is gone with it. _ordered[T: (float, str, datetime.date)] names the three kinds a literal comes in, and _compare narrows both sides before calling it; a cell and a literal of different kinds, which the dtype rules keep apart, are an AssertionError rather than a TypeError at <.

What the second commit took back out

The first cut used a four-method Protocol with Self for _ordered, two type statements, and a cast on the symbol table section. A constrained type variable says the same in one line, the two aliases are the plain unions every other alias in the package is, and the section needs no cast once narrowed. What stays on purpose: parse_text[T] mirrors the existing depth[T]; the TypedDict is the one way to type forwarded keywords without stating typeset's signature four times.

Behaviour

One change a user can see, in tests/typesetting/test_symbols.py: {'dimensions': ['generator']} and {'names': 'p_max'} are refused with dimensions: must be a mapping of names to entries, got list. On the base tree both crash with 'list' object has no attribute 'items'. Nothing else the suite covers changed: the golden output and the schema did not move.

Gates

pixi.sh is refused by this environment's egress proxy, so the gates ran from a uv environment on Python 3.13 with pydantic 2.13.5 against the lock's 2.13.4. That is a departure from the "everything runs in a pixi environment" default.

gate result
pytest -n auto 1351 passed, 1 skipped (#566: 1349 passed, 1 skipped)
ruff check, ruff format clean
pyrefly check 0 errors, 9 suppressed (#566: 10 suppressed)
typos, reuse lint 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, for want of a TeX distribution
grep -rn '\bAny\b' src/ no match
Left alone, and what lpspec sees
  • to_dict() returns dict[str, object]. lpspec's expressions.py hands it to a dict[str, Any] parameter, which accepts it, so lpspec type-checks as it did; indexing into the result from typed code now needs a narrowing, which is what a YAML document is.
  • The implicit-any-lambda pragmas on the parse actions stay, for the reason pyproject.toml gives: a typed callback only moves the error into pyparsing's untyped ParseResults.
  • _yaml.py keeps its one unknown-variable-type pragma on construct_document, whose stub returns Any.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

…ction that is not a mapping is refused

The public doors take Mapping[str, object] rather than dict[str, Any],
a raw value is an object until pydantic has read it, the two grammars
hand back the node type their child walk names, and the exclusivity
proof compares a cell with a literal of its own kind. Narrowing the
symbol table's sections turned an AttributeError on a list or a string
under dimensions: or names: into a SchemaError naming the rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
@read-the-docs-community

read-the-docs-community Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

…n rather than a protocol

A constrained type variable says what the four-method protocol said, in
one line. The two type statements are the plain unions every other alias
in the package is, and the symbol table section needs no cast once
narrowed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
FBumann pushed a commit that referenced this pull request Sep 19, 2026
…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
FBumann added a commit that referenced this pull request Sep 19, 2026
…ot a mapping is refused (#572)

* ci(typecheck): a new signature that says Any is refused

`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

* refactor: no signature says Any, and a symbol table section that is not 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Sep 19, 2026
…ather than Any

`explicit-any` is an error on `src/math_spec` since #572, and that PR took
only the fold's return type from #567, leaving `_nested` on this branch
with a signature whose `Any` no longer even imports. `_ParsedWhere` is the
union the measurement walks, which is #567's own answer for this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk
Two sites conflicted in model.py, and main's narrowing wins both: #572
took `ValidationInfo[object]` and `dict[str, object]` in place of this
branch's unparameterised `ValidationInfo` and pydantic's `JsonSchemaValue`,
which is itself an alias for `dict[str, Any]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk
@FBumann
FBumann removed this pull request from stack #560 September 19, 2026 18:10
@FBumann
FBumann added this pull request to stack #574 September 19, 2026 18:10
@FBumann FBumann closed this Sep 19, 2026
@FBumann
FBumann removed this pull request from stack #574 September 19, 2026 18:12
FBumann pushed a commit that referenced this pull request Sep 19, 2026
#567 is closed and this branch retargets onto #566. The typing work it
carried in its history is on main as #572, so model.py takes main's
narrowing: `ValidationInfo[object]` and `dict[str, object]` rather than
this branch's unparameterised `ValidationInfo` and pydantic's
`JsonSchemaValue`.

The two piecewise conflicts are this branch's own feature against the
code it renamed: the breakpoint dim is `along`, and a weight's where is
the block's where and its mask together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk
FBumann added a commit that referenced this pull request Sep 20, 2026
…ession grammar's arithmetic (#565)

* chore(language): a where comparison is one grammar rule over the expression grammar's arithmetic

The where grammar's three comparison rules — a name against a literal,
two relation columns, and position() against an integer — are one rule,
side <op> side, where a side is the expression grammar's ARITHMETIC. What
a side is, resolution decides with the schema in hand, and it hands back
the same typed nodes with the same messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* test: the parser test names the validation class that holds the refusals it points at

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* chore(parser): the where depth measurement names the nodes it walks rather than Any

`explicit-any` is an error on `src/math_spec` since #572, and that PR took
only the fold's return type from #567, leaving `_nested` on this branch
with a signature whose `Any` no longer even imports. `_ParsedWhere` is the
union the measurement walks, which is #567's own answer for this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Sep 22, 2026
* chore(language): a where comparison is one grammar rule over the expression grammar's arithmetic

The where grammar's three comparison rules — a name against a literal,
two relation columns, and position() against an integer — are one rule,
side <op> side, where a side is the expression grammar's ARITHMETIC. What
a side is, resolution decides with the schema in hand, and it hands back
the same typed nodes with the same messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* test: the parser test names the validation class that holds the refusals it points at

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* chore(parser): the where depth measurement names the nodes it walks rather than Any

`explicit-any` is an error on `src/math_spec` since #572, and that PR took
only the fold's return type from #567, leaving `_nested` on this branch
with a signature whose `Any` no longer even imports. `_ParsedWhere` is the
union the measurement walks, which is #567's own answer for this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk

* feat(language): a where may compare arithmetic over parameters

Either side of a where comparison may be an expression over
parameters: arithmetic, a reduction, a pullback, a translation with
its edge, a macro, a named expression. A side is expanded, typed,
degree-checked and dim-checked as an expression is, and refused where
it names a variable or a dual. Two parameters compare the same way.

The resolved tree holds ArithmeticComparisonNode over the core syntax
tree, and lowering rebuilds every mask with ExpressionComparisonNode
over program expressions. A case when: comparing expressions is
refused as undecidable before the data arrives.

expressions.md sentence lengths: n 68, avg 14.0, median 13, over 25
words 3 (base: n 56, avg 13.0, median 13, over 25 words 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): a comparison of expressions answers what it reads once, on the program's form

The resolved form of the comparison had a second walk collecting the
parameters and relations its sides read, and nothing asks a resolved
mask that question: lowering rebuilds every mask before one reaches a
consumer. The arm is the assertion the typesetter already carries for
the lowered node. The one-line mask wrapper in lowering is inlined, and
the two validation classes for what a where side may be are one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): a namespace is built from its schema and carries it, so nothing passes both

Namespace(schema) reads every declaration at construction; the eight
argument constructor and the classmethod that was its only caller are
gone. expression_of, _check_expression and _named took the schema and a
namespace built from that same schema; they take the namespace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): the single-text doors live with the tests that are their only callers

expression_of and where_of had no caller in the package, and the two
assertions that named expression_of as the door now name
resolve_expression, which is the one the package walks through. The
plain form of a where comparison is read by one method: a name that is
a value against another is arithmetic, which used to be a second
question asked after the first. The "prefix a context once" rule has
one home in errors.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(program): the branch's nodes follow the naming rule main now states

#585 renamed the program's where vocabulary and expression nodes on main. This
branch was written against the old names, and its own two comparison nodes
arrived with the suffix the rule reserves for the core AST. Applying the same
rename here first is what lets the base merge that follows agree line for line
rather than conflict on every one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

* docs(reading): the predicate union says which member a program never carries

The `Predicate` union said it was what a lowered mask's `root` is built of.
That is false here: it also holds `ArithmeticComparison`, which lowering
rewrites into an `ExpressionComparison`, so a consumer walking a program meets
every other member and never that one.

These lines were #580's, which is where they were written. They are only true
where the two nodes exist, and this is the branch that adds them.

docs/reference/reading.md: the two sentences added are 11 and 9 words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

* docs(reading): a comparison against a literal is not an expression comparison

A consumer walking a lowered mask gets ParameterComparison for `p_max > 5` and
ExpressionComparison for `1 * p_max > 5`, though both mask the same
coordinates. The page named only the second form.

Gates: pytest tests/test_docs.py tests/test_reading_page.py (34 passed), the
full suite (1383 passed, 1 skipped), mkdocs build --strict (clean, with the
docs.python.org inventory dropped for the run, which this environment's proxy
refuses with a 403), prettier --check (clean). Not run: typos, reuse lint,
taplo, zizmor, compile-tex, for want of the tools.

docs/reference/reading.md, by the measurement in the docs-writing skill:
n 55, avg 14.6, median 13, over25 7. Every long sentence predates this change;
the three added are 10, 20 and 9 words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJfEUsCDtwXuXV8CANCHuR

* fix(language): a comparison with the literal first is refused as the order it is (#594)

* fix(language): a where comparing two numbers, and a lone case comparing expressions, are refused at load

A comparison with a number on each side is decided before any data arrives,
and reached the program as an ExpressionComparison over two constants. A
cases: block of one case escaped the undecidable refusal, which only looked
at pairs. names_read on a mask over a cased entry dropped the data its
regions are decided by. The relation refusal still said every other
comparison tests a name against a literal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29

---------

Co-authored-by: Claude <noreply@anthropic.com>
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