Skip to content

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

Open
FBumann wants to merge 8 commits into
feat/solver-updatefrom
feat/typed-update-api
Open

Variable.update() / Constraint.update() as canonical mutation API#727
FBumann wants to merge 8 commits into
feat/solver-updatefrom
feat/typed-update-api

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented May 24, 2026

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
Copy link
Copy Markdown
Contributor

odow commented May 25, 2026

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
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 25, 2026

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
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 25, 2026

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
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.

2 participants