math-spec · issue #275 · one of these three is merged, the other two close

Three lookup proposals

This page is for whoever picks one of #428, #433 and #437. Five models are written three times, once per proposal. Every file is loaded on the branch that proposes it. Four of the five load under all three proposals, so there the choice is about what the file declares and what a call has to name. The fifth loads under one.

built 2026-09-11 09:32 UTC base d64e5da per: conditioning 4081e3e · keys and a dot 2d8735a · relations e7a95bc

#428

per: conditioning

The declaration keeps its arrow and names the dimensions the lookup varies along.

draft

#433

keys and a dot

The declaration takes a list of keys, and the call names the key it walks.

open, review requested

#437

relations

The declaration is a table of columns with a key, and the call names both ends of the walk.

open, review requested

What is measured, and what is not. Every YAML block was loaded on the branch it sits under. Every list of dimensions under it is what that loader reported, and every equation is what that branch's typesetter printed. comparison/verify.py makes the evidence and comparison/build.py writes the page, so no snippet here is typed by hand. The prose is not evidence. AI wrote it, and the trade-off at the end is one reading of the same files.

0before the models the kinds of pairing

The kinds of pairing a model needs

A lookup pairs the members of one dimension with the members of another: one generator with the bus it sits on. A lookup with no key is a relation, which claims nothing about how many and only says that a pair exists. Two questions decide how the file spells a pairing. How many does each thing have, and is there a number on the pair? Each row below answers both for one kind, in everyday terms first and then in a model, and under it every proposal writes the file it would write.

one each

Every pupil is in one class.

Every generator sits on one bus.

per: conditioning

a lookup

lookups:
  gen_bus: { over: generator, into: bus }
keys and a dot

a lookup

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

a lookup with a key

lookups:
  gen_bus: { columns: [generator, bus], key: generator }

one each, and it changes

Every pupil is in one class, and the class changes each school year.

Every generator bids in one zone, and the zone changes by period.

per: conditioning

the second dimension is a per:

lookups:
  zone_of: { over: generator, into: zone, per: [period] }
keys and a dot

the second dimension is a second key

lookups:
  zone_of: { over: [generator, period], into: zone }
relations

the second dimension is a second key column

lookups:
  zone_of: { columns: [generator, period, zone], key: [generator, period] }

two named slots

A journey has the station it leaves and the station it reaches.

A line has a bus at each end, and the flow leaves one and arrives at the other.

per: conditioning

two lookups, so a line may sit in one table and not the other, which leaves an end open

lookups:
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }
keys and a dot

two lookups, so a line may sit in one table and not the other, which leaves an end open

lookups:
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }
relations

one table, each end a named role, and a row carries every column — so both ends exist

lookups:
  ends: { columns: { line: line, bus0: bus, bus1: bus }, key: line }

one of its own kind

Every pupil has one buddy, who is also a pupil.

Every snapshot has one representative snapshot standing in for it.

per: conditioning

refused — a lookup maps into a different dimension

lookups:
  rep_of: { over: snapshot, into: snapshot }
keys and a dot

the same file, refused on #433 and accepted on the #436 draft stacked on it

lookups:
  rep_of: { over: snapshot, into: snapshot }
relations

a keyed self-map, where each end is a named role

lookups:
  represents: { columns: { snapshot: snapshot, stand_in: snapshot }, key: snapshot }

many each, nothing to count

A pupil can be in several clubs, and a club has several pupils.

A generator bids into several reserve products, and each product takes many.

per: conditioning

a table of ones, declared int because a flag cannot be multiplied

parameters:
  eligible: { dims: [generator, product], dtype: int }
keys and a dot

a table of ones, declared int because a flag cannot be multiplied

parameters:
  eligible: { dims: [generator, product], dtype: int }
relations

a lookup with no key, which is the structure itself

lookups:
  eligible: { columns: [generator, product] }

many each, of its own kind

A pupil sits next to several others.

A region touches several other regions.

per: conditioning

refused — a parameter cannot name one dimension twice, and no other rewrite is left

