fix: an expression too deep to walk is refused rather than crashing - #359
Merged
Conversation
`to_spec` documents `Raises: LanguageError`, and a 400-term sum raised `RecursionError` instead — a traceback through `expansion._descend`, with nothing naming the file, the declaration or what to write. That is a model a generator writes, not a pathological input. Two failures, one limit: a long chain parses and builds a tree the passes over it cannot recurse, while parentheses nested that far exhaust the stack inside pyparsing before a tree exists to measure. Both now raise a language error carrying the one rewrite — reduce over a dimension with sum(), or name the quantity under expressions:. Where strings are bounded the same way and get their own rewrite. The cap is 100, measured against a deepest-in-repository of 18 and a pipeline that survives 300 on a default stack; the gap is the room a caller's own frames need, since a guard that only holds for a shallow caller has not done its job. An expression nesting 101 to 300 deep loaded before and is refused now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HSgvWYbQLFB8GkLdiJSyuv
`expressions.md` published `NAME ::= [a-zA-Z][a-zA-Z0-9_]*` while `expression_parser.NAME` has always admitted a leading underscore — the same constant `model.py` validates every declaration name against, whose own error says "a letter or an underscore". So the page refused what the language accepts, and `_x + 1` parsed. The page is corrected rather than the code: one constant is the single home, and the schema's message already stated the intended rule. A test compares the published production against that constant, because nothing did and that is why it drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HSgvWYbQLFB8GkLdiJSyuv
FBumann
force-pushed
the
claude/package-performance-zmbv5a-fixes
branch
from
September 1, 2026 20:50
3f0169d to
24b7315
Compare
Documentation build overview
27 files changed ·
|
The depth guard moves into the shared parse_text helper main introduced in #357, so both memoised parsers refuse an over-deep tree through one call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SY1PzqSCRpFZsZ2y8iErwv
…guing for them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SY1PzqSCRpFZsZ2y8iErwv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The following content was generated by AI.
What this changes
Two contradictions in the tree, found while investigating load performance (#357, #358) and independent of both — this branches from
main.to_specraisedRecursionErrorwhere it documentsLanguageError. A 400-term sum is enough, and that is a model a generator writes rather than a pathological input. What a reader got was a traceback throughexpansion._descendnaming no file, no declaration, and nothing to write instead.There are two failure modes and one limit. A long chain parses fine and builds a tree the passes over it cannot recurse — expansion spends three Python frames per node. Parentheses nested that far exhaust the stack inside pyparsing, before a tree exists to measure. Both now raise a language error carrying one rewrite:
where:strings are bounded the same way, with their own rewrite.expressions.mdpublished a grammar that refused what the language accepts.NAME ::= [a-zA-Z][a-zA-Z0-9_]*, whileexpression_parser.NAMEhas always admitted a leading underscore — the same constantmodel.py:813validates every declaration name against, whose own error message says "a letter or an underscore"._x + 1parsed. The page is corrected rather than the code, because that constant is the single home and the schema's message already stated the intended rule.Merged with
main(#357 memoised both parsers and gave them oneparse_text). The guard lives in that helper: each parser passes its child function and its rewrite, so the memo, the parse failure and the depth refusal are one code path for both grammars.expression_parseris_expression_parsersince #342.Why
Both are the tree disagreeing with itself, which is the one thing this repository sells. A crash is not a refusal, and a published grammar that is not the implemented one is worse than no grammar.
The cap is 100, and it is a deliberate break. An expression nesting 101–300 deep loaded before and is refused now.
How 100 was chosen
to_spec→to_program→to_latex) still fineRecursionErrorThe gap between 100 and 300 is the room a caller's own frames need. A guard that only holds when
to_specis called from a shallow stack has not done its job — the failure point otherwise depends on the consumer's call depth and onsys.getrecursionlimit(), neither of which this package controls.And a chain long enough to reach it is precisely what
sum()over a dimension exists to replace, so the refusal points at the language's own answer before the general one.A claim in #358 that this corrects
#358's body and README say the
RecursionErroris the parser's, and that "a depth guard cannot be added inside pyparsing; a parser we own is what would make it fixable." That is wrong about the case that matters.parse_expressionhandles a 1000-term chain without trouble; the recursion is in our own tree walks, and the fix needed no new parser. Only the nested-parentheses variant fails inside pyparsing, and catchingRecursionErrorat the front door covers it. I have commented the correction on #358.What was verified
pytest— 1015 passed, 5 skipped, on 3.13 and on the 3.12 floor.RecursionErrorarm fails all six depth cases (three intest_parser.py, three intest_validation.py); reverting the doc line fails the new drift test intest_docs.py. Both were run.ruff check,ruff format --check,pyrefly check(0 errors, 9 suppressed — same asmain), and the realprettieron the changed page.tools.schemaand the typesetter goldens produces zero drift.examples/pypsa.yamland every other model still load unchanged.main:pixi run test(1031 passed),docs-buildandcompile-texpass in a fresh worktree, and so does everylintjob butpyrefly, which reports threetypes-PyYAMLstub errors there —origin/mainchecked out in the same worktree reports the same three, so that is the fresh environment rather than this diff.parse_textfails all six depth tests, re-run on the merged tree.Scope, and two defaults departed from
fixfor the crash,docsfor the drift. They are separable and would normally stack, but both are one-line-of-investigation findings and each is small; say the word and I will split them.MAX_DEPTHand the rewrite strings are inline, as caps and messages the code acts on. The depth numbers behind the cap live in this PR; the constant's comment carries the conclusion with the ref, and the docs page and the error message carry only the limit and the rewrite.test_a_name_may_open_with_an_underscoreis a pin, not a guard — it passes on the unfixed tree, because the code was already right. The actual guard for that finding istest_the_published_grammar_spells_a_name_the_way_the_code_reads_one, which does fail on the old page.expansion,resolutionandtypesettingiterative. That removes the ceiling rather than guarding it, and it is a much larger change than a contract fix warrants.🤖 Generated with Claude Code
https://claude.ai/code/session_01HSgvWYbQLFB8GkLdiJSyuv
Generated by Claude Code