Skip to content

Commit 58416a4

Browse files
committed
feat!: absence propagates and drops the row, on both lanes
Adopts linopy's v1 reading of absence as the language's own, and makes v1 the oracle rather than a mode we happen to survive. **What changes.** A term whose variable is masked out no longer contributes zero — it makes the row absent, so `x + y >= 10` is *no constraint* where `y` is masked rather than `x >= 10`. The old reading is how x - rel_max * size <= 0 silently became `x <= 0` on an unsized component: feasible model, plausible answer, no error. That is goal 1 of the v1 convention ("no silent wrong answers") and the whole of PyPSA/linopy#712, and it was reachable here. **Two things deliberately do not propagate.** A *reduction* skips absent slots (§13), so `sum(x, over=d)` stays defined when only some of `d` exists — without that, one masked component would delete a system-wide accounting row. A *parameter* covering only some coordinates is sparse encoding, not absence: its missing rows mean a zero coefficient (SPEC §8), which is what lets a coefficient table hold live entries only. Absence is a property of variables. **How the engine tells them apart.** `TermFragment.presence` carries the variable's own coordinates beside the term stream, because once `coeff x var` are multiplied the frame cannot say which side removed a row. It is set only for a variable whose declaration has a `where` — decided off the plan, before data — so an unmasked variable never imposes the cost, and `_label_frame` keeps both of its arithmetic paths (#152, #178) for every equation that does not turn on the difference. **The oracle is v1, and it raises rather than skips.** A skip would be the worst outcome available: the suite would go green having stopped comparing the lanes on exactly the cases the convention changed. No release carries the option yet, so `[tool.uv.sources]` pins PyPSA/linopy#717 by branch — by branch and not by rev on purpose, since a stale rev would measure us against a spec that has moved. Not included, and it is the follow-up this needs: `defined(v)` (#219). Dropping the row is now the only reading available, and the way to ask for the other one is complementary `where` clauses over a variable's existence — which is not yet sayable. Until it lands, a model wanting "keep the row, treat the term as zero" has to carry a parameter mirroring the variable's mask. `test_a_constraint_row_left_with_no_variables` stays xfailed and is *not* this: raw linopy builds a term-less row under both conventions (`labels=[0,1]`, `vars=[-1]`), so that divergence lives in our own eager lane and wants its own diagnosis. Refs #8, #219
1 parent f61de00 commit 58416a4

8 files changed

Lines changed: 199 additions & 49 deletions

File tree

SPEC.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,25 @@ A boolean mask; true means "this coordinate exists". Semantics are **row
247247
absence**, not zero-fill: a masked-out variable is not created, a masked-out
248248
constraint row is not built.
249249

250+
**Absence spreads.** A term whose variable does not exist at a coordinate does
251+
not contribute zero there — it makes the whole row absent, so `x + y >= 10` is
252+
*no constraint* where `y` is masked, not `x >= 10`. Zero-filling instead is how
253+
`x - rel_max * size <= 0` silently becomes `x <= 0` on an unsized component: a
254+
feasible model, a plausible answer, no error. To keep the row and treat the
255+
missing term as zero, say so — write the two cases as separate equations with
256+
complementary `where` clauses.
257+
258+
Two things deliberately do **not** spread. A **reduction** skips what is absent
259+
rather than propagating it, so `sum(x, over=d)` is defined when only some of `d`
260+
exists and the sum of nothing is zero — without that, one masked component would
261+
delete a system-wide accounting row. And a **parameter** covering only some
262+
coordinates is sparse *encoding*, not absence: its missing rows mean a zero
263+
coefficient (§8), which is why a coefficient table may hold live entries only.
264+
Absence is a property of variables.
265+
266+
This matches linopy's v1 arithmetic convention, which both lanes are built
267+
against; `farkas.linopy.semantics` is where the eager lane answers it.
268+
250269
```text
251270
where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr
252271
| "(" where_expr ")"

pyproject.toml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@ dependencies = [
5050
# The relational engine never imports them, and `dev` installs this extra by
5151
# name rather than repeating it — one place for the pins, so what CI tests the
5252
# lane against is what a user of the extra gets.
53+
#
54+
# The lower bound is 0.8, not 0.9, because the v1 arithmetic convention this
55+
# lane requires is not in a release yet: PyPSA/linopy#717 branches from 0.8.x
56+
# and reports a 0.8 dev version. `[tool.uv.sources]` below is what actually
57+
# resolves it for development and CI. When v1 ships, this becomes an ordinary
58+
# floor on the release that carries it and the source pin goes away.
5359
linopy = [
54-
"linopy>=0.9.0,<0.10",
60+
"linopy>=0.8.0.post1.dev0",
5561
"xarray>=2024.2.0",
5662
# also what `Result.to_pandas` / `to_dataarray` need. Declared here rather
5763
# than as a runtime dependency: pandas is a bridge out of the engine, not
@@ -191,3 +197,12 @@ unresolvable-dunder-all = true
191197
untyped-import = true
192198
unused-ignore = true
193199
variance-mismatch = true
200+
201+
# The v1 arithmetic convention is not released. farkas's eager lane is written
202+
# against it — absence propagates and drops a constraint row rather than being
203+
# filled with zero — so the oracle is only an oracle on a linopy that speaks it.
204+
# Pinned by branch rather than by rev deliberately: this is tracking active
205+
# upstream work, and a stale rev would quietly measure us against a spec that
206+
# has since moved. Removed once v1 ships.
207+
[tool.uv.sources]
208+
linopy = { git = "https://github.com/PyPSA/linopy", branch = "feat/arithmetic-convention" }

src/farkas/linopy/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ def _eval_ast(
305305
return node.value
306306

307307
if isinstance(node, VariableNode):
308-
return semantics.present(ctx.model.variables[node.name])
308+
return ctx.model.variables[node.name]
309309

310310
if isinstance(node, ParameterNode):
311311
return semantics.coefficient(ctx.dataset[node.name])

src/farkas/linopy/semantics.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
from typing import Any
4040

41-
__all__ = ['coefficient', 'present', 'vacated']
41+
__all__ = ['coefficient', 'vacated']
4242

4343

4444
def coefficient(parameter: Any) -> Any:
@@ -61,34 +61,6 @@ def coefficient(parameter: Any) -> Any:
6161
return parameter.fillna(0.0)
6262

6363

64-
def present(variable: Any) -> Any:
65-
"""A masked variable as a term contributing **zero** where it is absent.
66-
67-
Today's language rule: a term carrying a masked-out variable drops out while
68-
the row it sits in survives, so ``x + y >= 10`` is ``x >= 10`` wherever ``y``
69-
is masked. The relational lane gets that from row absence — the join finds no
70-
row, the term is not emitted, the constraint row still is.
71-
72-
Filling at the **leaf** is what preserves it. Fill any later and §2 has
73-
already absorbed the live terms sharing the slot (``(x + y).fillna(0)``
74-
yields a bare ``0 >= 10`` with ``x``'s coefficient gone); fill here and ``x``
75-
keeps it.
76-
77-
``to_linexpr()`` first, because ``Variable.fillna`` means two different
78-
things across the versions we support — a label fill routed through
79-
``.where()`` on the released line, an expression fill on the v1 branch. The
80-
expression method is the stable one, and the one we want.
81-
82-
.. note::
83-
This is the interim answer, not the settled one. v1 §6/§12 would drop the
84-
row instead, and its goal 1 ("no silent wrong answers") indicts the fill:
85-
``x - relmax * size <= 0`` with ``size`` masked silently becomes
86-
``x <= 0``. Adopting that reading is a change to *both* lanes and to
87-
SPEC §6, so it is its own commit — and it deletes this function.
88-
"""
89-
return variable.to_linexpr().fillna(0)
90-
91-
9264
def vacated(expression: Any) -> Any:
9365
"""A shifted expression with its vacated edge positions at **zero**.
9466
@@ -108,5 +80,12 @@ def vacated(expression: Any) -> Any:
10880
``x <= shift(dt, t=1)`` forces ``x <= 0`` at the first position unless it is
10981
masked. Until that is decided, this keeps the documented rule true by
11082
construction rather than by the legacy convention's accident.
83+
84+
``to_linexpr()`` first when the operand is still a bare ``Variable``:
85+
``Variable.fillna`` means two different things across the versions we
86+
support — a label fill routed through ``.where()`` on the released line, an
87+
expression fill on the v1 branch — and only the expression method is stable.
11188
"""
89+
if hasattr(expression, 'to_linexpr'):
90+
expression = expression.to_linexpr()
11291
return expression.fillna(0)

src/farkas/relational/compiler.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,12 @@ def _variable_fragment(self, name: str) -> TermFragment:
313313

314314
dims = self.program.variable(name).dims
315315
frame = self.variables[name].select(*dims, 'var_label', pl.lit(1.0, dtype=pl.Float64).alias('coeff'))
316-
presence = self.variables[name].select(*dims)
316+
# An *unmasked* variable exists at every coordinate of its foreach, so
317+
# its presence could only ever restrict nothing. Leaving it None is not
318+
# an optimisation detail: a presence frame is data, and carrying one
319+
# costs `_label_frame` both of its arithmetic paths. Whether it is
320+
# needed is decided here, off the declaration, before any data is read.
321+
presence = self.variables[name].select(*dims) if self.program.variable(name).where is not None else None
317322
return TermFragment(dims, frame, True, label_dims=frozenset(dims), presence=presence)
318323

319324
def _product(self, a: CompiledExpression, b: CompiledExpression, context: str) -> CompiledExpression:
@@ -414,14 +419,46 @@ def _translate_fragment(self, p: TermFragment, s: plan.Translate, context: str)
414419
moved = pl.col(_ORD_IN) + s.by
415420
if s.wrap:
416421
moved = (moved % card + card) % card
417-
frame = (
418-
p.frame.join(incoming, on=s.dimension, how='inner')
419-
.drop(s.dimension)
420-
.with_columns(moved.alias(_ORD_OUT))
421-
.join(outgoing, on=_ORD_OUT, how='inner')
422-
.select(*others, s.dimension, *p.carried)
422+
423+
def remap(source: pl.LazyFrame, carried: list[str]) -> pl.LazyFrame:
424+
return (
425+
source.join(incoming, on=s.dimension, how='inner')
426+
.drop(s.dimension)
427+
.with_columns(moved.alias(_ORD_OUT))
428+
.join(outgoing, on=_ORD_OUT, how='inner')
429+
.select(*others, s.dimension, *carried)
430+
)
431+
432+
frame = remap(p.frame, p.carried)
433+
presence = None
434+
if p.presence is not None:
435+
# Presence is a coordinate set, so it travels through the same map.
436+
presence = remap(p.presence, [])
437+
if not s.wrap:
438+
presence = pl.concat([presence, self._vacated(p, s, card, others)], how='vertical_relaxed').unique()
439+
return TermFragment(p.dims, frame, p.is_term, p.keyed, p.label_dims, presence)
440+
441+
def _vacated(self, p: TermFragment, s: plan.Translate, card: int, others: list[str]) -> pl.LazyFrame:
442+
"""The edge positions ``shift`` leaves with nothing to move in.
443+
444+
They are *not* absent. SPEC §7 fixes what they contribute — "vacated
445+
positions contribute **zero**" — which is a declared rule of the
446+
language, so they stay present and the row survives. That is the same
447+
answer the eager lane gives them (``semantics.vacated``), and it is why
448+
the two lanes agree about an acyclic recurrence's first step.
449+
450+
Only the ``shift`` edge qualifies. A coordinate the variable's own mask
451+
removed is genuinely absent, and remapping already dropped it above.
452+
"""
453+
table = self.dimensions[s.dimension]
454+
edge = table.filter(((pl.col('ord') - s.by) < 0) | ((pl.col('ord') - s.by) >= card)).select(
455+
pl.col('val').alias(s.dimension)
423456
)
424-
return TermFragment(p.dims, frame, p.is_term, p.keyed, p.label_dims)
457+
if not others:
458+
return edge
459+
# One vacated row per other-dim combination the variable actually has:
460+
# a coordinate it never covers gains nothing from an edge it never sees.
461+
return p.presence.select(*others).unique().join(edge, how='cross') if p.presence is not None else edge
425462

426463
# ------------------------------------------------------------------
427464
# assembly helpers used by the executor

src/farkas/relational/executor.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ def _label_frame(
497497
where: plan.Predicate | None,
498498
label: str,
499499
start: int,
500+
restrictions: Sequence[tuple[tuple[str, ...], pl.LazyFrame]] = (),
500501
) -> tuple[pl.DataFrame, int]:
501502
"""The masked coord product of *dims* with a dense *label* from *start*.
502503
@@ -514,19 +515,30 @@ def _label_frame(
514515
Both return ``(dims…, label)`` in that column order. A mask that
515516
removes nothing has to be indistinguishable from no mask, down to the
516517
schema.
518+
519+
*restrictions* are variable-presence frames a constraint row must be
520+
contained in: absence propagates into a comparison and drops the row
521+
(v1 ``convention.rst`` §6, §12). They are semi-joins, so they can only
522+
remove rows — but which rows is not known until data is read, and that
523+
is what costs the two fast paths, so the caller passes them only when a
524+
variable in the equation is actually masked (:func:`_absence_restrictions`).
517525
"""
518-
if where is None:
519-
frame = self._q.frame(dims, None)
520-
rows = math.prod(self._dim_card[d] for d in dims)
521-
return self._positional(frame, dims, label, start), start + rows
526+
if not restrictions:
527+
if where is None:
528+
frame = self._q.frame(dims, None)
529+
rows = math.prod(self._dim_card[d] for d in dims)
530+
return self._positional(frame, dims, label, start), start + rows
531+
532+
free = _free_prefix(dims, _predicate_dims(where, self._param_dims()))
533+
if free:
534+
return self._factored(dims, free, where, label, start)
522535

523-
free = _free_prefix(dims, _predicate_dims(where, self._param_dims()))
524-
if free:
525-
return self._factored(dims, free, where, label, start)
536+
restricted = self._q.frame(dims, where)
537+
for on, presence in restrictions:
538+
restricted = restricted.join(presence.unique(), on=list(on), how='semi')
526539

527540
materialised = (
528-
self._q.frame(dims, where)
529-
.sort([_ordinal(d) for d in dims])
541+
restricted.sort([_ordinal(d) for d in dims])
530542
.select(*dims)
531543
.with_row_index(label, offset=start)
532544
.select(*dims, pl.col(label).cast(pl.Int64))
@@ -674,7 +686,8 @@ def _build_constraint(self, c: plan.ConstraintDeclaration) -> tuple[pl.DataFrame
674686
f'foreach {list(c.dims)} — missing a Sum/GroupSum?'
675687
)
676688

677-
labelled, self._n_rows = self._label_frame(c.dims, c.where, 'row', self._n_rows)
689+
restrictions = _absence_restrictions([p for p, _ in terms])
690+
labelled, self._n_rows = self._label_frame(c.dims, c.where, 'row', self._n_rows, restrictions)
678691
frame = labelled.lazy()
679692
self._constraints[c.name] = frame # kept for the dual read-back
680693

@@ -1000,3 +1013,30 @@ def _stack(frames: list[pl.DataFrame], columns: tuple[str, ...]) -> pl.DataFrame
10001013
if frames:
10011014
return pl.concat(frames)
10021015
return pl.DataFrame(schema={name: _DTYPES[name] for name in columns})
1016+
1017+
1018+
def _absence_restrictions(terms: Sequence[TermFragment]) -> list[tuple[tuple[str, ...], pl.LazyFrame]]:
1019+
"""The presence frames a constraint's rows have to be contained in.
1020+
1021+
Absence propagates into a comparison and drops the row there (v1
1022+
``convention.rst`` §6 and §12): ``x + y >= 10`` is not ``x >= 10`` where
1023+
``y`` is masked, it is no constraint at all. A term whose variable is absent
1024+
therefore restricts the row set rather than merely contributing nothing.
1025+
1026+
Only *variable* absence counts. A sparse parameter is a compressed dense
1027+
array whose missing rows mean a zero coefficient (SPEC §8), which is why the
1028+
fragment carries :attr:`~farkas.relational.compiler.TermFragment.presence`
1029+
separately from its frame, and why this reads that rather than the frame.
1030+
1031+
**A fragment with nothing to restrict is skipped**, and that is load-bearing
1032+
rather than tidy: a restriction is data — which rows survive is unknown until
1033+
the presence frames are read — so it costs ``_label_frame`` both of its
1034+
arithmetic paths. An unmasked variable's presence is its whole coordinate
1035+
product and would remove nothing, so it never gets to impose that cost.
1036+
"""
1037+
out: list[tuple[tuple[str, ...], pl.LazyFrame]] = []
1038+
for p in terms:
1039+
if p.presence is None or not p.dims:
1040+
continue
1041+
out.append((p.dims, p.presence))
1042+
return out

tests/oracle.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,26 @@
3030
xr = pytest.importorskip('xarray', reason=_REASON)
3131
pd = pytest.importorskip('pandas', reason=_REASON)
3232

33+
# **The oracle is v1, and only v1.** A differential test is an oracle only if
34+
# the thing it compares against is the convention we implement. Legacy is the
35+
# one linopy is retiring: it fills every absent slot with 0, so it agrees with a
36+
# lane that keeps a constraint row whose variable is masked, and disagrees with
37+
# one that drops it. Measuring against legacy would pin this package to the
38+
# behaviour v1 classifies as a bug (PyPSA/linopy#712).
39+
#
40+
# So this raises rather than skipping. A skip here would be the worst outcome
41+
# available — the suite would go green having quietly stopped checking the lanes
42+
# against each other on precisely the cases the convention changed.
43+
if 'semantics' not in getattr(linopy.options, '_defaults', {}):
44+
raise RuntimeError(
45+
f'linopy {linopy.__version__} has no options["semantics"], so it cannot speak the v1 '
46+
f'arithmetic convention this package is written against. The oracle would silently '
47+
f'measure against the legacy convention instead. Install the pin in pyproject.toml '
48+
f'([tool.uv.sources]: PyPSA/linopy@feat/arithmetic-convention) — `uv sync`.'
49+
)
50+
51+
linopy.options['semantics'] = 'v1'
52+
3353
# Spelled out rather than aliased to something shorter: this module also
3454
# re-exports the *real* ``linopy`` above, so the shim needs a name that cannot
3555
# be confused with it. ``farkas_linopy`` names the module it actually is, which

tests/test_relational.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,43 @@ def test_a_parameter_covering_a_subset_of_its_dims_means_zero_on_both_lanes():
707707
# t=0 carries `0 * x <= 0` — a row that exists and constrains nothing,
708708
# which is what a zero coefficient and a zero right-hand side mean.
709709
assert run.result.objective == pytest.approx(10.0 + 4.0 + 5.0, rel=RTOL)
710+
711+
712+
ABSENT_VARIABLE_MODEL = {
713+
'dimensions': {'f': {'values': ['a', 'b']}},
714+
'parameters': {'gate': {'dims': ['f'], 'dtype': 'bool'}, 'relmax': {'dims': ['f']}, 'cost': {'dims': ['f']}},
715+
'variables': {
716+
'x': {'foreach': ['f'], 'bounds': {'lower': 0, 'upper': 100}},
717+
'size': {'foreach': ['f'], 'where': 'gate', 'bounds': {'lower': 0, 'upper': 50}},
718+
},
719+
'constraints': {'envelope': {'foreach': ['f'], 'equations': [{'expression': 'x - relmax * size <= 0'}]}},
720+
'objectives': {'total': {'sense': 'maximize', 'equations': [{'expression': 'sum(x * cost, over=f)'}]}},
721+
}
722+
723+
724+
def test_a_term_whose_variable_is_absent_drops_the_row_on_both_lanes():
725+
"""Absence propagates into the comparison; it does not zero the term.
726+
727+
``x - relmax * size <= 0`` where ``size`` is masked out used to build
728+
``x <= 0`` — a row that silently pinned the flow to zero. Plausible answer,
729+
no error, which is goal 1 of linopy's v1 convention ("no silent wrong
730+
answers") and the whole of PyPSA/linopy#712. Under §6 the slot is absent and
731+
§12 drops the row instead, so ``x`` is left free at ``f=b`` and bounded only
732+
by its own declaration.
733+
734+
The oracle is the point: the eager lane gets this from linopy's own v1
735+
semantics, the relational lane from carrying variable presence apart from
736+
the term stream. Two independent implementations, one answer.
737+
"""
738+
data = {
739+
'gate': pd.Series({'a': True}),
740+
'relmax': pd.Series({'a': 0.5, 'b': 0.5}),
741+
'cost': pd.Series({'a': 1.0, 'b': 1.0}),
742+
}
743+
with differential(ABSENT_VARIABLE_MODEL, data, lp=True) as run:
744+
# one call, then zip: `primal` is a label join and does not promise row order,
745+
# so reading it twice and pairing the columns can mismatch them.
746+
solved = run.result.primal('x')
747+
x = dict(zip(solved['f'], solved['value'], strict=True))
748+
assert x['a'] == pytest.approx(25.0, rel=RTOL), 'sized: x <= 0.5 * size, size <= 50'
749+
assert x['b'] == pytest.approx(100.0, rel=RTOL), 'unsized: the row is gone, so only the bound holds'

0 commit comments

Comments
 (0)