Skip to content

feat(language): a relation between dimensions, walked in the direction each call names - #437

Merged
FBumann merged 16 commits into
mainfrom
claude/lookup-relations-vhvfjd
Sep 15, 2026
Merged

FBumann merged 16 commits into
mainfrom
claude/lookup-relations-vhvfjd

Conversation

@FBumann

@FBumann FBumann commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "I feel like having a purely relational lookup is much more capable! With the direction per operator…?" — "I want the relation to be well defined (one to one, one to many, many to many etc), and to make the data I track safe and actionable" — "I want the keywords to clearly say what happens" — "columns or dims? Or axes?"

Follow-up (#477): the names @brynpickering proposed in the thread, with by= kept.

Note

The following content was generated by AI.

A relation is a table between dimensions, key: names the columns that are unique together, and every call names which column it removes and which it lands on. Two keywords are renamed with it: shift and sum_back walk their axis with along= rather than over=, and sum_back(within=) becomes window=. sum(x, over=d) is unchanged from main.

#422 is on main, and this is the third proposal on top of it for #275 and #161, beside #428 (per:) and #433 (keys and a dot), which stay open until #275 is decided. It also covers #424, which #436 answers on top of #433.

The feature in one example

On main a lookup is an arrow. over: is the dimension it starts at and into: the one it lands on. sum(p, by=gen_bus) follows the arrow, at(x, by=gen_bus) follows it backwards, and those are the only two directions there are.

lookups:
  gen_bus: { over: generator, into: bus }

Here a relation is a table with one column per dimension it relates. key: names the columns that hold each combination once. The table has no direction. Each call says which column leaves the frame and which arrives.

relations:
  gen_bus:    { columns: [generator, bus], key: generator }                     # one bus per generator
  zone_of:    { columns: [generator, period, zone], key: [generator, period] }  # a generator's zone, per period
  ends:       { columns: {line: line, bus0: bus, bus1: bus}, key: line }        # a line's two ends, one table
  connection: { columns: [generator, bus] }                                     # no key: many-to-many

With p over [generator, period], f over [line, period] and price over [zone, period]:

sum(p, by=gen_bus, over=generator, into=bus)                           # [generator, period] → [bus, period]; main's sum(p, by=gen_bus), both sides defaulted
sum(p, by=zone_of, over=generator, into=zone)                          # [generator, period] → [zone, period]; period is joined on
sum(p, by=zone_of, over=period, into=zone)                             # [generator, period] → [generator, zone]; the same table the other way
at(price, by=zone_of, over=zone, into=generator)                       # [zone, period] → [generator, period]; each generator's zone price
sum(f, by=ends, over=line, into=bus1) - sum(f, by=ends, over=line, into=bus0)  # [line, period] → [bus, period]; a nodal balance through one table
sum(p, by=connection, over=generator, into=bus)                        # [generator, period] → [bus, period]; a bare relation, both ends named

A side the declaration decides may be left out. gen_bus has one key column and one value column, so sum(p, by=gen_bus) and at(price, by=gen_bus) are complete. zone_of has one value column, so into=zone may go, and over= stays because the key has two columns. ends has one key column, so over=line may go. connection has no key, so both stay.

A model on main with a one-key, one-value lookup keeps every by= call and every sum(over=) as it is. The declaration changes once: {over: generator, into: bus} becomes {columns: [generator, bus], key: generator}. A shift or sum_back changes over= to along=. The data it binds is unchanged.

What the key means

key: is the database word: the columns that identify a row. key: generator says each generator appears once, so the other columns are a function of it. key: [generator, period] says each pair appears once, and neither column need be unique on its own. The claim is checked when data is bound, so a generator on two buses is refused where a 0/1 parameter would have doubled the sum in silence (#161). The thread asked whether a key must be unique: yes, and the bind check is what enforces it.

Each cardinality is one declaration:

to say write
many-to-one {columns: [generator, bus], key: generator}
one-to-many {columns: [generator, bus], key: generator} — the same table, walked the other way: sum(p, by=gen_bus) collects a bus's generators, at(x, by=gen_bus) reads a generator's bus
many-to-many {columns: [generator, bus]}, no key
one-to-one sayable — #424's self-map is {columns: {snapshot: snapshot, rep: snapshot}, key: snapshot} — but key: cannot narrow the bind check to it. The check stays many-to-one.

The key also decides which walks the table allows. This is the rule a reader most needs, because it answers "what can be removed":

a call needs because
sum(by=) the key not wholly inside the produced and joined columns a sum adds rows up. Walked to the key it finds one row per coordinate, which is a read, so it is refused toward at
at(by=) the key inside the produced and joined columns a read is one value per coordinate
shift, sum_back, position with by= a key column over the walked dimension a coordinate is in one group, or it has no neighbour
where: "zone_of == 'A'" a key, and the column compared a value column a comparison is one value per coordinate
where: connection (bare) nothing a row exists, or it does not

So sum may remove a key column (over=generator on gen_bus) or a value column (over=zone on connection), but sum(p, by=gen_bus, over=bus, into=generator) is refused: with generator the key, each generator finds one row and nothing is added. The rewrite is at.

A relation with no key: is a bare relation. sum walks it with both ends named, a bare where tests it, and nothing else touches it. Its columns not walked are joined on. That is what many-to-many can say, and all it can say.

The keywords

Five keywords are about relations. Each means one thing wherever it appears.

keyword what it names on sum on at on shift, sum_back, position
by= the relation the call walks optional required optional
over= what leaves the frame: a column of by=, or a dimension when there is no by= the key by default a value by default —
into= what arrives: a column of by= a value by default the key by default —
along= the ordered axis walked and kept — — required on shift and sum_back; position(d, …) takes the axis positionally
within= the value columns the group is made of — — all value columns by default

A default is taken from the declaration where it leaves one candidate. A table with two key columns or two value columns has no default on that side, and the call names it. The refusal lists the candidates. into= and within= without a by= are refused. over= without one names a dimension of the operand, which is main's sum(x, over=d).

over= and into= say the frame changes. along= and within= say it does not.

The renames

Three renames land with the feature. All three change models that load on main today. sum(x, over=d) is not one of them: its 235 call sites are untouched.

on main here why
shift(x, over=d), sum_back(x, over=d) shift(x, along=d), sum_back(x, along=d) over= on sum means removed, and on shift it meant walked and kept. sum keeps the word every reader says, and the operators that keep the axis take along=, which also carries that the axis is ordered. 226 call sites in src/, tests/, docs/, examples/.
sum_back(x, over=d, within=n) sum_back(x, along=d, window=n) within= now names a partition's group. The node was always Window and its field width. 71 call sites.
lookups: {l: {over: a, into: b}} relations: {l: {columns: [a, b], key: a}} the declaration is the feature. relations:, because a keyless table looks nothing up. columns: and not dims:, because a relation may name one dimension twice (ends, a self-map) and a frame never does. 27 files.

Counts are git grep on origin/main at 5656b11. per= was the other candidate for the group keyword and lost because per is this language's word for a frame: a constraint is one row per coordinate.

from=, to=, consume=, produce= and lookups: appear in this PR's commit history and nowhere on main. They were earlier spellings of over=, into=, within= and relations:, renamed inside the PR after the thread's comments, the last round in #477. The diff against main shows the final spelling only.

What is new

Nothing sayable on main is unsayable here. New:

  • a map that varies along a second dimension (zone_of), walked from either key column
  • a table with several value columns, walked to one, or landed on their product: sum(p, by=gen_bt, into=[bus, technology])
  • a line's two ends, and a self-map (A lookup from a dimension into itself, so representative snapshots are sayable #424), through named columns
  • a masked sum: sum(load * p, by=gen_bus) where the operand already carries bus joins on it, where main refused it
  • one calendar table partitioning at day, week and season: shift(x, along=snapshot, by=cal, within=week)
  • a many-to-many relation as a keyless relation, where main needs a parameter of ones
  • two where forms: ends.bus0 != ends.bus1, and a bare where: connection
  • a new refusal: a sum that walks to the key, since it reads rather than sums

How to review this

  • The semantics are in src/math_spec/resolution.py (_walk, _partition_walk, _relation_ref), model.py (RelationBlock, the declaration rules) and program.py (RelationDeclaration, Walk). Those three files are 958 of the 1548 lines changed under src/.
  • The rules a modeller meets are docs/reference/language/dimensions.md under relations, and each has a refusal case in tests/test_validation.py.
  • The renames are four commits, each mechanical and readable on its own: 684fff5 (within=, window=), cf7ca1b (consume=, produce=), 1baa338 (columns:), 3d261e4 (feat(language): relations replace lookups, walked with over= and into= rather than consume= and produce= #477: relations:, over=, into=, along=).
  • examples/ changes are the renames only: 138 lines out, 138 in.
The whole model, which loads on the branch, with the frame the loader reports for each row
dimensions:
  generator: {}
  zone: {}
  bus: {}
  technology: {}
  line: {}
  period: { dtype: int }

relations:
  gen_bus:    { columns: [generator, bus], key: generator }
  gen_bt:     { columns: [generator, bus, technology], key: generator }
  zone_of:    { columns: [generator, period, zone], key: [generator, period] }
  ends:       { columns: {line: line, bus0: bus, bus1: bus}, key: line }
  connection: { columns: [generator, bus] }

parameters:
  cost:     { dims: [generator] }
  demand:   { dims: [zone, period] }
  price:    { dims: [zone, period] }
  load:     { dims: [bus, period] }
  tech_cap: { dims: [bus, technology] }

variables:
  p: { dims: [generator, period], bounds: { lower: 0 } }
  f: { dims: [line, period] }

constraints:
  zone_balance: # remove generator, join period, land on zone → [zone, period]
    dims: [zone, period]
    expression: sum(p, by=zone_of, over=generator) >= demand

  history: # the same table, removed along its other key column → [generator, zone]
    dims: [generator, zone]
    expression: sum(p, by=zone_of, over=period) <= 100

  capped_revenue: # at removes the value column, joins period, lands on generator
    dims: [generator, period]
    expression: at(price, by=zone_of, into=generator) * p <= 1000

  nodal: # one table for both ends of a line
    dims: [bus, period]
    expression: sum(p, by=gen_bus) + sum(f, by=ends, into=bus1) - sum(f, by=ends, into=bus0) == load

  by_bus_and_tech: # one table landed on a product, in one join
    dims: [bus, technology, period]
    expression: sum(p, by=gen_bt, into=[bus, technology]) <= tech_cap

  reachable: # a bare relation, walked with both ends named
    dims: [bus, period]
    expression: sum(p, by=connection, over=generator, into=bus) <= 2 * load

  no_loop: # two columns of one table compared
    dims: [line, period]
    where: "ends.bus0 != ends.bus1"
    expression: f <= 10

  first_in_zone: # a partition walks the key column over period and groups by the value columns
    dims: [generator, period]
    where: "position(period, by=zone_of) == 0 AND zone_of == 'A'"
    expression: p <= 10

  within_bus: # grouped by one named value column of a two-value table
    dims: [generator, period]
    expression: p <= shift(p, along=generator, offset=1, edge=0, by=gen_bt, within=bus)

  parallel: # grouped by a pair of buses: lines between the same two ends are neighbours
    dims: [line, period]
    expression: f <= shift(f, along=line, offset=1, edge=0, by=ends)

objective: { sense: minimize, expression: sum(p * cost) }

$$\sum_{g \in \mathcal{G} : \mathrm{zone_of}(g, e) = z} p_{g,e} \ge \mathrm{demand}_{z,e} \qquad \sum_{g \in \mathcal{G} : (g, b) \in \mathrm{connection}} p_{g,e} \le 2 \cdot \mathrm{load}_{b,e} \qquad p_{g,e} \le p_{g \boxminus_{0}^{\mathrm{gen_bt.bus}(g)} 1,, e}$$

What each walk does to the frame
call removed landed on joined on and kept result
sum(p, by=gen_bus) generator (the key) bus (the value) — bus, period
at(x, by=gen_bus) bus (the value) generator (the key) — generator, …
sum(p, by=zone_of, over=generator) generator zone period zone, period
sum(p, by=zone_of, over=period) period zone generator generator, zone
sum(p, by=zone_of, over=[generator, period]) both keys zone — zone
sum(q, by=zone_of, over=zone, into=generator) zone (a value column) generator period refused: one row per coordinate, a read; use at
at(price, by=zone_of, into=generator) zone generator period generator, period
sum(p, by=gen_bt, into=[bus, technology]) generator both values — bus, technology, period
at(tech_cap, by=gen_bt, over=[bus, technology]) both values generator — generator
sum(f, by=ends, into=bus1) line bus1 — (bus0 is not read) bus, period
sum(p, by=connection, over=generator, into=bus) generator bus — bus, period
sum(load * p, by=gen_bus) with load[snapshot, bus] generator bus, already carried, so joined on too — snapshot, bus: a masked sum
shift(x, along=period, by=zone_of) walks period the group: zone generator unchanged; groups are (generator, zone)
shift(x, along=generator, by=gen_bt, within=bus) walks generator the group: bus — unchanged; groups are buses
shift(f, along=line, by=ends) walks line the group: (bus0, bus1) — unchanged; groups are pairs of buses
position(generator, by=gen_bt, within=[bus, technology]) == 0 walks generator the group: both — first generator of each bus-and-technology
The ten rules, each decided at load
# Rule Refused with
1 columns: names at least two columns, each over a declared dimension, each column name once; a list names columns after their dimensions, a mapping gives names where two columns share a dimension; a column named like a dimension is over that dimension "has 1 column(s)", "names dimension 'bus' twice under 'columns:'. Give the two columns roles: columns: {bus0: bus, bus1: bus}", "names column 'bus' after dimension 'bus', but the column is over 'line'"
2 key: names columns the relation has, each once, one per dimension, and not all of them "has key column 'z', which is not one of its columns", "has two key columns over 'bus' … no frame carries a dimension twice", "has every column in its key, so the key determines nothing"
3 over= and into= name columns of the relation by= names — one each or a list each — with no column on both sides, none twice, and no two over one dimension; sum and at take both, a partition takes within= alone. An into= or within= with no by= is refused; an over= with no by= names a dimension "over= and into= both name ['h']", "into=['h', 'h'] names a column twice", "into=['bus0', 'bus1'] names two columns over ['bus'], and the operand carries each dimension once", "shift(within=) names a column of a relation, and no by= names the relation"
4 A side the call leaves unsaid is taken from the declaration where it has exactly one candidate "'zone_of' has 2 key columns (['generator', 'period']), and the call has to say which over= names"; "'gen_bt' has 2 value columns (['bus', 'technology']), and the call has to say which into= names"; "'rel' declares no key, so nothing says which column sum walks"
5 The other key columns are joined on: the operand carries each of their dimensions once; a value column not walked is not read; a produced dimension the operand already carries is joined on too; a sum or at lands on each dimension once "joins on ['period'] (columns ['period'] of 'zone_of'), which the expression does not carry", "produces ['bus'] more than once"
6 at reads one value, so the key lies inside the produced columns and the joined columns; a bare relation is never read by at. The mirror holds for sum: a sum whose key lies there sums one term per coordinate, so it too is refused, toward at "at reads one value per coordinate, and 'rel' is not single-valued in ['g'] at the columns the operand fixes"; "this sum walks to the key ['g'], so each coordinate has one term and nothing is added up — that is a read, which is at()'s"
7 A partition walks the one key column over the dimension it walks, joins on the other key columns, and groups by the value columns within= names — all of them where it names none; the group lands nothing, so it may pair two columns over one dimension; within= naming a key column is refused, and a bare relation partitions nothing "'rel' declares no key, so no coordinate is in exactly one group", "has no key column over 'snapshot'", "within=['g'] names a key column of 'lz', and a partition groups by value columns"
8 A by= list walks each relation by its declared arrow; every entry removes the same dimensions and no two land on the same one "a list walks each relation by its declared key and value, so a column keyword has nothing to name"
9 A where comparison reads a value column of a keyed relation at its key, named where the key determines several; two compared columns are over one dimension and their relations keyed over the same dimensions; a bare name tests that a row exists "compares a column of 'rel', which declares no key", "'g' is a key column of 'lk', which the frame supplies rather than reads"
10 At bind: one column per declared column, named after it; every value a label of its dimension; a keyed table single-valued per key tuple the consumer's DataError
Capability against #428 and #433, on the same base

"Yes" is sayable in one call with the language checking its shape; "workaround" is sayable another way. The same comparison runs on five loadable models in #453.

capability main #428 per: #433 keys and dot #437 relations
a map keyed by one dimension, walked both ways yes yes yes yes
a map that varies along a second dimension no yes yes yes
the same table walked from its other key no no, a second declaration yes yes
a table with several value columns, walked to one no, one lookup per value no no yes
landing on a product of two value columns workaround: two tables and by=[…] workaround workaround yes, into=[…]
reading a two-column slot at once workaround workaround workaround yes, over=[…]
removing two key columns at once workaround: nested sums workaround workaround yes, over=[…]
spreading a coarse quantity onto its keys (a value removed, the key landed on) no no no yes, via at (a sum here is refused, since it reads)
a masked sum, the produced dim already carried refused refused refused yes, joined on
a line's two ends in one table no no no yes
a self-map (representative snapshots) no no no, #436 adds it yes, by role columns
one calendar table partitioning at day, week and season no no no yes, within=
an unweighted many-to-many relation workaround: an int parameter of ones same same yes, a keyless relation
a weighted many-to-many relation a parameter same same same, a parameter
single-valuedness checked at bind per over per (over, *per) per key tuple per key tuple, and absent by choice
rules to state 5 7 7 10
call-site syntax beyond by= none none a dot over=, into=, within=
existing declarations rewritten none none none all, once

Nothing sayable in an earlier column is unsayable in a later one. The last three rows are the price.

Decisions taken, and why

The declaration makes the cardinality claim; the call makes the direction. key: says what is single-valued per what, which is a fact about the data, checked at bind. Which way a call walks is a fact about the call. What the key buys is not an arrow but a licence: a read, a partition and a comparison each need one value per coordinate, and only a key promises it. A bare relation is walked by sum alone, with both ends named, and tested by a bare where.

The keywords are the words a modeller already says. "Sum over generator" is standard mathematics, so over= is what a sum or at removes, and it composes with by=: sum(p, by=zone_of, over=generator). into= is where the result lands. along= is the axis shift and sum_back walk and keep, and it carries that the axis is ordered. The earlier spellings consume= and produce= named the walk's effect in the program's own words, and lost in the thread because they are energy-system vocabulary: a modeller writing a balance constraint should not meet consume= as syntax. Each of over= and into= takes a list, because a walk between two sets of columns needs two names. by= stays, because on a partition it reads as the group-by it is, and via= would suggest a path where there is none.

One rule went with it. sum used to refuse over= and by= together — "a lookup carries its own dimensions, so by= leaves over= nothing to add". They compose instead: by= names the table and over= names what leaves the frame. at_most_one_of is now unused on every built-in.

A partition takes within=, and sum_back's length is window=. The columns a result carries and the columns a walk groups by are two different things, and the frame changes in the first and not the second. A partition contains, so within= is the word; it held sum_back's length, which becomes window=, the name the program always used (the node is Window, its field width). per= was the cheaper candidate and lost because per is this language's word for a frame — a constraint is one row per coordinate — and a keyword renamed to stop implying the result gains a dimension should not imply one per bus.

The declaration says columns:, not over:. The reference has called them columns since #422 — "a table with one column per dimension it relates", "its value columns", "a key has one column per dimension" — and the schema key was the last place that disagreed. dims: would have been wrong beside parameters:, where dims: means a frame and a frame is a product of distinct dimensions; a relation's columns may name one dimension twice, which is what a self-map and a line's two ends need. With over: gone from the declaration, over= means one thing: the column a sum or at removes.

The block is relations:. A lookup promises a function, and here that holds only with key:. A keyless table such as connection: {columns: [generator, bus]} looks nothing up; it says which pairs exist, and a bare where: connection tests membership. relations: is right in both cases, and key: is what turns a relation into a function. The class names follow (RelationBlock, RelationDeclaration, Program.relations).

key: stays. The thread asked whether a modeller reads it as the database word. The reference opens the section with "the columns that are unique together", which is the claim, and the candidate unique: is an adjective the prose would still have to give a noun.

A produced dimension the operand already carries is joined on. #422 refused sum(load * p, by=gen_bus) as needing bus twice. Under a relational reading each term is restricted to the row where the generator's bus is the row's bus, a masked sum. The refusal is gone.

A sum toward the key is refused. With the key inside the produced and joined columns, each coordinate finds one row and nothing is added. That is at's read, and the refusal names it. The fan-out acceptance case in test_dimensions.py is dropped, and a-sum-that-walks-to-the-key-is-a-read in test_validation.py fails with DID NOT RAISE LanguageError when the guard is deleted.

Only key columns are joined on. Walking ends from line into bus1 must not join on bus0: a value column not walked is not read.

Roles are essential, not sugar. A self-map and a line's two ends are tables with two columns over one dimension, so columns need names of their own. columns: {bus0: bus, bus1: bus} is coroa's mapping form, taken whole.

Deferred. Several candidate keys (one-to-one): key: would widen to a list of keys, and each rule keeps its shape. piecewise: over: and sos: over: name a declaration's breakpoint axis, not a call keyword, and are untouched.

What consumers see, and where it lands

RelationDeclaration(name, columns, key), with columns as (role, dimension) pairs in declared order, is where a relation's roles, value columns and column dimensions live, and it is the same object the resolver reads and the program carries. Walk(relation, consumed, produced, joined) holds that declaration and three role tuples. GroupSum(operand, walks) and At(operand, walks) hold their walks alone, so a node cannot carry a coordinate without its walk; GroupSum.over and GroupSum.into are the dims that over= and into= name. Every partition is one Walk whose consumed is the key column over the walked dimension and whose produced is the group columns — Translate.partition, Window.partition and DimensionPositionNode.partition alike. Program.relations is a dict by name. The two relation-only where forms name.col OP value and name.a OP name.b are new grammar, as is a grouped position(…).

  • operators.py: a role kwarg kind and a dimension_or_role one, which is what lets over= name a dimension alone and a column beside by=; kind_of takes the call's by= to decide. at_most_one_of is now unused.
  • _expression_parser.py: no dotted names; over=/into= are ordinary kwargs, a name or a bracketed list.
  • _where_parser.py: name.column on either side of a comparison; position(d, by=l[, within=c | [c, …]]).
  • model.py: RelationBlock with columns (list or mapping) and key; rules 1 and 2. The parsed (role, dimension) pairs are RelationBlock.pairs, since columns is now the field.
  • resolution.py: _walk (rules 3, 4, 6), _partition_walk (rule 7), _relation_ref (rules 5 and 8), _relation_column and the pair rule (rule 9).
  • dimensions.py, lowering.py: sum reduces what over= names; the partitions are unchanged.
  • piecewise.py: the three constraints the expansion writes are sums, and carry the keyword.
  • program.py, separability.py, exclusivity.py: the declaration, the walk, and a rank subject that carries its partition.
  • typesetting: a keyed read prints as name(key…) or name.col(key…), a bare relation as (…) ∈ name; the legend prints → for a keyed table and ⊆ for a bare one.
  • docs and tests: the relations section rewritten around the relation, the key and the walk; every declaration rule and call-site rule above has a refusal case in test_validation.py, every walk in the frame table a dims case in test_dimensions.py, the lowering test covers every node, and the golden model prints in three formats.
Verified

CI is green on 3d261e4, the current head, with #477 merged in. That covers pyrefly, typos, taplo, zizmor, mkdocs build --strict and compile-tex.

pixi.sh is blocked in the sessions that built this, so every local run is a uv venv on Python 3.13 with the pinned ruff. On 3d261e4: pytest -q -n auto gives 1270 passed, 1 skipped. ruff check and ruff format --check clean on the pinned 0.16.1, prettier --check clean on the pinned 3.9.3, typos and reuse lint clean, every generator re-run including the schema, which lists columns as the relation's required key. The whole model above loads through to_spec on 3d261e4, and the refused row of the frame table is refused with the sentence rule 6 quotes.

Coverage moved with the rules: over-and-by-together asserted the rule that is gone and its case is deleted, replaced by a dims inference (by-and-over-compose) that sum(p, by=gen_bz, over=generator, into=bus) reaches the same frame as the defaulted call; from-without-by is into-without-by, since an over= with no by= is a dimension.

The conflict with main from #429's foreach: to dims: rename is resolved in fec2851.

Why

#433 answered coroa's design by keeping the lookup a function and letting the call pick which key it walks. The objection was that a table has no direction until something walks it, and the direction belongs to the verb. This PR takes that seriously: the declaration keeps the one claim only the data can be held to, and every direction is the call's.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
https://claude.ai/code/session_014DGTkrKPqMWnTnhzmASgCn
https://claude.ai/code/session_01FPJEkH4kkG9onVCpYkBTM7

@FBumann
FBumann added this pull request to stack #439 September 9, 2026 17:45
Base automatically changed from claude/lookup-syntax-per-keyword-vhvfjd to main September 9, 2026 17:46
@FBumann
FBumann force-pushed the claude/lookup-relations-vhvfjd branch from 57202ec to f20880a Compare September 9, 2026 17:51
@FBumann

FBumann commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@brynpickering @coroa @FabianHofmann I think I found the most capable and complex form of lookups with this.
The key idea is that a lookup only provides a mapping and information about wether columns are unique or not (key).

Everything else is determined where its used: operator(from=..., into=...)

This makes it much more obvious how a lookup is used in an operator and it can be reused in multiple places.
And the spec can validate that for example at() can only use akey column!

Happy to discuss naming etc.

@FBumann
FBumann marked this pull request as draft September 9, 2026 17:55
FBumann pushed a commit to fluxopt/specsolve that referenced this pull request Sep 9, 2026
…o one calendar table serves every granularity

Follows the update to energy-models/mathspec#437: a partition walk's produced columns are the group — every value column, or the ones `into=` names — so `shift`, `sum_back` and `position` on both lanes read the group off the walk rather than off the declaration. The pin moves to the rebased follow-up commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPeAmePdhNTDaNH2Q1CDr
@FBumann
FBumann marked this pull request as ready for review September 9, 2026 18:59
@FBumann
FBumann removed this pull request from stack #439 September 10, 2026 06:46
@FBumann
FBumann added this pull request to stack #448 September 10, 2026 06:46
claude and others added 8 commits September 10, 2026 08:47
…the direction each call names

`over:` lists the columns, `key:` is the claim that makes the table a
map, and `from=`/`to=` on the call say which column an operator
consumes and which it produces; the other key columns are joined on.
A one-key, one-value table still reads `sum(p, by=gen_bus)` and
`at(x, by=gen_bus)` unchanged. Without a key the table is a bare
relation: `sum` walks it with both ends named, a bare `where` tests it,
and `at`, `shift` and `position` refuse it. Two columns over one
dimension are named by role, which is how a self-map is declared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…oduct or is read at two columns at once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…the row, and the golden model renders it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…ion walks the one key column over its dimension

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…so one calendar table serves every granularity

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…a key nor a column name claims a dimension it is not over

A partition lands nothing, so its group columns are not checked as
dimensions the call produces. A key names one column per dimension,
since a frame carries each once. A column named like a dimension is
over that dimension. GroupSum and At hold their walks alone and read
over, coordinate and into off them; a Walk holds its LookupDeclaration,
which is the one home of a lookup's roles, values and column dims.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD5LpGRzAWdi5sKWXDdnHC
…and each cardinality names its declaration

The lookups reference says what `key:` means in uniqueness terms, that a
composite key leaves each column non-unique on its own, and which
declaration says many-to-one, one-to-many and many-to-many. One-to-one is
named as a claim the language does not have.

Page measure after the change (docs-writing script): 78 sentences, median 25
words, 38 over 25; the two new paragraphs add sentences of 7 to 25 words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016LDbfiWuoU2U8g8vV6iWq5
…in, and a walk naming two columns over one dimension is refused (#444)

* refactor(program): a grouped position carries the walk it counts within

`DimensionPositionNode` carries its partition as a `Walk`, as `Translate`
and `Window` do, in place of the lookup name, the walked column, the group
columns and the joined dimensions, so a consumer reads every partition one
way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPeAmePdhNTDaNH2Q1CDr

* fix(language): a walk naming two columns over one dimension is refused

A `from=` or `into=` list naming two columns over one dimension was
accepted and lowered to an operand consuming that dimension twice; it is
refused at resolution, naming the columns and the dimension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPeAmePdhNTDaNH2Q1CDr

---------

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/lookup-relations-vhvfjd branch from 2546a8a to 75840fd Compare September 10, 2026 08:50
@brynpickering

Copy link
Copy Markdown
Contributor

@FBumann I can see the power that this has to define pretty much anything we want but with the risk that it is quite confusing for users to both read and write the YAML math.

  • key as the term used in the lookups doesn't feel quite right. Although I understand it as a dictionary key, it requires some Python understanding to get that syntax
  • We could converge on dims or foreach instead of over to align with other blocks, since it's defining the dimensions involved in the lookups?
  • You say one-to-one isn't something that is available; how does this work with A lookup from a dimension into itself, so representative snapshots are sayable #424?

@FBumann

FBumann commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor Author

@bryn I iterated with claude on the "key" concept, because i wasnt fully convinced either. But I am now.

About confusion: I think its much less confusing and surprising than the alternative: having the lookup directional and having "magic operators" whose behaviour depends on how the lookups direction is set. But Id like to discuss this based on examples

@FabianHofmann

FabianHofmann commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

This feels very generalized and neat! I really like this


image

having the unique keys/key-combinations defined is a clever way to deal with things. I would also suggest to think about argument names further. In particular, I find the from keyword-attribute in the sum function weird. Why not over?

@FBumann

FBumann commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@FabianHofmann I dodnt oterate on the keywords per operator. But I think its a key feature of this proposal to be able to choose more intuitive names per operator!
Ill work on it tomorrow. You can also propose sth if you want to

FBumann pushed a commit that referenced this pull request Sep 10, 2026
…e only #437 can say

Adds a primer above the models: seven cards, each one kind of pairing in
everyday terms first and then in a model, with the spelling and what each
proposal does with it. Every `says` entry is measured — the file is under
`models/` or `probes/` and the branch's own answer is in `evidence.json`.

Adds a fifth model, `models/p5`: which regions are neighbours. Both columns
are regions, so the fallback for a relation — a parameter over the pair — is
refused, and the two left-hand panels print that refusal instead of a frame.
`build.py` renders a refused model as evidence rather than treating it as a
failure.

Two measured findings the primer prints verbatim:

- a `dtype: bool` flag cannot be multiplied, so the table of ones that stands
  in for an unweighted relation has to be declared `dtype: int`;
- #436's description sends an undirected neighbour relation to "a parameter
  over `[bus, bus]`", and that file does not load, on #436's own branch or any
  other.

`verify.py` gains #436's branch, which is not a fourth proposal but the draft
stacked on #433: a claim about the self-map is a claim about #433 with #436.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4kdrj2n7tESNrgYmfXkAq
@FBumann FBumann changed the title feat(language): a lookup is a relation between dimensions, walked in the direction each call names feat(language): a relation between dimensions, walked in the direction each call names Sep 15, 2026

@FabianHofmann FabianHofmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@FBumann
FBumann merged commit 3284926 into main Sep 15, 2026
7 checks passed
FBumann pushed a commit that referenced this pull request Sep 15, 2026
Re-applies the renames on top of #437, which brought relations in. The
three relation where-leaves drop the `Node` suffix with the rest. Two
follow-ups #437 made possible land with it: `Walk` and
`RelationDeclaration` are frozen dataclasses, the declaration no longer
carries its own name because `Program.relations` keys it, and a `Walk`
carries the name instead. `Sum.over`, `GroupSum.over` and `.into` keep
their names, because the file kept `over=` and `into=`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fhGZgaBspo7mh9Hjd3KtT
FBumann added a commit that referenced this pull request Sep 15, 2026
…449)

* docs: the README shows the math a model prints, in all three formats

The README described the typesetter and never showed it. The dispatch model
now stands beside the equations printed from it, in the Markdown that GitHub
renders as math. `tools/home_math.py` writes the block from
`examples/dispatch.yaml`, so nothing in it is hand-typed.

The visible block carries no legend and no symbol table. Three legend tables
are half the length of the document, and a derived symbol is the file's own
name, so the equations read without them: 44 lines become 23. A smaller model
does not do this. The same model cut to two parameters, with no `where:` and
no upper bound, prints 45 lines, because dropping the table adds the
convention note. Four folded blocks hold the whole document: with the symbol
table and its legend, as LaTeX, and as Typst.

The Typst block is printed with no table, which makes it the evidence for what
a derived symbol looks like. A parameter is upright, so `load` prints as
\mathrm{load}_t and `p_max` as \mathrm{p}^{\mathrm{max}}_g. Three pages claimed
\mathit{load}_t and p^{\mathrm{max}}_g for the same two names, and
docs/reference/notation.md contradicted the legend printed further down its
own page.

The README also named a variable the example does not declare: the model
declares `p`, and three sentences called it `dispatch`.

README.md: n 88, avg 12.7, median 11, over25 13. Every sentence over 25 words
is in a section this commit does not touch.

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

* docs: the examples say dispatch and capacity, rather than p and p_max

`p` and `p_max` are PyPSA's spellings, so a reader without that background
had to guess what the decision was. The gallery models and the reference
pages now name the decision `dispatch` and its bound `capacity`, and
`p_min` becomes `min_output`.

The six `pypsa*.yaml` files keep `Generator_p_nom` and `Generator_p_max_pu`,
which are PyPSA's own API and are the point of those files. The golden
fixture and the operator probes keep `p` too: their subject is which
construct prints what, not what a model calls things.

Also on this page: `docs/howto/print.md` said a parameter with no symbol
table prints as $\mathit{load}_t$, and it prints $\mathrm{load}_t$.

Two headings that narrated rather than named a subject become `The math it
prints` and `Spec` and `Program`, in the README and on the site homepage
together.

The `How` tab on the homepage repeated six sentences of
`docs/reference/typeset.md`. It now says what `symbols` does, then that it
is optional, and leaves the rest to the link it already carried.

`tests.fixtures.DISPATCH_MODEL` stays on `p` and `p_max`. It is an inline
dict that 11 test files vary; renaming it moved 87 assertions and changed
nothing a reader sees. Its docstring no longer claims to be
`examples/dispatch.yaml` name for name.

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

* docs: the README names sum's own keyword, which is over

#437 wrote `sum(consume=)` into the README's opening paragraph and its prior
art section. No version of the language has parsed `consume=`: the keyword
that reduces a dimension away is `over=`, as `BUILTINS['sum'].usage` and
`docs/reference/language/operators.md` both say.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
FBumann added a commit to fluxopt/specsolve that referenced this pull request Sep 15, 2026
…h-spec 0.0.0-alpha.89 does (#1646)

> **Prompt:** Let's update lpspec to the latest mathspec version

> [!NOTE]
> The following content was generated by AI.

The pin moves to `v0.0.0-alpha.89`, whose one feature
([#437](energy-models/mathspec#437)) replaced
`lookups:` with `relations:` and renamed two operator keywords. Both
lanes read the new plan; every model, example, rung and page is written
in it.

Either lane still builds the single-valued map the old lookup was — two
columns, one of them the key. A wider table is refused at `lowered`, so
`check`, `build` and an archive refuse the same file.

<details><summary>What the language change is</summary>

A relation is a table with one column per dimension it relates, and
`key:` names the columns a row is identified by. The declaration fixes
no direction: the operator says which column it consumes and which it
produces. `shift(over=, …)` became `shift(along=, …)`, and
`sum_back(over=, within=)` became `sum_back(along=, window=)`.

| before | after |
| --- | --- |
| `lookups: {gen_bus: {over: generator, into: bus}}` | `relations:
{gen_bus: {columns: [generator, bus], key: generator}}` |
| `shift(soc, over=snapshot, offset=1)` | `shift(soc, along=snapshot,
offset=1)` |
| `sum_back(started, over=hour, within=min_up)` | `sum_back(started,
along=hour, window=min_up)` |

</details>

<details><summary>What moved in the source</summary>

- **`relations.py`** is new: the one reading of a declared relation —
`refusal`, and the accessors that are well-defined once a wider table
has been turned away. Documented in
[ARCHITECTURE](docs/about/architecture.md)'s module table.
- **Both lanes read the plan's new shape**: `Walk` on `GroupSum`, `At`,
`Translate.partition`, `Window.partition` and
`DimensionPositionNode.partition`; `RelationComparisonNode`,
`RelationPairComparisonNode` and `RelationDefinedNode` in `where`.
- **`sources.py`** reads a relation's table under its declared *roles*
and hands on the `(key dim, relation)` frame both lanes already read, so
a self-map supplies cleanly and no reader downstream changed shape. The
checks are the ones it ran before: keys are labels, values are labels,
one row per key, no null.
- **The engine reads the plan directly**, not `lpspec.relations`: its
subpackage may import only `lpspec.errors` and `math_spec.program` (hard
rule 2), and `Walk` already carries everything it asked
`DimensionDeclaration.targets` for. `_Walk.of`, `_named_amount` and
`_grouped_into` take the walk instead of a partition name, which drops a
`compiler.program` lookup from `_grouped_into`.
- **The PyPSA parity runner** reads a relation's `key:` where it read
`lookup.over`, and the projection keeps the dimensions a relation's
`columns:` name. Second commit; it is why `differential/` is a separate
`chore`.

</details>

<details><summary>What I deliberately did not do</summary>

**The new reach is not implemented.** A bare relation, a key of several
columns, a walk with a column joined on, and `within=` on a partition
are all refused rather than built. Following a pin is not the PR that
grows two lanes a new join shape, and the refusal names the rewrite its
shape asks for:

```
relation 'gen_bus' declares no key, and this package builds the single-valued map: one row
per key, so a walk reaches one label rather than a set of them. Declare key: on it — one of
['generator', 'bus'], whichever holds each label once — or carry the membership as a bool
parameter over ['generator', 'bus'] and select on it with a where string.
```

```
relation 'zone_of' has 3 columns keyed by 2, and this package builds the single-valued map:
two columns, one of them the key, so a walk trades one dimension for one other and joins on
nothing. Split it into one relation per pair — 'zone_of' over ['generator', 'period', 'bus']
becomes a map per value column, each keyed by the same single column — or carry the wider
table as a parameter over its dimensions and select on it with a where string.
```

Worth its own issue, since the relational lane is close:
`_remap_fragment` already trades tuples of dims, and a joined column is
that join on more keys.

</details>

<details><summary>Coverage that moved</summary>

`test_a_hand_built_node_whose_tuples_disagree_is_refused` asserted a
guard the new IR makes unreachable — `GroupSum` derives `coordinate`,
`over` and `into` from `walks`, so the two tuples cannot disagree. The
`zip(strict=True)` it certified is still there and still reachable, by
the route that now exists: a walk onto two columns at once. It is
`test_a_hand_built_walk_onto_two_columns_is_refused` in the same file,
with the same purpose in its docstring.

`tests/test_sum_by_lookups.py` is `tests/test_sum_by_relations.py`.
`tests/test_assertions.py`'s ratchet falls 208 → 207.

</details>

<details><summary>The new guard, deleted and re-run</summary>

`relations.refusal`'s loop deleted, whole suite re-run:

```
FAILED tests/test_label_coords.py::test_a_relation_wider_than_a_map_is_refused_with_the_rewrite_named[a-bare-relation-is-a-set-per-label-and-not-a-map]
FAILED tests/test_label_coords.py::test_a_relation_wider_than_a_map_is_refused_with_the_rewrite_named[a-key-of-two-columns-joins-on-one-the-walk-does-not-trade]
2 failed, 3964 passed, 251 skipped, 1 xfailed
```

| guard | caught by |
| --- | --- |
| `relations.refusal`'s shape loop | the two cases above, and nothing
else |

Its complement,
`test_the_map_the_wider_shapes_are_rewritten_to_is_the_one_that_loads`,
keeps the pair from passing by refusing everything.

</details>

<details><summary>PyPSA parity</summary>

Every rung matches, and `references.json` and the written tables come
back **byte-identical** — so no objective, dual or structural comparison
moved. The rung projections changed in layout only, because the first
commit hand-edited files the harness generates; they and the ladder
pages that embed them are regenerated by the harness and by
`tools.ladder`.

```
rung_01_transport: MATCH · 45 rows, 16 columns · duals on 45 rows · 10 equal · 0 region
rung_02_storage: MATCH · 103 rows, 48 columns · duals on 103 rows, 2 negated · 27 equal · 0 region
rung_03_expansion: MATCH · 2 of 59 names differ · duals on 184 rows, 4 negated · 57 equal · 3 region
rung_04_ramps: MATCH · 64 rows, 20 columns · duals on 64 rows, 2 negated · 12 equal · 0 region
rung_05_global_constraints: MATCH · 2 of 24 names differ · duals on 102 rows, 2 negated · 23 equal · 2 region
rung_06_kvl: MATCH · 2 of 21 names differ · duals on 123 rows · 19 equal · 3 region
rung_07_commitment: MATCH · 116 rows, 44 columns · no duals — mixed-integer · 20 equal · 0 region · 3 recorded
rung_08_modular_big_m: MATCH · 191 rows, 80 columns · no duals — mixed-integer · 36 equal · 0 region
rung_09_multilink: MATCH · 92 rows, 32 columns · duals on 92 rows · 8 equal · 0 region
rung_10_quadratic_costs: MATCH · 60 rows, 24 columns · duals on 60 rows · 8 equal · 0 region
rung_11_ac_dc_meshed: MATCH · 2 of 19 names differ · duals on 468 rows · 17 equal · 1 region · 1 recorded
rung_12_linearized_uc: MATCH · 128 rows, 44 columns · duals on 128 rows, 1 negated, 2 names differ · 24 equal · 0 region · 3 recorded
rung_13_losses: MATCH · 2 of 23 names differ · duals on 150 rows · 22 equal · 0 region · 1 recorded
rung_14_stochastic: MATCH · 3 of 18 names differ · duals on 87 rows, 1 names differ · 17 equal · 0 region · 2 recorded
rung_15_multi_period: MATCH · 80 rows, 31 columns · duals on 80 rows · 14 equal · 0 region
rung_16_link_delay: MATCH · 52 rows, 20 columns · duals on 52 rows · 8 equal · 0 region
every rung matches PyPSA as deep as the engines allow, and says how deep that is
```

Run a second time on the regenerated tree with no further change, which
is what the job's `git diff --exit-code -- differential/pypsa` step
asks.

</details>

<details><summary>What was verified, and what was not</summary>

Every gate the required check runs, plus the docs build, the bench
harness and the parity runner, on `14de16e`:

```
ruff check .                              All checks passed!
ruff format --check .                     321 files already formatted
pyrefly check                             0 errors (both configs)
pytest tests -n 4                         3967 passed, 251 skipped, 1 xfailed
mkdocs build --strict                     Documentation built in 10.01 seconds
pytest bench/test_harness.py              145 passed, 10 skipped
differential/pypsa/parity.py <corpus>     every rung matches
```

Generated output was regenerated by its own generator and the diff read:
`tools.gallery_math` (47 pages), `tools.ladder` (the ladder page and 16
rungs), the parity runner (`references.json`, `rungs/`, `tables/`), `uv
lock`. The YAML fences the gallery pages carry byte for byte, and the
two PyPSA reference `build()` fences, were refreshed from their files.

**Not run, and why.** `pixi` is not installed in this environment, so
the gates ran in a `uv` venv on the pinned `ruff==0.16.1` and
`pyrefly==1.2.0` rather than through `pixi run check`. On an unpinned
`pyrefly 1.3.1` six errors appear, all in `api.py`, `frames.py` and
`assembly.py`, none of which this diff touches. No benchmark was taken:
nothing here claims a number, and the machine was not idle — CodSpeed
reports the PR does not alter performance.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RuEnfVdQZKP2fxRWrgJRH1

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Sep 15, 2026
Brings the branch up to 0b4f046, which carries #437's relations rename and
#449's gallery renames. Deliberately 0b4f046 rather than main: main also
carries #474, which #481 reverts, and merging it here would resurrect it.

Seven files conflicted. The renames main made are taken, and this branch's
new nodes are kept alongside them:

- The frame-check message keeps this branch's `leaf` phrase, which the two
  expression-comparison nodes need because they carry no name, with main's
  wording and `where-relation` spelling for the four named cases.
- `_comparison` takes main's dotted-column body, with this branch's
  `expressions:` check in front of it.
- `_expression_comparison` and main's `_relation_column` are both kept.
- `LookupNode` is `RelationNode`, and a translation's `partition` is now a
  `Walk`, so `names_read` reads `.name` off it.
- The new cases spell `shift`/`sum_back` with `along=` and `window=`.

The generated schema, goldens and pages are regenerated, not hand-merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VApqcmBKLXtKTmk3ajmtK
FBumann pushed a commit that referenced this pull request Sep 15, 2026
Brings this branch onto #469 as rebuilt on the alpha.90 release, so it picks
up #437's relations rename and #449's gallery renames.

Nine files conflicted, and three more merged cleanly while still written in
the old vocabulary, which was the larger half of the work:

- The typesetting union this branch adds was named `RelationNode`, which
  main now uses for a resolved `by=`. The alias is `AlignedComparison`, and
  `_relation` reads a relation column through `_value_read` and a position
  group through `_position_group`, as main's `_predicate` does.
- `_comparison` keeps main's dotted-column body and this branch's parameter
  pair, which is taken only where neither side names a column.
- `_parameter_pair_error` and main's `_relation_pair_error` are both kept.
- `examples/commitment.yaml` assumed `p_min <= p_max`, which #449 renamed to
  `min_output <= capacity`.
- The new cases spell `shift`/`sum_back` with `along=` and `window=`.
- The expressions page said two parameters cannot be compared, which this
  branch makes false; it now states both pair forms.

The generated schema, goldens and pages are regenerated, not hand-merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VApqcmBKLXtKTmk3ajmtK
FBumann pushed a commit that referenced this pull request Sep 15, 2026
Brings the branch up to 0b4f046, which carries #437's relations rename.
Deliberately 0b4f046 rather than main: main also carries #474, which #481
reverts.

`operators.py` conflicted on `kind_of`, which is this branch's whole subject.
Main widened the signature — a `with_relation` flag, and `relation`/`role`
in place of `lookup` — and this branch makes the return optional, so the
merged one is main's signature and body with `| None` on the end and the
`required_value_kwargs` check restored above the fallback.

The tests' expected signature is main's spelling of `shift`, with `along=`,
`by=<relation>` and `within=<column>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VApqcmBKLXtKTmk3ajmtK
FBumann pushed a commit that referenced this pull request Sep 15, 2026
Brings the branch up to 0b4f046. Deliberately 0b4f046 rather than main: main
also carries #474, which #481 reverts.

The lookup half of this branch had to be rewritten rather than merged. #437
replaced `lookups:` with `relations:`, so the `over:`/`into:` map this branch
declared `coverage:` on no longer exists. `coverage:` now sits on
`RelationBlock`, through the same `_CoveredBlock` the parameter uses, and
`RelationDeclaration` carries it into the program.

**This takes a reading the author should confirm.** A relation declares no
direction, so `total` can no longer mean "every label of the dimension it is
over". It now means the table carries a row for every coordinate of its
`key:`, and where there is no key, for every combination of its columns'
dimensions. The keyless case is the one worth arguing about: a bare relation
is many-to-many, so a `total` claim over the full product is rarely what a
file means, and refusing `coverage:` there may be the better answer.

The parameter half is untouched by the rename and merged as it stood.

Coverage moved: `TestLookupCoverage` is `TestRelationCoverage` and declares
`columns:`/`key:`; the schema enum row is `RelationBlock`. The schema,
goldens and pages are regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VApqcmBKLXtKTmk3ajmtK
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants