Skip to content

feat(language)!: a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens - #664

Open
FBumann wants to merge 49 commits into
mainfrom
claude/serene-goldberg-m8xwru-2
Open

FBumann wants to merge 49 commits into
mainfrom
claude/serene-goldberg-m8xwru-2

Conversation

@FBumann

@FBumann FBumann commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: Try to do a new branch from #641 and rebase it onto 661. […] Now, let's discuss a better api for sum, at and potential future join or groupby operations. What's the best syntax imaginable in the yaml? It should be intuitive, safe and close to the internals […] Let's do this as 2 stacked PRs. One introducing the new syntax, but keeping the dot notation internally, and then the one adding the axis class […] Agreed, keep at() add this reasoning to the pr […] Review the stack of #665 and below. Should we merge it into a single PR for better reviewability? […] Merge them and update the PR description. And close 641 […] Please review 664 and 665 […] Fold 665 into 664 and merge main […] Close 665 and add the ! to the title […] Maybe we should always require the column name in over instead of the dim thats used in the relation! That would remove this problem. And only change over= if a relation uses a dim twice, where its also ok to be explicit! […] Implement this right in 664 as a new commit, and pdate the PR description and docs etc […] Coroas comment: It would probably be good to rename PulledBackPredicate to JoinedPredicate […] Update 664 and resove conflicts

Note

The following content was generated by AI.

A call reads one relation, and names its columns as relation[column, …]. The program writes each such call as a Join, which opens one Axis for each column it drops. A Sum over those axes closes them. This PR includes #662 and #665.

What this changes

The syntax

was is
sum(p, by=gen_bus, over=generator, into=bus) sum(p, over=generator, by=gen_bus[bus])
sum(p, by=slot_of, over=[generator, period], into=[bus, technology]) sum(p, over=[generator, period], by=slot_of[bus, technology])
at(cap, by=gen_zone, over=zone, into=generator) at(cap, by=gen_zone[zone])
shift(p, …, by=cal, within=[day, week]) shift(p, …, within=cal[day, week])
position(h, by=cal, within=day) == 0 position(h, within=cal[day]) == 0
calendar.month == 'jan', ends.bus0 != ends.bus1 calendar[month] == 'jan', ends[bus0] != ends[bus1]
  • In a sum, over= names what leaves. Beside by=, a name in over= is a column of the relation where it has one, and a dimension otherwise, summed away after the group-by. So sum(p, over=[generator, snapshot], by=gen_bus[bus]) is one call, and sum(x, over=from, by=nbr[to]) says which of two columns over one dimension the operand is joined on.
  • Every column a call touches is written in it. A relation may gain a value column without changing what any call means, which is what composing a relation across files needs. The declaration already refuses a column named after a dimension it is not over, so a name reads the same as a column and as a dimension.
  • A lookup names the value columns it reads, and the key arrives. It joins on each key column over a dim the operand carries, unless a column in by= already matches that dim. So at(x, by=rep_of[rep]) reads x at rep(t).
  • The grammar allows one table per call. A list of relations cannot be written.
  • A macro formal can stand inside a selection. sum(x, over=d, by=rel[col]) binds rel and col at the call site.
  • A join operator apart from sum and at is refused, and limits.md records this. The axis must close in the call that opens it.

The program

  • Join and JoinColumns replace Direction, GroupSum and Pullback. A sum through a relation lowers to Sum(Join(x, columns), over=axes), and at() lowers to the bare Join. feat(program): a sum through a relation is a sum over the axes its join opens #641 has the design and its guard table.
  • JoinedPredicate replaces PulledBackPredicate. at(has_curve, by=zone_of[zone]) in a where is the predicate's lookup Join, and the two names pair as Translate and TranslatedPredicate do.
  • Column(relation, name) and Axis(dimension, column=None) are public in mathspec.program. Sum.over and JoinColumns.axes are tuple[Axis, ...]. A declared dimension is Axis(dimension). A join opens Axis(dimension, column) for each column it drops, so a map into its own dimension gives Axis('snapshot', Column('rep_of', 'snapshot')), which is not Axis('snapshot'). The type keeps them apart, where feat(program): a sum through a relation is a sum over the axes its join opens #662 used a dotted name. str() gives the user spelling, generator or zone_of[generator].
  • Frames stay sets of dimension names. dims_of reads a sum over a join as one rule, so a join's axes never reach a frame (chore(program): a frame stays a set of dimension names, and only the sum over a join reads the axes it opens #799).
  • Separability, boundedness, advice and the typesetter read Join, and separability reads axis.column to tell a grouping from a plain sum.

