Repository navigation
fix(language): at refuses a read that consumes a column over the dimension it joins on, as sum does - #562
Merged
FBumann merged 8 commits intoSep 20, 2026
Conversation
…nsion it joins on, as sum does The dim rule of a relation read is stated once, in the direction's own terms: the consumed dims go, the produced arrive, the joined stay. sum and at each stated it through RelationNode's fine and coarse sides, which inverted between the two operators, and at's joined-column check read the wrong side: it asked whether a joined dimension was produced, which the landing check already refuses, rather than consumed. RelationNode holds the use alone. The typesetter prints a relation read one way, from the relation's name, so Direction and Partition no longer delegate key, values and roles, and RelationDeclaration's key is required, since a bare relation keys every column. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
Documentation build overview
19 files changed ·
|
FBumann
added this pull request to stack #560
September 18, 2026 21:00
…ode rather than one node holding either Every consumer narrowed RelationNode's use field the moment it had the node, so the union was a second assert at six sites for one fact. The two nodes carry one kind each, and the kwarg union names both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…on is read directly rather than through accessors that invert GroupSum and At lose relation, over, into and joined. On At the last three inverted the call's own kwargs, so every reader had to know that At.over was the produced dims. Readers name the side they mean off the direction. Namespace.shape_of was one dict with two spellings. The resolver's RelationDeclarations are the program's: Resolved carries them, lowering reads them, and every Direction holds the same object the program's dimensions hold rather than an equal copy built again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
… the at refusal names the columns the call lands on Both messages were written in the node convention this PR removes, where At.over was the produced side. The rewrite swapped over= and into=, so following it was refused in turn; the at refusal said the operand fixes the key columns, and it is the result that does. The tests match the whole rewrite now, which is what let the swap survive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…ith the same message every operator does Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
This was referenced Sep 18, 2026
FBumann
removed this pull request from stack #560
September 19, 2026 18:10
FBumann
added this pull request to stack #574
September 19, 2026 18:10
FBumann
removed this pull request from stack #574
September 19, 2026 18:12
FBumann
added this pull request to stack #575
September 19, 2026 18:12
Carries origin/main up the stack of #494. #529 gave every expression node a __str__; this branch had split RelationNode into DirectionNode and PartitionNode, so both get one. The dims conflicts are #529's obsolete RelationNode arm against this branch's Direction/Partition refactor, which wins. The node-prints-itself test came from #529 against the two-field RelationDeclaration and the unsplit node; it is rebuilt on both halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUyQztqnwapp3vHPhzBLw5
FBumann
removed this pull request from stack #575
September 20, 2026 20:34
FBumann
added this pull request to stack #583
September 20, 2026 20:35
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.
Stacked on #559. A model that loaded and computed the wrong thing is refused now:
at()accepted a read consuming a column over the dimension it joins on. The rest of the diff is the cleanup that made it visible.Why it went unasked, and the guard that asks it
_check_joinedasked whether a joined dimension was inby.dimensions. Forsumthat is the consumed dims, which is the right question: the operand carries one coordinate per dimension, so a column consumed and a column joined on cannot share one. Foratthe same field meant the produced dims, which the landing check already refuses — so the consumed case was never asked on that side.That inversion is the reason two conventions existed. In the call,
over=is always the columns consumed andinto=always the columns produced, onsumandatalike, and that is whatDirectionstores. On the nodes, the two words meant the fine and coarse sides in the sum's sense, so thatAtcould be the adjoint ofGroupSum.test_a_joined_column_is_not_also_consumedhas one case per operator. Deletingor d in consumedfrom_check_joined:or d in consumedBoth fail, so neither operator's side of the guard is unreached. The tree was restored with
git checkout --and re-run clean at 1333 passed.The cleanup, and why it is in this PR
src/is 149 insertions against 233 deletions over 9 files. The dim rule of a relation read is stated once, in the direction's own terms: the consumed dims go, the produced arrive, the joined stay._read_dimsis the rule forsum(by=)andat(by=), and the landing check is inside it. Each operator keeps only its own "operand does not carry" message.by=is aDirectionNodeor aPartitionNode.RelationNodeheld aDirection | Partitionfield that every consumer narrowed the moment it had the node — a second assert at six sites for one fact. A program type cannot sit in the parser's union directly, so the parser namesDirectionandPartitionunderTYPE_CHECKINGand the golden census reads the union at runtime.position()in awhereis the atom itself, so itsPartitionstays a field onDimensionPositionNode.GroupSumandAtholdoperandanddirection, nothing else. Separability and advice name the side they mean offdirection, and the inversion has nowhere to live.RelationDeclarationper relation for its namespace and lowering built the same ones again per dimension.Resolvedcarries the resolver's, and everyDirectionholds the same object the program's dimensions hold — the test asserts identity, not equality._relation_readtakes the relation's name and looks it up;_columnand_position_groupare gone.Namespace.shape_of;Direction.key,.values,.roles;Partition.key,.values;_known_roles'Nonearm;RelationDeclaration.key's unused()default and the empty-key fallback beside it — a bare relation keys every column, so the key is never empty.Two message corrections, one of which main has since shipped
Neither changes what loads.
atrefusal said the operand fixes the key columns; it is the result that does. Corrected here.at(..., over=<into>, into=<over>), a rewrite the language then refused. This is no longer this PR's — main shipped it independently in fix(language): a sum refused for walking to the key names an at() the language accepts #558, and this diff no longer touches that message.tests/test_validation.pymatches the whole message on both, which is what let the swap survive.The break
GroupSumandAtlose.relation,.over,.intoand.joined; read.direction.name,.consumed_dims,.produced_dimsand.joined_dims, which say the same thing on both nodes.Directionloses.key,.valuesand.roles,Partitionloses.keyand.values; the same facts are on.relation.RelationDeclaration(name, columns)with no key no longer constructs.Resolvedgains arelationsfield.RelationNodeandshape_ofare private names.All are
programorresolutionexports. lpspec reads them heavily —.intoat 27 sites,.overat 18,At15,GroupSum13,.joined13 — and pins math-spec by tag, so it follows once for this stack rather than once per merge.Rebased onto main at
bda1fc5Merged, not replayed: the hard rule here is never to force-push.
maincamein at the bottom of the stack (#494) and was carried up through every branch
between, so this one's base is current.
Six conflicts here, all from #529, which gave every expression node a
__str__.This branch had split
RelationNodeintoDirectionNodeandPartitionNode,so each half gets one —
self.direction.nameandself.partition.name. Thefive
dimensions.pyconflicts are #529'sRelationNodearm against theDirection/Partitionrefactor, which wins;.shownis gone from the tree.#529's
test_a_node_resolution_built_prints_the_name_the_file_wrotearrivedwritten against the unsplit node and the two-field
RelationDeclaration, andauto-merged with no conflict into a file that did not compile. It is rebuilt
on both halves, one case each. Deleting
PartitionNode.__str__fails the newpartition case and nothing else, so the added case is reachable.
The mutation table above was re-run on this head, not carried over.
Gates
pixi.shis refused by this environment's egress proxy, so the gates ran from auvenvironment on Python 3.12 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.pytest -n autoruff check,ruff formatpyrefly checkexplicit-any = "error"grep -rn '\bAny\b' src/mkdocs build --strictcompile-tex,typos,reuse lint,prettierCI ran the whole gate on this head and was green, which covers the four rows above that were not run here. The 6 skips are a missing
typst, not failures.Every generator was re-run: neither the schema, nor the golden
.outfiles, nor the generated pages moved — the proof that the typesetter prints the same thing through fewer paths.Looked at and left alone
Direction.nameand.dim, and the same onPartition: one hop to.relation, with about twenty callers between them. Removing them spreads.relation.across every site and removes no concept.RelationBlockinmodel.pyandRelationDeclarationinprogram.pystill describe one declared relation twice, on the two sides of the schema-to-program seam. What this PR folds is the second builder of the program-side one, not the pair.position()'s "within=unsaid" refusal keeps its own rewrite line:positionis not in the operator table thatunsaid_ends_errorreads its usage from, and the reason sentence is already shared._relation_ref's two dicts over_role_nameexist for the type narrowing; folding them costs apyreflysuppression.🤖 Generated with Claude Code
https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk
Generated by Claude Code