Skip to content

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 into
claude/intelligent-heisenberg-l81m8wfrom
fix/relation-read-rule
Sep 20, 2026
Merged

FBumann merged 8 commits into
claude/intelligent-heisenberg-l81m8wfrom
fix/relation-read-rule

Conversation

@FBumann

@FBumann FBumann commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Any additional simplifications or removals? Consider refactoring the relational things! Also typesetting. As a stacked PR" — "Why is Relation Node not PartitionNode and DirectionNode?" — "Any indirection thats worth removing?" — "Push it. And i think we should fold the rest in too" — "The GroupSUm and At args seem very confusing. Why do they invert? Anything wrong here?" — "Should we inify them?" (the partition on position() and on shift/sum_back)

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.

at(load, by=diag, over=rep, into=generator)
  diag: {key: [snapshot, generator], values: {rep: snapshot}}   load: dims [snapshot, bus]

on #559  LOADED  — read `rep` at the operand's snapshot, joined on the key's
                   snapshot at the same coordinate, landed on [bus, generator]
on this  REFUSED — DimensionError: at(by=diag) joins 'diag' on ['snapshot'] through
                   more than one column, and the operand carries each dimension once.
Why it went unasked, and the guard that asks it

_check_joined asked whether a joined dimension was in by.dimensions. For sum that 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. For at the 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 and into= always the columns produced, on sum and at alike, and that is what Direction stores. On the nodes, the two words meant the fine and coarse sides in the sum's sense, so that At could be the adjoint of GroupSum.

test_a_joined_column_is_not_also_consumed has one case per operator. Deleting or d in consumed from _check_joined:

deleted suite
or d in consumed 2 failed, 1331 passed, 6 skipped — exactly the two cases

Both 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.

  • One dim rule. _read_dims is the rule for sum(by=) and at(by=), and the landing check is inside it. Each operator keeps only its own "operand does not carry" message.
  • A resolved by= is a DirectionNode or a PartitionNode. RelationNode held a Direction | Partition field 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 names Direction and Partition under TYPE_CHECKING and the golden census reads the union at runtime. position() in a where is the atom itself, so its Partition stays a field on DimensionPositionNode.
  • A node's direction is read directly. GroupSum and At hold operand and direction, nothing else. Separability and advice name the side they mean off direction, and the inversion has nowhere to live.
  • A relation is declared once. The resolver built a RelationDeclaration per relation for its namespace and lowering built the same ones again per dimension. Resolved carries the resolver's, and every Direction holds the same object the program's dimensions hold — the test asserts identity, not equality.
  • The typesetter prints a relation read one way. _relation_read takes the relation's name and looks it up; _column and _position_group are gone.
  • Names nothing needed. Namespace.shape_of; Direction.key, .values, .roles; Partition.key, .values; _known_roles' None arm; 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.

  • The at refusal said the operand fixes the key columns; it is the result that does. Corrected here.
  • The sum-to-key refusal named 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.py matches the whole message on both, which is what let the swap survive.
The break

GroupSum and At lose .relation, .over, .into and .joined; read .direction.name, .consumed_dims, .produced_dims and .joined_dims, which say the same thing on both nodes. Direction loses .key, .values and .roles, Partition loses .key and .values; the same facts are on .relation. RelationDeclaration(name, columns) with no key no longer constructs. Resolved gains a relations field. RelationNode and shape_of are private names.

All are program or resolution exports. lpspec reads them heavily — .into at 27 sites, .over at 18, At 15, GroupSum 13, .joined 13 — and pins math-spec by tag, so it follows once for this stack rather than once per merge.

Rebased onto main at bda1fc5

Merged, not replayed: the hard rule here is never to force-push. main came
in 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 RelationNode into DirectionNode and PartitionNode,
so each half gets one — self.direction.name and self.partition.name. The
five dimensions.py conflicts are #529's RelationNode arm against the
Direction/Partition refactor, which wins; .shown is gone from the tree.

#529's test_a_node_resolution_built_prints_the_name_the_file_wrote arrived
written against the unsplit node and the two-field RelationDeclaration, and
auto-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 new
partition 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.sh is refused by this environment's egress proxy, so the gates ran from a uv environment 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.

gate result
pytest -n auto 1333 passed, 6 skipped (#559: 1330 passed, 6 skipped)
ruff check, ruff format clean
pyrefly check 0 errors, 8 suppressed, under main's explicit-any = "error"
grep -rn '\bAny\b' src/ no match
mkdocs build --strict not run at this commit
compile-tex, typos, reuse lint, prettier not run locally

CI 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 .out files, nor the generated pages moved — the proof that the typesetter prints the same thing through fewer paths.

Looked at and left alone
  • Direction.name and .dim, and the same on Partition: one hop to .relation, with about twenty callers between them. Removing them spreads .relation. across every site and removes no concept.
  • RelationBlock in model.py and RelationDeclaration in program.py still 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: position is not in the operator table that unsaid_ends_error reads its usage from, and the reason sentence is already shared.
  • _relation_ref's two dicts over _role_name exist for the type narrowing; folding them costs a pyrefly suppression.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk


Generated by Claude Code

…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
@read-the-docs-community

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

Copy link
Copy Markdown

@FBumann
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
@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 removed this pull request from stack #574 September 19, 2026 18:12
@FBumann
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
FBumann removed this pull request from stack #575 September 20, 2026 20:34
@FBumann
FBumann added this pull request to stack #583 September 20, 2026 20:35
@FBumann
FBumann merged commit c10c181 into main Sep 20, 2026
6 checks passed
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