parameters:
  adjacent: { dims: [region, region], dtype: int }
keys and a dot

refused — a parameter cannot name one dimension twice, and no other rewrite is left

parameters:
  adjacent: { dims: [region, region], dtype: int }
relations

a bare self-relation, with a role for each side

lookups:
  adjacent: { columns: { region: region, neighbour: region } }

many each, with a number on the pair

Each pupil gets a number of biscuits at each club.

Each generator delivers to each bus at some efficiency.

per: conditioning

a parameter, whose own rows are the pairing

parameters:
  efficiency: { dims: [generator, bus] }
keys and a dot

a parameter, whose own rows are the pairing

parameters:
  efficiency: { dims: [generator, bus] }
relations

a parameter, whose own rows are the pairing

parameters:
  efficiency: { dims: [generator, bus] }

Which kind you have

  1. Each thing has exactly one. Give the lookup a key. The loader then holds the data to one row per key, and a second row is a refusal.
  2. A number rides on the pair. Write a parameter. Its own rows are the pairing already, and a lookup beside it would say the same thing twice.
  3. Both sides are the same dimension. Only #437 says it. A parameter cannot name one dimension twice, so nothing else is left to write.

keys and a dot · a flag in arithmetic

Constraint 'reserve': 'eligible' is declared dtype: bool, and an expression is arithmetic — only dtype: float and dtype: int bind a column it can be done to. A flag masks rather than scales: name it in a where ("eligible", "NOT eligible"), which is what a mask is — or declare it dtype: int where the 0/1 is meant to arrive as data and be multiplied by.

The stand-in for an unweighted pairing is a table of ones, multiplied into the sum. The dtype that says “a flag” is refused in arithmetic, so the ones are declared as numbers instead.

keys and a dot · a lookup into its own dimension

Lookup 'rep_of' maps 'snapshot' into itself. A lookup maps into a different dimension.

Measured on #433's head. #436 is a draft stacked on it, and on that head the same file loads.

#436 · the rewrite its description prescribes

Parameter 'adjacent' names dimension 'region' twice. A frame is a product of distinct dimensions.

#436's description sends an undirected neighbour relation to “a parameter over [bus, bus]”. That file does not load, on that branch or on any other. It is the fifth model below.

1demands a lookup that varies along a second dimension

A zone that changes by period

The generators bidding in a zone must meet its demand. Which zone a generator bids in changes by investment period, so the lookup is read at a period as well as at a generator. #161 opened this case, and all three proposals were written for it.

$$ \sum_{g \in \mathcal{G} \,:\, \mathrm{zone\_of}(g,\ e) = z} p_{g,e} \ge \mathrm{demand}_{z,e} \qquad \forall\, z \in \mathcal{Z},\ e \in \mathcal{E} $$

All three proposals print this. Only the name of the lookup changes, so the choice is about the file and not about the model it stands for.

per: conditioning#428

one tableThe call is unchanged from a one-key lookup.

lookups:
  zone_of: { over: generator, into: zone, per: [period] }

  zone_balance:
    expression: sum(p, by=zone_of) >= demand
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { over: generator, into: zone, per: [period] }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of) >= demand

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵
loader reports
zone_balance→[zone, period]
keys and a dot#433

one tableThe call names the key it walks.

lookups:
  zone_of: { over: [generator, period], into: zone }

  zone_balance:
    expression: sum(p, by=zone_of.generator) >= demand
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { over: [generator, period], into: zone }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of.generator) >= demand

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵
loader reports
zone_balance→[zone, period]
relations#437

one tableThe call names the column it consumes.

lookups:
  zone_of: { columns: [generator, period, zone], key: [generator, period] }

  zone_balance:
    expression: sum(p, by=zone_of, consume=generator) >= demand
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { columns: [generator, period, zone], key: [generator, period] }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of, consume=generator) >= demand

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵
loader reports
zone_balance→[zone, period]

2demands the same table, walked from its other key

A cap across a stay in a zone

This constraint reads the lookup of problem 1 the other way. Hold a generator and a zone, then sum its output over the periods it bid there. The table is the one the constraint above already uses. What differs is whether the file can say so with one declaration.

$$ \sum_{g \in \mathcal{G} \,:\, \mathrm{zone\_of}(g,\ e) = z} p_{g,e} \ge \mathrm{demand}_{z,e} \qquad \forall\, z \in \mathcal{Z},\ e \in \mathcal{E} $$
$$ \sum_{e \in \mathcal{E} \,:\, \mathrm{zone\_of}(g,\ e) = z} p_{g,e} \le 100 \qquad \forall\, g \in \mathcal{G},\ z \in \mathcal{Z} $$

All three proposals print this. Only the name of the lookup changes, so the choice is about the file and not about the model it stands for.

per: conditioning#428

two declarationsper: fixes which dimension is consumed, so the other direction needs a second lookup. Nothing ties the two declarations together. To the loader they are two lookups, and a consumer binds two tables.

lookups:
  zone_of: { over: generator, into: zone, per: [period] }
  zone_of_by_period: { over: period, into: zone, per: [generator] }

  zone_balance:
    expression: sum(p, by=zone_of) >= demand

  history:
    expression: sum(p, by=zone_of_by_period) <= 100
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { over: generator, into: zone, per: [period] }
  zone_of_by_period: { over: period, into: zone, per: [generator] }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of) >= demand
  history:
    foreach: [generator, zone]
    expression: sum(p, by=zone_of_by_period) <= 100

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵zone_of_by_period: ℰ × 𝒢 → 𝒵
loader reports
zone_balance→[zone, period]
history→[generator, zone]
keys and a dot#433

one tableThe dot picks the other key. One declaration, two walks.

lookups:
  zone_of: { over: [generator, period], into: zone }

  zone_balance:
    expression: sum(p, by=zone_of.generator) >= demand

  history:
    expression: sum(p, by=zone_of.period) <= 100
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { over: [generator, period], into: zone }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of.generator) >= demand
  history:
    foreach: [generator, zone]
    expression: sum(p, by=zone_of.period) <= 100

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵
loader reports
zone_balance→[zone, period]
history→[generator, zone]
relations#437

one tableconsume= picks the other key column. One declaration, two walks.

lookups:
  zone_of: { columns: [generator, period, zone], key: [generator, period] }

  zone_balance:
    expression: sum(p, by=zone_of, consume=generator) >= demand

  history:
    expression: sum(p, by=zone_of, consume=period) <= 100
the whole file
dimensions:
  generator: {}
  zone: {}
  period: { dtype: int }

lookups:
  zone_of: { columns: [generator, period, zone], key: [generator, period] }

parameters:
  demand: { dims: [zone, period] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  zone_balance:
    foreach: [zone, period]
    expression: sum(p, by=zone_of, consume=generator) >= demand
  history:
    foreach: [generator, zone]
    expression: sum(p, by=zone_of, consume=period) <= 100

objective: { sense: minimize, expression: sum(p * cost) }
legend printszone_of: 𝒢 × ℰ → 𝒵
loader reports
zone_balance→[zone, period]
history→[generator, zone]

3demands two columns over one dimension

Nodal balance over lines

Flow leaves one bus and arrives at another, so a line carries two bus labels. The balance at a bus sums generation there, plus flow arriving, minus flow leaving. The second constraint drops any line whose two ends are the same bus.

$$ \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{g,e} + \sum_{l \in \mathcal{L} \,:\, \mathrm{ends.bus1}(l) = b} f_{l,e} - \left( \sum_{l \in \mathcal{L} \,:\, \mathrm{ends.bus0}(l) = b} f_{l,e} \right) = \mathrm{load}_{b,e} \qquad \forall\, b \in \mathcal{B},\ e \in \mathcal{E} $$
$$ f_{l,e} \le 10 \qquad \forall\, l \in \mathcal{L},\ e \in \mathcal{E} \,:\, \mathrm{ends.bus0}(l) \neq \mathrm{ends.bus1}(l) $$

All three proposals print this. Only the name of the lookup changes, so the choice is about the file and not about the model it stands for.

per: conditioning#428

two tablesA lookup has one target, so the two ends are two lookups. per: adds nothing here, and the file is the one the language accepts today.

lookups:
  gen_bus: { over: generator, into: bus }
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }

  nodal:
    expression: sum(p, by=gen_bus) + sum(f, by=line_bus1) - sum(f, by=line_bus0) == load

  no_loop:
    where: "line_bus0 != line_bus1"
    expression: f <= 10
the whole file
dimensions:
  bus: {}
  line: {}
  generator: {}
  period: { dtype: int }

lookups:
  gen_bus: { over: generator, into: bus }
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }

parameters:
  load: { dims: [bus, period] }
  cost: { dims: [generator] }

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

constraints:
  nodal:
    foreach: [bus, period]
    expression: sum(p, by=gen_bus) + sum(f, by=line_bus1) - sum(f, by=line_bus0) == load
  no_loop:
    foreach: [line, period]
    where: "line_bus0 != line_bus1"
    expression: f <= 10

objective: { sense: minimize, expression: sum(p * cost) }
legend printsline_bus0: ℒ → ℬline_bus1: ℒ → ℬgen_bus: 𝒢 → ℬ
loader reports
nodal→[bus, period]
no_loop→[line, period]
keys and a dot#433

two tablesThe same file as per:. A column is named after its dimension and cannot appear twice, so the two ends stay two lookups.

lookups:
  gen_bus: { over: generator, into: bus }
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }

  nodal:
    expression: sum(p, by=gen_bus) + sum(f, by=line_bus1) - sum(f, by=line_bus0) == load

  no_loop:
    where: "line_bus0 != line_bus1"
    expression: f <= 10
the whole file
dimensions:
  bus: {}
  line: {}
  generator: {}
  period: { dtype: int }

lookups:
  gen_bus: { over: generator, into: bus }
  line_bus0: { over: line, into: bus }
  line_bus1: { over: line, into: bus }

parameters:
  load: { dims: [bus, period] }
  cost: { dims: [generator] }

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

constraints:
  nodal:
    foreach: [bus, period]
    expression: sum(p, by=gen_bus) + sum(f, by=line_bus1) - sum(f, by=line_bus0) == load
  no_loop:
    foreach: [line, period]
    where: "line_bus0 != line_bus1"
    expression: f <= 10

objective: { sense: minimize, expression: sum(p * cost) }
legend printsline_bus0: ℒ → ℬline_bus1: ℒ → ℬgen_bus: 𝒢 → ℬ
loader reports
nodal→[bus, period]
no_loop→[line, period]
relations#437

one tableRoles name the two columns, so both ends are one table with one key. The legend prints one map into a product.

lookups:
  gen_bus: { columns: [generator, bus], key: generator }
  ends: { columns: { line: line, bus0: bus, bus1: bus }, key: line }

  nodal:
    expression: sum(p, by=gen_bus) + sum(f, by=ends, produce=bus1) - sum(f, by=ends, produce=bus0) == load

  no_loop:
    where: "ends.bus0 != ends.bus1"
    expression: f <= 10
the whole file
dimensions:
  bus: {}
  line: {}
  generator: {}
  period: { dtype: int }

lookups:
  gen_bus: { columns: [generator, bus], key: generator }
  ends: { columns: { line: line, bus0: bus, bus1: bus }, key: line }

parameters:
  load: { dims: [bus, period] }
  cost: { dims: [generator] }

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

constraints:
  nodal:
    foreach: [bus, period]
    expression: sum(p, by=gen_bus) + sum(f, by=ends, produce=bus1) - sum(f, by=ends, produce=bus0) == load
  no_loop:
    foreach: [line, period]
    where: "ends.bus0 != ends.bus1"
    expression: f <= 10

objective: { sense: minimize, expression: sum(p * cost) }
legend printsgen_bus: 𝒢 → ℬends: ℒ → ℬ × ℬ
loader reports
nodal→[bus, period]
no_loop→[line, period]

4demands landing on two value columns at once

A cap per bus and technology

Each generator sits on a bus and has a technology. The cap applies to the pair, so the sum lands on both dimensions in one grouping. Each generator is counted once, at its own bus and its own technology.

$$ \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bt.bus}(g) = b \wedge \mathrm{gen\_bt.technology}(g) = t} p_{g,e} \le \mathrm{tech\_cap}_{b,t} \qquad \forall\, b \in \mathcal{B},\ t \in \mathcal{T},\ e \in \mathcal{E} $$

All three proposals print this. Only the name of the lookup changes, so the choice is about the file and not about the model it stands for.

per: conditioning#428

two tablesOne lookup per value, and a by= list joins them at the call.

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

  by_bus_and_tech:
    expression: sum(p, by=[gen_bus, gen_tech]) <= tech_cap
the whole file
dimensions:
  bus: {}
  technology: {}
  generator: {}
  period: { dtype: int }

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

parameters:
  tech_cap: { dims: [bus, technology] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  by_bus_and_tech:
    foreach: [bus, technology, period]
    expression: sum(p, by=[gen_bus, gen_tech]) <= tech_cap

objective: { sense: minimize, expression: sum(p * cost) }
legend printsgen_bus: 𝒢 → ℬgen_tech: 𝒢 → 𝒯
loader reports
by_bus_and_tech→[bus, technology, period]
keys and a dot#433

two tablesThe same file as per:.

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

  by_bus_and_tech:
    expression: sum(p, by=[gen_bus, gen_tech]) <= tech_cap
the whole file
dimensions:
  bus: {}
  technology: {}
  generator: {}
  period: { dtype: int }

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

parameters:
  tech_cap: { dims: [bus, technology] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  by_bus_and_tech:
    foreach: [bus, technology, period]
    expression: sum(p, by=[gen_bus, gen_tech]) <= tech_cap

objective: { sense: minimize, expression: sum(p * cost) }
legend printsgen_bus: 𝒢 → ℬgen_tech: 𝒢 → 𝒯
loader reports
by_bus_and_tech→[bus, technology, period]
relations#437

one tableOne table carries both value columns, and produce= lands on both in one join.

lookups:
  gen_bt: { columns: [generator, bus, technology], key: generator }

  by_bus_and_tech:
    expression: sum(p, by=gen_bt, produce=[bus, technology]) <= tech_cap
the whole file
dimensions:
  bus: {}
  technology: {}
  generator: {}
  period: { dtype: int }

lookups:
  gen_bt: { columns: [generator, bus, technology], key: generator }

parameters:
  tech_cap: { dims: [bus, technology] }
  cost: { dims: [generator] }

variables:
  p: { foreach: [generator, period], bounds: { lower: 0 } }

constraints:
  by_bus_and_tech:
    foreach: [bus, technology, period]
    expression: sum(p, by=gen_bt, produce=[bus, technology]) <= tech_cap

objective: { sense: minimize, expression: sum(p * cost) }
legend printsgen_bt: 𝒢 → ℬ × 𝒯
loader reports
by_bus_and_tech→[bus, technology, period]

5demands a relation between two members of one dimension

Neighbouring regions

A region is capped on what its neighbours produce. Neighbourhood carries no number. Two regions touch, or they do not, and each region touches several. Both columns of the lookup are regions, and that is what separates this case from every one above.

$$ \sum_{r' \in \mathcal{R} \,:\, \left( r,\ r' \right) \in \mathrm{adjacent}} x_{r',p} \le \mathrm{cap}_{r,p} \qquad \forall\, r \in \mathcal{R},\ p \in \mathcal{P} $$

Only #437 prints this. The other two columns have no file that loads, so there is nothing to compare it against.

per: conditioning#428

no rewriteThe fallback for a relation is a parameter over the pair, and a parameter cannot name one dimension twice. There is nothing to fall back to.

parameters:
  adjacent: { dims: [region, region], dtype: int }
  cap: { dims: [region, period] }
  cost: { dims: [region] }

  neighbourhood:
    expression: sum(x * adjacent, over=region) <= cap
the whole file
dimensions:
  region: {}
  period: { dtype: int }

parameters:
  adjacent: { dims: [region, region], dtype: int }
  cap: { dims: [region, period] }
  cost: { dims: [region] }

variables:
  x: { foreach: [region, period], bounds: { lower: 0 } }

constraints:
  neighbourhood:
    foreach: [region, period]
    expression: sum(x * adjacent, over=region) <= cap

objective: { sense: minimize, expression: sum(x * cost) }
the loader refuses it
Parameter 'adjacent' names dimension 'region' twice. A frame is a product of distinct dimensions.
keys and a dot#433

no rewriteThe same refusal. #436 adds the self-map to this proposal, and a self-map gives each region one neighbour. Neighbourhood gives it several.

parameters:
  adjacent: { dims: [region, region], dtype: int }
  cap: { dims: [region, period] }
  cost: { dims: [region] }

  neighbourhood:
    expression: sum(x * adjacent, over=region) <= cap
the whole file
dimensions:
  region: {}
  period: { dtype: int }

parameters:
  adjacent: { dims: [region, region], dtype: int }
  cap: { dims: [region, period] }
  cost: { dims: [region] }

variables:
  x: { foreach: [region, period], bounds: { lower: 0 } }

constraints:
  neighbourhood:
    foreach: [region, period]
    expression: sum(x * adjacent, over=region) <= cap

objective: { sense: minimize, expression: sum(x * cost) }
the loader refuses it
Parameter 'adjacent' names dimension 'region' twice. A frame is a product of distinct dimensions.
relations#437

one tableTwo roles over one dimension, and no key. Each region has many neighbours, so nothing is single-valued.

lookups:
  adjacent: { columns: { region: region, neighbour: region } }

  neighbourhood:
    expression: sum(x, by=adjacent, consume=neighbour, produce=region) <= cap
the whole file
dimensions:
  region: {}
  period: { dtype: int }

lookups:
  adjacent: { columns: { region: region, neighbour: region } }

parameters:
  cap: { dims: [region, period] }
  cost: { dims: [region] }

variables:
  x: { foreach: [region, period], bounds: { lower: 0 } }

constraints:
  neighbourhood:
    foreach: [region, period]
    expression: sum(x, by=adjacent, consume=neighbour, produce=region) <= cap

objective: { sense: minimize, expression: sum(x * cost) }
legend printsadjacent ⊆ ℛ × ℛ
loader reports
neighbourhood→[region, period]

+outside the five

A masked sum

The language refuses this sum today, because bus would be needed twice. It weights generation by the load at its own bus. Each term reads load at the bus the generator sits on, and the sum lands on that same bus. Under relations the produced column is joined on instead, so the sum is masked. This case is not one of the five above, and it is the one capability difference they do not show.

per: conditioning#428

refusedThe result would need bus twice, once as the operand's own dimension and once as the group it is placed into.

the loader refuses it
Constraint 'masked': sum(by=gen_bus) targets ['bus'], which the expression already carries (['bus', 'generator', 'period']). The result would need ['bus'] twice — once as the operand's own dim and once as the group it is placed into. Sum over one of the two first, or group into a dimension the operand does not have.
keys and a dot#433

refusedThe result would need bus twice, once as the operand's own dimension and once as the group it is placed into.

the loader refuses it
Constraint 'masked': sum(by=gen_bus) targets ['bus'], which the expression already carries (['bus', 'generator', 'period']). The result would need ['bus'] twice — once as the operand's own dim and once as the group it is placed into. Sum over one of the two first, or group into a dimension the operand does not have.
relations#437

acceptedThe join on the produced column restricts each term to its own bus.

lookups:
  gen_bus: { columns: [generator, bus], key: generator }

  masked:
    expression: sum(load * p, by=gen_bus) <= 1000
$$ \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathrm{load}_{b,e} \cdot p_{g,e} \le 1000 \qquad \forall\, b \in \mathcal{B},\ e \in \mathcal{E} $$

=the whole comparison

Capability and cost

Every proposal has its own row under every capability, even where two of them say the same thing. Each capability links to the model that measured it. The last block is the price, quoted from the pull requests, and nothing here checks it.

A lookup that varies along a second dimension

per: conditioning
one table, with per: [period] on the declaration
keys and a dot
one table, with a second key and a dot at the call
relations
one table, with a second key column

The same table walked from its other key

per: conditioning
a second declaration of the same table, which nothing ties to the first
keys and a dot
one table, walked by=zone_of.period
relations
one table, walked consume=period

A line's two ends in one table

per: conditioning
two lookups, one per end
keys and a dot
two lookups, one per end
relations
one table, with the roles bus0 and bus1

Landing on two value columns at once

per: conditioning
two lookups, joined by a by=[…] list at the call
keys and a dot
two lookups, joined by a by=[…] list at the call
relations
one table, landed by produce=[bus, technology]

A masked sum: the produced dimension already carried

per: conditioning
refused — the result would need bus twice
keys and a dot
refused — the result would need bus twice
relations
accepted — the produced column is joined on instead

Unweighted many-to-many, as structure rather than data

per: conditioning
a table of ones, declared dtype: int because a flag cannot be multiplied
keys and a dot
a table of ones, declared dtype: int because a flag cannot be multiplied
relations
a lookup with no key

A relation between two members of one dimension

per: conditioning
no rewrite — a parameter cannot name one dimension twice
keys and a dot
no rewrite — a parameter cannot name one dimension twice
relations
one table, two roles over one dimension, no key

What it costs, quoted from each pull request

per: conditioning#428
7 rules · no new call syntax · no declaration rewritten · +394 −67
keys and a dot#433
7 rules · a dot at the call · no declaration rewritten · +601 −161
relations#437
10 rules · consume=, produce=, within= · every declaration rewritten once · +2325 −1000

!what the loader says

Refusal messages

This language fails at load, and its messages name the rewrite. A proposal that refuses a mistake well is worth as much as one that says more. These are the messages, unedited.

keys and a dot · by=zone_of with no dot

Constraint 'zone_balance': sum(by=zone_of): 'zone_of' is keyed by ['generator', 'period'], and the call has to say which key sum walks — the others are joined on and kept. Write by=zone_of.generator or by=zone_of.period.

Names both keys and both rewrites.

relations · by=zone_of with no consume=

Constraint 'zone_balance': sum(by=zone_of): 'zone_of' has 2 key columns (['generator', 'period']), and the call has to say which consume= names.

Names the key columns, and the keyword that has to pick one.

per: conditioning · the second direction from one declaration

Constraint 'history': the expression carries dims ['period'] that are not in foreach ['generator', 'zone'] — every stray dim multiplies the rows this constraint builds; add it to foreach if that is intended, or sum it out.

The refusal is real, and it is an error about the frame, which is the set of dimensions a row carries. It is not an error about the lookup. So it cannot name the rewrite, which is a second declaration in another block.

?what is left to decide

The trade-off

Capability alone does not decide this. Four of the five models are sayable under all three proposals, because the two narrower ones reach the same rows with more declarations. What differs is where the file puts a fact, and what a reader of a call has to know already. The fifth model is the exception, and the one place a column is empty.

per: conditioning

The call site is untouched. by=zone_of keeps the meaning it has today, and the lookup keeps its arrow. The direction belongs to the declaration, so a table read both ways is declared twice. The loader does not know the two are one table.

Is a call that names nothing worth a declaration per direction?

keys and a dot

One declaration per table. The key list is the cardinality claim, and the dot names the key a call walks. A column is named after its dimension, so a table cannot hold two columns over one dimension. A line's two ends stay two lookups.

Is one new token at the call site the whole price worth paying?

relations

A lookup is a table of named columns, and key: is the one claim the data can be held to. Every direction belongs to the call, which names it with consume= and produce=. This says everything the other two say and more. It costs ten rules, three keywords, and a rewrite of every declaration in the tree.

Does the language want a relation, or a map?