Conversation
…d reads one at a neighbour Closes #590 and #591. Two calls read a predicate where every other operator reads arithmetic: count(<predicate>, over=<dim>) <op> <integer> shift(<predicate>, along=<dim>, offset=<integer>) `count` reduces the counted dimension away, so the count is one number per remaining coordinate and a claim about each group needs no word for the group. `shift` is false where the translation vacates and takes no `edge=`: the arithmetic form needs one because no number is neutral, and false is what a missing row already means in a mask. Both are where-only atoms rather than `BUILTINS` rows, as `position()` is. `count` is spelled in the grammar because only the grammar can decide to read its argument as a predicate; every other predicate-reading call stands where arithmetic cannot, so the comparison above it has already been tried. `CountComparison` and `TranslatedPredicate` join `Predicate` and carry their operand as a `Mask` — the wrapper a leaf holds a predicate in, where a connective holds a bare one. A walk recurses through the second and stops at the first, and `names_read` and `dims` see through both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBU3ocfqkHe99mWmxijw64
Documentation build overview
25 files changed ·
|
…e-split-kdqegz-arithmetic-where' into claude/adoring-brahmagupta-ovwe3v-operators # Conflicts: # src/math_spec/exclusivity.py
…brahmagupta-ovwe3v-operators Stacks the count and shift operators on assumptions: rather than beside them, so the three land in one chain on 566. Resolved by hand: - walk.py: 589 folds six comparison arms of `_where` into `sides()` behind `AlignedComparison`. `CountComparison` joins that union and becomes an `elif` in `sides()`, so an assumption whose predicate is one count aligns on the relation as every other comparison does. `TranslatedPredicate` stays a branch of `_where`: it recurses rather than comparing. - tests/test_lowering.py, tests/typesetting/test_walk.py: both sides append cases at the same point; both kept. - The latex and typst goldens are regenerated, not resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
FBumann
changed the base branch from
claude/expression-parser-language-split-kdqegz-arithmetic-where
to
claude/adoring-brahmagupta-ovwe3v
September 21, 2026 16:24
FBumann
added this pull request to stack #601
September 21, 2026 16:27
…brahmagupta-ovwe3v-operators Carries main at 0.0.0-alpha.110 up the stack from #589. Merged clean, and the suite is green: 1448 passed, 5 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
…v' into claude/adoring-brahmagupta-ovwe3v-operators
…v' into claude/adoring-brahmagupta-ovwe3v-operators # Conflicts: # src/math_spec/resolution.py
…rd given twice, are refused at load count(…) >= 0 and count(…) < 0 are settled by a count never being negative, and loaded. A keyword written twice in count() or shift() over a predicate was last-wins, where the arithmetic grammar refuses it. A bare count(), a count on the right of its comparison, and a keyword count() lacks each got a sentence about something else; each now names its rewrite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29
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.
Closes #590 and #591. Two calls read a predicate where every other operator reads arithmetic:
count(<predicate>, over=<dim>)answers a number,shift(<predicate>, along=, offset=)answers a predicate. Together they say what #582 needs a curve to state as language.Stacked on #589, which is stacked on #566. The chain is 566 → 589 → 592.
What this changes
countreduces the counted dimension away, assum(over=)does, so the count is one number per remaining coordinate and a claim about each group needs no word for the group. It is compared against a whole number: a count is a number of coordinates, so a fraction and a parameter are both refused, and so is a comparison the count settles on its own —>= 0,< 0, or any negative number — because a count is never negative.shiftover a predicate is false where the translation vacates, and takes noedge=. The arithmetic form needs one because no number is neutral and inventing one changes the answer; false is what a missing row already means in a mask.by=,within=andedge='wrap'are refused rather than ignored, per #591's conservative landing. A keyword written twice is refused as the arithmetic grammar refuses it, rather than the last one winning.Both are where-only atoms, not
BUILTINSrows — the shapeposition()already has. A row in that table would also put them inBUILTIN_NAMES, which the expression resolver, the unknown-operator message and the golden operator census read, and neither is an operator arithmetic may call. So acountthat is not on the left of a comparison — bare, or on the right — gets a where refusal namingcount(<predicate>, over=<dimension>) <op> <integer>, not the arithmetic grammar's unknown operator.A count comparison aligns on its relation. #589 folds the comparisons that are one relation between two sides into
Walk.sidesbehindAlignedComparison.CountComparisonis one of them, so an assumption or a constraint whose whole predicate is a count prints with the relation leading the right-hand side, as every other comparison does.Why this shape
Why
countand notsumorlenNot
sum(<predicate>), though it would add no name. Summing a predicate means summing its 0/1 values, and that is precisely the coercion the language already refuses:Adopting it would make
summean one thing over a float column and another over a bool one, and that refusal would have to be rewritten to say when a flag does scale. The escape hatch it names — declare the columndtype: int— already serves anyone who wants the arithmetic reading.There is a conceptual reason under the practical one.
shiftandatover a predicate are vocabulary-preserving: predicate in, predicate out. Counting is vocabulary-crossing: predicate in, number out. "Any operator takes either vocabulary" is therefore not one rule but two, and naming the crossing operator separately is what makes that visible rather than buried in the argument's dtype. The parser agrees:sum(c > 0, over=g)fails arithmetic at the parse, butsum(points, over=bp)parses as arithmetic on a bare name and never reaches the predicate reading, so the common case would need resolution to re-interpret it by dtype — more special-casing than a keyword, not less.Not
len().len(x)names the size of a container, and there is no container here: the set being sized is the one the predicate admits, which the file never names. Worse,len(points, over=bp)reads as the size of the axis — a different and also-useful question, andlen(snapshot)is the obvious spelling for it. Spendinglenhere would block that.This does not foreclose porting the others. The grammar production is
<name>(<predicate>, kwargs…)for any name; only resolution gates it, which is whysum_back(flag, along=g, window=2)gets a language refusal rather than a parse error today. Addingatover a predicate later is one resolution arm and one program node — no new name, no grammar change.The architecture, and the two forks taken
countis spelled in the grammar;shiftis not.position()needs no grammar rule because it parses as an ordinary call and resolution routes it.countcannot: its argument is a predicate, and the arithmetic call rule takes arithmetic arguments. So the where grammar readscount(<where_expr>, …)itself. Every other predicate-reading call stands where arithmetic cannot — as a bare atom, after the comparison above it has been tried — so one generic production covers it and the name stays free. A single generic production for both was the first attempt and was wrong: it shadowedsum(p_max, over=generator) >= budget, whose operand also parses as a predicate.A count comparison is its own parse node. Hanging the call off
UnresolvedComparisonNode.leftwidened that field to carry a predicate, and the widening then reachedexpansion.expand,_side_nameand every other reader of a comparison side — nine type errors for one feature.UnresolvedCountNodekeeps the widening where the feature is.A leaf carries its predicate as a
Mask, a connective as a barePredicate. That is the rule the two new nodes follow: a walk recurses through a connective and stops at a leaf, so a leaf's predicate is a field it reads rather than a child.MaskjoinsCARRIERSin the golden census for the same reason.A larger refactoring that is now reasonable — not done here
Predicatehas sixteen members, and eight of them are one relation between two things:ParameterComparison,RelationComparison,RelationPairComparison,DimensionComparison,DimensionPosition,ExpressionComparison,ArithmeticComparison, and nowCountComparison. Each of_atom_dims,_atom_names,_subject_of,_observe,_atomand_check_where_dimscarries an eight-way match over them, and adding a ninth meant touching all six. The typesetter's is now one union and onesideshelper, which #589 landed.It is a real cleanup and it is not this PR. It also has a prerequisite and a risk, both now written down: #593 has to land first, because
ParameterComparisonis the only comparison over parameters the exclusivity check can reason about, and folding it away without the peel would silently stop models loading. And a flatComparison(left: Term, op, right: Term)makes roughly forty illegal side-pairs representable that the current split makes unconstructable, which matters becausereading.mdtells consumers they may build predicates themselves.What this does not do
Contiguousand the curvature conditions are now expressible, which is what #582's checklist asked for, but this PR emits nothing: nopiecewise:block writes anassumptions:entry here. That work lands in #582 on top of this branch, which now carries bothassumptions:and the two operators.Stacked on #589, and what the merge took
The base was #566's branch and is now #589's, so the three land as one chain rather than a fan. #589 was merged in, not rebased onto: the hard rule here is never to force-push.
Five files conflicted. Resolved by hand:
src/math_spec/typesetting/walk.py. feat(language): a model declares what it assumes of its data #589 folds six comparison arms of_whereintoWalk.sidesbehindAlignedComparison; this branch had addedCountComparisonto the old shape.CountComparisonjoins that union and becomes oneelifinsides.TranslatedPredicatestays a branch of_where, because it recurses rather than comparing.tests/test_lowering.py,tests/typesetting/test_walk.py. Both sides append cases at the same point. Both kept; feat(language): a model declares what it assumes of its data #589's first rendering case takes back the@EVERY_FORMATthe two blocks shared.tests/typesetting/golden/latex.out,typst.out. Regenerated, not resolved.Format.set_of, which both PRs add with the same body, merged clean, as did the golden fixture andmarkdown.out. Every generated page was re-run and none moved.The review round merged #589's review commit in as its own commit (
1f5c01c), with one conflict insrc/math_spec/resolution.pyresolved by keeping both sides' helpers.Gates
pixiis not installed in this environment, so the gates ran from auvenvironment on Python 3.12 with the pinnedruff==0.16.1andpyrefly==1.2.0. That is a departure from the "prefix every command withpixi run" default.Run on the head:
pytest -q -n 4ruff checkruff format --checkpyrefly checkmkdocs build --strictdocs.python.orginventory dropped for the run, which the proxy refuses with 403render-texcompile-textypos,reuse lintprettier --checkprettier@3reportsmkdocs.yml, and reports it identically onmain, so it is the version and not this change. Clean overdocs,examplesandtools.The three golden files were regenerated and read: both this branch's counts and #589's Assumptions section print into them.
The review commit (
2199a95) was written as eight failing cases first intest_a_shape_the_language_refuses: a keyword given twice forcountand forshift, a barecount, acounton the right, a negative literal,>= 0and< 0, and theby=message no longer speaking of an edge. It removes the onepyreflysuppression the parse-action lambda needed.Mutation table
Each guard deleted in turn, the suite run, the file restored and the tree
checked clean.
TestAPredicateIsAnOperand::test_a_shape_the_language_refusestest_a_predicate_a_leaf_carries_is_lowered_like_any_other_masknames_readseeing through both carrierstest_a_count_reduces_the_dim_it_counts_along_awaywhentest_a_count_is_undecidable_in_a_case_when— and without it the_atomassertion fires, so the two layers agreetest_a_name_the_operand_does_not_declare_is_reported_rather_than_walked(both cases)The last one is a bug this PR found in itself. Resolution collects problems rather than raising, so a failed operand comes back unresolved — and asking it for its dims asserted instead of refusing.
count(nope, over=g) >= 2raisedAssertionError: UnresolvedNameNode reached a predicate walk unresolved. The test was written against that failure first.This table was measured before the merge with #589; the review re-measured the first, fourth and fifth rows after it, and they hold.
Coverage, and what the fixture gained
TestAPredicateIsAnOperandholds every shape: seven the language admits, nineteen it refuses, the undecidable case, the unresolved operand, and the dim rule.names_read.counted,counted_hereandrun_start, so both new arms of the walk print into a committed file and the line census reaches them.MaskjoinsCARRIERSin the node census: it is a wrapper the walk steps through, likeDirectionandPartition.assumptions:entry, though the merge makes one expressible andsidesprints it. That pairing is feat(language): a model writes its formulations out on request, and a curve prints as the curve it states #582's, which is the PR that writes such an entry.shift(<predicate>, offset=0)loads as the identity, as the arithmeticshiftdoes.2.0is admitted as a whole number where the grammar page writes INTEGER.🤖 Generated with Claude Code
https://claude.ai/code/session_01DBU3ocfqkHe99mWmxijw64
https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29