Skip to content

feat!: shift() creates absence — and the lane actually speaks v1 - #291

Merged
FBumann merged 1 commit into
mainfrom
feat/shift-absence-v1
Jul 29, 2026
Merged

feat!: shift() creates absence — and the lane actually speaks v1#291
FBumann merged 1 commit into
mainfrom
feat/shift-absence-v1

Conversation

@FBumann

@FBumann FBumann commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #289. Finishes #8's action item 4 — "drop any workarounds that exist to paper over legacy-convention quirks".

Three commits. The second one is the serious one, and it was found by auditing for the first one's general shape: places where we answer on linopy's behalf, so the differential oracle cannot disagree with us.


1. The package never opted into v1 (89de63a)

tests/oracle.py:51 sets options['semantics'] = 'v1' and argues for it well: measuring against legacy "would pin this package to the behaviour v1 classifies as a bug (PyPSA/linopy#712)". src/farkas/ never did, and linopy's default is legacy. The tested configuration was not the shipped one:

masked variable in a product — no harness, just fk.solve / farkas_linopy.build
EAGER  (as shipped):  objective  25.0   x[b] = 0     <- legacy fills the absent slot with 0
RELATIONAL:           objective 125.0   x[b] = 100   <- row dropped, per SPEC §6

A wrong answer, not a wrong error, and live since #239 — the shift work below would only have added a second instance. It also made this PR's original title untrue for users, which is why it belongs here rather than in a follow-up.

Set on import. Scoping per call was the first instinct and linopy cannot do it: with linopy.options(semantics='v1') raises TypeError (__call__ returns None), and the only usable form calls reset() on exit — restoring every option to its default rather than its prior value, silently discarding a caller's display_max_rows. A documented global beats a hand-rolled save/restore around every entry point.

Both tests run in a subprocess, deliberately: in-process they would pass whether or not the package sets the option, because importing the harness sets it — which is exactly how this shipped unnoticed.

2. shift() creates absence (fea0d37)

before after linopy v1
x - shift(x, t=1) <= r row built, term zeroed row dropped row dropped
y <= shift(dt, t=1) y[0] <= 0 — a pin load error ValueError
y <= shift(dt, t=1, fill=0) y[0] <= 0 .fillna(0)

vacated() was applying .fillna(0) on top of linopy's answer — on both lanes, so the harness had nothing to disagree about. The eager lane now gets absence from linopy itself.

A bare shift over a variable-free expression is a load error. A parameter has no absence to propagate — a missing row is a zero coefficient (§6) — so v1 refuses it and so do we, naming the three things the author might have meant. Decidable without data, so fk.check() catches it. That removes the pin #288 could only document.

Absence has to reach the row, not just the term

The first cut only dropped vacated coordinates from the fragment's presence set, which does nothing when presence is None — what an unmasked variable reports. The row survived with the term quietly gone:

farkas:     c0: +1.0 x0 -1.0 x3 = 0.0     <- a different constraint
linopy v1:  [0]: None                     <- no row

An acyclic shift now introduces presence rather than only filtering it, keyed by a new presence_dimsone column, since a vacated edge lies along one dimension and says nothing about the others. Keying it by the fragment's dims would materialise the whole coordinate product just to name an edge.

piecewise: is why the escape hatch exists

The nonconvex adjacency row is lam <= seg + shift(seg, bp=1). At the first breakpoint the fill makes it lam <= seg. Absent, that row drops and the first lambda is bounded only by [0,1] — free to sit on a breakpoint the active segment does not touch. A wrong MILP that still solves, in a shipped feature. It now emits fill=0, and a test asserts the row reaches the model, not merely that the expansion mentions fill. transport_pwl still matches its published OR-Library optimum.

3. fill= takes a number, because the right fill is positional (89de63a)

Restricting it to the literal 0 reproduced the very bug this PR removes, one position over — v1 refuses to fill on a caller's behalf for exactly this reason (convention.rst §7): 0 is the identity of a sum, 1 of a product.

