Companion to #1230, which does the same job for LP/QP. Same contract, different path — and a much larger one, so the ordering below matters more than the total.
The line
docs/dev/lp-qp-boundary.md §0 (on claude/bold-lovelace-9f1wzw, not yet merged) states it as "Python turns a Model into a problem; Rust turns a problem into a certified result," with the producer staying in Python.
That wording is too blunt for MINLP, and AMP is what shows it (see below). _relax/term_classifier.py moved an analyze-once DAG walk into Rust and was right to — a 53k-node body is slow even once per solve. The rule that actually holds:
The certificate must live in Rust and exist exactly once. Producer work may be accelerated in Rust wherever it pays. What is forbidden is a twin: the same computation implemented on both sides with a silent fallback between them.
For MINLP the crossing should also be once per solve, not once per node. Language is the second-order question; duplication with a silent fallback is the defect.
What is wrong
M1 — every MINLP algorithm exists twice, so eight solve loops are live
| algorithm |
Python |
Rust |
| spatial (nonconvex) |
inlined in solve_model, solver.py:12610 |
bnb/spatial_tree.rs:324 solve_spatial_tree |
| convex MINLP |
_solve_nlp_bb, solver.py:17082 |
bnb/convex_kernel.rs:1200 solve_tree |
| MILP |
_solve_milp_bb, solver.py:23091 |
bnb/milp_driver.rs:1166 solve_milp |
| MIQP |
_solve_miqp_bb, solver.py:23811 |
— |
| AMP (adaptive multivariate partitioning) |
solvers/amp.py:2865 |
— (Rust helper only; see below) |
This is a half-finished port kept as mutual fallback, not a design. solve_model is ~9,000 lines (solver.py:7377–:16438) with the spatial node loop inlined into it.
M2 — the Python loops have drifted copies of each other
Measured by difflib.SequenceMatcher over the extracted bodies:
| helper |
sites |
lines |
similarity |
_debug_validate_candidate |
:12551, :17045 |
27 / 27 |
1.000 (verbatim) |
_record_improver |
:12061, :17036 |
6 / 6 |
1.000 (verbatim) |
_improver_allowed |
:12047, :17023 |
13 / 12 |
0.640 |
_maybe_inject_snapped |
:22997, :23655 |
58 / 61 |
0.319 |
The two verbatim pairs are cheap to fix. The two drifted pairs are the real hazard: the same named helper now behaves differently depending on which loop you are in, and nothing says so.
M3 — the per-node relaxation layer is in Python, and its Rust twin is not load-bearing
_relax/ is 74,642 lines. Classified by how often the code actually runs:
| bucket |
files |
lines |
belongs |
| per-node hot path |
24 |
27,935 |
Rust |
analyze-once producer (incl. convexity/, symbolic/, presolve/) |
22 + 3 dirs |
33,567 |
stays Python (may be accelerated; see the line above) |
| no in-package consumer |
6 |
2,199 |
delete |
| optional/research with consumers |
7 |
3,984 |
out of scope |
| unclassified remainder |
— |
6,957 |
needs triage |
Most of the hot path already has a Rust counterpart:
| Python |
LOC |
Rust |
LOC |
mccormick_lp.py |
3,102 |
bnb/spatial_kernel.rs |
811 |
obbt.py |
2,620 |
bnb/obbt_sweep.rs |
197 |
cutting_planes.py |
1,201 |
lp/gomory.rs |
658 |
incremental_mccormick.py |
1,178 |
bnb/mccormick_patch.rs |
1,585 |
rlt_cuts.py |
229 |
lp/aggregation.rs |
484 |
cover_cuts.py |
156 |
lp/cover.rs |
350 |
cmir_cuts.py |
151 |
lp/mir.rs |
609 |
The blocker is producer coverage, not missing Rust. The native spatial kernel is default-ON (#764, graduated in #892) but its own re-graduation panel records engaged 4/66 (solver.py:875 — dispatch, nvs13, st_e13, tanksize). The producer declines ~94% of the corpus, so the Python loop is still the real engine on almost everything and neither path can retire. Writing more Rust does not change this number.
M4 — dead weight that must not be ported
Verified to have no importer anywhere in the package outside their own file: differentiable_solve.py (329), icnn_trainer.py (501), chebyshev_model.py (435), taylor_model.py (411), ellipsoidal_arith.py (393), embedding.py (130) — 2,199 lines. Separately there are 158 distinct DISCOPT_* flags (64 read in solver.py alone), 17 of them default-"0", several of which never graduated.
M5 — AMP is the worked example of the twin pattern (and is not a consolidation target)
AMP (Adaptive Multivariate Partitioning, Nagarajan et al. CP 2016 / JOGO 2018) was contributed by @bernalde in #44 and #86. It is a distinct global algorithm — MILP relaxation for LB, fixed-interval NLP for UB, adaptive partition refinement — not a variant of spatial B&B. It is opt-in (solver="amp", solver.py:8691), never on the default path, and carries 3,523 lines of Python, 799 lines of Rust, 8,837 lines of tests and its own CI workflow (.github/workflows/amp-integration.yml).
It is listed here for three reasons, all informational:
- It is the eighth loop (
amp.py:2865) and was missing from the original M1 table.
- It shares the M3 hot path.
amp.py imports _relax/milp_relaxation, _relax/nonlinear_bound_tightening and _relax/model_utils, so M3 work touches AMP whether or not it intends to. Anyone changing those modules needs the AMP CI lane green.
- It states the inverted boundary explicitly, and demonstrates the twin defect.
amp.rs:1-4: "keep the high-level AMP algorithm in Python while moving repeated expression-tree walks into Rust." Its one exported routine reaches Python through _relax/term_classifier.py:662, which tries the Rust classifier and falls back to _classify_nonlinear_terms_python — declining when general_nl_count != 0, when the model has an expandable square, and, via two bare except Exception: return None, on any Rust failure at all. The code's own comment says "the Rust term classifier has blind spots." Same shape as the spatial kernel at 4/66.
Two consequences worth acting on independently of AMP's future:
amp.rs is misnamed. classify_nonlinear_terms is general term-classification infrastructure consumed through term_classifier.py across the codebase, not AMP-specific. Nobody consolidating _relax/ would think to look in a file called amp.rs.
- The two silent
except Exception: return None fallbacks should report. A Rust classifier failure currently degrades to Python with no signal — CLAUDE.md §7 in production code rather than in an instrument.
Explicitly out of scope for this issue: whether AMP is kept, retired, or restructured. It is a contributed, opt-in, separately-CI'd subsystem, and that decision belongs with @bernalde rather than being swept into a de-duplication pass. Consolidating our own half-finished port is a different kind of change from restructuring a collaborator's contributed algorithm.
Work, in order
The ordering is the point. Doing 3 before 1 and 2 grows the tree instead of shrinking it.
-
Delete before porting. Remove the M4 modules and the default-off flags whose paths never graduated. Cheapest large reduction, needs no Rust, and it stops the port from carrying dead code across the boundary. Bound-neutral by construction — verify with an unchanged smoke suite.
-
Raise producer coverage until the decline rate is near zero. Instrument build_spatial_kernel_spec to report why it declines, per instance, over the in-repo corpus and the MINLPLib snapshot; then close the top decline reasons. This is the gating measurement for everything after it. Definition of progress: engaged N/66 where N is tracked and rising. Until this lands, the Rust kernel is a second implementation rather than a replacement.
-
Retire each Python twin the moment its Rust counterpart is default-ON and its producer is near-total. Make the deletion a deliverable of the same PR, not a follow-up — deferred retirement is how eight loops accumulated. Bound-neutral: identical node_count and certified objective on a certifying panel; any drift means the change is wrong.
-
Fold M2's duplicated helpers into one definition while the loops still exist. Independent of everything else and safe now; the two verbatim pairs are trivial, and the two drifted pairs need a decision on which behavior is correct before merging them.
-
Move the remaining hot-path relaxation code into Rust, per-family, each behind the CLAUDE.md §5 regime appropriate to it (bound-neutral for a straight port, bound-changing for anything that alters an envelope or a cut).
-
Make the remaining producer twins loud, not silent. Wherever a Rust producer path falls back to a Python one (term_classifier.py:662 is the known case), the fallback must be counted and surfaced, so "the Rust path is in use" is a measured fact rather than an assumption. This is the M5 lesson generalized, and it is a precondition for retiring any producer twin.
Explicitly out of scope
The 33,567-line producer bucket stays in Python as the default. convexity/ (11,412), symbolic/ (3,521), problem_classifier.py, term_classifier.py, factorable_reform.py, gdp_reformulate.py and the rest are symbolic pattern-matching over a DAG that runs once per solve. That is the modeling-language producer this architecture is meant to keep in Python. Accelerating a specific hot walk in Rust is allowed where measured to pay (that is what AMP's classifier did); wholesale porting the bucket is the mistake this issue exists to avoid.
AMP's future — see M5.
Done when
- One solve loop per algorithm, in Rust, with the Python twin deleted rather than flagged off (AMP excepted, per M5).
- The spatial producer's decline rate is tracked and near zero on the in-repo corpus.
- No remaining Rust/Python twin falls back silently — every fallback is counted and surfaced.
_relax/ contains no module without an in-package consumer.
- Every retirement is evidenced bound-neutral (
node_count and objective exactly unchanged) or, where it isn't, carries its §5 panel.
Expect this to land as many PRs. Use Contributes to rather than a closing keyword until the loops are actually gone.
Context: #764 (kernel), #892 (default-ON), #902 (the incumbent-quality regression that ungraduated it once), #789 (feature parity), #1230 (LP/QP companion), #44 / #86 (AMP).
Companion to #1230, which does the same job for LP/QP. Same contract, different path — and a much larger one, so the ordering below matters more than the total.
The line
docs/dev/lp-qp-boundary.md§0 (onclaude/bold-lovelace-9f1wzw, not yet merged) states it as "Python turns aModelinto a problem; Rust turns a problem into a certified result," with the producer staying in Python.That wording is too blunt for MINLP, and AMP is what shows it (see below).
_relax/term_classifier.pymoved an analyze-once DAG walk into Rust and was right to — a 53k-node body is slow even once per solve. The rule that actually holds:For MINLP the crossing should also be once per solve, not once per node. Language is the second-order question; duplication with a silent fallback is the defect.
What is wrong
M1 — every MINLP algorithm exists twice, so eight solve loops are live
solve_model,solver.py:12610bnb/spatial_tree.rs:324solve_spatial_tree_solve_nlp_bb,solver.py:17082bnb/convex_kernel.rs:1200solve_tree_solve_milp_bb,solver.py:23091bnb/milp_driver.rs:1166solve_milp_solve_miqp_bb,solver.py:23811solvers/amp.py:2865This is a half-finished port kept as mutual fallback, not a design.
solve_modelis ~9,000 lines (solver.py:7377–:16438) with the spatial node loop inlined into it.M2 — the Python loops have drifted copies of each other
Measured by
difflib.SequenceMatcherover the extracted bodies:_debug_validate_candidate:12551,:17045_record_improver:12061,:17036_improver_allowed:12047,:17023_maybe_inject_snapped:22997,:23655The two verbatim pairs are cheap to fix. The two drifted pairs are the real hazard: the same named helper now behaves differently depending on which loop you are in, and nothing says so.
M3 — the per-node relaxation layer is in Python, and its Rust twin is not load-bearing
_relax/is 74,642 lines. Classified by how often the code actually runs:convexity/,symbolic/,presolve/)Most of the hot path already has a Rust counterpart:
mccormick_lp.pybnb/spatial_kernel.rsobbt.pybnb/obbt_sweep.rscutting_planes.pylp/gomory.rsincremental_mccormick.pybnb/mccormick_patch.rsrlt_cuts.pylp/aggregation.rscover_cuts.pylp/cover.rscmir_cuts.pylp/mir.rsThe blocker is producer coverage, not missing Rust. The native spatial kernel is default-ON (#764, graduated in #892) but its own re-graduation panel records
engaged 4/66(solver.py:875— dispatch, nvs13, st_e13, tanksize). The producer declines ~94% of the corpus, so the Python loop is still the real engine on almost everything and neither path can retire. Writing more Rust does not change this number.M4 — dead weight that must not be ported
Verified to have no importer anywhere in the package outside their own file:
differentiable_solve.py(329),icnn_trainer.py(501),chebyshev_model.py(435),taylor_model.py(411),ellipsoidal_arith.py(393),embedding.py(130) — 2,199 lines. Separately there are 158 distinctDISCOPT_*flags (64 read insolver.pyalone), 17 of them default-"0", several of which never graduated.M5 — AMP is the worked example of the twin pattern (and is not a consolidation target)
AMP (Adaptive Multivariate Partitioning, Nagarajan et al. CP 2016 / JOGO 2018) was contributed by @bernalde in #44 and #86. It is a distinct global algorithm — MILP relaxation for
LB, fixed-interval NLP forUB, adaptive partition refinement — not a variant of spatial B&B. It is opt-in (solver="amp",solver.py:8691), never on the default path, and carries 3,523 lines of Python, 799 lines of Rust, 8,837 lines of tests and its own CI workflow (.github/workflows/amp-integration.yml).It is listed here for three reasons, all informational:
amp.py:2865) and was missing from the original M1 table.amp.pyimports_relax/milp_relaxation,_relax/nonlinear_bound_tighteningand_relax/model_utils, so M3 work touches AMP whether or not it intends to. Anyone changing those modules needs the AMP CI lane green.amp.rs:1-4: "keep the high-level AMP algorithm in Python while moving repeated expression-tree walks into Rust." Its one exported routine reaches Python through_relax/term_classifier.py:662, which tries the Rust classifier and falls back to_classify_nonlinear_terms_python— declining whengeneral_nl_count != 0, when the model has an expandable square, and, via two bareexcept Exception: return None, on any Rust failure at all. The code's own comment says "the Rust term classifier has blind spots." Same shape as the spatial kernel at 4/66.Two consequences worth acting on independently of AMP's future:
amp.rsis misnamed.classify_nonlinear_termsis general term-classification infrastructure consumed throughterm_classifier.pyacross the codebase, not AMP-specific. Nobody consolidating_relax/would think to look in a file calledamp.rs.except Exception: return Nonefallbacks should report. A Rust classifier failure currently degrades to Python with no signal — CLAUDE.md §7 in production code rather than in an instrument.Explicitly out of scope for this issue: whether AMP is kept, retired, or restructured. It is a contributed, opt-in, separately-CI'd subsystem, and that decision belongs with @bernalde rather than being swept into a de-duplication pass. Consolidating our own half-finished port is a different kind of change from restructuring a collaborator's contributed algorithm.
Work, in order
The ordering is the point. Doing 3 before 1 and 2 grows the tree instead of shrinking it.
Delete before porting. Remove the M4 modules and the default-off flags whose paths never graduated. Cheapest large reduction, needs no Rust, and it stops the port from carrying dead code across the boundary. Bound-neutral by construction — verify with an unchanged smoke suite.
Raise producer coverage until the decline rate is near zero. Instrument
build_spatial_kernel_specto report why it declines, per instance, over the in-repo corpus and the MINLPLib snapshot; then close the top decline reasons. This is the gating measurement for everything after it. Definition of progress:engaged N/66where N is tracked and rising. Until this lands, the Rust kernel is a second implementation rather than a replacement.Retire each Python twin the moment its Rust counterpart is default-ON and its producer is near-total. Make the deletion a deliverable of the same PR, not a follow-up — deferred retirement is how eight loops accumulated. Bound-neutral: identical
node_countand certified objective on a certifying panel; any drift means the change is wrong.Fold M2's duplicated helpers into one definition while the loops still exist. Independent of everything else and safe now; the two verbatim pairs are trivial, and the two drifted pairs need a decision on which behavior is correct before merging them.
Move the remaining hot-path relaxation code into Rust, per-family, each behind the CLAUDE.md §5 regime appropriate to it (bound-neutral for a straight port, bound-changing for anything that alters an envelope or a cut).
Make the remaining producer twins loud, not silent. Wherever a Rust producer path falls back to a Python one (
term_classifier.py:662is the known case), the fallback must be counted and surfaced, so "the Rust path is in use" is a measured fact rather than an assumption. This is the M5 lesson generalized, and it is a precondition for retiring any producer twin.Explicitly out of scope
The 33,567-line producer bucket stays in Python as the default.
convexity/(11,412),symbolic/(3,521),problem_classifier.py,term_classifier.py,factorable_reform.py,gdp_reformulate.pyand the rest are symbolic pattern-matching over a DAG that runs once per solve. That is the modeling-language producer this architecture is meant to keep in Python. Accelerating a specific hot walk in Rust is allowed where measured to pay (that is what AMP's classifier did); wholesale porting the bucket is the mistake this issue exists to avoid.AMP's future — see M5.
Done when
_relax/contains no module without an in-package consumer.node_countand objective exactly unchanged) or, where it isn't, carries its §5 panel.Expect this to land as many PRs. Use
Contributes torather than a closing keyword until the loops are actually gone.Context: #764 (kernel), #892 (default-ON), #902 (the incumbent-quality regression that ungraduated it once), #789 (feature parity), #1230 (LP/QP companion), #44 / #86 (AMP).