Skip to content

feat(csr): native soften for frozen constraints - #979

Merged
FabianHofmann merged 2 commits into
masterfrom
feat/csr-soften
Sep 25, 2026
Merged

FabianHofmann merged 2 commits into
masterfrom
feat/csr-soften

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes #975. Part of #972 (PR 3), stacked on #974.

Note

The following content was generated by AI.

Changes proposed in this Pull Request

A frozen CSRConstraint can now be softened natively, without densifying:

m.add_constraints(x >= 1, name="c", freeze=True, penalty=10)   # used to raise
c = m.add_constraints(x >= 1, name="d", freeze=True)
c.soften(penalty=10)                                            # used to raise
  • soften and slack move from Constraint to ConstraintBase. Validation (positive penalty, objective exists, registered constraint, no double softening, no mixed signs), slack creation (mask=self.mask, max_violation as upper bound) and the objective update are shared.
  • Each subclass implements _attach_slack(slack, sign). Dense: update(lhs=...) as before. Frozen: appends one CSR entry per active row (two for ==, same signs as dense) and widens the matrix to the current variable count.
  • The frozen constraint is softened in place, like the dense one, so the user's handle stays the registered constraint. The CSR arrays are replaced, not mutated; the positional cache is reset.
  • The slack names are stored on the frozen constraint and survive to_dense()/mutable(), Constraint.freeze(), pickle and netcdf.
  • add_constraints(penalty=, freeze=True) no longer raises. The "soften needs freeze=False" error and its densify reason are removed.
  • A fully masked frozen inequality now keeps its sign (was recorded as =), so it gets the same slack structure as dense.
  • The objective stays sparse after softening.

Tests

  • test_frozen_soften_matches_dense: <=, >=, == × no mask / mask / all masked × soften, soften(max_violation=), penalty= with freeze=True and with the model default. Compares rows and objective cell by cell with dense soften, checks no densify notice, the registered handle, netcdf round trip and .slack on all copies.
  • Sparse objective stays sparse; solved objective equals dense (HiGHS); sense="max" with a DataArray penalty.
  • Error paths for frozen; dense-only duplicates of test_constraint.py are not repeated.
  • A review pass also compared A, b, sense, c, bounds and LP/MPS output against dense for masked rows, empty rows, variables added/removed after freezing, scalar and aux-coord constraints, row scaling, and both semantics, and ran track_updates=True re-solves. All identical.
Local results
  • Full suite without xpress (no license) and test/remote (oetc extras missing): 7086 passed, 0 failed (before review follow-ups); test_csr.py, test_constraint.py, test_constraints.py, test_io.py after follow-ups: 1011 passed, only the xpress license failures.
  • ruff check, ruff format: clean. mypy: only the local-stub error at test/test_solvers.py:225, not present in CI.

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@FabianHofmann FabianHofmann added the sparse Sparse / CSR-backed expressions and constraints label Sep 24, 2026
Base automatically changed from feat/csr-boundaries to master September 24, 2026 14:58
Constraint.soften moves to ConstraintBase; CSRConstraint appends slack terms to its sparse rows in place, keeps slack names through to_dense/freeze/netcdf, and add_constraints(penalty=, freeze=True) softens natively.
@codspeed

codspeed Bot commented Sep 24, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 7 improved benchmarks
❌ 1 regressed benchmark
✅ 173 untouched benchmarks
⏩ 181 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_to_lp[nodal_balance-severity=0] 3.3 MB 3.7 MB -11.85%
⚡ test_to_lp[expression_arithmetic-n=10] 1,338.9 KB 718.6 KB +86.33%
⚡ test_to_lp[sparse_network-n=10] 1,337.8 KB 718.3 KB +86.23%
⚡ test_to_lp[qp-n=1000] 2.6 MB 2 MB +30.22%
⚡ test_build[nodal_balance_sparse-severity=0] 1.5 MB 1.3 MB +13.94%
⚡ test_build[nodal_balance_sparse-severity=100] 1.5 MB 1.3 MB +13.94%
⚡ test_build[nodal_balance_sparse-severity=50] 1.5 MB 1.3 MB +13.94%
⚡ test_to_lp[cumsum-severity=100] 208.9 MB 184.4 MB +13.29%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing feat/csr-soften (0b095a0) with master (38fbd4a)2

Open in CodSpeed

Footnotes

  1. 181 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on master (b9a698b) during the generation of this report, so 38fbd4a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@github-actions

Copy link
Copy Markdown

Build cost — v1 vs legacy

v1 build peak & time relative to legacy, on this commit — not a comparison against master (that is CodSpeed).

peak — v1 / legacy time — v1 / legacy
peak v1/legacy time v1/legacy
Full table (time + peak, mean)
benchmarks/drivers/test_build.py::test_build[basic-n=10]
                 time (s)         peak (KiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.0866 (1.07)   │   15.03 (1.00) 
 (v1)       0.08103 (1.0)   │    15.00 (1.0) 

benchmarks/drivers/test_build.py::test_build[basic-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.0933 (1.08)   │   12.04 (1.00) 
 (v1)       0.08627 (1.0)   │    12.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=0]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03796 (1.10)   │   15.20 (1.0) 
 (v1)        0.03463 (1.0)   │   15.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=100]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05482 (1.06)   │   44.93 (1.0) 
 (v1)        0.05173 (1.0)   │   44.93 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04152 (1.09)   │   11.51 (1.0) 
 (v1)        0.03814 (1.0)   │   11.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09682 (1.05)   │   24.34 (1.06) 
 (v1)        0.09218 (1.0)   │    23.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.1096 (1.06)   │   16.12 (1.00) 
 (v1)        0.1034 (1.0)   │    16.12 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=10000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.02348 (1.07)   │   752.18 (1.10) 
 (v1)        0.02189 (1.0)   │    685.15 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=100]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.0233 (1.08)   │   3.12 (1.33) 
 (v1)        0.0216 (1.0)   │    2.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=0]
                 time (s)          peak (MiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.0647 (1.22)   │   126.16 (1.44) 
 (v1)       0.05295 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=100]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06323 (1.23)   │   126.16 (1.44) 
 (v1)        0.05134 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=50]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06293 (1.18)   │   126.16 (1.44) 
 (v1)        0.05314 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[masked-n=100]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05643 (1.05)   │    715.12 (1.0) 
 (v1)        0.05397 (1.0)   │   787.73 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05222 (1.08)   │   4.54 (1.27) 
 (v1)        0.04833 (1.0)   │    3.57 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.3749 (1.06)   │   704.12 (1.09) 
 (v1)        0.3522 (1.0)   │    643.85 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.3872 (1.04)   │   18.34 (1.0) 
 (v1)        0.3731 (1.0)   │   18.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.3826 (1.04)   │   9.54 (1.0) 
 (v1)        0.3685 (1.0)   │   9.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.07361 (1.06)   │   3.77 (1.12) 
 (v1)        0.06916 (1.0)   │    3.37 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=50]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.0744 (1.08)   │   216.59 (1.10) 
 (v1)       0.06899 (1.0)   │    196.23 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=0]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.03774 (1.09)   │   938.49 (1.0) 
 (v1)        0.03472 (1.0)   │   938.49 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03908 (1.08)   │   9.66 (1.0) 
 (v1)        0.03613 (1.0)   │   9.66 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.0388 (1.10)   │   5.32 (1.0) 
 (v1)       0.03534 (1.0)   │   5.32 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=0]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02096 (1.01)   │   1.28 (1.0) 
 (v1)        0.02077 (1.0)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02109 (1.01)   │   1.28 (1.0) 
 (v1)        0.02097 (1.0)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02083 (1.00)   │   1.28 (1.0) 
 (v1)        0.02082 (1.0)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                time (s)          peak (KiB) 
 name               mean   │            mean 
─────────────────────────────────────────────
 (legacy)   0.188 (1.07)   │   946.85 (1.06) 
 (v1)       0.1754 (1.0)   │    891.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=10]
                 time (s)         peak (KiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.1841 (1.06)   │   12.01 (1.00) 
 (v1)        0.1744 (1.0)   │    11.99 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04705 (1.06)   │   147.70 (1.06) 
 (v1)        0.04434 (1.0)   │    139.87 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04643 (1.07)   │   2.60 (1.09) 
 (v1)        0.04328 (1.0)   │    2.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=0]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03856 (1.08)   │   696.75 (1.03) 
 (v1)        0.03582 (1.0)   │    673.70 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=100]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08448 (1.02)   │   137.97 (1.0) 
 (v1)        0.08268 (1.0)   │   137.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.06089 (1.12)   │   69.22 (1.0) 
 (v1)        0.05451 (1.0)   │   69.22 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.0446 (1.10)   │   402.33 (1.00) 
 (v1)       0.04064 (1.0)   │    402.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04402 (1.09)   │   3.19 (1.19) 
 (v1)        0.04023 (1.0)   │    2.69 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.04875 (1.08)   │   29.00 (1.54) 
 (v1)        0.04529 (1.0)   │    18.84 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.05987 (1.15)   │   37.95 (1.43) 
 (v1)        0.05224 (1.0)   │    26.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[storage-n=10]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)    0.09318 (1.0)   │    410.93 (1.0) 
 (v1)       0.09485 (1.02)   │   427.84 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.09907 (1.0)   │     9.94 (1.0) 
 (v1)       0.1024 (1.03)   │   10.22 (1.03) 

📊 Interactive plots + CSV: download the semantics-report-v1-vs-legacy artifact from this run.

Report-only · not a gate · refreshed on every push · obsolete once legacy is dropped.

@FabianHofmann
FabianHofmann merged commit 14f601e into master Sep 25, 2026
23 of 25 checks passed
@FabianHofmann
FabianHofmann deleted the feat/csr-soften branch September 25, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sparse Sparse / CSR-backed expressions and constraints

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native sparse soften for frozen constraints (penalty= with freeze=True)

1 participant