Why

This is a break. Every file that writes by=R, over=, into= or R.c stops loading, with no hand-written message for the old spelling. The release that carries it raises the minor version.

Every relation call lowers to one Join and a reduction over the axes it opens. The syntax now has the same shape. over= keeps its meaning from a plain sum. by= holds the group-by key, which a pandas or SQL reader expects. The relation is written once, so a call cannot mix tables.

over= reads column names so that a relation can grow. An earlier version of this PR read over=snapshot as a dimension and inferred the column over it, with over=rep_of[snapshot] for the case where two columns are over one dimension. Under that rule, adding alt: snapshot to rep_of made an existing sum(x, over=snapshot, by=rep_of[rep]) stop loading, because two columns now matched. A column name never matches twice.

With Axis, a consumer reads axis.dimension or axis.column, and pyrefly checks it. It does not parse strings. The axes are needed only between a join and the sum that closes it, and the resolver is the only code that builds that pair, so frames do not carry axes.

Method, departures, alternatives, new refusals, coverage moved, gates, not done

How this PR was made

This PR was three stacked PRs. #662 moved #641's program change onto main, with the syntax by=R, over=, into=. This PR replaced that syntax and kept dotted axis names in the program. #665 replaced those names with Axis. The review of the stack found that this PR removed about a third of the lines #662 added, and that #665 removed a convention this PR introduced, so both are folded in: #662's commits are in this branch, and #665 is the squash commit 6a899c9. The branch is not rebased and not force-pushed.

33b8eab merges main, which had moved by #763 (adds_to:), #795, #809 and the 0.2.1 release. The 25 conflicts take main's structure with this branch's spelling of each relation call. The one old-syntax expression main added outside the conflicts, in tests/test_pypsa_split.py, and a prose line on the library generator page are rewritten the same way. The changelog gets a fresh ## Upcoming version heading above 0.2.1.

24c7354 changes over= beside by= from dimensions to column names, after a comparison of the two syntaxes on 29 situations found that a value column added to a relation broke an existing sum under the dimension reading and nothing else. The three PyPSA maintenance constraints that wrote over=X_maintenance_cover[start] now write over=start.

7bb84bc renames PulledBackPredicate to JoinedPredicate, as coroa asked in the comments, and the prose that called a lookup a pullback follows.

9d68a67 merges main again, with the nine PyPSA docs PRs #786 to #817. The one conflict is the changelog. Three at() calls those PRs added in the line and transformer fragments, and in the one-file spec, are rewritten from by=snapshot_period, over=period, into=snapshot to by=snapshot_period[period], and the generated pages follow.

#662 took #641's net diff against its merge base with main and applied it with git apply --3way. #641 still carried #638 as 13 commits that main holds squashed, so a rebase would have applied #638 twice. #662's body lists how each conflict was resolved.

Departures from the discussion

  • at(x, by=R[c]) keeps a keyword. The discussion wrote at(cap, gen_zone[zone]), positional. limits.md says everything a modeller passes goes in a keyword value, so that a macro can pass it. by= is that keyword, and it reads the same in sum and at.
  • Old spellings get no hand-written message, as AGENTS.md says. into= is a call-shape error, and R.c is a parse error.

Considered and not done: a lookup as an index, cap[gen_zone[zone]]

The index form reads like the printed math, $\mathrm{cap}_{\mathrm{zone_of}(g)}$, and it is shorter. It changes nothing that a file can say, and it has five costs:

  1. Brackets would have two meanings. name[...] here means "columns of this relation". cap[...] would mean "read this array at". The parser cannot tell which applies, and the reader has to.
  2. The operand is any expression. at(p * eff, by=…) would become (p * eff)[gen_bus[bus]]: a postfix operator on arithmetic, with a precedence against ** and unary minus.
  3. It breaks the pairing with sum. sum(x, over=d, by=R[c]) and at(x, by=R[c]) are the two directions through one table. The same by= shows this.
  4. It invites indexing that the language does not have. cap['north'], p[t - 1] and p[0] would read as if they should work. Those are label selection, shift and position.
  5. It adds nothing to the program. The node, the typesetter output and the refusals are the same.

New refusals, each with the test that fails without it

Each guard was deleted in turn, the named test file run, and the tree restored. #641 has the guard table for the program change.

guard caught by
a formal in a selection is substituted test_a_call_site_binds_the_relation_and_its_columns[names]
a selection formal takes only a name test_a_formal_inside_a_selection_takes_a_name_and_nothing_else
a template formal inside a selection is left bare test_a_formal_stands_where_a_call_site_will_bind_it[a-relation-and-its-columns]
a selection in arithmetic is refused test_a_selection_bound_into_arithmetic_is_refused
a selection in over= is refused, with the bare name test_a_refused_call_is_not_read_by_the_call_around_it, [a-selection-in-over-beside-by], [a-selection-in-over-without-by]
a name in over= that is no column and no dimension [a-name-neither-a-column-nor-a-dimension]
a sum reading nothing through its relation is refused, naming the columns over the dimension written [a-sum-reading-nothing-through-its-relation], [a-dimension-two-columns-are-over-is-no-column]
a lookup through a bare relation is refused [at-through-a-bare-relation]
a lookup of a key column is refused [a-read-of-a-key-column]
a lookup joins only on dims its columns do not match test_dim_inference[and-so-does-its-lookup]
a comparison of several columns names the rewrite test_a_relation_column_is_named_in_brackets
a join's axis is not the dimension's own axis 5 tests, e.g. test_dim_inference[a-map-into-its-own-dimension-keeps-the-frame]
separability reads a join axis as a grouping test_a_grouping_that_sums_the_axis_away_couples_it

test_a_value_column_added_to_a_relation_changes_no_call is the case 24c7354 was made for. On the tree before it, the test fails with "over=snapshot matches 2 columns of 'rep_of'".

Two asserts in dimensions.py from #799 have no test that fails without them: a sum over a join closes exactly join.columns.axes, and a join that opens axes is read only by the sum that closes them. The resolver makes both true, and no probe builds such a node by hand.

Coverage that moved

  • Two refusals are now legal lookups. at(zone_cap, by=gen_zone[zone]), where zone_cap lacks the key's snapshot, and at(load, by=diag[rep]), where rep and the key share snapshot. Both are in test_a_lookup_joins_on_the_key_columns_the_operand_carries_and_the_read_does_not_match, with their frames.
  • Three refusals of the dimension reading of over= are gone with it. "matches 2 columns" cannot arise from a column name. "over= and by= name two tables" cannot be written. "sums away the dim it groups onto" is now the case "over= and by= both name the column", [over-and-by-the-same-column].
  • A lookup can no longer group by a value column, because the syntax cannot say it. Three cases for that are removed.
  • "names 2 relations" is gone, because the grammar cannot write it. by=[lk, lk2] is refused as "takes columns of one relation".
  • The "leaves within= unsaid" cases are gone, because within= now carries the relation.
  • The lookup "several rows per group" check is removed. A lookup reads value columns at the whole key, so it cannot fail.
  • GroupSum and Pullback tests read Join. tests/test_lowering.py and tests/test_separability.py take feat(program): a sum through a relation is a sum over the axes its join opens #641's names and messages.
  • tests/test_dimensions.py compares frames to string sets. The self-map test asserts node.over == (Axis('snapshot', Column('rep_of', 'snapshot')),) and the frame of the sum, and no longer the frame of the bare join, which no longer exists.
  • tests/test_lowering.py and tests/test_expansion.py build Sum with Axis values. tests/typesetting/test_golden.py lists Axis and Column in CARRIERS.

Generated files

tools.schema, tools.gallery, tools.notation, tools.spec_math, tools.home_math, tools.expansion_math and tests.typesetting.golden were run again after each merge of main and after each later commit. The schema carries the relation block's docstring; the three maintenance pages carry over=start; the notation page's heading reads "Shift and lookup in a condition". The one golden change against main is the legend's shift(within=relation[c]).

