Skip to content

Variable.update() / Constraint.update() as canonical mutation API#727

Merged
FabianHofmann merged 13 commits into
feat/solver-updatefrom
feat/typed-update-api
May 27, 2026
Merged

Variable.update() / Constraint.update() as canonical mutation API#727
FabianHofmann merged 13 commits into
feat/solver-updatefrom
feat/typed-update-api

Conversation

@FBumann

@FBumann FBumann commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #718. Exploratory branch for the API discussion (#718 review thread).

Adds typed .update() methods on Variable and Constraint so every mutation goes through one validated entry point. The existing 7 setters survive as one-line shims that forward to .update(), so no user-visible breakage — but _coef_dirty, broadcasting, sign normalisation, and the rhs-rearrange-to-lhs logic all live in one method.

API

Variable.update(*, lower=None, upper=None) -> Variable

Constraint.update(
    constraint=None,   # positional: a complete ConstraintLike, e.g. x + 5 <= 3
    *,
    lhs=None,          # replace whole expression
    rhs=None,          # set rhs (Variable/Expression rearranged onto lhs, like add_constraints)
    sign=None,         # set sign
    coeffs=None,       # partial: coefficient values
    variables=None,    # partial: variable labels
) -> Constraint

Two call shapes for Constraint, mirroring add_constraints:

c.update(x + 5 <= 3)               # complete replacement via a comparison
c.update(lhs=x, sign="<=", rhs=-2) # partial / explicit

Composition rules:

  • lhs= replaces the whole expression first; rhs= rearrangement then sees the new lhs.
  • lhs= is mutually exclusive with coeffs= / variables= (whole vs partial).
  • Positional constraint is mutually exclusive with all keyword arguments.
  • sign= applied last so it composes cleanly.
  • Returns self for chaining.

Setter shims

Forwarders, no other behaviour:

setter forwards to
var.lower = x var.update(lower=x)
var.upper = x var.update(upper=x)
c.lhs = expr c.update(lhs=expr)
c.rhs = … c.update(rhs=…)
c.sign = s c.update(sign=s)
c.coeffs = x c.update(coeffs=x)
c.vars = v c.update(variables=v)

Closes the #718 review A1 residual

Last commit (70dbed4) flips ModelDiff.from_snapshot(same_model=…) default from True to False. The flag-trust path (skip_coef_compare = same_model and not coef_dirty) is now precise through Constraint.update() — it sets the flag in one place; setters forward there. But c.coeffs.values[...] = ... still bypasses _coef_dirty, and with the old default, that bypass silently produced wrong diffs.

The only production caller (Solver._update_locked) passes same_model explicitly, so it's unaffected. Same-model warm-update paths now value-diff the CSR data instead of trusting the flag — small perf cost (50–200 ms at Mayk-scale per his bench), correct by default. Solver-aware callers who own the mutation contract can opt back into the optimization with same_model=True.

What's NOT in this branch

  • Variable.update(type=…) for binary/integer/continuous switching (would need new validation logic).
  • Objective.update(...) (separate container; the persistent-solver diff already tracks obj_linear / obj_sense).
  • Setter removal. We chose to keep shims for back-compat; some setters predate feat(persistent): in-place solver updates (Solver framework + HiGHS/Gurobi/Xpress/Mosek) #718 by ~4 years and removal is a bigger break worth its own PR.
  • Migration of internal setter call sites (sanitize_zeros and friends). Setters are pure shims so the migration would be cosmetic; postpone until / unless setters get actually removed.
  • Batching the multiple assign_multiindex_safe calls a multi-attr update makes.

Warts (inherited, not introduced)

  • variables= kwarg shadows the .vars attribute spelling — picked the unambiguous name to dodge Python's vars() builtin shadow; .vars (read) stays as the attribute.
  • rhs=Variable/Expression silently rearranges onto lhs (kept for symmetry with add_constraints; documented).
  • lhs= xor coeffs= / variables= is a runtime check, not a type-system constraint.
  • Mixing positional + keyword is a runtime check too.

Test plan

  • test_variable.py, test_constraint.py, test_constraints.py, test_constraint_coef_dirty.py, test_model.py, test_variables.py — 280 pass (266 existing + 14 new for .update() directly, incl. the positional ConstraintLike form).
  • PR feat(persistent): in-place solver updates (Solver framework + HiGHS/Gurobi/Xpress/Mosek) #718's persistent-solver suite: test_persistent_snapshot_diff.py, test_persistent_snapshot_buffers.py, test_persistent_solver_extras.py, test_persistent_solver_orchestrator.py, test_persistent_apply_update.py — 112 tests pass on this branch with the same_model=False default.
  • Pre-commit clean.

🤖 Generated with Claude Code

FBumann and others added 4 commits May 24, 2026 17:16
Introduces typed ``.update()`` methods on Variable and Constraint as
the single, validated, multi-attribute mutation entry point.

- ``Variable.update(lower=, upper=)``: validates non-constant
  inputs are rejected, new dims are rejected, and the resulting
  ``lower <= upper`` invariant holds across all coords. Returns
  ``self`` for chaining.
- ``Constraint.update(rhs=, sign=)``: constant RHS only. The
  legacy ``c.rhs = variable`` rearrange-to-lhs path stays on the
  setter (different semantic, deserves its own explicit method).

The existing ``.lower`` / ``.upper`` / ``.sign`` setters become
thin shims that forward to ``.update()``, so single-attribute
writes (``z.lower = 2``) stay ergonomic and the canonical
validation runs in one place. The ``.rhs`` setter forwards
constants through ``.update()`` and keeps the expression-rhs
rearrange behaviour.

This is the on-top experiment for the design discussion on #718.
``.lhs`` / ``.coeffs`` / ``.vars`` setters are untouched for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the existing ``c.rhs = expr`` setter and ``add_constraints``
which both accept mixed-side input and rearrange the residual onto
lhs. ``c.update(rhs=x + 5)`` now subtracts ``x`` from lhs and stores
``5`` on rhs. ``.rhs`` setter collapses to a one-line shim.

Variable bound rejection of Variable/Expression is kept (bounds are
numeric, not symbolic); docstring clarified to spell out that
pandas / xarray / numpy arrays are first-class (time-varying bounds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etters

Adds lhs / coeffs / vars to the canonical mutation API. All
.lhs / .coeffs / .vars setters now forward to .update() — every
Constraint mutation goes through one method with one validation
path, one place that flips _coef_dirty.

Composition rules:
- lhs= replaces the whole expression first; subsequent rhs=
  rearrangement (Variable/Expression in rhs) sees the new lhs.
- lhs= and coeffs= / vars= are mutually exclusive (whole
  replacement vs partial array update).
- sign= is applied last so it composes cleanly.

Internal Constraint.sanitize_zeros migrated to update(vars=,
coeffs=) — no more internal setter calls in linopy/.

389 tests pass across mutation + persistent-solver suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoids shadowing Python's vars() builtin. The .vars attribute on
Constraint stays (it parallels the .data.vars internal name);
only the kwarg gets the unambiguous spelling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@odow

odow commented May 25, 2026

Copy link
Copy Markdown
Contributor

This is much closer to how JuMP does things. We have set_ functions instead of modify, but same thing.

In MathOptInterface we have a CachingOptimizer which controls the "pass to solver OR rebuild" logic:

I tried to cover the caching optimizer in my MOI talk, but I don't really like it. It was too brief and I did things in the wrong order: https://youtu.be/M31xoZGyj9w?si=NTQHUsnq7o9Lg770&t=2211

FBumann and others added 2 commits May 25, 2026 11:19
Mirrors add_constraints' dispatch: c.update(x + 5 <= 3) is now
shorthand for c.update(lhs=x, sign='<=', rhs=-2), extracted from
the AnonymousConstraint / ConstraintBase the comparison produces.

Mutually exclusive with the per-attribute kwargs; clear error when
mixed.

Also reverts the internal sanitize_zeros migration. The setters
are pure shims forwarding to update(), so the migration didn't
change behaviour or cost — just spelling. The original setter
syntax reads more naturally there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The positional ConstraintLike form (c.update(x + 5 <= 3)) always
rewrites lhs / sign / rhs and flips _coef_dirty. For hot loops that
only touch one part, kwarg form (c.update(rhs=...)) skips the
unchanged attributes and is materially cheaper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up: what .update() should grow to cover

Mapping against the per-backend apply_update calls in #718 — the backends all expose more than this PR exposes today. Concrete TODO:

Next milestones (additive, don't change existing shape):

  • Variable.update(type=…) — switch between "continuous" / "integer" / "binary" / "semi_continuous". All four backends expose it (changeColsIntegrality / setAttr(VType) / chgcoltype / putvartypelist). Validation: binary → bounds in {0,1}, semi_continuous → positive lower.
  • model.objective.update(expression=, sense=) (or Model.update_objective(...)) — backends expose changeColsCost / setAttr(Obj) / chgobj / putclist for linear coefficients and changeObjectiveSense / ModelSense / chgobjsense / putobjsense for sense. The persistent-solver snapshot already tracks obj_linear and obj_sense — only the user-facing mutation API is missing.

Later, if a workflow surfaces:

  • Per-cell coefficient shortcut (c.update(coefficient={x: 2.0}) or c.update_coefficient(x, 2.0)). Maps to changeCoeff / chgCoeff / chgmcoef. Today coeffs= replaces the whole array.
  • Variable.update(cost=…) shortcut for setting objective linear coefficients per variable. Canonical location is the objective; this would just be sugar.

Intentionally out of scope — solvers don't support, so neither should .update():

Each item is additive — won't reshape the current .update() signature.

FBumann and others added 2 commits May 25, 2026 13:27
Closes the A1 residual from the #718 review. The flag-trust path
(`skip_coef_compare = same_model and not coef_dirty`) is correct
through Constraint.update() (set in one place, shims forward), but
`c.coeffs.values[...] = ...` still bypasses _coef_dirty. With
same_model=True as the default, that bypass silently produces wrong
diffs.

Flip the default to False. Cross-model paths (the only production
caller, Solver._update_locked, passes explicitly) are unaffected.
Same-model warm-update paths now value-diff the CSR data — small
perf hit (50-200ms at Mayk-scale per Mayk's bench), correct by
default. Solver-aware callers who own the mutation contract can
opt back into the optimization with `same_model=True`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- examples/manipulating-models.ipynb: rewrite mutation cells to use
  Variable.update / Constraint.update; setter form is mentioned in
  notes as syntactic sugar for the same call.
- examples/creating-constraints.ipynb: reframe the CSRConstraint vs
  Constraint API table around .update() as the mutation API; setters
  are sugar.
- Setter docstrings now say 'syntactic sugar for Constraint/Variable
  .update; do not add logic here so the contract stays single-sourced'
  — a directive to future contributors as much as to readers.

No deprecation, no breaking change. .update() is the documented
canonical mutation API; the seven setters continue to exist as
one-line shims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

API surface from a user perspective

Tying this back to the _coef_dirty / same_model discussion on #718: if Variable.update() / Constraint.update() become the canonical mutation path, the dirty flag becomes a reliable invariant and the leaky same_model=True parameter on ModelDiff.from_snapshot can go away.

Two follow-ups worth committing to here:

  1. Auto-detect identity in the diff — store a weakref to the source model on ModelSnapshot. from_snapshot consults _coef_dirty only when identity matches. Users get one safe signature, no flags.

  2. Split Solver.update(model, apply=True) into diff(model) and apply(diff) (per earlier discussion). update doing both is fine as sugar but the primitives should be separate — easier to reason about, easier to test, and removes the need to expose ModelDiff.from_snapshot / from_models publicly at all (their only legitimate caller becomes Solver.diff).

End state: users call var.update(...) to mutate, solver.diff(model) to inspect, solver.apply(diff) to push. No dirty flags, no same_model, no from_snapshot in the public surface.

@FBumann
FBumann marked this pull request as ready for review May 25, 2026 16:43
Adds DeprecationWarning to all seven mutation setters (Variable.lower,
Variable.upper, Constraint.coeffs, Constraint.vars, Constraint.sign,
Constraint.rhs, Constraint.lhs). Each setter still forwards to .update()
so existing code keeps working; the warning points at the canonical API.

Internal sanitize_zeros migrated off setters (the last linopy/ caller).
api.rst gains Modification sections listing .update() for both Variable
and Constraint; tutorial notes rewritten to teach .update() and flag
setters as deprecated. Release note added.

dual.setter / solution.setter untouched — result assignment, not
mutation, different deprecation track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

With update being the user facing concept for updateing a linopy Model, we should rename som of the internals if they do sth related, but different:

  • Solver.apply_update → Solver.apply_diff
  • Solver._update_locked → Solver._apply_diff_locked

This results in a clear split:

  • Update: Changes to the linopy Model
  • Diff: The Difference between the Model a solver holds currently and anotherlinopy.Model.

Users do updates, we push DIffs internally to the solver.

Constraint.update tests: lhs-only, coeffs-only (vars preserved),
compound lhs+sign, mutually-exclusive lhs+coeffs and lhs+variables.
Variable.update tests: upper-only, valid array bound.

Migrate test_constraint_coef_dirty.py from the now-deprecated setters
to .update(), exercising the canonical path; add positional-form and
no-op cases. Net effect: same dirty-flag invariants, 7 fewer warnings
per pytest run.

Docs: Constraint.update rhs= gains a worked example showing the two
forms (constant vs variable/expression). add_constraints rhs gets a
matching note pointing at the linopy invariant so the rearrangement
rule is documented at the creation site too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FabianHofmann

Copy link
Copy Markdown
Collaborator

API surface from a user perspective

Tying this back to the _coef_dirty / same_model discussion on #718: if Variable.update() / Constraint.update() become the canonical mutation path, the dirty flag becomes a reliable invariant and the leaky same_model=True parameter on ModelDiff.from_snapshot can go away.

Two follow-ups worth committing to here:

  1. Auto-detect identity in the diff — store a weakref to the source model on ModelSnapshot. from_snapshot consults _coef_dirty only when identity matches. Users get one safe signature, no flags.
  2. Split Solver.update(model, apply=True) into diff(model) and apply(diff) (per earlier discussion). update doing both is fine as sugar but the primitives should be separate — easier to reason about, easier to test, and removes the need to expose ModelDiff.from_snapshot / from_models publicly at all (their only legitimate caller becomes Solver.diff).

End state: users call var.update(...) to mutate, solver.diff(model) to inspect, solver.apply(diff) to push. No dirty flags, no same_model, no from_snapshot in the public surface.

I like that very much. let's do that. we can also make that another pr or adjust it directly in #718 - does not need to be added here.

For now, I would like to keep the setters and not deprecate them. fine if I adjust that here?

@FabianHofmann

Copy link
Copy Markdown
Collaborator

I you know what, let's deprecate them :)

@FBumann

FBumann commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

@FabianHofmann As you wish :)
I read about pythons convention of removal:
First, we deprecate a feature (Deprectation Warning, silent by default in python, users need to actively activate DeprecationWarning), then we change that to a Future Warning (Users see it), then we remove the feature, probably with v1.

SO its softer than a FutureWarning. Mostly a notice in the release notes.
Sounds good?

@FabianHofmann FabianHofmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonderful @FBumann, some requests below

Comment thread linopy/constraints.py Outdated
Comment thread linopy/constraints.py Outdated
Comment thread linopy/constraints.py
rhs: ExpressionLike | VariableLike | ConstantLike | None = None,
sign: SignLike | None = None,
coeffs: ConstantLike | None = None,
variables: variables.Variable | DataArray | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add a deprecation warning if dataarray is passed as variables. we should only support variable type in future.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even a Future Warning? Is this currently a use case?

Comment thread linopy/constraints.py Outdated
if variables is not None:
from linopy.variables import Variable as _Variable

v = variables.labels if isinstance(variables, _Variable) else variables

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

somewhere here would be the deprecation warning I mentioned above

Comment thread linopy/variables.py Outdated
Comment thread linopy/variables.py Outdated
Comment on lines +972 to +977
non_constant = (
Variable,
ScalarVariable,
expressions.LinearExpression,
expressions.QuadraticExpression,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ConstantLike from types.py to test

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to add a function self._validate_update(lower=None, upper=None) which checks for correct type and dimensions in a for loop. after validation they can be broadcasted, added and finally checked (final_lower < final_upper) here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So _validate_update would NOT check (final_lower < final_upper) ?

@FabianHofmann

Copy link
Copy Markdown
Collaborator

@FabianHofmann As you wish :) I read about pythons convention of removal: First, we deprecate a feature (Deprectation Warning, silent by default in python, users need to actively activate DeprecationWarning), then we change that to a Future Warning (Users see it), then we remove the feature, probably with v1.

SO its softer than a FutureWarning. Mostly a notice in the release notes. Sounds good?

sounds very good

- Constraint._assign_lhs_expr → _assign_lhs (drop redundant suffix;
  the method already takes a LinearExpression, so the type was in the
  signature, not the name).
- Add Constraint._assign_data(**fields) helper. Wraps the four
  ``self._data = assign_multiindex_safe(self.data, **kw)`` callsites
  inside update() (rhs / coeffs / vars / sign). Untouched: the same
  pattern in dual.setter, sanitize_missings, sanitize_infinities —
  those aren't update() and stay out of scope here.
- Add types.CONSTANT_TYPES tuple, derived from ConstantLike via
  get_args so the two cannot drift. Variable.update bound validation
  flipped from negative (isinstance against a hand-rolled
  non_constant tuple) to positive (isinstance against CONSTANT_TYPES);
  drops a redundant in-function ``from linopy import expressions``
  (the module-level import already covered it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

@FabianHofmann
Still todo, waiting for your take:

  • Adding self._validate_update(lower=None, upper=None): SHould it also do the lb<=ub cehck?
  • variables: variables.Variable | DataArray | None = None: Deprecationwarning or Future Warning right away?

@FabianHofmann

Copy link
Copy Markdown
Collaborator

@FabianHofmann Still todo, waiting for your take:

  • Adding self._validate_update(lower=None, upper=None): SHould it also do the lb<=ub cehck?
  • variables: variables.Variable | DataArray | None = None: Deprecationwarning or Future Warning right away?

sorry, got swallowed by the other prs. yes and futurewarning

…_validate_update

Constraint
- sanitize_zeros now writes _data via _assign_data directly (no
  longer round-trips through update(variables=DataArray), which
  would self-trigger the new deprecation warning).
- Constraint.update(variables=...) emits FutureWarning when passed a
  raw DataArray of integer labels; Variable is the supported input.
  The path stays accepted for back-compat and will be removed
  alongside the v1 cleanup.

Variable
- Extract Variable._validate_update(*, lower, upper) — validates,
  broadcasts, and runs the cross-bound (lb<=ub) check, returning a
  dict ready for assignment. update() body shrinks to ~3 lines.
  Coord validation (parity with add_variables) deferred to #726 land.

Tests
- test_constraint_coef_dirty's variables= test now passes a Variable
  instead of a DataArray (matches the supported input).
- test_constraint_vars_setter_with_array wrapped in
  pytest.warns(FutureWarning) — locks in the deprecation contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y inside

The helper now writes fields AND flips _coef_dirty when the written
set includes coeffs or vars. Callsites in update() (coeffs / vars
branches) and sanitize_zeros drop their explicit `self._coef_dirty = True`
lines — the rule lives in one place, can't be forgotten by future
field additions.

rhs / sign writes still don't dirty (correctly). _assign_lhs is
untouched: it uses a different write mechanic (drop_vars + assign)
and manages its own flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FabianHofmann
FabianHofmann merged commit 08732a9 into feat/solver-update May 27, 2026
3 checks passed
@FabianHofmann
FabianHofmann deleted the feat/typed-update-api branch May 27, 2026 10:02
FabianHofmann added a commit that referenced this pull request Jun 24, 2026
…urobi/Xpress/Mosek) (#718)

* feat(constraints): add _coef_dirty flag + rhs setter short-circuit

Tracks per-Constraint coefficient mutation via a single boolean slot,
flipped in coeffs/vars/lhs setters. Pure-constant rhs writes now
short-circuit and leave coeffs/vars buffers untouched (by identity),
so rhs-only updates don't trigger expensive coefficient recompare on
the persistent-solver fast path.

* feat(persistent): add ModelSnapshot, CoefPattern, StructuralKey

Pure-Python snapshot primitives for the persistent-solver Phase 1.
Deep-copies value-side fields (var_lb/ub, con_rhs/sign, obj_linear),
holds vlabels/clabels by reference, stores canonical CSR
(indptr, indices) per constraint container. No Solver import.

* feat(persistent): add ModelDiff and compute_diff

Pure-function diff for the persistent-solver Phase 1. Detects
structural, coord, sparsity, quadratic-objective, value-only var/con,
and objective-linear/sense changes. Supports same_model fast path
via _coef_dirty and cross-model full re-scan. Includes a focused
test suite covering capture, mutation paths, deep-copy invariant,
and the same_model toggle.

* feat(solvers): add persistent-update orchestration to Solver

- supports_persistent_update class flag (default False)
- snapshot/_rebuilds/_in_place_updates/_last_rebuild_reason fields
- snapshot capture at end of direct _build, _clear_coef_dirty helper
- apply_update stub raising UnsupportedUpdate
- solve(model, assign) dispatcher with diff-or-rebuild path
- update(model, apply=True) primitive returning ModelDiff
- threading.Lock around diff+apply+resnapshot
- __getstate__/__setstate__ drop native handle and snapshot

* test(persistent): smoke test Solver orchestrator with fake backend

* feat(solvers): short-circuit rebuild when backend lacks persistent-update support

Skip diff computation entirely when supports_persistent_update is False
on apply, per plan: 'dispatcher checks flag before calling — if False,
skips diffing entirely and goes to rebuild.'

* feat(solvers): Gurobi apply_update for persistent solves

* feat(solvers): HiGHS apply_update for persistent solves

* test(persistent): cross-model, pickle, threading, failure-path coverage

* refactor(persistent): row-block numpy snapshot/diff

Replace xarray-based snapshot and CSR pattern compare with per-row
canonicalised numpy buffers; new ContainerVarUpdate / ContainerRowUpdate
payloads. Gurobi/HiGHS apply_update rewritten around batched setAttr /
changeColsBounds / changeColsCost / changeColsIntegrality; coefficient
writes touch only changed cells. Cross-model diff now ~matches same-model
cost for bound/rhs/coef-value sweeps.

* feat(persistent): opt-in coord-equality via ignore_dims

compute_diff/Solver.solve/Solver.update grow an ignore_dims kwarg.
None (default) keeps the current no-coord-check behaviour;
any iterable opts into per-container coord-equality on every dim
not in the set, supporting rolling-horizon workflows where e.g.
the snapshot dim is expected to drift.

* feat(persistent): lazy-build Solver, ModelDiff constructors, disallow_rebuild

- Solver.from_name now accepts model=None; the first solve(m, ...) builds.
- compute_diff folded into ModelDiff.from_snapshot classmethod; new
  ModelDiff.from_models diffs two linopy models directly.
- Solver.solve grows disallow_rebuild=True, which raises
  RebuildRequiredError instead of falling back to a rebuild.

* feat(persistent): opt-in update tracking, snapshot-free ModelDiff.from_models

- Add `track_updates` flag (default False) to Solver; skip ModelSnapshot
  capture when disabled. Raise UpdatesDisabledError on solve(model)/update()
  if a built solver was constructed without tracking.
- Rewrite ModelDiff.from_models to build directly from two models without
  capturing snapshots; share helpers with from_snapshot.
- Update persistent tests to opt into track_updates=True; add coverage
  for the disabled path.

* feat(persistent): wire ModelDiff.from_models for track_updates=False

Cross-instance resolves now diff via from_models against the previously
built model, with no snapshot. Same-instance mutation still raises
UpdatesDisabledError. Snapshot recapture is skipped in this mode.
Add cross-instance solve/update tests for the no-snapshot path.

* refactor(persistent): cleanups, VarKind enum, fold _clear_coef_dirty

Collapse _diff_objective QUAD_OBJ branches; cache n_coef_updates;
short-circuit _canonicalize_rows when rows already sorted; tighten
buffer extraction. Introduce VarKind enum used across snapshot/diff
and HiGHS/Gurobi apply_update; reuse linopy.constants sign tokens.
Move _clear_coef_dirty into ModelSnapshot.capture.

* refactor(persistent): CSR-backed ContainerConBuffers

Source con buffers from Constraint.to_matrix_with_rhs, replacing the
dense (n_rows, max_n_term) arrays with CSR (indptr, indices, data).
Sign dtype adopts 'U1' across the persistent layer and apply_update
in HiGHS/Gurobi consumes CSR-slice payloads instead of -1 masks.
Deletes _canonicalize_rows and the _INT64_MAX sentinel.

* refactor(persistent): flat-native ModelDiff storage

Replace per-container ContainerVarUpdate/ContainerRowUpdate dicts with
flat arrays (var_bounds_*, var_type_*, con_coef_* COO, con_rhs_*,
con_sign_*) plus VarSlice/ConSlice per-container offsets for
diagnostics. Add con_rhs_as_bounds() for ranged-row solvers. Backend
apply_update bodies collapse to flat-array calls; remove duplicated
label->position resolution.

* feat(persistent): apply_update for Xpress and Mosek

Implement in-place model updates for Xpress (chgbounds/chgrhs/chgmcoef/
chgrowtype/chgobj/chgobjsense/chgcoltype) and Mosek (chgvarbound/
chgconbound/putaijlist/putclist/putvartypelist/putobjsense). Mosek
rejects constraint sign change to trigger rebuild. Consolidate
gurobi/highs apply_update tests into a single parametrized file that
also covers xpress and mosek.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(persistent): serialize concurrent solves; satisfy mypy

* hold solver lock through _run_direct so two threads calling
  solve(model) on the same Solver no longer race on the native handle
  (HiGHS returned 0.0 from the second concurrent solve).
* narrow Optional ndarrays in persistent.diff.push_var / push_con and
  in HiGHS/Gurobi/Xpress/Mosek apply_update objective paths.
* widen Constraint.rhs setter to ExpressionLike | VariableLike |
  ConstantLike to match the as_expression call in the body.
* widen Constraints.__getitem__(str) return type to Constraint (the
  dominant case) so tests can set .rhs/.coeffs/.sign without ignores.
* add docs for in-place solver updates.

* harden coords comparison

* Variable.update() / Constraint.update() as canonical mutation API (#727)

* feat: Variable.update() / Constraint.update() as canonical mutation API

Introduces typed ``.update()`` methods on Variable and Constraint as
the single, validated, multi-attribute mutation entry point.

- ``Variable.update(lower=, upper=)``: validates non-constant
  inputs are rejected, new dims are rejected, and the resulting
  ``lower <= upper`` invariant holds across all coords. Returns
  ``self`` for chaining.
- ``Constraint.update(rhs=, sign=)``: constant RHS only. The
  legacy ``c.rhs = variable`` rearrange-to-lhs path stays on the
  setter (different semantic, deserves its own explicit method).

The existing ``.lower`` / ``.upper`` / ``.sign`` setters become
thin shims that forward to ``.update()``, so single-attribute
writes (``z.lower = 2``) stay ergonomic and the canonical
validation runs in one place. The ``.rhs`` setter forwards
constants through ``.update()`` and keeps the expression-rhs
rearrange behaviour.

This is the on-top experiment for the design discussion on #718.
``.lhs`` / ``.coeffs`` / ``.vars`` setters are untouched for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(update): Constraint.update accepts Variable/Expression rhs

Mirrors the existing ``c.rhs = expr`` setter and ``add_constraints``
which both accept mixed-side input and rearrange the residual onto
lhs. ``c.update(rhs=x + 5)`` now subtracts ``x`` from lhs and stores
``5`` on rhs. ``.rhs`` setter collapses to a one-line shim.

Variable bound rejection of Variable/Expression is kept (bounds are
numeric, not symbolic); docstring clarified to spell out that
pandas / xarray / numpy arrays are first-class (time-varying bounds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(update): extend Constraint.update to lhs/coeffs/vars; shim all setters

Adds lhs / coeffs / vars to the canonical mutation API. All
.lhs / .coeffs / .vars setters now forward to .update() — every
Constraint mutation goes through one method with one validation
path, one place that flips _coef_dirty.

Composition rules:
- lhs= replaces the whole expression first; subsequent rhs=
  rearrangement (Variable/Expression in rhs) sees the new lhs.
- lhs= and coeffs= / vars= are mutually exclusive (whole
  replacement vs partial array update).
- sign= is applied last so it composes cleanly.

Internal Constraint.sanitize_zeros migrated to update(vars=,
coeffs=) — no more internal setter calls in linopy/.

389 tests pass across mutation + persistent-solver suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(update): rename Constraint.update kwarg vars= -> variables=

Avoids shadowing Python's vars() builtin. The .vars attribute on
Constraint stays (it parallels the .data.vars internal name);
only the kwarg gets the unambiguous spelling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(update): accept positional ConstraintLike in Constraint.update

Mirrors add_constraints' dispatch: c.update(x + 5 <= 3) is now
shorthand for c.update(lhs=x, sign='<=', rhs=-2), extracted from
the AnonymousConstraint / ConstraintBase the comparison produces.

Mutually exclusive with the per-attribute kwargs; clear error when
mixed.

Also reverts the internal sanitize_zeros migration. The setters
are pure shims forwarding to update(), so the migration didn't
change behaviour or cost — just spelling. The original setter
syntax reads more naturally there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(update): note kwarg form is the targeted, cheap path

The positional ConstraintLike form (c.update(x + 5 <= 3)) always
rewrites lhs / sign / rhs and flips _coef_dirty. For hot loops that
only touch one part, kwarg form (c.update(rhs=...)) skips the
unchanged attributes and is materially cheaper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(persistent): default ModelDiff.from_snapshot(same_model=False)

Closes the A1 residual from the #718 review. The flag-trust path
(`skip_coef_compare = same_model and not coef_dirty`) is correct
through Constraint.update() (set in one place, shims forward), but
`c.coeffs.values[...] = ...` still bypasses _coef_dirty. With
same_model=True as the default, that bypass silently produces wrong
diffs.

Flip the default to False. Cross-model paths (the only production
caller, Solver._update_locked, passes explicitly) are unaffected.
Same-model warm-update paths now value-diff the CSR data — small
perf hit (50-200ms at Mayk-scale per Mayk's bench), correct by
default. Solver-aware callers who own the mutation contract can
opt back into the optimization with `same_model=True`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: teach .update() in tutorials; mark setters as syntactic sugar

- examples/manipulating-models.ipynb: rewrite mutation cells to use
  Variable.update / Constraint.update; setter form is mentioned in
  notes as syntactic sugar for the same call.
- examples/creating-constraints.ipynb: reframe the CSRConstraint vs
  Constraint API table around .update() as the mutation API; setters
  are sugar.
- Setter docstrings now say 'syntactic sugar for Constraint/Variable
  .update; do not add logic here so the contract stays single-sourced'
  — a directive to future contributors as much as to readers.

No deprecation, no breaking change. .update() is the documented
canonical mutation API; the seven setters continue to exist as
one-line shims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* deprecate(update): warn on mutation setters; promote .update() in docs

Adds DeprecationWarning to all seven mutation setters (Variable.lower,
Variable.upper, Constraint.coeffs, Constraint.vars, Constraint.sign,
Constraint.rhs, Constraint.lhs). Each setter still forwards to .update()
so existing code keeps working; the warning points at the canonical API.

Internal sanitize_zeros migrated off setters (the last linopy/ caller).
api.rst gains Modification sections listing .update() for both Variable
and Constraint; tutorial notes rewritten to teach .update() and flag
setters as deprecated. Release note added.

dual.setter / solution.setter untouched — result assignment, not
mutation, different deprecation track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(update): edge-case coverage; document rhs-rearrangement invariant

Constraint.update tests: lhs-only, coeffs-only (vars preserved),
compound lhs+sign, mutually-exclusive lhs+coeffs and lhs+variables.
Variable.update tests: upper-only, valid array bound.

Migrate test_constraint_coef_dirty.py from the now-deprecated setters
to .update(), exercising the canonical path; add positional-form and
no-op cases. Net effect: same dirty-flag invariants, 7 fewer warnings
per pytest run.

Docs: Constraint.update rhs= gains a worked example showing the two
forms (constant vs variable/expression). add_constraints rhs gets a
matching note pointing at the linopy invariant so the rearrangement
rule is documented at the creation site too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(update): address inline feedback on #727

- Constraint._assign_lhs_expr → _assign_lhs (drop redundant suffix;
  the method already takes a LinearExpression, so the type was in the
  signature, not the name).
- Add Constraint._assign_data(**fields) helper. Wraps the four
  ``self._data = assign_multiindex_safe(self.data, **kw)`` callsites
  inside update() (rhs / coeffs / vars / sign). Untouched: the same
  pattern in dual.setter, sanitize_missings, sanitize_infinities —
  those aren't update() and stay out of scope here.
- Add types.CONSTANT_TYPES tuple, derived from ConstantLike via
  get_args so the two cannot drift. Variable.update bound validation
  flipped from negative (isinstance against a hand-rolled
  non_constant tuple) to positive (isinstance against CONSTANT_TYPES);
  drops a redundant in-function ``from linopy import expressions``
  (the module-level import already covered it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* deprecate(update): FutureWarning on DataArray as variables=; extract _validate_update

Constraint
- sanitize_zeros now writes _data via _assign_data directly (no
  longer round-trips through update(variables=DataArray), which
  would self-trigger the new deprecation warning).
- Constraint.update(variables=...) emits FutureWarning when passed a
  raw DataArray of integer labels; Variable is the supported input.
  The path stays accepted for back-compat and will be removed
  alongside the v1 cleanup.

Variable
- Extract Variable._validate_update(*, lower, upper) — validates,
  broadcasts, and runs the cross-bound (lb<=ub) check, returning a
  dict ready for assignment. update() body shrinks to ~3 lines.
  Coord validation (parity with add_variables) deferred to #726 land.

Tests
- test_constraint_coef_dirty's variables= test now passes a Variable
  instead of a DataArray (matches the supported input).
- test_constraint_vars_setter_with_array wrapped in
  pytest.warns(FutureWarning) — locks in the deprecation contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(constraints): _assign_data → _update_data; manage _coef_dirty inside

The helper now writes fields AND flips _coef_dirty when the written
set includes coeffs or vars. Callsites in update() (coeffs / vars
branches) and sanitize_zeros drop their explicit `self._coef_dirty = True`
lines — the rule lives in one place, can't be forgotten by future
field additions.

rhs / sign writes still don't dirty (correctly). _assign_lhs is
untouched: it uses a different write mechanic (drop_vars + assign)
and manages its own flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(update): rename Constraint.update kwarg variables= back to vars=

Restores symmetry with Constraint.vars (property) and data.vars
(underlying xarray Dataset key) — the original rename traded one
small asymmetry (read vs. mutate kwarg) for a worse one (Python
property name vs. Dataset key name).

The `vars()` builtin shadowing inside the kwarg position is benign:
we never call `vars()` here, and dropping the rename also lets the
top-level `linopy.variables` module be used directly inside the
function body instead of importing `Variable as _Variable` to dodge
the kwarg shadow.

Closes #730.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* restore(update): keep Constraint.update kwarg as variables=

Reverts cf845e1. Under the A/B framing for the .vars naming question,
update(variables=...) is Category A — it accepts a linopy.Variable.
The previous "restore symmetry with .vars / data.vars" argument
conflated two layers:

  - Public Python API speaks about linopy variables → variables=
  - Internal xarray Dataset key stays as "vars" (xarray collision
    on Dataset.variables blocks renaming the key)

The asymmetry between property/kwarg name and Dataset key name is
principled (API layer vs. storage layer), not arbitrary — same
pattern ORMs / serializers use. Keeping variables= here lines up
with the broader .vars → .variables direction now being considered
for properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: ensure dims in solution assignment

* refactor(persistent): address review round

ModelDiff.from_snapshot/from_models return RebuildReason on rebuild (NONE dropped);
diff walkers moved onto _DiffBuilder with context in __init__, single _cat helper.
Snapshot buffers share constraint arrays (identity fast path); CSRConstraint.sanitize_zeros copy-on-write.
Use isinstance(val, ConstantLike) in Variable._validate_update.

* fix: CI failures after master merge

Variable.fix()/unfix() set both bounds atomically via update() instead of
sequential deprecated setters (tripped new lower<=upper cross-validation).
Fail fast on quadratic lhs in Constraint.update; type-narrowing fixes for mypy.

* refactor(persistent): lazy COO expansion of coefficient diffs

ModelDiff stores per-container _CoefDelta (changed rows referencing the
CSR buffers); con_coef_rows/cols/vals materialize on first access via
cached property. Expansion is now vectorized; backends guard on
n_coef_updates. Follows coroa's suggestion on PR 718.

* refactor(persistent): assemble snapshot from diff walk; pure capture (A2+A6)

_DiffBuilder records target buffers/coords; ModelDiff.snapshot replaces the
O(nnz) re-capture after in-place updates. ModelSnapshot.capture no longer
mutates the model: the _coef_dirty clear moves to the solver, coupled to
snapshot adoption (build + successful apply, never on apply=False).

* refactor(persistent): template-method apply_update (A4, D2-D4)

Base Solver orchestrates the diff sections and validates up front (sign
support; Mosek semi-continuous now fails before any native mutation);
backends implement _apply_* hooks. Binary [0,1] re-clamp lifted to base
with Gurobi no-op (VType 'B' implies bounds natively). self.sense now
set uniformly; HiGHS vtype map cached; Xpress/Mosek list-conversion helpers.

* refactor(persistent): lock-free diff preview, document solve atomicity (A5)

update(model, apply=False) computes the diff without the solver lock
(immutable snapshot buffers, same_model=False since _coef_dirty cannot be
trusted concurrently). solve keeps the coarse lock: apply->run must be
atomic and native handles are not thread-safe. Tests pin the non-blocking
preview and the preview/apply asymmetry for raw .values mutations.

* refactor(persistent): split diff/apply, namedtuple ctx (coroa review)

Replace _update_locked(apply=...) with _compute_diff + _apply_locked,
dropping the dead apply=False branches. Read supports_* off the instance
and give Gurobi's apply context a _GurobiApplyCtx namedtuple.

* chore(codespell): whitelist 'coo' (sparse COO format) to fix false positive

* refactor(persistent): delegate from_models to from_snapshot

ModelDiff.from_models now captures a snapshot of model_a and defers to
from_snapshot(same_model=False), removing the duplicated baseline-extraction
and diff loops.

* refactor(persistent): drop dead per-container structural guards

The global _structural_reason precheck (via vlabels/clabels, the
concatenation of per-container active_labels) already pins each
container's active_labels and shape before diff_var/diff_con run, so the
per-container shape/active_labels mismatch branches were unreachable
duplication that silently degraded to a rebuild. Removed from diff_var,
diff_con, and diff_objective.

* test(persistent): cover diff rebuild reasons and snapshot var kinds

Adds coverage for QUAD_OBJ, variable-type change, sign-only mutation,
top-level STRUCTURAL_LABELS (vlabels/clabels), indices SPARSITY, binary/
integer/semi-continuous capture, and ModelDiff inspect/repr helpers.
persistent/diff.py 81->96%, snapshot.py 87->99%.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Felix <117816358+FBumann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants