Skip to content

fix(linopy)!: the lane selects v1 on import, and fill= takes the identity of its position - #293

Merged
FBumann merged 2 commits into
mainfrom
fix/linopy-lane-speaks-v1
Jul 29, 2026
Merged

fix(linopy)!: the lane selects v1 on import, and fill= takes the identity of its position#293
FBumann merged 2 commits into
mainfrom
fix/linopy-lane-speaks-v1

Conversation

@FBumann

@FBumann FBumann commented Jul 29, 2026

Copy link
Copy Markdown
Owner

These two commits were on #291's branch but not in its squash#291 merged fea0d37 alone. So the half of that work that makes it correct for users is missing from main, and 0.1.0-alpha.12 shipped without it.

main is currently diverging, measured

#291 made the relational lane drop the row for an acyclic shift and removed the vacated() fill from the eager lane. But the package never opts into linopy's v1 convention, and linopy's default is legacy, which zero-fills absent slots. So the eager lane keeps the row the native engine drops:

### merged main (0.1.0-alpha.12) ###       ### with this PR ###
EAGER      :  30.0  (3 rows built)         EAGER      : 300.0  (2 rows built)
RELATIONAL : 300.0                         RELATIONAL : 300.0

A plain ramp constraint, x - shift(x, t=1) <= r. 10× apart, no error either side.

The suite does not see it because tests/oracle.py:51 sets options['semantics'] = 'v1' and src/farkas/ does not — the tested configuration is not the shipped one. That predates #291 for masked variables (25.0 vs 125.0, live since #239); #291 added the shift case on top.

1. The lane selects v1 on import

tests/oracle.py already argues the case: measuring against legacy "would pin this package to the behaviour v1 classifies as a bug (PyPSA/linopy#712)". The setting belongs where users get it.

Set on import, because linopy cannot scope 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. The cost is real and stated in the comment: a process importing this module has its own linopy arithmetic changed too.

Both tests run in a subprocess. In-process they would pass whether or not the package sets the option, since importing the harness sets it — which is exactly how this shipped unnoticed. One asserts the option; the other drives both lanes from a clean interpreter and asserts they agree.

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

Restricting it to the literal 0 reproduced the very bug #291 exists to remove, one position over. v1 refuses to fill on a caller's behalf for precisely 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, unchanged. A vacated slot contributes no term there, so a nonzero fill would be a constant standing where a term was.

3. The dependency floor is now load-bearing

Dropping the version guard means the import does options['semantics'] = 'v1' unconditionally, and set_value raises KeyError for anything lacking it. 0.9.0 > 0.8.0.post1.dev0, so a linopy released without v1 would satisfy the floor and crash the import. [tool.uv.sources] does not cover this — 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.

Gates: 534 passed, ruff clean, pyrefly 0 errors.

FBumann added 2 commits July 29, 2026 10:41
…tity of its position

Two corrections to the same mistake, found by auditing for other places we
answer on linopy's behalf.

**The package never opted into v1.** `tests/oracle.py` sets
`options['semantics'] = 'v1'` and argues for it well — measuring against legacy
"would pin this package to the behaviour v1 classifies as a bug". `src/farkas/`
did not, and linopy's default is legacy. So the tested configuration was not the
shipped one, and the two lanes answered the same YAML differently:

    masked variable in a product, no test harness
    EAGER  (as shipped)   objective  25.0   x[b] = 0     legacy fills absent with 0
    RELATIONAL            objective 125.0   x[b] = 100   row dropped, SPEC §6

A wrong answer, not a wrong error, and live since #239 — this commit's shift
work would merely have added a second instance. Set on import: the option is
global, and linopy's own context manager cannot scope it (`__exit__` calls
`reset()`, restoring *all* options to their defaults rather than their prior
values, so it would discard a caller's `display_max_rows`).

The two tests for it run in a **subprocess**. An in-process assertion would pass
whether or not the package sets it, since importing the harness sets it — which
is precisely how this shipped unnoticed.

**`fill=` takes a number because the right fill is positional.** 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. Restricting to the literal 0 got that wrong —
`x * shift(eff, t=1, fill=1)` leaves the first coordinate governed by its own
bound, where `fill=0` pins it, which is the pin this branch exists to remove
wearing the coefficient's hat.

Over data any number is accepted, since it is a data fill both lanes do
natively; the relational lane now writes the rows for a nonzero one, because a
const fragment reads a missing row as zero and `fill=1` exists only if something
puts it there. Over an operand carrying a variable 0 remains the only
representable fill — there a vacated slot contributes no term, and a nonzero one
would be a constant standing where a term was.

BREAKING CHANGE: importing `farkas.linopy` sets `linopy.options['semantics']`
to 'v1' process-wide. Models built through this lane change answer wherever a
variable is masked or shifted — that is the point, and it is the answer the
native engine has always given.
@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: 10 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: 2427a5fc-e957-4963-b01e-7360234c5a06

📥 Commits

Reviewing files that changed from the base of the PR and between b9cfd30 and 784f4e8.

📒 Files selected for processing (7)
  • docs/SPEC.md
  • pyproject.toml
  • src/farkas/linopy/__init__.py
  • src/farkas/lowering.py
  • src/farkas/relational/compiler.py
  • tests/test_linopy_lane.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 #33812002 | 📁 Comparing 784f4e8 against latest (b9cfd30)

  🔍 Preview build  

1 file changed
± SPEC/index.html

@FBumann
FBumann merged commit 451c354 into main Jul 29, 2026
4 checks passed
@FBumann
FBumann deleted the fix/linopy-lane-speaks-v1 branch July 31, 2026 10:51
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.

1 participant