Gates, on 9d68a67

gate result
pixi run lint clean
pixi run test 2665 passed
pixi run typecheck 0 errors
docs-build, compile-tex not run here: the proxy refuses docs.python.org and the tectonic bundle host. render-tex rendered all 55 documents. CI runs both.

Not done

  • Relation columns are still called roles internally (JoinColumns, _role_name, key_roles). JoinColumns.kept_dims has no reader. That cleanup is separate.
  • The plain dims in over= are summed after the group-by, as Sum(Sum(Join(x)), plain). Summing them before the join would make sum(lim * p, over=[generator, bus], by=gen_bus[bus]) legal, where today the clash refusal fires. That is a language decision, separate from this PR.
  • No new reduction. A mean(x, over=a, by=R[b]) would lower the same way. It needs a language decision first: whether an empty group is absent.
  • Consumers of the program, such as fluxopt/lpspec, are not updated. GroupSum, Pullback, Direction and PulledBackPredicate are gone, and Sum.over holds Axis values.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MKjqJocyxdRYvx7xin6NCM
https://claude.ai/code/session_01YVJQzL5G4boHcJDwrhgfag

… and every curve is written out before a model becomes one

Resolved is gone. Lowering builds the Program as a model loads and holds
it on the Spec; to_program(spec) returns that object, and returns the
expansion's for a model with a curve, so a program holds the rows a curve
states and never the curve. Named joins program.Expression, so a use of an
expressions: entry stands where it is read and the typesetter prints from
the program beside the file. ExpressionDeclaration carries its dims, and
piecewise.curve reads a block's typed links and frame off the model for
the expansion and the walk. PiecewiseDeclaration, Program.piecewise,
Spec.resolved, lowering.inline and the refusal of a model still carrying
a curve are removed.

The typesetter's output is unchanged: the golden files and the generated
pages regenerate byte for byte.

Docs sentences, after (before): reading.md n 75 avg 16.1 median 15
over25 10 (72, 16.4, 14, 13); piecewise.md n 70 avg 17.6 median 15
over25 13 (69, 17.8, 15, 14).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…s included, and the typesetter reads it alone

to_program lowers the model as it arrived: a piecewise: block still in it
is a curve on the program, typed, with its links, signs, method, gate, mask
and frame, and the program of spec.expand('piecewise') carries the rows
instead. Every declaration carries its description, and the program the
file's. The typesetter takes a Program and reads nothing else; the walk no
longer re-resolves a curve's links at print time, and piecewise.curve is
gone. relations_of moves from Spec to Program. advice() writes curves out
itself and refuses a program still carrying one.

The typeset output is unchanged: the golden files and the generated pages
regenerate byte for byte.

Docs sentences, after (before): reading.md n 75 avg 16.8 median 15
over25 12 (75, 16.1, 15, 10); piecewise.md n 70 avg 17.6 median 15
over25 14 (70, 17.6, 15, 13).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
… lowering, before any expression is read

The nine after-validators on Spec that read one declaration against the
others — name collisions, frames over declared dimensions, relation
targets, bound names, set shapes and bounds, curve references and the names
an expansion would collide with — are functions in validation.py, collected
by reference_errors and run first by lowering.lower. Spec keeps the shape
rules pydantic decides per block, and no longer imports sos or the operator
table; side_columns is the public name of the relation helper the rules
share. Every message is the same string, and every refusal the same class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
… is advised as its rows are

The guard landed in the previous commit without the test that fails without
it. With the guard deleted, the new test fails: a program with a block is
advised on the file's own rows as if the curve stated none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
… the program owns the vocabulary the file declares in

Loading no longer expands every curve to validate the expansion: the rows a
curve states are held to the language when expand() writes them out, since
an expansion is a model like any other. A model with a curve is validated
once at load rather than twice.

The dtype, domain, absence, sense, set-order and method vocabulary moves
from model.py into program.py, which model.py imports. program.py no longer
imports model.py, so the type-only import of Program on Spec and its noqa
go, and sos.py imports Spec plainly instead of inside a function.

Load cost, before (after): examples/piecewise.yaml validated 2 (1) times
and parsed 9 (4) expressions; examples/sos.yaml the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…he one door to both states is to_spec

to_program is gone. Spec.program is the model typed, section for section,
built once as the model loads; spec.expand(...).program is its rows. A
consumer building rows reads the sections it takes and refuses a curve or a
set it finds, the way it already had to for a set; advice() does so for a
Program handed to it, and writes curves out itself for a file or a Spec.
typeset and typeset_declaration take a Program as before.

Docs sentences, after (before): reading.md n 75 avg 17.1 median 15
over25 14 (76, 16.8, 15, 12); piecewise.md n 70 avg 17.7 median 15
over25 13 (70, 17.6, 15, 14); what-counts-as-public-api.md n 18 avg 16.0
median 14 over25 3 (unchanged count); limits.md n 55 avg 18.3 median 19
over25 14 (unchanged count).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…the model loads

The private attribute lowering filled and the property asserting over it
are one cached_property computing lower(self); the after-validator forces
it, so a Spec in hand has still passed the whole language.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…ules beside the namespace

_Resolver, one class over both grammars, is ExpressionResolver in
_expression_resolver.py and WhereResolver in _where_resolver.py, the where
walk building a side that is an expression through the expression walk.
resolution.py keeps the Namespace and the doors lowering calls. The three
methods the where walk reads from the expression walk are public on it;
names_in and the literal-number helper move to the parser module beside
the other helpers over parsed nodes. Every message is the same string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…he program carries, and nothing else

The Curve alias, a tacit protocol between the pydantic block and the
program declaration, is gone: assumptions_of, Emitted.of and the curvature
rule take a PiecewiseDeclaration. The expansion keeps the block for the
link text its rows repeat and takes the declaration for the frame and the
names it writes. The two emitted-name collision rules read the program
rather than the file, so they run once the declarations exist; every
message is the same string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…s use off the program, and the walk keeps no record

The glossary and the three kinds of note move from walk.py to legend.py.
What they explain is read off the program before anything prints, by
notice(), so the walk no longer fills a Noticed record as it prints and a
subscript no longer mutates the walk through its context. Walk.line refuses
a name declared as none of the five kinds or as two, so typeset_declaration
is one call. Symbols is a frozen record built by symbols_for. The typeset
output is unchanged: the golden files match byte for byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…, the errors, the advice and the typesetter

Thirteen names leave math_spec's top level: the message builders
call_shape_error, edge_error, unknown_operator_message and schema_error,
the tables BUILTIN_NAMES and EDGE_WRAP, the vocabulary sets ADVICE_KINDS,
DIMENSION_DTYPES, PARAMETER_DTYPES, VARIABLE_DOMAINS, VARIABLE_ABSENCE and
CURVATURES, and SosBlock. The message builders and tables stay in their
modules for the resolver; the vocabulary sets are deleted, since the
Literals they were the set form of are what a consumer pins against, and
nothing in the package read them. did_you_mean stays: it is the one wording
a consumer's own refusals share with the language's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…vocabulary with no Literal form

A consumer pins its operator table against BUILTIN_NAMES, and unlike the
dtype vocabularies it has no Literal on math_spec.program to pin against
instead; the module it lives in is package-private.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…d advice writes nothing out on the caller's behalf

UnexpandedCurveError carries the one sentence every consumer building rows
says of a program still carrying a piecewise: block, naming the blocks and
the expansion to pass. The language does not raise it at load, since a
model with a curve is printed and edited as written; advice() raises it
for a file, a Spec or a Program alike, and no longer expands a file or a
Spec itself. The check verb and the check how-to expand first, as a front
end may.

Docs sentences, after (before): reading.md n 76 avg 17.1 median 15
over25 14 (75, 17.1, 15, 14); check.md n 20 avg 12.5 median 14 over25 1
(19 sentences before, one added).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
…ck verb takes --expand like every other

The check verb read a file with its curves written out, the one verb that
read a file differently from the rest. Every verb now reads the file as
written and takes --expand; check refuses a curve model in the one wording
until asked. The premise is on the public-API page, beside the other things
every function keeps, and the reading page and the check how-to say it
where a consumer meets it.

Docs sentences, after (before): check.md n 22 avg 13.0 median 14 over25 2
(20, 12.5, 14, 1); what-counts-as-public-api.md n 22 avg 16.4 median 14
over25 4 (18, 16.0, 14, 3); reading.md n 77 avg 17.1 median 15 over25 14
(76, 17.1, 15, 14).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
UnexpandedCurveError is gone. advice words the refusal of a curve left as
written itself, as a LanguageError, and a consumer building rows words its
own; the reading page shows the idiom with the consumer's own error. A
sentence is not a thing one tool should export for another to reuse, where
a computation such as did_you_mean is.

Docs sentences, after (before): reading.md n 76 avg 17.4 median 15
over25 15 (77, 17.2, 15, 14); piecewise.md n 70 avg 17.7 median 15
over25 13 (70, 17.7, 15, 14).

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

The change of #641, carried onto #661 as one commit. #641 carries #638
as its separate commits, and main holds #638 squashed, so its net diff
against its merge base with main (1e010ca) is applied here, not its
history. The resolver edits move into _expression_resolver and
_where_resolver, where #661's stack split resolution.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHNF149r8B4tGmGUKmAdUd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHNF149r8B4tGmGUKmAdUd
…, and a join is never written apart from its reduction

The relations page says by= is not the grouping, so a reader does not
read it as a group-by key. The predicate at() paragraph drops the
consumed and produced wording the join design retired. The limits page
records a join operator apart from sum and at as refused, with the
reason: the axis a join opens must close in the call that opens it.

Docs sentences, after (before): relations.md n 45 avg 16.3 median 15
over25 8 (43, 16.2, 14, 8); expressions.md n 92 avg 15.3 median 14
over25 11 (91, 15.4, 14, 11); limits.md n 55 avg 18.3 median 19
over25 14 (unchanged; the new text is a table row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHNF149r8B4tGmGUKmAdUd
…s expand

"states rows rather than being one" was hard to parse on first read, and
the sentence named the fix with a different word from the method the user
calls. The refusal now says the block is still a curve, that advice reads
the rows a curve is expanded into, and to expand first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
The unboundedness pass read a set as it is, every variable it restricts
being named by a row, and refused a curve. A curve states its rows the
same way: each link names the variables a link row would, so the pass
reads the links and the answer is the expansion's with nothing expanded.
The refusal goes, and check loses --expand, a flag that would change no
answer.

Guard: with the line reading the links deleted, three tests fail: the
advice test over the file, the Spec and the Program; the boundedness
case carried-by-a-curve; and the existing test that a curve holds its
variables, which now runs on the block.

Docs sentences, after (before): check.md n 20 avg 12.8 median 14 over25
1 (21, 13.3, 14, 2); what-counts-as-public-api.md n 20 avg 18.7 median
15 over25 4 (20, 18.1, 15, 4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHNF149r8B4tGmGUKmAdUd
Ports #662 onto main's rename to mathspec, its autoref docstrings and the model-to-spec wording, and adds its changelog line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUK4sePJqJfxJJZLhdvZnS
…662

Rewrites main's newer calls, pypsa topic files and library example to relation[column]. Keeps main's over= lists and its refusal of a dimension named twice; a where read of several columns is by=relation[a, …]. Adds the changelog line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUK4sePJqJfxJJZLhdvZnS
FBumann pushed a commit that referenced this pull request Sep 29, 2026
…664

Main's given-term, declared-frame and composition checks compare frames of axes. Adds the changelog line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUK4sePJqJfxJJZLhdvZnS
@read-the-docs-community

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

Copy link
Copy Markdown

FBumann pushed a commit that referenced this pull request Sep 30, 2026
…wru-3, with #662 and #664 as one changelog line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKjqJocyxdRYvx7xin6NCM
@FBumann FBumann changed the title feat(language): a call names the columns of a relation as relation[column], so it reads one table feat(language): a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens Sep 30, 2026
@FBumann
FBumann removed this pull request from stack #670 September 30, 2026 13:28
@FBumann
FBumann changed the base branch from claude/serene-goldberg-m8xwru to main September 30, 2026 13:29
@FBumann
FBumann added this pull request to stack #796 September 30, 2026 13:29
claude added 3 commits October 2, 2026 07:07
…nds for (#665)

Axis(dimension, column) replaces the dotted axis names in Sum.over and
JoinColumns.axes. A join's axis names its Column(relation, name), a
dimension's own axis has no column, and frames stay sets of dimension
names. Folded into #664, so its changelog line is #664's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVJQzL5G4boHcJDwrhgfag
…e 0.2.1 release, written in relation[column]

The 25 conflicts take main's structure (adds_to:, given: expressions:,
the objective each fragment sets) with this branch's spelling of each
relation call. The one old-syntax expression main added outside the
conflicts, in tests/test_pypsa_split.py, and the prose line on the
library generator page are rewritten the same way. The changelog gets a
fresh "Upcoming version" heading above 0.2.1 for this PR's line. The
generated pages, the schema and the golden output were regenerated and
did not change beyond the conflict resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVJQzL5G4boHcJDwrhgfag
…the old relation syntax stops loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVJQzL5G4boHcJDwrhgfag
@FBumann FBumann changed the title feat(language): a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens feat(language)!: a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens Oct 2, 2026
…n, so a relation may gain a column without changing a call

Beside by=, each name in over= is read as a column of the relation
where it has one, and as a dimension summed away after the group-by
otherwise. The declaration already refuses a column named after a
dimension it is not over, so the two readings never disagree. The
over=relation[column] form and the two refusals that came with it, a
dimension two columns are over and over= and by= naming two tables, are
gone: the column's bare name says which. Every column a call touches is
written in it, so adding a value column to a relation leaves every call
as it was, which is what composing a relation across files needs.

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

coroa commented Oct 2, 2026 •

Copy link
Copy Markdown

I like what has happened since i last looked.

The Program nodes are used for typesetting and dimensions checks and so on. I think that was inevitable. cool that this has landed (even though this was not part of this PR).

The lowered code in this PR is a lot more straightforward:

sum(p, over=generator, by=gen_bus[bus])
# → Sum(Join(p, JoinColumns(joined=('generator',), grouped=('bus',))), over=('gen_bus.generator',))

and #665 with the Axis node also seemed to make sense here.

I still have a bit of troubles with the interpretation of the actual syntax, i do like the arrow stuff better. Here it is a bit unlcear that generator comes in by the gen_bus relation, and again basically mirrors the over -> into pattern.

@coroa

coroa commented Oct 2, 2026 •

Copy link
Copy Markdown

It would probably be good to rename PulledBackPredicate to JoinedPredicate, due to the symmetry with:

at(cap, by=zone_of[zone])          # Join(cap, cols)                        expression
at(has_curve, by=zone_of[zone])    # PulledBackPredicate(Mask(has_curve), cols, dims)   predicate

@FBumann

FBumann commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

I still have a bit of troubles with the interpretation of the actual syntax, i do like the arrow stuff better. Here it is a bit unlcear that generator comes in by the gen_bus relation, and again basically mirrors the over -> into pattern.

If you are talking about syntax, are you talking about the yaml syntax or the "program syntax" (Sum(Join(p, JoinColumns(joined=('generator',), grouped=('bus',))), over=('gen_bus.generator',)))

…oin is the expression it builds

PulledBackPredicate is renamed, so the two nodes at() lowers to pair the
way Translate and TranslatedPredicate do. The prose that called a lookup
a pullback follows, on the notation page's heading and in the golden
model's comment. Asked for by coroa on #664.

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

coroa commented Oct 2, 2026

Copy link
Copy Markdown

The program syntax is a definite improvement. The actual yaml syntax i am still at odds with, but hopefully we can improve that with the better program syntax.

@FBumann

FBumann commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@FabianHofmann We have @coroa 's go to merge this as its a clear improvement, although he might want to discuss some follow up syntax changes as he has had some more time to look into the details.

@FBumann FBumann added the v0.3.0 label Oct 2, 2026
…PRs, written in relation[column]

The one conflict is the changelog, which keeps both sides under the
"Upcoming version" heading. Three expressions main added in the line and
transformer fragments, and in the one-file pypsa spec, wrote at() with
by=, over= and into=; they are rewritten as at(x, by=snapshot_period[period]).
The generated pages were regenerated from the rewritten examples.

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

This branch has not been deployed

No deployments
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 v0.3.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants