Skip to content

fix: Model.copy() silently drops the quadratic part of the objective - #905

Open
FBumann wants to merge 4 commits into
masterfrom
fix/copy-quadratic-objective
Open

fix: Model.copy() silently drops the quadratic part of the objective#905
FBumann wants to merge 4 commits into
masterfrom
fix/copy-quadratic-objective

Conversation

@FBumann

@FBumann FBumann commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #903.

Note

The following content was generated by AI.

Model.copy() wrapped the objective's Dataset in LinearExpression unconditionally, so
a QuadraticExpression objective was silently downgraded — Model.type flipped from QP
to LP, the quadratic term's second variable column was reinterpreted as a linear row, and
the copy solved a different problem without any warning (-14.0 instead of 0.75 in the
issue's repro). copy.copy/copy.deepcopy route through the same function.

Three commits, the last one droppable on its own:

  1. test — strict-xfail regression test over Model.copy / copy.copy / copy.deepcopy.
  2. fix (copy) — dispatch on type(m.objective.expression), as _copy_expr already does
    for named expressions. Also switches assert_model_equal from assert_linequal to
    assert_exprequal for the objective: assert_linequal rejects a QuadraticExpression
    outright, so assert_model_equal could not be used on a QP model at all, and it now
    catches a mismatch in expression type.
  3. fix (netcdf)not part of the issue, drop this commit if you'd rather keep it
    separate.
    to_netcdf/read_netcdf downgraded the objective exactly the same way, so a
    saved QP came back an LP. The expression type is now stored next to the objective and
    restored on read, falling back to the presence of the factor dimension for files written
    by earlier versions.
Issue repro, before and after
# before
QP True QuadraticExpression
LP False LinearExpression
copy.copy LP False
copy.deepcopy LP False
original objective: 0.75000000000001
copy objective    : -14.0

# after
QP True QuadraticExpression
QP True QuadraticExpression
copy.copy QP True
copy.deepcopy QP True
original: 0.75000000000001 copy: 0.75000000000001
Q is None on copy: False   c shape: (2,)

FBumann and others added 3 commits August 24, 2026 13:59
Strict-xfail regression test for #903: Model.copy() and the
copy.copy / copy.deepcopy protocols rebuild the objective as a
LinearExpression, silently turning a QP into a different LP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Model.copy() wrapped the objective data in LinearExpression regardless
of its actual type, so a QuadraticExpression objective was silently
downgraded: Model.type flipped from QP to LP, the quadratic term's
second variable column was reinterpreted as a linear row, and the copy
solved a different problem without warning. Dispatch on the source
expression's type, as _copy_expr already does for named expressions.

assert_model_equal compared objectives with assert_linequal, which
rejects a QuadraticExpression outright, so it could not be used on a QP
model at all. It now dispatches via assert_exprequal, which also makes a
difference in expression type fail the comparison.

Closes #903

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
to_netcdf/read_netcdf rebuilt the objective as a LinearExpression, so a
saved QP was read back as a different LP — the same silent downgrade as
Model.copy in the previous commit. Store the expression type next to the
objective and restore it on read, falling back to the presence of the
factor dimension for files written by earlier versions.

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

github-actions Bot commented Aug 24, 2026

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.06951 (1.11)   │   13.33 (1.00) 
 (v1)        0.06263 (1.0)   │    13.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[basic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.07514 (1.10)   │   11.09 (1.00) 
 (v1)        0.06801 (1.0)   │    11.09 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=0]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.02954 (1.12)   │   15.17 (1.0) 
 (v1)        0.02643 (1.0)   │   15.17 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=100]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04354 (1.15)   │   44.93 (1.0) 
 (v1)        0.03794 (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.03265 (1.11)   │   11.51 (1.0) 
 (v1)        0.02932 (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.07863 (1.07)   │   24.23 (1.10) 
 (v1)        0.07325 (1.0)   │    22.05 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08937 (1.10)   │   15.64 (1.00) 
 (v1)        0.08134 (1.0)   │    15.64 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=10000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.01934 (1.09)   │   752.15 (1.10) 
 (v1)        0.01777 (1.0)   │    685.11 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=100]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.01879 (1.08)   │   3.09 (1.34) 
 (v1)        0.01734 (1.0)   │    2.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=0]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05399 (1.28)   │   126.16 (1.44) 
 (v1)        0.04226 (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.05322 (1.29)   │   126.16 (1.44) 
 (v1)        0.04119 (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.05371 (1.29)   │   126.16 (1.44) 
 (v1)        0.04172 (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.04189 (1.05)   │    714.28 (1.0) 
 (v1)        0.03996 (1.0)   │   785.32 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04064 (1.14)   │   4.40 (1.34) 
 (v1)        0.03549 (1.0)   │    3.27 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.3105 (1.05)   │   702.97 (1.10) 
 (v1)        0.2944 (1.0)   │    639.22 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.3256 (1.05)   │   18.34 (1.0) 
 (v1)        0.3095 (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.3226 (1.06)   │   9.54 (1.0) 
 (v1)        0.3039 (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.05942 (1.11)   │   3.64 (1.14) 
 (v1)        0.05374 (1.0)   │    3.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=50]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06273 (1.18)   │   216.15 (1.10) 
 (v1)        0.05326 (1.0)   │    195.76 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=0]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.02894 (1.11)   │   935.30 (1.0) 
 (v1)        0.02601 (1.0)   │   935.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03014 (1.11)   │   9.66 (1.0) 
 (v1)        0.02724 (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.02963 (1.11)   │   5.32 (1.0) 
 (v1)        0.02668 (1.0)   │   5.32 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1449 (1.07)   │   891.72 (1.07) 
 (v1)        0.1356 (1.0)   │    836.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=10]
                time (s)         peak (KiB) 
 name               mean   │           mean 
────────────────────────────────────────────
 (legacy)   0.141 (1.06)   │   11.02 (1.03) 
 (v1)       0.1326 (1.0)   │    10.67 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04074 (1.10)   │   147.66 (1.06) 
 (v1)        0.03705 (1.0)   │    139.84 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=10]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.0399 (1.08)   │   2.57 (1.09) 
 (v1)       0.03694 (1.0)   │    2.35 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=0]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.02997 (1.11)   │   696.72 (1.14) 
 (v1)        0.02696 (1.0)   │    611.13 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=100]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.06916 (1.09)   │   137.97 (1.0) 
 (v1)        0.06365 (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.04687 (1.10)   │   69.22 (1.0) 
 (v1)        0.04275 (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.03447 (1.14)   │   377.39 (1.04) 
 (v1)        0.03021 (1.0)   │    363.14 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03431 (1.15)   │   3.10 (1.41) 
 (v1)        0.02974 (1.0)   │    2.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.03894 (1.11)   │   28.94 (1.61) 
 (v1)         0.0352 (1.0)   │    17.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.04985 (1.14)   │   37.95 (1.43) 
 (v1)        0.04371 (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.07761 (1.0)   │    397.39 (1.0) 
 (v1)       0.07817 (1.01)   │   414.56 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)    0.08359 (1.0)   │    9.62 (1.0) 
 (v1)       0.08487 (1.02)   │   9.90 (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.

@codspeed-hq

codspeed-hq Bot commented Aug 24, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 3.61%

⚠️ 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

⚡ 3 improved benchmarks
❌ 4 regressed benchmarks
✅ 168 untouched benchmarks
⏩ 175 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory test_to_lp[milp-n=50] 2 MB 2.6 MB -23.25%
Memory test_to_lp[merge_balance-severity=0] 2.6 MB 3.2 MB -17.9%
Memory test_to_lp[nodal_balance-severity=50] 2.8 MB 3.3 MB -15.02%
Memory test_to_lp[rolling-severity=0] 2.7 MB 3.1 MB -13.86%
Memory test_to_lp[masked-n=100] 2.7 MB 2.1 MB +29.23%
Memory test_to_lp[cumsum-severity=100] 208.7 MB 177.5 MB +17.59%
Memory test_to_lp[storage-n=10] 2.9 MB 2.6 MB +10.31%

Tip

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


Comparing fix/copy-quadratic-objective (586dd5b) with master (09c34dd)

Open in CodSpeed

Footnotes

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

The attribute carrying an expression's class through to_netcdf has been
spelled out at each of its four use sites since it was introduced for
named expressions. Give it a constant, next to NETCDF_VERSION_ATTR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann requested a review from FabianHofmann August 24, 2026 12:08
@FBumann
FBumann marked this pull request as ready for review August 24, 2026 12:08
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.

Model.copy() silently drops the quadratic part of the objective

1 participant