Where a lookup may be plural¶
Status: Draft / language note, aimed upstream at
lpspec.
Companion: lpspec-direction.md, which settles that
topology is data and every relation is a lookup rather than an adjacency
matrix.
A lookup maps one dimension's members onto another's. This page asks, position by position, which parts of that may be plural — and finds that the answer differs at every one, which is itself the argument for keeping them separate keys.
Math throughout uses $…$ / $$…$$ so it renders both in the docs site and on
GitHub.
What a lookup is today¶
lookups: {over: <dim>, into: <dim>} — a named single-valued map out of a
dimension, checked for containment when data is bound. Our program declares
six. sum(x, by=L) lands terms on the target dimension; at(x, by=L) reads a
target-indexed value back onto the source rows; shift(x, by=L) partitions the
axis it walks. The verbs are the directions of one edge.
lpspec.to_latex renders the declaration as an arrow between index sets:
and the use as a restriction on the summation index. This is the real carrier
balance, verbatim from to_latex(PROGRAM):
The typesetting confirms why a lookup is directed: it prints as a function,
and over is the side the function is total on. The direction is not an
orientation a caller could supply — it is which side is single-valued, which is
what makes \(\mathrm{at}\) well defined and what lets the map live as a column on
the source dimension's index.
The four positions¶
| position | plural? | why |
|---|---|---|
over — the source being contracted |
no | contraction captures the index; a pair index escapes the \(\forall\), and it means nothing for the verbs that contract nothing |
per — the source being conditioned on |
yes, freely | join keys, and joins compose; each adds one free variable to the restriction |
into — the target |
no | one arrow is one function; two targets are two lookups |
by= — the call |
yes — shipped | grouping by several maps at once is a fact about one aggregation, not about any map |
The rest of this page is that table, argued.
over may not be plural¶
Bidding zones are reconfigured between investment periods: the balance is still one equation per zone, timestep and period, but which generators sit in a zone depends on the period. The map wanted is
and the question is what an operator does with the extra source axis. Spelled
as a composite source, sum(by=) contracts everything the lookup is over, so
both axes go:
The summation index is now a pair, so \(p\) is bound by the \(\sum\) and cannot
also appear in the quantifier. That pools every period's membership into one
sum indexed \((z, t)\) — not the constraint, and refused rather than silently
wrong, since foreach: [zone, time, period] no longer matches the expression's
dims.
It does not type-check for the other verbs. at(x, by=L) contracts
nothing; it reads a value through the map onto the source rows. Under a
composite source there is no \(\sum\) to bind the pair, so the lookup application
carries a free variable in a subscript. The same objection holds for
shift(…, by=L). A composite over: would be a form usable by one of the
three operators that accept by=.
And it is recoverable anyway — see below.
per may be plural, freely¶
The same map, declared as a family of functions indexed by period. over is
contracted; per is joined on and kept:
\(p\) is free inside the restriction and bound by the \(\forall\). Ordinary set-builder notation with nothing invented, and it is the constraint that was wanted.
The test¶
Is \(p\) bound by the \(\sum\) or by the \(\forall\)?
That single question is the whole difference, and the notation makes it visible
where the YAML does not. It also names the two kinds of source axis a keyed map
has — the key is over ∪ per, and composite over: is the corner where the
whole key is contracted.
Arity fails in opposite directions¶
Widening per changes nothing structural:
A set-builder restriction does not care how many free variables it carries, and
at composes the same way: \(\mathit{zone\_price}_{\,\mathrm{zone\_of}(l,p,s),\,t}\).
That is the cleanest argument for the split. Arity breaks over because
contraction captures indices; arity is free for per because conditioning only
adds a column to the join. Two source-axis kinds that fail in opposite
directions under widening are two different things wearing one name.
Why per is the general one¶
Composite behaviour is a follow-up call away from per —
sum(sum(x, by=zone_of), over=period) — while per behaviour is unreachable
from composite, because \(p\) is destroyed inside the operator with no point at
which it exists to be kept. One direction is a second call; the other is
information gone.
per also composes with all three verbs unchanged, because it is a property of
the map's key rather than a new role for the map — and the roles (group
target, read-through, axis partition) are orthogonal to how the map is
addressed.
The distinction already exists in the operator vocabulary.
sum_back(x, over=<dim>, within=<n|parameter>) walks one axis while something
else partitions it, described upstream as "the same lookup in a different
position: it says which rows are neighbours, not which group a term lands in."
per: moves that same contracted-versus-conditioning split onto the
declaration, which is where it belongs when it is a property of the map rather
than of one call.
Two rules, at any arity¶
per⊆ dims(expression). The operand must carry every conditioning axis, or the join has no key column. The same check at arity one or five.- The key size is the product. With one
perdim the map is still comfortably an index column; with three it is the size of an ordinary parameter over those dims, and the "a lookup lives as a column on the source dimension's index" story has dissolved. Which is evidence for the discriminator beinginto:— its values are labels of a dimension, so it can be traversed — rather than its storage or its arity.
The genuinely open question is not arity. It is what should happen when a per
dim is absent from the operand but present in the constraint's foreach:
refuse, or broadcast the result into it. Refusing fits the language's
strictness; broadcasting is what a caller would expect. Widening per makes
that surface more often, but it exists at arity one too.
into may not be plural — by= already is¶
into: [zone, region] buys nothing. sum(by=) lands terms on one dimension, so
a two-target lookup would need the call to pick which anyway, and the storage is
two index columns either way. Two lookups sharing a key is the honest spelling,
and the type says so:
One arrow, one function.
The real case behind the question is not solved by two declarations: aggregating
by (zone, technology), where both are attributes of the generator. After
sum(rate, by=zone_of) the source dim is gone, so tech_of is unreachable and
nesting cannot help. What is needed is grouping by both at once — a conjunction
of restrictions:
This shipped — lpspec #704,
closed. sum(array, by=[lookup, …]) lands onto every dim the lookups map into,
and at takes the same list, the pullback binding each source row at its own
(zone, tech) value. Both lanes agree.
Note it needs no per: at all — both lookups are over: generator with no
conditioning axis — which is why this half shipped first and separately.
The two rules on a list¶
- Shared
over. Every lookup in the list must be over the same dimension — only the rows actually being collapsed can be grouped. - Distinct
into. Two lookups in one list may not target the same dimension, or the result dims collide: the result is \((\mathrm{dims}(x) \setminus \{over\}) \cup \{\mathrm{into}(l), \mathrm{into}(m)\}\), and a union cannot hold one dim twice.
The first is derivable from what grouping means; the second is a consequence of the result-dim algebra and is easy to miss. Both are checked at load.
A misreadable docstring¶
Builtin.lookup_kwargs is documented as "lookup_kwargs name a lookup",
singular — which describes the kwarg's kind, not its arity. The grammar
resolves a name list in that position. It reads like a restriction and is
not one.
Not at our pin¶
The list form is on lpspec main; pyproject.toml pins
v0.0.1-alpha.213, where it does not parse:
by=zone_of ok
by=[zone_of, tech_of] SchemaError: Failed to parse expression … Expected end of text, found '('
So using it in program.yaml waits on a pin bump — which, per the pin's own
comment, is a deliberate change with the suite behind it.
The rule that falls out¶
The declaration stays a single-valued function between two dimensions. All composition happens in the operators.
The language's own stated taste is evidence for this partition rather than
against it. The sibling kwargs were deliberately collapsed into one by=:
a lookup carries its own dimensions, so the sibling kwargs that used to restate them (
sum'sover=besidegroup_by=,at'sonto=) are gone — what the two-keyword spelling once said, the name's kind now says, checked at load.
That principle is facts about the map live on the map, and per: obeys it —
the lookup still carries all its own dimensions, now including the conditioning
ones, which is why by='s grammar needs no change to accept one. Which maps you
group by simultaneously is a fact about one aggregation rather than about any
map, so it varies per call and belongs on the call.
The two moves are not in tension: over=/onto= went onto the declaration
and by= gained a list, in the same design pass. They are the two halves of
one rule.
Expressible without either¶
Neither is an expressiveness gap — which is why the ask is about legibility rather than reach. A weighted many-to-many relation is an ordinary sparse parameter, and a 0/1 membership table is a map:
parameters:
in_zone: {dims: [flow, zone, period]} # 1.0 where the flow is in that zone
is_tech: {dims: [flow, technology]}
constraints:
zone_balance:
foreach: [zone, time, period]
expression: "sum(rate * carrier_sign * in_zone, over=flow) == 0"
and two-way grouping is the same trick twice —
sum(rate * in_zone * is_tech, over=flow) over
foreach: [zone, technology, time, period]. That is what to write until the
lpspec pin carries by=[…].
Sparsity carries the edges, so in_zone holds exactly one row per
\((\text{flow}, \text{period})\) — the same row count a conditioned lookup
would store. And sum(by=) lowers to a join and a group-by anyway, which is
what a product against a sparse 0/1 parameter already is.
Declare them float with 1.0 values rather than bool: the program uses
boolean parameters only in where: clauses, never in arithmetic, so coercion in
a product is unverified.
So what a declared map buys over a membership parameter is the containment check, the function arrow in the typeset output, and a declaration that says the map is a map. Not expressiveness — legibility, and one check that would otherwise go unwritten.
The ask, which is one item¶
per: on the declaration — lpspec
#1163, filed with the
bidding-zone probe. Generalises to any arity for free.
The issue already assumes the list form exists: a per: lookup inside one
should require every element to agree on per:, rhyming with the shared-over:
rule #704 enforces. So the two halves compose without either needing to know
about the other.
What this means for fluxopt¶
The same limitation exists one layer up, and is ours rather than lpspec's.
Multi-node folds the node into the carrier label (carrier:node), and
carrier_of is over: flow with one value — so a flow that moves between zones
across periods would need different carrier labels per period, which the
element layer cannot state.
The membership-parameter form above is how it would be written, and it would sit
beside carrier_balance rather than replacing it. That is worth an issue
independently of whether the language ever grows per:.