feat(language): a relation between dimensions, walked in the direction each call names - #437
Conversation
Documentation build overview
48 files changed ·
|
57202ec to
f20880a
Compare
|
@brynpickering @coroa @FabianHofmann I think I found the most capable and complex form of lookups with this. Everything else is determined where its used: This makes it much more obvious how a lookup is used in an operator and it can be reused in multiple places. Happy to discuss naming etc. |
…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
…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>
2546a8a to
75840fd
Compare
|
@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.
|
|
@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 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! |
…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
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
…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>
…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>
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
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
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
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

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:shiftandsum_backwalk their axis withalong=rather thanover=, andsum_back(within=)becomeswindow=.sum(x, over=d)is unchanged frommain.#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
maina lookup is an arrow.over:is the dimension it starts at andinto: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.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.With
pover[generator, period],fover[line, period]andpriceover[zone, period]:A side the declaration decides may be left out.
gen_bushas one key column and one value column, sosum(p, by=gen_bus)andat(price, by=gen_bus)are complete.zone_ofhas one value column, sointo=zonemay go, andover=stays because the key has two columns.endshas one key column, soover=linemay go.connectionhas no key, so both stay.A model on
mainwith a one-key, one-value lookup keeps everyby=call and everysum(over=)as it is. The declaration changes once:{over: generator, into: bus}becomes{columns: [generator, bus], key: generator}. Ashiftorsum_backchangesover=toalong=. The data it binds is unchanged.What the key means
key:is the database word: the columns that identify a row.key: generatorsays 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 a0/1parameter 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:
{columns: [generator, bus], key: generator}{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{columns: [generator, bus]}, no key{columns: {snapshot: snapshot, rep: snapshot}, key: snapshot}— butkey: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":
sum(by=)atat(by=)shift,sum_back,positionwithby=where: "zone_of == 'A'"where: connection(bare)So
summay remove a key column (over=generatorongen_bus) or a value column (over=zoneonconnection), butsum(p, by=gen_bus, over=bus, into=generator)is refused: withgeneratorthe key, each generator finds one row and nothing is added. The rewrite isat.A relation with no
key:is a bare relation.sumwalks it with both ends named, a barewheretests 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.
sumatshift,sum_back,positionby=over=by=, or a dimension when there is noby=into=by=along=shiftandsum_back;position(d, …)takes the axis positionallywithin=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=andwithin=without aby=are refused.over=without one names a dimension of the operand, which ismain'ssum(x, over=d).over=andinto=say the frame changes.along=andwithin=say it does not.The renames
Three renames land with the feature. All three change models that load on
maintoday.sum(x, over=d)is not one of them: its 235 call sites are untouched.mainshift(x, over=d),sum_back(x, over=d)shift(x, along=d),sum_back(x, along=d)over=onsummeans removed, and onshiftit meant walked and kept.sumkeeps the word every reader says, and the operators that keep the axis takealong=, which also carries that the axis is ordered. 226 call sites insrc/,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 alwaysWindowand its fieldwidth. 71 call sites.lookups: {l: {over: a, into: b}}relations: {l: {columns: [a, b], key: a}}relations:, because a keyless table looks nothing up.columns:and notdims:, because a relation may name one dimension twice (ends, a self-map) and a frame never does. 27 files.Counts are
git greponorigin/mainat 5656b11.per=was the other candidate for the group keyword and lost becauseperis this language's word for a frame: a constraint is one row per coordinate.from=,to=,consume=,produce=andlookups:appear in this PR's commit history and nowhere onmain. They were earlier spellings ofover=,into=,within=andrelations:, renamed inside the PR after the thread's comments, the last round in #477. The diff againstmainshows the final spelling only.What is new
Nothing sayable on
mainis unsayable here. New:zone_of), walked from either key columnsum(p, by=gen_bt, into=[bus, technology])sum(load * p, by=gen_bus)where the operand already carriesbusjoins on it, wheremainrefused itshift(x, along=snapshot, by=cal, within=week)mainneeds a parameter of oneswhereforms:ends.bus0 != ends.bus1, and a barewhere: connectionsumthat walks to the key, since it reads rather than sumsHow to review this
src/math_spec/resolution.py(_walk,_partition_walk,_relation_ref),model.py(RelationBlock, the declaration rules) andprogram.py(RelationDeclaration,Walk). Those three files are 958 of the 1548 lines changed undersrc/.docs/reference/language/dimensions.mdunderrelations, and each has a refusal case intests/test_validation.py.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
What each walk does to the frame
sum(p, by=gen_bus)generator(the key)bus(the value)bus, periodat(x, by=gen_bus)bus(the value)generator(the key)generator, …sum(p, by=zone_of, over=generator)generatorzoneperiodzone, periodsum(p, by=zone_of, over=period)periodzonegeneratorgenerator, zonesum(p, by=zone_of, over=[generator, period])zonezonesum(q, by=zone_of, over=zone, into=generator)zone(a value column)generatorperiodatat(price, by=zone_of, into=generator)zonegeneratorperiodgenerator, periodsum(p, by=gen_bt, into=[bus, technology])generatorbus, technology, periodat(tech_cap, by=gen_bt, over=[bus, technology])generatorgeneratorsum(f, by=ends, into=bus1)linebus1bus0is not read)bus, periodsum(p, by=connection, over=generator, into=bus)generatorbusbus, periodsum(load * p, by=gen_bus)withload[snapshot, bus]generatorbus, already carried, so joined on toosnapshot, bus: a masked sumshift(x, along=period, by=zone_of)periodzonegenerator(generator, zone)shift(x, along=generator, by=gen_bt, within=bus)generatorbusshift(f, along=line, by=ends)line(bus0, bus1)position(generator, by=gen_bt, within=[bus, technology]) == 0generatorThe ten rules, each decided at load
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 dimensionkey:names columns the relation has, each once, one per dimension, and not all of themover=andinto=name columns of the relationby=names — one each or a list each — with no column on both sides, none twice, and no two over one dimension;sumandattake both, a partition takeswithin=alone. Aninto=orwithin=with noby=is refused; anover=with noby=names a dimensionsumoratlands on each dimension onceatreads one value, so the key lies inside the produced columns and the joined columns; a bare relation is never read byat. The mirror holds forsum: asumwhose key lies there sums one term per coordinate, so it too is refused, towardatwithin=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 nothingby=list walks each relation by its declared arrow; every entry removes the same dimensions and no two land on the same onewherecomparison 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 existsDataErrorCapability 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.
mainper:by=[…]into=[…]over=[…]over=[…]at(asumhere is refused, since it reads)within=intparameter of onesover(over, *per)by=over=,into=,within=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 bysumalone, with both ends named, and tested by a barewhere.The keywords are the words a modeller already says. "Sum over generator" is standard mathematics, so
over=is what asumoratremoves, and it composes withby=:sum(p, by=zone_of, over=generator).into=is where the result lands.along=is the axisshiftandsum_backwalk and keep, and it carries that the axis is ordered. The earlier spellingsconsume=andproduce=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 meetconsume=as syntax. Each ofover=andinto=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, andvia=would suggest a path where there is none.One rule went with it.
sumused to refuseover=andby=together — "a lookup carries its own dimensions, so by= leaves over= nothing to add". They compose instead:by=names the table andover=names what leaves the frame.at_most_one_ofis now unused on every built-in.A partition takes
within=, andsum_back's length iswindow=. 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, sowithin=is the word; it heldsum_back's length, which becomeswindow=, the name the program always used (the node isWindow, its fieldwidth).per=was the cheaper candidate and lost becauseperis 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:, notover:. 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 besideparameters:, wheredims: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. Withover:gone from the declaration,over=means one thing: the column asumoratremoves.The block is
relations:. A lookup promises a function, and here that holds only withkey:. A keyless table such asconnection: {columns: [generator, bus]}looks nothing up; it says which pairs exist, and a barewhere: connectiontests membership.relations:is right in both cases, andkey: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 candidateunique: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 needingbustwice. 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 intest_dimensions.pyis dropped, anda-sum-that-walks-to-the-key-is-a-readintest_validation.pyfails withDID NOT RAISE LanguageErrorwhen the guard is deleted.Only key columns are joined on. Walking
endsfromlineintobus1must not join onbus0: 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:andsos: 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), withcolumnsas(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)andAt(operand, walks)hold their walks alone, so a node cannot carry a coordinate without its walk;GroupSum.overandGroupSum.intoare the dims thatover=andinto=name. Every partition is oneWalkwhoseconsumedis the key column over the walked dimension and whoseproducedis the group columns —Translate.partition,Window.partitionandDimensionPositionNode.partitionalike.Program.relationsis a dict by name. The two relation-onlywhereformsname.col OP valueandname.a OP name.bare new grammar, as is a groupedposition(…).operators.py: arolekwarg kind and adimension_or_roleone, which is what letsover=name a dimension alone and a column besideby=;kind_oftakes the call'sby=to decide.at_most_one_ofis now unused._expression_parser.py: no dotted names;over=/into=are ordinary kwargs, a name or a bracketed list._where_parser.py:name.columnon either side of a comparison;position(d, by=l[, within=c | [c, …]]).model.py:RelationBlockwithcolumns(list or mapping) andkey; rules 1 and 2. The parsed(role, dimension)pairs areRelationBlock.pairs, sincecolumnsis now the field.resolution.py:_walk(rules 3, 4, 6),_partition_walk(rule 7),_relation_ref(rules 5 and 8),_relation_columnand the pair rule (rule 9).dimensions.py,lowering.py:sumreduces whatover=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 asname(key…)orname.col(key…), a bare relation as(…) ∈ name; the legend prints→for a keyed table and⊆for a bare one.relationssection rewritten around the relation, the key and the walk; every declaration rule and call-site rule above has a refusal case intest_validation.py, every walk in the frame table a dims case intest_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 --strictandcompile-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 autogives 1270 passed, 1 skipped.ruff checkandruff format --checkclean on the pinned 0.16.1,prettier --checkclean on the pinned 3.9.3,typosandreuse lintclean, every generator re-run including the schema, which listscolumnsas the relation's required key. The whole model above loads throughto_specon 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-togetherasserted the rule that is gone and its case is deleted, replaced by a dims inference (by-and-over-compose) thatsum(p, by=gen_bz, over=generator, into=bus)reaches the same frame as the defaulted call;from-without-byisinto-without-by, since anover=with noby=is a dimension.The conflict with
mainfrom #429'sforeach:todims: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