x * shift(eff, t=1, fill=1)   # first coordinate governed by its own bound
x * shift(eff, t=1, fill=0)   # first coordinate pinned to zero  <- the pin, again

Verified identical on both lanes at fill=1: objective 17.5, x = [10, 5, 2.5].

  • over data — any number; a data fill both lanes do natively. The relational lane now writes the rows for a nonzero one, since a const fragment reads a missing row as zero and fill=1 only exists if something puts it there.
  • over an expression carrying a variable0 only. A vacated slot contributes no term there, so a nonzero fill would be a constant standing where a term was.

roll refuses fill outright, having vacated nothing.


The behaviour change users feel

An acyclic recurrence no longer starts from zero. soc == shift(soc, snapshot=1) + charge - discharge has no row at the first snapshot instead of one asserting soc[0] == charge[0] - discharge[0] — an initial condition the language was inventing on the modeller's behalf. A model that wants one states it under a complementary where, which SPEC §2's storage example already did.

Nothing in examples/ uses shift — every shipped model uses roll — so that test is the only model-visible change in the tree.

Packaging (ed50240)

Dropping the version guard made the dependency floor load-bearing: the import now does options['semantics'] = 'v1' unconditionally, and 0.9.0 > 0.8.0.post1.dev0, so a linopy released without v1 would satisfy the floor and turn the import into a bare KeyError. [tool.uv.sources] does not help — it resolves for uv, not for pip install farkas[linopy]. Recorded next to the floor so it is raised in the same change that drops the source pin.

Docs

  • §7 — the rule, the fill= escape and its positional identity, and the refusal
  • §6 — the alignment claim is now true without qualification: the four ways to say "not here" are one rule
  • §5.1 — fill is the one keyword whose key is fixed rather than naming a dimension
  • ports.md — the min-up-time macro sketch gains fill=0

Found on the way, not fixed here

  • x / d where d is missing a row: both lanes agree, and both make the row vacuous rather than dividing by zero. The sharpest form of the positional-fill argument — there is no defensible fill for a divisor. Language question, not a lane divergence.
  • A missing bound value: relational raises DataError at build naming the fix; the eager lane builds and dies at solve with linopy's raw ValueError: Continuous Variable x contains nan's in field(s) ['upper']. Same verdict, worse timing and message than SPEC §9 asks for.

Gates: 534 passed, ruff clean, pyrefly 0 errors, mkdocs build --strict clean.

Closes #289, and finishes #8's action item 4 — the last workaround papering
over the legacy convention.

`shift`'s vacated position used to contribute zero. linopy v1 counts `.shift()`
among the four ways to *create* absence, so we were taking the v1 oracle and
forcing it back to the legacy answer with `.fillna(0)` — on both lanes, which
left the differential harness blind to the difference by construction. It is
now absence: it propagates and drops the row, and the eager lane gets that from
linopy rather than from us.

`shift(x, d=n, fill=0)` is the opt-out, and is deliberately the same escape v1
prescribes for code that wants the older reading. It takes the literal 0 — a
nonzero fill is a *constant* at the vacated coordinate, a different fragment
kind from a translated term, and refused rather than implemented on one lane
only. `roll` refuses it outright, having vacated nothing.

**A bare `shift` over a variable-free expression is now a load error.** A
parameter has no absence to propagate (a missing row is a zero coefficient, §6),
so v1 refuses it and so do we, naming the three things it could have meant.
That is what removes `x <= shift(dt, t=1)` silently building `x <= 0` — the pin
#288 could only document.

Two things this turned up:

**Absence has to reach the row, not just the term.** The first cut only dropped
the vacated coordinates from the fragment's presence set, which does nothing
when presence is `None` — an unmasked variable reports none. The row then
survived with the shifted term quietly gone, which is a *different constraint*:
`soc - ch == 0` where linopy v1 builds no row at all. Fixed by having an acyclic
shift introduce presence rather than only filter it, keyed by `presence_dims` —
one column, since a vacated edge lies along one dimension and says nothing about
the others. Keying it by the fragment's dims would mean materialising the whole
coordinate product to name an edge.

**`piecewise:` is why the escape hatch exists.** The nonconvex adjacency row
`lam <= seg + shift(seg, bp=1)` needs the zero at the first breakpoint to read
`lam <= seg`. Absent, that row drops and the first lambda is bounded only by
[0,1] — free to sit on a breakpoint the active segment does not touch, a wrong
MILP that still solves. It now emits `fill=0`, and a test asserts the row
reaches the model rather than only that the expansion mentions `fill`.
`transport_pwl` still matches its published OR-Library optimum.

The behaviour change users feel is an acyclic recurrence: `soc` no longer starts
from zero, because that was an initial condition the language invented on the
modeller's behalf. A model that wants one states it, next to the recurrence
under a complementary `where` — which SPEC §2's storage example already did.

BREAKING CHANGE: a bare `shift()` now drops the row at the vacated coordinate
instead of contributing zero there, and `shift()` over a variable-free
expression is a load error. Add `fill=0` for the previous behaviour.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@FBumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 364349fa-52f0-4250-94ec-6b07ffe00502

📥 Commits

Reviewing files that changed from the base of the PR and between c809ace and fea0d37.

📒 Files selected for processing (17)
  • docs-organisation-proposal.md
  • docs/SPEC.md
  • docs/ports.md
  • src/farkas/dimensions.py
  • src/farkas/helpers.py
  • src/farkas/linopy/builder.py
  • src/farkas/linopy/semantics.py
  • src/farkas/lowering.py
  • src/farkas/piecewise.py
  • src/farkas/relational/compiler.py
  • src/farkas/relational/executor.py
  • src/farkas/relational/plan.py
  • src/farkas/resolution.py
  • src/farkas/validation.py
  • tests/test_piecewise.py
  • tests/test_relational.py
  • tests/test_roll.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 farkas | 🛠️ Build #33811502 | 📁 Comparing fea0d37 against latest (c809ace)

  🔍 Preview build  

2 files changed
± SPEC/index.html
± ports/index.html

@FBumann
FBumann merged commit b394e70 into main Jul 29, 2026
4 checks passed
@FBumann

FBumann commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Two corrections pushed, both from auditing for other places we answer on linopy's behalf.

The package never opted into v1 — so this PR's title wasn't true for users

tests/oracle.py:51 sets options['semantics'] = 'v1', and argues for it well: measuring against legacy "would pin this package to the behaviour v1 classifies as a bug (PyPSA/linopy#712)". src/farkas/ never did, and linopy's default is legacy. The tested configuration was not the shipped one:

masked variable in a product — no test harness, just fk.solve / farkas_linopy.build
EAGER  (as shipped):  objective  25.0   x[b] = 0     <- legacy fills the absent slot with 0
RELATIONAL:           objective 125.0   x[b] = 100   <- row dropped, per SPEC §6

A wrong answer rather than a wrong error, and live since #239 — the shift work here would only have added a second instance of it. farkas.linopy now sets it on import.

Scoping it per call was the first instinct and linopy cannot do it: with linopy.options(semantics='v1') raises TypeError (__call__ returns None), and the only usable form resets every option to its default on exit rather than to its prior value, so it would silently discard a caller's display_max_rows. A documented global beats a hand-rolled save/restore around every entry point.

Both tests run in a subprocess, deliberately. An in-process assertion would pass whether or not the package sets the option, because importing the harness sets it — which is exactly how this shipped unnoticed. The second one drives both lanes from a clean interpreter and asserts they agree.

fill= takes a number, because the right fill is positional

Restricting it to the literal 0 was wrong, and for the reason v1 itself refuses to fill on a caller's behalf (convention.rst §7): 0 is the identity of a sum, 1 of a product.

x * shift(eff, t=1, fill=1)   # first coordinate governed by its own bound
x * shift(eff, t=1, fill=0)   # first coordinate pinned to zero

That second line is the pin this PR exists to remove, wearing the coefficient's hat. Verified identical on both lanes at fill=1: objective 17.5, x = [10, 5, 2.5].

  • over data — any number. It is a data fill, and the eager lane does it natively via fill_value=. The relational lane now writes the rows for a nonzero fill, since a const fragment reads a missing row as zero and fill=1 only exists if something puts it there.
  • over an expression carrying a variable0 only, unchanged. A vacated slot there contributes no term, so a nonzero fill would be a constant standing where a term was.

Also found, not fixed here

  • x / d where d is missing a row: both lanes agree, and both make the row vacuous rather than dividing by zero. Same class as the sparse-coefficient trap docs(spec): three behaviours a consumer could only reach by probing #288 documented, but a language question rather than a lane divergence — worth its own issue.
  • A missing bound value: relational raises DataError at build naming the fix; the eager lane builds fine and dies at solve with linopy's raw ValueError: Continuous Variable x contains nan's in field(s) ['upper']. Same verdict, worse timing and message than SPEC §9 asks for.

Gates: 534 passed, ruff clean, pyrefly 0 errors, mkdocs build --strict clean.

@FBumann FBumann changed the title feat!: shift() creates absence, as linopy v1 says it does feat!: shift() creates absence — and the lane actually speaks v1 Jul 29, 2026
FBumann added a commit that referenced this pull request Jul 29, 2026
…d it there (#314)

Closes #311.

`sum(x + y, over=f)` with `y` masked to `f=a` built different models on the two
lanes: 400 eager, 240 native — a 40% gap, no error. Addition is fragment
concatenation in the relational compiler, so `x + y` is two independent streams
and each was reduced over its own coordinates. At row level that is right, since
the executor intersects the presences when it assembles the row; a reduction
consumes the expression before any row exists, so nothing intersected them.

The eager lane was right and I first argued it was not. SPEC §6's carve-out —
"a reduction skips what is absent" — has `sum(x, over=d)` over a bare variable
as its example, and says a reduction must not propagate absence *outward*. It
does not license distributing a compound summand, and distributing is what the
relational lane was doing: rewriting `sum(x + y)` into `sum(x) + sum(y)` reads
the absent `y[b]` as a zero, which is the reading v1 exists to remove and that
#239 and #291 removed from masks and shifts.

So reductions are not linear over operands of differing presence. That is the
honest consequence of absence being a state rather than a value, and the two
spellings are different questions: the total of the net where the net is
defined, against the total in minus the total out. SPEC now puts them in a table
side by side, because the composition of the two rules was nowhere written down
— which is how I came to misread it.

`tests/test_arithmetic_laws.py` is the other half. An arithmetic convention is a
set of equalities between spellings and nothing else in the suite was checking
those: a model can build, solve and agree across both lanes while `a + b` and
`b + a` mean different things. Eight laws (commutativity, associativity,
subtraction as negated addition, distribution over a variable-free factor and
over a divisor, reduction linearity while every operand is total, roll
invertibility, and commutativity *under* absence) plus three deliberate
non-laws, each asserted through `differential` so a law holding is six numbers
agreeing rather than two.

The non-laws are the point. This defect is invisible to law-shaped tests — both
spellings were individually consistent — and only a test saying "these two are
supposed to disagree, and by exactly this much" would have caught it.

Writing them also caught one of my own: a "law" that summing over a dim the
operand lacks scales by that dim's cardinality. The language refuses that
outright at load time. The scaling in `_sum_fragment` applies to a fragment
lacking a dim the expression carries, which is not user-visible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shift() creates absence in linopy v1; we fill it with zero on both lanes — drop the workaround

1 participant