Variable.update() / Constraint.update() as canonical mutation API#727
Variable.update() / Constraint.update() as canonical mutation API#727FBumann wants to merge 8 commits into
Conversation
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>
|
This is much closer to how JuMP does things. We have
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 |
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>
Follow-up: what
|
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>
API surface from a user perspectiveTying this back to the Two follow-ups worth committing to here:
End state: users call |
Stacked on #718. Exploratory branch for the API discussion (#718 review thread).
Adds typed
.update()methods onVariableandConstraintso 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
Two call shapes for Constraint, mirroring
add_constraints:Composition rules:
lhs=replaces the whole expression first;rhs=rearrangement then sees the new lhs.lhs=is mutually exclusive withcoeffs=/variables=(whole vs partial).constraintis mutually exclusive with all keyword arguments.sign=applied last so it composes cleanly.selffor chaining.Setter shims
Forwarders, no other behaviour:
var.lower = xvar.update(lower=x)var.upper = xvar.update(upper=x)c.lhs = exprc.update(lhs=expr)c.rhs = …c.update(rhs=…)c.sign = sc.update(sign=s)c.coeffs = xc.update(coeffs=x)c.vars = vc.update(variables=v)Closes the #718 review A1 residual
Last commit (
70dbed4) flipsModelDiff.from_snapshot(same_model=…)default fromTruetoFalse. The flag-trust path (skip_coef_compare = same_model and not coef_dirty) is now precise throughConstraint.update()— it sets the flag in one place; setters forward there. Butc.coeffs.values[...] = ...still bypasses_coef_dirty, and with the old default, that bypass silently produced wrong diffs.The only production caller (
Solver._update_locked) passessame_modelexplicitly, 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 withsame_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 tracksobj_linear/obj_sense).sanitize_zerosand friends). Setters are pure shims so the migration would be cosmetic; postpone until / unless setters get actually removed.assign_multiindex_safecalls a multi-attr update makes.Warts (inherited, not introduced)
variables=kwarg shadows the.varsattribute spelling — picked the unambiguous name to dodge Python'svars()builtin shadow;.vars(read) stays as the attribute.rhs=Variable/Expressionsilently rearranges onto lhs (kept for symmetry withadd_constraints; documented).lhs=xorcoeffs=/variables=is a runtime check, not a type-system constraint.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 positionalConstraintLikeform).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 thesame_model=Falsedefault.🤖 Generated with Claude Code