Skip to content

feat(language): a relation's direction is written inside by=, as relation(a -> b) - #499

Closed
FBumann wants to merge 9 commits into
claude/vigilant-allen-498nwifrom
claude/vigilant-allen-498nwi-walk
Closed

FBumann wants to merge 9 commits into
claude/vigilant-allen-498nwifrom
claude/vigilant-allen-498nwi-walk

Conversation

@FBumann

@FBumann FBumann commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "This is the shape!" — after "Maybe a single relations key would be better?", "Is the lookup name always the first name in the by=?" and the rules written down in the session. Stacked on #493, which is the base branch. Supersedes #495, whose thread discusses the earlier over=a -> b form. A follow-up fixed the vocabulary: "its a direction, not a walk!" — the walk is the traversal, the direction is what the call writes.

A later follow-up, after reading #515 beside this one, shortened the read: "Yes, land that on #499" — that being one end on an at, so at(cap, by=ends(bus0)) loads and the key is not written out.

Note

The following content was generated by AI.

What this changes

by= is the only relational keyword. It names a relation, and the direction of the walk through it is written after the name, relation(a -> b). into=, within= and the direction form of over= are gone.

before now
sum(p, by=zone_of, over=generator, into=zone) sum(p, by=zone_of(generator -> zone))
sum(p, by=gen_bt, into=[bus, technology]) sum(p, by=gen_bt), or written out, by=gen_bt(generator -> [bus, technology])
sum(f, by=ends, over=line, into=bus1) sum(f, by=ends(line -> bus1))
at(cap, by=ends, over=bus0) at(cap, by=ends(bus0))
at(tech_cap, by=gen_bt, over=[bus, technology]) at(tech_cap, by=gen_bt)
shift(x, along=t, by=cal, within=week) shift(x, along=t, by=cal(week))
position(g, by=gen_bt, within=[bus, technology]) position(g, by=gen_bt([bus, technology]))
sum(p, by=gen_bus), at(price, by=gen_bus), by=[gen_bus, gen_tech] unchanged

The rules.

  • A call writes what the declaration does not decide, and nothing more. A bare by= means the declaration decides the whole direction. A sum names both ends, since either can vary: sum(p, by=zone_of) with two key columns is refused toward by=zone_of(<dimension> -> zone). A read and a partition name one — the columns read, or the columns grouped by — because the other end is not theirs to choose. A read lands on the whole key, and a partition takes its other end from along=.
  • The left end of a sum names a dimension, every other end names columns. A key has one column per dimension, so generator names the key column over it, and a renamed key column is written by its dimension. Value columns may share a dimension, so bus1 is named by column.
  • over= and along= keep one meaning. over= is a reduction over a dimension, along= the axis a translation moves along. A sum with a by= takes no over=; the call shape refuses both together.
  • Two defaults become rules. A bare sum lands on every value column (sum(p, by=gen_bt) onto bus × technology), and a bare read consumes every value column the operand carries (at(tech_cap, by=gen_bt)). Both were refused before. Each is the rule sum(x) already follows for dimensions, and each is refused where two value columns share a dimension, naming the column to write.
  • The relation and both ends are nodes. A macro binds a formal at any of the three: sum(x, by=l(a -> b)) with l=, a= and b= at the call.

Nothing changes for a consumer: Walk, GroupSum, At and the partitions lower as before. A read's split into consumed, joined and produced is Walk.read_by, one rule the dim check and the lowering share. The typesetter's math did not move; the golden .out files are unchanged.

Why

Two keywords for one direction, with the table named in a third, made the relational calls hard to read, and over= meant a dimension in one call and a column in the next. Writing the direction after the relation puts the table first, shows the direction unmistakably, and leaves every keyword with one meaning. The probe rows on the operators page show each form beside the math it prints, which is where to judge it.

Why one end on a read, added after #515

The read was the one place this shape cost more than the keywords it replaced. at(cap, by=ends(bus0 -> line)) writes a landing that the declaration already fixes — a read lands on the whole key, always — so the arrow's right side could only ever be right or refused. #515's by=ends.bus0 is shorter for exactly that reason.

One end already meant something on a partition, by=cal(week): the columns the group is read from, with the other end coming from along=. Giving it the same meaning on a read makes the parenthesis one rule rather than two, and the read as short as #515's without leaving this PR's shape.

The math is the check that nothing was lost. The operator probe's cell is byte-identical before and after — at(cap, by=ends(bus0)) prints $f_l \le \mathrm{cap}_{\mathrm{ends.bus0}(l)}$, exactly as the long form did.

Refusals, each naming the rewrite
  • sum(p, by=zone_of) with two key columns: 'zone_of' has 2 key columns (['generator', 'period']), and the call has to say the direction: by=zone_of(<dimension> -> zone).
  • sum(p, by=lk(h)): by=lk(h) names one end, and a sum names both: what leaves and what arrives. Write by=lk(<dimension> -> h).
  • sum(p, by=lk(g -> h), over=g): sum() takes at most one of by= or over= — a relation carries its own dimensions, and a direction names what leaves inside by=, so over= has nothing to add.
  • sum(p, by=lk(h -> g)) where h is a value column's dimension: a sum consumes key columns, and 'lk' holds 'h' as a value column. To read it, write at(..., by=lk(h)).
  • sum(p, by=lz) with h0, h1 over one dimension: by=lz lands on ['h0', 'h1'], two columns over ['h'] …
  • at(r, by=lk(g)), naming a key column as the column read: by=lk(g) reads the key column(s) ['g'], and at reads value columns at the key. Read a value column, among ['h'], or walk the other way with sum.
  • at(r, by=lk(h -> g)) with key [g, z]: lands on ['g'], and a read lands on the whole key, ['g', 'z']. Write by=lk(h), which names the columns read and lands on the key, or write that key out.
  • at(cap, by=ends): 'ends' has two value columns over ['bus'] (['bus0', 'bus1']), so nothing says which one is read. Name it: by=ends(bus0).
  • shift(..., by=lz(g -> h)): a partition walks the axis along= names, and the parenthesis names only the columns its group is read from. Write by=lz(h).
  • sum(p, over=lk(h -> g)): names a direction through a relation, and a direction belongs in by=. Write sum(<expr>, by=lk(h -> g)).
  • into=, within=: the closed call shape.
  • by=l(a, b -> c) is a parse error, since a comma separates arguments; brackets for a list at an end.
Guards and their tests

Each guard deleted in turn, its test run, all failing without it. The two the read's change touches were re-checked in the session that shortened it; the rest were checked when they landed:

  • one end written on a sum (_walk) → -k a-sum-with-one-end-written
  • a read landing short of the key (_read) → -k a-read-landing-short-of-the-key
  • a read naming a key column as the column read (_read) → -k a-read-naming-a-key-column
  • a bare read through two value columns over one dimension (_read) → -k bare-read-through-two
  • a left end on a partition (_partition_walk) → -k partition-with-a-left-end
  • a direction in over= (_dim_ref) → -k a-direction-in-over
  • a direction in arithmetic through a macro formal (_arith) → -k direction-bound-to-a-formal
  • a left end not over a key column (_key_columns_over) → -k "key-is-not-over or walks-to-the-key"

Coverage moved when the read's one-end refusal went: a-read-with-one-end-written is deleted, since at(r, by=lk(h)) now loads, and a-read-naming-a-key-column-as-the-column-it-reads takes over the rule that the named column is a value column. Two rows were added to test_dim_inference, at(bz, by=gen_bz([bus, zone])) and at(load, by=pair(b0)); both failed on the tree before the change, with the refusal the change deletes.

Verified

pixi is not installable in this session (the installer is blocked by the proxy with a 403), so the gates ran from a uv venv on Python 3.13 with the pinned ruff==0.16.1 and pyrefly==1.2.0:

  • pytest -q -n 4: 1317 passed, 1 skipped (the whole suite, test_docs.py included).
  • ruff check, ruff format --check: clean. prettier --check on the changed pages: clean. reuse lint: compliant, 212/212. typos: clean.
  • pyrefly check: 11 errors, 10 suppressed — the same count on the unmodified tree, and every one is a missing stub or pydantic import this venv lacks rather than a finding about the diff.
  • Generated files regenerated with their tools: tests.typesetting.golden, tools.notation, tools.spec_math, tools.gallery, tools.home_math, tools.schema. The .out files, docs/reference/notation.md, docs/index.md and the README did not move at all, so the printed math is identical. docs/reference/language/operators.md and docs/examples/operators.md move only in the probe's label and its model.
  • mkdocs build: no link or anchor warning. --strict aborts here on fetching docs.python.org/objects.inv through the proxy, which is the environment, not a page.

Not run: pixi run ci as one command, zizmor, taplo, compile-tex.

Deliberately not done: refusing the long at(cap, by=ends(bus0 -> line)) now that one end says the same thing. Two spellings for one read is a question worth deciding — the shorter is what the docs and the probe teach, and the longer shows the reader of a file what the result is over. Say the word and it becomes one spelling.

🤖 Generated with Claude Code

https://claude.ai/code/session_016enncWLMR8XjuSQ8JBKXGn

…as one arrow

`sum(x, by=l, over=a, into=b)` is written `sum(x, by=l, over=a -> b)`, and
`into=` is gone. `over=` keeps its one meaning, what leaves the frame, and an
arrow adds where it lands. Each end is a bare name or a bracketed list, as
the two keywords took before. The short forms are unchanged: `over=a` where
the declaration decides the landing, and `by=l` alone where it decides both.

An arrow is a node with two children rather than a pair of strings, so a
macro formal at either end is bound like any other. `at` and the partitions
take no arrow, and say so with the column to write instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…ocs already call it

`ArrowNode` is `DirectionNode`, and every message, page and test id says
direction. The reference already said an operator walks a table "in the
direction the call names", so the PR introduced a second word for one thing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…the language now does

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…left end is a dimension

`sum(p, by=zone_of, over=generator)` is refused toward
`over=generator -> zone`: a call names the whole direction or none of it, so
a bare `over=` is only ever a reduction over a dimension, and the shape of the
value says which reading applies. The left end of a direction names the
dimension a key column is over, since a key has one column per dimension, and
the right end names columns, which may share one.

A sum that consumed a value column was refused as a read; that refusal is now
the left end's, and it still names the at() to write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…elation(a -> b)

`sum(p, by=zone_of, over=generator -> zone)` is `sum(p, by=zone_of(generator -> zone))`,
`at(cap, by=ends, over=bus0)` is `at(cap, by=ends(bus0 -> line))`, and
`shift(x, along=t, by=cal, within=week)` is `shift(x, along=t, by=cal(week))`.
`by=` is the one relational keyword: it names the table, and the parenthesis
holds what the call says about the walk through it — both ends for a sum and
a read, the group alone for a partition, whose other end is `along=`. A call
writes the whole walk or nothing. `over=` and `along=` keep their one
meaning, and a sum with a `by=` takes no `over=`.

Two defaults become rules, as `sum(x)` takes every dimension: a bare sum
lands on every value column, and a bare read consumes every value column the
operand carries. The relation and both ends are nodes, so a macro binds a
formal at any of the three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…e walk is the traversal it names

The walk is the traversal, with its consumed, joined and produced columns,
which is what the reference and `Walk` already call it. What the call writes
after the relation, `a -> b`, is the walk's direction. Every message, page,
docstring and test id that said "walk" for the written value now says
direction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
@FBumann FBumann changed the title feat(language): a walk through a relation is written inside by=, as relation(a -> b) feat(language): a relation's direction is written inside by=, as relation(a -> b) Sep 16, 2026
@FBumann FBumann added the area: relations relations and dimensions: the relation design label Sep 16, 2026
…te way from the function the math prints

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
…n-498nwi-walk

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uvytQ8L5Kz5s5tZYpkwpp
@FBumann FBumann added the proposal: direction in by The direction rides inside by=, as by=zone_of(generator -> zone) (#499) label Sep 17, 2026 — with Claude
…he whole key

A read lands on the whole key, and the declaration says what that key is,
so `at(cap, by=ends(bus0))` carries the direction on its own. One end
inside the parenthesis now means the same thing on a read as on a
partition: the columns read or grouped by, with the other end taken from
the declaration or from `along=`. A sum still names both ends, since
either of them can vary.

The longer `at(cap, by=ends(bus0 -> line))` still loads, and prints the
same math.

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

FBumann commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Closed in favor of #540

Will revisit for syntax discussions

@FBumann

FBumann commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Superseeded by #596

@FBumann FBumann closed this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: relations relations and dimensions: the relation design proposal: direction in by The direction rides inside by=, as by=zone_of(generator -> zone) (#499)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants