Skip to content

Clp-core: true Forrest-Tomlin (sparse Hessenberg) + solution-writer & unbounded fixes#23

Open
andig wants to merge 66 commits into
mainfrom
rewrite/clp-factorization-core
Open

Clp-core: true Forrest-Tomlin (sparse Hessenberg) + solution-writer & unbounded fixes#23
andig wants to merge 66 commits into
mainfrom
rewrite/clp-factorization-core

Conversation

@andig

@andig andig commented Jul 12, 2026

Copy link
Copy Markdown
Member

Clp-faithful engine core: true Forrest-Tomlin + solution-writer / unbounded fixes

Branch delivers the Clp-core rewrite (docs/rewrite-clp-core.md): Forrest-Tomlin
factorization, sparse factorize, D1 model reduction, 9/9 Cgl generators, DSE
dual — all behind env gates, default engine byte-identical. This PR's headline
work is the true Forrest-Tomlin column update plus two correctness fixes
surfaced by the ../optimizer battery models. Consolidated results in
Benchmarks at the end.

020 vs CBC — parity status (honest)

Node/tree parity: achieved — 774 nodes vs CBC's 833. Wall parity (3.6s):
not achieved, and it is a floor, not a missing fix.
The campaign took 020
from 60s → 12.6s → 10.9s (gap to CBC ~16× → ~3×); every remaining lever is
either the Go-vs-C++ per-pivot kernel or a tree-perturbing change that re-rolls
020's roundoff-fragile search.

The 10.9-vs-3.6s wall factors exactly into two locked terms:

factor cbcgo real CBC ratio
pivot volume 182,619 99,465 1.84×
per-pivot 59.7 µs 36.2 µs 1.65×
wall 10.9s 3.6s 3.03×
  • Per-pivot 1.65× — Go sparse-triangular-solve (s -= cv[k]·y[r] random
    gather; ~45% of the solve is the dual BTRAN) vs Clp's C++ kernel. A codegen
    floor: bounds-check elimination on the hot loops measured a no-op (the cost
    is the memory gather, not the checks).
  • Volume 1.84× — strong-branch probes (81748 pivots vs CBC's 55266
    strong-branch iterations, 1.5×) + the root incumbent dive. faceWalk fires once at the
    root and spends 37711 pivots to find the first feasible point where CBC's
    DiveCoefficient needs 706 (53×): the model forces incremental per-variable
    diving (RENS batch-rounding fails on 020), so faceWalk re-solves once per
    fixed integer. CBC's cheaper (crunched/hot-started) re-solves and coefficient
    selection are exactly what re-rolls 020's fragile tree. Every volume-cutting
    lever confirms it: faceWalk pivot budget → timeout (loses the root incumbent),
    CBC_CRUNCH → 846 nodes, CBC_FREECOL → 3400 nodes, probe stall/cap/DSE →
    all worse.

Reaching 3.6s needs a scope decision, not a search/correctness fix: either
accept node-parity + this floor, or authorize a cgo/C-kernel (or SIMD/assembly)
sparse BTRAN/FTRAN to close the 1.65× per-pivot. The tree-safe wins that stay
on by default (gap-scheduled diving, slices.SortFunc ratio test, pooled
propagate) took 020 from 12.6→10.9s without moving a single node.

Simplex: faster dual ratio-test sort (e9d9386, 9aedff3)

The Clp long-step / DSE ratio test sorts entering candidates by ascending
ratio every dual pivot. sort.Slice did this by reflection — 740ms / 6.8% of
the 020 solve. Microbenchmark (200-elem tie-heavy sort): sort.Slice
6743ns/3allocs → concrete sort.Sort 3269ns/1alloc → slices.SortFunc
3080ns/0allocs. Shipped slices.SortFunc (2.2× over sort.Slice,
zero-alloc). Order is byte-identical to sort.Slice — verified across 200
tie-heavy seeds and by 020 staying at 774 nodes / 182619 pivots. Numerics-
neutral, no tree re-roll.

MIP: three CBC-parity levers — diving, presolve, alloc (1b5829f, 554b68a, 74f91ba)

Measured the three remaining 020 levers against real CBC 2.10.3 and shipped
what wins on the default; gated what re-rolls 020's proof-fragile tree.

1 — Gap-scheduled diving (1b5829f, ships on). CBC runs diving heuristics
only while the gap is open. cbcgo bursted at every 256th node unconditionally;
on 020 the lone mid-tree burst fired with the incumbent already within 0.02 of
the optimum, found nothing, and cost ~26k pivots through the full
rens→faceWalk→fpump→dive fallback chain. Gating the burst on !closeGap
(incumbent >10% from the node bound) skips it — the root burst still always
runs (no incumbent yet). 020 208k→183k pivots, 12.9→10.9s, tree identical
(774 nodes), robustness 5/5
; 018/021 byte-identical. Rejected by
measurement: faceWalk internal pivot budget (16×/24× rows no-op, 8× loses the
root incumbent → timeout — the root walk needs its full 37k pivots);
burst-failure backoff (the costly burst is the first mid one, nothing later
to disable).

2 — Presolve: the reduction gap is CoinPresolve column elimination, not
doubletons (74f91ba, gated).
A structural census of the golden models
finds 0 duplicate rows, 0 duplicate columns, 0 equality doubletons — every
equality row has 3+ nonzeros, all 2-term rows are inequalities — matching CBC,
which prints 0 substitutions. Yet CBC reduces 020 to 1375 rows / 1399 cols
where cbcgo reaches 2032 / 2421; the ~1000-col gap is 260 free columns + 721
continuous singletons
. Free-column removal (CoinPresolve empty-column
removal: fix at the cost-optimal bound, exact elimConst postsolve) ships
opt-in CBC_FREECOL=1 — optimum-preserving and ~2× on 018 (31746→13917
pivots, 314→163ms) but the perturbed model re-rolls 020's tree (774→3400
nodes), same fragility class as crunch. Fixing (105 = CBC's 105) and big-M
coefficient strengthening (301 ≈ CBC's 304, Cgl0010I-style) already match
and ship on by default.

3 — Alloc: pooled propagate queue (554b68a, ships on, numerics-neutral).
propagate allocated its worklist per call and used a reslice-front + append
FIFO that reallocated ~O(64)× per call. Rewrote it as a pooled cap-nr ring
(inQ dedups → at most nr rows queued, so the ring is exact); FIFO order is
byte-preserved → results identical (020 774 nodes / 182619 pivots). 020
total allocs 1878MB→1625MB. Wall is flat on the golden cases (GC ran
concurrently) — a memory/GC-pressure reduction, not a speedup.

Simplex/MIP: strong-branch probe cost vs CBC — Clp crunch probes (e60547e)

Head-to-head against real CBC 2.10.3 on 020 (-log 3): CBC spends 2032
strong probes / 55,266 hot-start iterations
— 27 per probe and 56% of all
its simplex work
— vs cbcgo's 1022 probes / 82k pivots (80/probe, 50% of
total). Probe economics already match CBC; the absolute wall gap is
per-pivot engine constants. Every CBC hot-start mechanism was then built and
measured on 020:

  • Clp crunch (CBC_CRUNCH=1, ships opt-in): probes solve a row-subset LP
    with provably-redundant rows dropped (interval activity inside the row
    bounds, Clp's crunch() test behind solveFromHotStart; only slack-basic
    rows drop so the reduced basis stays square by cofactor expansion). Probe
    bounds are exactly equal to full-LP probes — not a relaxation guess (a
    first slack-basic-only version weakened seeds and blew the tree to 57s).
    020 drops ~24% of rows; 021 probe pivots 128→28. Opt-in because the changed
    probe roundoff re-rolls 020's tree lottery: CBC_MAXETAS=24 loses its
    proof. Defaults stay byte-identical (774 nodes, robustness sweep 5/5).
  • Rejected by measurement: DSE/perturbed probe duals (65 piv/probe but
    +0.7s wall), probe stall-exit at 15 degenerate pivots (16/probe but weak
    pseudocost seeds → 774→1567 nodes, 31s), probe cap 100→300 (107/probe —
    pure degenerate grind, probes never converge), pre-probe refactorize à la
    markHotStart (byte-identical trees, neutral wall).
  • Probe-side waste removed: probes skip Solution() extraction — 312MB
    of the 020 alloc profile was probe x/rowAct/rc/price nobody read.
  • Honest pivot accounting: the attribution counters (pivot split,
    heuristic budgets' bases) now include the DSE dual's pivots
    (Stats.Dual2); node/sb numbers were blind to dual2 work before.

MIP: probing coefficient strengthening + CBC cutoff-increment gap (abaebf8)

Two CBC-parity changes that stabilize the search tree and prove 020 for the
first time. (1) CglProbing coefficient strengthening (CBC's Cgl0003I
"strengthened rows"): while probing binary x_j, one-sided rows tighten their
x_j coefficient and rhs against the probe side's propagated activity — the
implication-aware version of the static big-M tightening; multi-pass as
CglPreProcess. 018's pre-cut root bound jumps 18092 → 18291.44; fix/strengthen
counts match CBC's preprocessing on 020/021. (2) Default absolute gap 1e-5
mirroring CBC's default cutoff increment (CbcCutoffIncrement);
-allow/-ratio override. Soundness locked by TestProbeStrengthenSound
(random big-M MIPs, sampled feasible points must survive probing).

MIP: in-tree global cut rounds (3b26247)

Every 256 nodes the tree separates the globally-valid cut families (knapsack
cover, clique, zero-half, flow cover, lift-project) at the current node's
fractional point; on success the LP rebuilds and open nodes cold-restart on
the tightened relaxation. This is the globally-valid subset of CBC's in-tree
cutting — Gomory/probing node cuts are basis-/implication-local and would need
per-subtree row lifecycle management. On 020 the rounds separate nothing new
after the root (measured no-op); kept for cover/clique-separable models.
Raising the root cut pivot budget 10×→30× rows measured strictly worse on 020
(vertex-lottery incumbent regression) and stays at 10×.

MIP: reduced-sub-problem RENS as the burst primary (6fe2392)

CBC runs its heuristics as mini branch and bound on reduced problems; cbcgo's
bursts now do the same — RENS (integral-fix + rounding + node-capped sub-MIP)
leads, faceWalk is the fallback. Five earlier demotion attempts measured
wall-negative; under the substituted model the burst order no longer re-rolls
the tree (020 node counts identical per interval either way), so the
CBC-shaped ordering ships neutrally. 020 attribution: faceWalk 200k → 41k
pivots; strong-branch probes are now the top consumer (82k of 205k).

MIP: equality-chain substitution + node-local probing cuts (506643c)

substituteChains (CglPreProcess / CoinPresolve): implied-free continuous
columns substituted out of equality rows — x = (b − rest)/a drops the column
and the row, folding the expression into every other row containing x, with
exact primal/dual postsolve (x back from the pivot row, rewritten-row
activities restored per record, the dropped row's dual from stationarity;
rowMap remaps the reduced row space; reductions chain with singleton
elimination). Default-on (CBC_SUBST=0 disables). Main models substitute few
columns (the evcc bounds usually bind) but heuristic sub-MIP clones
substitute hundreds, and the change removes 020's last bad basin: 020 now
proves at 5/5 refactorize intervals, 10.1–26.2s
(was 4/5 + one timeout);
defaults 020 12.6s / 774 nodes. probingCutsNode: CglProbing implication
cuts derived under node bounds through the local-cut channel; measured still
net-negative on 020 (activation repair at subtree boundaries), stays behind
CBC_LOCALCUTS.

simplex/mip: per-solve IterCap; elimfix + maxEtas 64 default; cut pruning (6d76217)

Takes 020 from 59.8s to 15.6s proven (real CBC 3.6s) with 018/021
unregressed. (1) Per-solve pivot cap (SetIterCap, honored by the primal
loop and the DSE dual; heuristic windows use 2m+200): kills the single-LP
degenerate grinds that made every model transformation a lottery re-roll —
CBC's hot-start/heuristic iteration caps. (2) Fixed-column elimination
default-on
+ default CBC_MAXETAS 32→64: with the cap in place elimination
is a clean win (018 0.18s, 021 2.2s, 020 proven at 15–32s on 4/5 refactorize
intervals; interval 32 times out, hence the default move — scaling made
higher intervals 021-safe). (3) Local-cut effectiveness pruning: a batch
must lift the node bound or is retracted (rows truncated, activations
stripped, two strikes disable); kept batches drop non-binding cuts. With
pruning CBC_LOCALCUTS=1 now proves 020 (57s — was a timeout) but still
loses to the 16s default, so it stays opt-in.

MIP: local in-tree cuts, RENS sub-MIP fallback, fixed-column elimination (69bd7a2)

The three remaining CBC feature gaps, built and benchmarked; two land gated
where the vertex lottery still punishes them (defaults keep the golden
numbers). (1) Node-local in-tree cuts (CBC_LOCALCUTS=1): GMI cuts from a
node's basis are stored as free rows — vacuous for propagation, heuristics and
the .sol writer — and activated per subtree via bound overrides on their
logical variables, inherited by children: the full local-cut lifecycle with no
LP row add/remove. Correct, but net-negative on 020 (degenerate node grind)
pending cut-effectiveness pruning. (2) Burst heuristics as reduced
sub-problems
: RENS's failure path is now a node-capped sub-MIP on the
integral-fixed neighborhood via the shared solveNeighborhood engine
(replacing the LP dive); faceWalk stays the burst primary — five reorderings
measured wall-negative. (3) Fixed-column elimination (CBC_ELIMFIX=1):
LB==UB columns fold into row bounds with exact primal/dual postsolve (020
−364 cols, 021 −643; objectives verified exact); 021 2.9s → 1.7s, but 018/020
regress via a single-LP feasibility-pump grind on the scaled reduced model —
gated until in-solve preemption exists. The pump also gains a
between-iteration pivot budget (no-op on golden paths).

MIP: RINS as a node-capped sub-MIP + polish pivot budget (56c82f5)

RINS now clones the problem, fixes the integers where the node LP and the
incumbent agree (≥half must fix), and solves the reduced neighborhood as a
real sub-MIP (MaxNodes 50, ≤1s, incumbent as MIP start, no nesting) — CBC's
CbcHeuristicRINS design — instead of walking the full LP; it runs only
against a NEW incumbent (CBC semantics). 1-opt polish gets a pivot budget on
top of its time box. New pivot attribution (SOLVER_DEBUG) showed node
re-solves were already cheap on 020 — 12.6 pivots/node vs CBC's 53
iterations/node — and RINS drops 20k → 4.5k pivots; robustness reaches 4/5
intervals proving. Measured and rejected: strongProbeCap 100→25 (fewer
pivots, +30s wall) and three faceWalk budget variants (all wall-negative —
the walk pins the good tree path).

True Forrest-Tomlin — O(nnz) row-spike update (5cdd266, supersedes b561cb4)

Two generations of fixes. b561cb4 replaced the original dense trailing-block
re-triangularization (O(block²) per pivot, 256-row cap → refactorize almost
every pivot) with a sparse Hessenberg sweep. That was still Bartels-Golub in FT
clothing: rows never renumber, so the trailing block's subdiagonal (the old U
diagonals) is never zero and every update appended Θ(m−p) ops to the R-file
— replayed by every subsequent ftran/btran. At maxEtas=32 on 018 (m=725)
that's ~11k replayed ops per solve, 29× the eta path's per-pivot cost.

5cdd266 implements the real Forrest-Tomlin update: the leaving pivot's
row/column cycle to the last step and the row spike is eliminated against the
existing U rows — O(nnz(row)) R-ops per update. To survive step renumbering
without rewriting the factor, L and the R-file are keyed by immutable orig
rows and U by immutable basis cols; only permutation arrays and slice headers
shift per update. A lazy column-wise U index (ucRows) makes the leaving
column's entry deletion O(nnz(col)).

018 under CBC_FT=1: 23.1s → 6.7s; 021 per-pivot cost reaches parity with
the eta path (50µs vs 52µs). The remaining FT-vs-eta wall gap is node-basin
placement, not kernel cost — FT stays opt-in.

Solution-writer fix — unnamed cut rows (14241b9)

Branch-and-bound appends cut rows with empty names; solfile.Write emitted them
as 3-token lines ( 2666 0 0), and PuLP's readsol does l[3] on every
line → IndexError, so large MIP solves failed to parse entirely (the
../optimizer 020/021 cases; 018 was spared only because it needs no cuts).
PuLP maps by name and ignores unknowns, so unnamed internal rows are dropped —
matching real CBC, whose .sol carries only the original model.

simplex/mip: Clp geometric scaling (CBC_SCALE) — 021-safe, big speedups (b4ee1dc)

The evcc models carry a 1e6 constraint-coefficient range (big-M + D1). Clp
scales every model; cbcgo did not, so 021 carried ~1e-3 feasibility slop and
its proven optimum drifted with any perturbation — at higher maxEtas the
unscaled solve falsely proved a suboptimal 8.676 (a soundness bug).

Added Clp-style geometric row/column scaling, verified line-by-line against
Clp source (ClpSimplex::createRim / ClpPackedMatrix::scale): objective
*columnScale, column bounds *inverseColumnScale, row bounds *rowScale,
solution *columnScale. Scaling is internal to the LP; bounds/solution/duals/
tableau unscale at the boundary so mip/cuts work unchanged (CBC/OSI-style).
Integer columns keep colScale=1 (clean rounding); the feasibility pump uses
a new original-space objective interface. Key fix: each cut round
rebuilds+rescales the LP, so WarmSolveExtended on the pre-rebuild
(stale-scaled) basis produced garbage (021 round-1 bound 8.70→10.57, cutting
off the optimum) — cold-solve cut rounds when scaled. Impact when it landed:
018 8.8s→0.4s, 021 15.4s→2.6s and CBC's exact optimum at every maxEtas,
020's incumbent from garbage to CBC's region. Now on by default (CBC_SCALE=0
disables); well-conditioned models stay byte-identical.

simplex: pool the DSE node re-solve workspace (f7d6921)

dual2Run (the DSE dual that drives deep branch-and-bound node re-solves)
allocated ~10 fresh slices per warm re-solve. Clp keeps these as persistent
work arrays on ClpSimplex; pooled them the same way into a per-LP dual2WS
(each simplex.Build makes a fresh LP, so m/nt are frozen for its
lifetime — no size guard). Correctness-neutral. BenchmarkDual2WarmResolve
(warm bound-change re-solve, m=60): 18596 → 11639 ns/op (−37%), 25217 →
8017 B/op (−68%), 40 → 28 allocs/op
. On the large ../optimizer models the
node re-solve wall is dominated by the eta FTRAN/BTRAN so the pooling is in
the noise there (018/020/021 unchanged and correct). Adds an env-gated
(CBC_CPUPROFILE) dev CPU-profile hook to cmd/cbc.

MIP: CBC-quality feasibility pump + pump-first ordering (27b01a8)

The old feasibility pump used pure L1 distance and a dual WarmSolve after each
objective swap (mathematically wrong — dual repair assumes dual feasibility a
cost change breaks), so it converged to a bad basin (evcc 020 incumbent ~42),
and faceWalk ran first on a shared deadline, starving it. Rewritten as the
Achterberg-Berthold objective pump: blend the decaying true objective into the
L1 projection, project with a new PrimalResolve (correct after a cost change),
anti-cycle by flipping the most-fractional roundings, and run the pump first
at the root with its own sub-budget (CBC's ordering). On ../optimizer 020 the
incumbent goes 42 → 0.541 (≈ CBC's optimum 0.5583). 018/021 unchanged; full
suite + PuLP green.

MIP: CglPreProcess applied uniformly — CBC/Clp parity (6b2f460)

Preprocessing (binary big-M coefficient strengthening + forcing/redundant-row
removal) was gated on a cbcgo-specific heuristic — the root-LP integer
fractionality. CBC/Clp apply CglPreProcess / presolve uniformly (gated on
structure, not the relaxation). The gate also skipped D1 on the evcc 021 case,
where cbcgo then returned a suboptimal point (8.6837) labelled Optimal — below
its own LP relaxation: a soundness bug. Removed the gate (and the CBC_D1 env,
d1Adaptive, d1FracThreshold); D1 now runs on every model. 018 and 021 then
matched CBC's raw MIP objective (021: 8.70073 vs CBC 8.70087 — was the wrong
8.6837); 020's cut bound (−0.629) matched CBC's region.

Sparser factors — Clp Markowitz least-fill pivoting (d0a7a8c)

rebuild picked the largest entry in the shortest row. Now, among the row's
max-abs entries (numerically strongest — no stability loss), it takes the
sparsest column by active-row count: Clp/CoinFactorization's Markowitz rule.
On ../optimizer 021 (3312 rows) the average factor L+U nnz drops 8429 → 6436
(~24% sparser)
, wall-clock neutral. Pivot choice never changes the
elimination math, so correctness is unchanged.

FT solve speedups (43cb3ba)

Profile-driven, correctness-neutral: btran's U^-T push skips zero steps (dual
pricing has a near-unit RHS), and refactorize no longer builds the sparse-LU
st.f every time under FT — it drives all solves via the FT factor, so st.f
is built lazily only as a singular-fallback (was a full luFactorize per
refactorize, ~14% of FT solve time). 021 whole-solve under CBC_FT: 9.1s → 7.4s.

Unbounded-ray verification (98c120e)

The primal loop declared Unbounded the instant the ratio test found no blocking
row, trusting a possibly-stale factorization. The optimal path already guards
against this with a refactorize+reprice recheck; this adds the symmetric guard
for unboundedness (as CBC/Clp do) — a ray must survive a clean factorization to
be reported. Resets per pivot; genuine unbounded LPs still resolve.

Safety

Default engine unchanged on the golden path: go test ./... green, PuLP
COIN_CMDTest suite green (78 passed, only the 2 pre-documented failures).
Scaling (CBC_SCALE), the extra Cgl families (CBC_CGL) and the DSE dual
are on by default; Forrest-Tomlin stays opt-in behind CBC_FT=1.

Benchmarks (evcc golden cases, Apple M4; real CBC 2.10.3)

Defaults on both solvers, wall-clock + nodes + objective:

case main (pre-rewrite) this branch real CBC
018 4.9s, 18291.45 0.34s, 7 nodes, 18291.4519 0.04s, 0 nodes, 18291.4519
021 5.8s, 8.6901 (wrong) 2.2s, 3 nodes, 8.70087 0.09s, 0 nodes, 8.70083
020 60s, −140 (garbage) 10.9s, 774 nodes, 0.55835 proven 3.6s, 833 nodes, 0.55835

Tree robustness — nodes (and wall) as solve roundoff is perturbed via the
refactorize interval CBC_MAXETAS ∈ {24, 32, 48, 64, 100}:

case before strengthening after real CBC (any seed)
018 27–336 nodes, 0.3–6.4s 7–10 nodes, 0.20–0.65s 0 nodes
021 3–291 nodes, 2.6–31s 3 nodes every run, 2.7–5.7s 0 nodes
020 never proven proven 5/5, 10.3–24.6s 833 nodes, 3.6s

020 root parity: preprocessing fixes 105 = CBC's 105 (~350 rows strengthened
vs CBC's 501); root cut bound −0.884 → −0.664 in 4 rounds vs CBC −0.885 →
−0.658 in 10 passes — within 1% of CBC's closed distance. Remaining gap vs
CBC: root-closing power (CBC ends 018/021 at 0 nodes via CglPreProcess +
pump/mini-B&B) and 020 tree cost. Pivot attribution shows 020's node
re-solves are already cheap (12.6 pivots/node vs CBC's 53 iterations/node);
the bulk is faceWalk (~198k) + strong-branch probes (~112k), both measured
to resist capping (three budget variants wall-negative), plus CBC's ~950
node-local probing cuts that cbcgo's global-only in-tree rounds can't match.

andig added 30 commits July 11, 2026 18:49
…ivot)

Replace the dense O(block^2) trailing-block re-triangularization in
replaceColumn with sparse Hessenberg elimination on the U rows: build the
shifted upper-Hessenberg block sparse, eliminate its single subdiagonal with
partial pivoting, record swaps+elims into the R-file. Cost is O(spike + fill),
not O(block^2), and there is no block-size cap forcing a refactorize on large
problems, so FT stays active on big trailing blocks.

L, prow and rinvrow are untouched (the R-file carries the row permutation), so
the row-renumber L-corruption trap does not arise. An ftFillCap guard bails to
a pooled refactorize on pathological fill.

BenchmarkFTReplace (m=300, dense spike): 1.36ms -> 132us/op (~10x). PuLP
COIN_CMDTest suite under CBC_FT=1 matches the default engine (78 passed, same
2 documented failures) and is no slower. New TestFTReplaceColumnLarge exercises
m up to 400 with blocks far past the old 256 cap.
Branch-and-bound appends cut rows to the problem with empty names. Write
emitted them as "  2666            0            0" — a 3-token line once the
blank %-20s name collapses under split(). PuLP's readsol does l[3] on every
data line, so a nameless entry raised IndexError and the whole solve failed
to parse (surfaced as a spurious "Unbounded"/crash on the ../optimizer
020/021 cases; 018 was unaffected only because it needs no cuts).

PuLP maps rows/cols by name and ignores names it doesn't know, so dropping
the unnamed internal entries is correct and matches real CBC, whose .sol
contains only the original model's rows. 018/020/021 now parse and return a
solution end-to-end. Regression test asserts every data line keeps >=4 tokens.

Note: this was not an unboundedness-detection bug — that path is correct
(minimizing 020 without -max genuinely is unbounded). The defect was purely
in the solution-file writer.
…ncluding

The primal loop declared Unbounded the moment the ratio test found no blocking
row, trusting the current (possibly stale) factorization. A numerically spurious
ray from an aged eta/FT factor would be reported unchecked. The optimal path
already guards against this with a refactorize+reprice recheck (the `cleaned`
flag); this adds the symmetric guard for unboundedness, as CBC/Clp do.

On the first would-be-unbounded conclusion we refactorize, recompute basics,
reprice and re-test; only a ray that survives a clean factorization is reported
Unbounded. The flag resets on every pivot so each fresh ray is verified once.
Genuine unbounded LPs still resolve (TestUnbounded/free/elastic-penalty pass).
Two profile-driven wins on the active FT path (CBC_FT), both correctness-neutral:

- btran U^-T push now skips zero steps. Dual pricing btran has a near-unit
  (very sparse) RHS, so most steps were multiplying by zero. Mirrors the
  existing skip in forwardLR's L^-1 push.
- refactorize no longer builds the sparse-LU (st.f) every time under FT. FT
  drives all solves; st.f is only a fallback used when the FT factor is nil.
  It is now built lazily — only when FT is disabled or the factor goes singular
  — cutting a full luFactorize per refactorize (was ~14% of FT solve time).

Measured on ../optimizer 021 (3312 rows) under CBC_FT: whole-solve 9.1s -> 7.4s
(~19%); the FTPROF micro-driver 8.71s -> 7.20s. Objectives unchanged. Default
engine untouched (st.f path is identical when FT is off); full suite + PuLP
COIN_CMDTest green.
rebuild's factorize picked the largest entry in the shortest row as pivot.
Now, among the numerically strongest entries (within ftPivTol=1.0 of the row
max, i.e. the max-abs entries), it takes the sparsest column by active-row
count — Clp/CoinFactorization's Markowitz least-fill rule. Tie-breaking toward
low-fill columns costs no stability (pivot is still a max-magnitude entry) and
cuts fill on structured (±1) bases where many entries tie at the max.

Column counts are maintained incrementally in wColLen (pivot row leaving,
cancellations, fill). Pivot choice never changes the elimination math, so
correctness is unchanged (TestFTReplaceColumn/Large pass).

Benchmarked against ../optimizer (avg factor L+U nnz over a real solve):
  021 (3312 rows): 8429 -> 6436  (~24% sparser), wall-clock neutral (~7.4s).
  018: ~unchanged. Default engine untouched (FT-only path). Full suite + PuLP
  COIN_CMDTest green.
Lazy st.f (commit 43cb3ba) could leave both st.ft and st.f nil when a
factorization failed mid-solve, crashing ftranVec (../optimizer case 012 under
CBC_FT). Build st.f once as a persistent emergency fallback; still skip the
redundant factorize on every subsequent FT-success refactorize, so the solve
speedup is preserved (021 FT stays ~7.3s).
CBC's CglPreProcess (D1: coefficient strengthening + integer redundant-row
removal) was env-gated off because it slows problems whose LP relaxation is
already tight. But a loose relaxation genuinely needs it. Decide per-solve like
CBC: solve the root LP relaxation once (throwaway LP, m.LP untouched) and enable
D1 when the fraction of integer variables that are fractional exceeds
d1FracThreshold (0.18) — scale-invariant, so it separates a loose model from a
tight one regardless of size.

Wire the decision (d1Adaptive) through both D1 components: presolve's coefficient
strengthening (presolve.go) and dropRedundantRows' integer-row removal.

Effect on ../optimizer: 020 (fracFrac 0.227 -> D1 on) root cut bound -1.448 ->
-0.884 and incumbent -140 -> -0.079 (near optimum 0.558); 021 (fracFrac 0.077 ->
D1 off) stays 4.0s with no regression; 018 (0.155 -> off) unchanged. Default and
PuLP COIN_CMDTest suites green.
andig added 30 commits July 12, 2026 20:40
The old replaceColumn was Bartels-Golub in FT clothing: it kept rows at
fixed steps and LU-swept the whole trailing (m-p) Hessenberg block, whose
subdiagonal (the old U diagonals) is never zero — so every update appended
Th(m-p) ops to the R-file, and every ftran/btran/replaceColumn replayed all
of them. At maxEtas=32 that is ~11k replayed ops per solve on 018 (m=725):
29x the eta path's per-pivot cost.

Rewrite as true FT: the leaving pivot's row/column cycle to the last step
and the row spike is eliminated against the existing U rows — O(nnz(row))
R-ops per update. To survive step renumbering without rewriting the factor,
L and the R-file are keyed by (immutable) orig rows and U by (immutable)
basis cols; only the permutation arrays and slice headers shift per update.
A column-wise U index (ucRows, lazy) makes the leaving column's entry
deletion O(nnz(col)).

018 CBC_FT=1: 23.1s -> 6.7s; 021 per-pivot now at parity with the eta path
(50us vs 52us). Remaining FT-vs-eta wall gap is node-path chaos, not kernel
cost: tiny roundoff shifts B&B into different node basins, and the eta path
swings identically across refactorize intervals (021 CBC_MAXETAS=24: 131
nodes/31s vs 3 nodes/2.7s at 16). FT therefore stays opt-in; README updated
with the root cause and measurements.
Two CBC-parity changes that together stabilize the search tree and prove
020 for the first time.

1. CglProbing coefficient strengthening (Cgl0003I): while probing binary
x_j, one-sided rows now tighten their x_j coefficient and rhs against the
probe side's propagated activity — the implication-aware version of the
static big-M tightening presolve already did. Runs multi-pass (as CBC's
CglPreProcess) until quiet. On 018 this strengthens 149 coefficients and
lifts the pre-cut root bound from 18092 to 18291.44; on 020/021 it matches
CBC's preprocessing fix/strengthen counts (153 fixed / ~400 strengthened).

2. Default absolute gap 1e-5, mirroring CBC's default cutoff increment
(CbcCutoffIncrement): nodes within 1e-5 of the incumbent prune, as real
CBC does. -allow/-ratio still override.

Tree robustness (nodes across CBC_MAXETAS 24/32/48/64/100):
  018: 27-336 -> 6-8, wall 0.3-6.4s -> 0.14-0.2s, objective = CBC exactly
  021: 3-291  -> 3 everywhere, wall 2.6-30.7s -> 2.9-5.5s
  020: proven optimal for the first time (57s me=32, 49s me=100; CBC 3.6s);
       me=48 still times out — residual variance on the hardest case.

Soundness: TestProbeStrengthenSound (random big-M MIPs, sampled feasible
points must survive probing); full suite + PuLP COIN_CMDTest green.
Every 256 nodes (max 8 rounds) the tree separates the globally-valid cut
families (knapsack cover, clique, zero-half, flow cover, lift-project) at
the current node's fractional point; on success the LP rebuilds and open
nodes cold-restart on the tightened relaxation (parentState=nil path).
CBC generates cuts throughout the search; this is the globally-valid
subset — Gomory/probing node cuts are basis-/implication-local and would
need per-subtree row lifecycle management.

020 benchmark vs real CBC (Apple M4, 120s cap):
  preprocessing: 105 fixed = CBC's 105; ~350 rows strengthened (CBC 501);
    1533 rows kept vs CBC's 1375
  root cuts:     -0.884 -> -0.664 in 4 rounds vs CBC -0.885 -> -0.658 in
    10 passes (within 1% of CBC's closed distance)
  tree:          proven at 3/5 refactorize intervals (48-92s, 1.7-3.7k
    nodes); misses end within 2e-4 of optimum; CBC 3.6s/833 nodes
  on 020 the in-tree rounds separate nothing new after the root (measured
  no-op); CBC's tree advantage is ~950 node-local probing cuts + cheaper
  node re-solves.

No regressions: 018 7 nodes/142ms, 021 3 nodes/3.0s (unchanged), full
suite + PuLP COIN_CMDTest green. Cut pivot budget kept at 10x rows —
30x measured strictly worse on 020 (vertex-lottery incumbent regression).
RINS now clones the problem, fixes the integers where the node LP and the
incumbent agree (>=half must fix), and solves the reduced neighborhood as a
real sub-MIP (MaxNodes 50, <=1s, incumbent as MIP start, no nested subs) —
CBC's CbcHeuristicRINS design — instead of walking the full LP. It runs only
against a NEW incumbent (CBC semantics: the neighborhood is a function of
(incumbent, LP); repeats are redundant). polishIncumbent (1-opt) gets a
3x-rows pivot budget on top of its time box: with RINS improving more often
it otherwise blew up to 233k pivots on 020.

020 pivot attribution (new SOLVER_DEBUG split): node re-solves are already
cheap — 24k pivots over 1894 nodes = 12.6/node vs CBC's 53 total
iterations/node. RINS drops 20k->4.5k pivots; robustness improves to 4/5
refactorize intervals proving (24: 93s, 32: 59s, 48: 94s — was a timeout —
100: 45.5s; me=64 misses by 1.5e-5 vs the 1e-5 gap).

Measured and rejected (kept out): strongProbeCap 100->25 (sb 112k->31k
pivots, nodes -25%, but wall +30s via a worse per-pivot mix) and three
faceWalk budget variants (all +30..55s — its walk pins the good tree path;
truncating it re-rolls the lottery). facewalk 198k + strong-branch 112k
remain the 020 pivot bulk; both resist capping.

018 6-8 nodes/145-189ms, 021 3 nodes/3-5.9s across all intervals.
Full suite + PuLP COIN_CMDTest green.
Feature parity build-out for the three measured CBC gaps, each benchmarked
on the golden cases; two land gated where the vertex lottery still punishes
them, defaults keep 018 0.14s / 021 2.9s / 020 57s proven.

1. Node-local in-tree cuts (CBC_LOCALCUTS=1): GMI cuts from a node's basis
are valid only under its bounds. Rows are stored free (Sense NR: vacuous for
propagation, heuristics and the .sol writer) and activated for the subtree
via bound overrides on their logical variables, which children inherit —
full local-cut lifecycle without LP row add/remove. Infra: baseBound()
(override revert for logical vars), propagatedChild/dive guards for logical
overrides. Measured on 020: correct but net-negative (842 pivots/node
degenerate grind) without CBC's cut-effectiveness pruning; gated until that
lands. The globally-valid CGL round stays default-on.

2. Burst heuristics as reduced sub-problems: rensImprove keeps its cheap
single-shot rounding, but its failure path is now a node-capped sub-MIP on
the integral-fixed neighborhood (CBC RENS / mini branch and bound) via the
shared solveNeighborhood() engine (also used by RINS), replacing the LP
dive. faceWalk stays the burst primary: five reorderings (rens-first,
budget caps) all measured wall-negative — its vertex path pins the good
tree on 020/021.

3. Fixed-column elimination (CBC_ELIMFIX=1): LB==UB columns (probing-fixed
binaries + presolve-fixed) fold a*val into row bounds and drop, with exact
primal/dual postsolve (elimConst records). Removes 364 cols on 020, 152 on
018, 643 on 021; external objectives verified exact. Measured: 021 2.9s ->
1.7s, but 018/020 regress via a single-LP feasibility-pump grind on the
scaled reduced model (018: 192k pivots in one projection solve) — gated
until in-solve preemption exists. Pump also gets a between-iteration pivot
budget (10x rows; no-op on golden paths).

Full suite + PuLP COIN_CMDTest green; defaults byte-identical on the
golden benchmark table.
…ning

Three changes that together take 020 from 59.8s to 15.6s proven (real CBC
2.10.3: 3.6s) with 018/021 unregressed.

1. Per-solve pivot cap (SetIterCap): run() and dual2Run now honor IterCap
without probe semantics; heuristic windows (bursts, RINS) set 2m+200. This
kills the single-LP degenerate grinds that made every model transformation
a lottery re-roll (018 under elimination: 192k pivots in ONE pump
projection -> capped, 1.58s -> 0.15s). CBC caps heuristic/hot-start
iterations the same way.

2. Fixed-column elimination default-on (CBC_ELIMFIX=0 disables) + default
CBC_MAXETAS 32 -> 64. With the cap in place elimination is a clean win:
018 0.15s (= before), 021 2.9s -> 1.8s, and 020 proves at 15-32s on 4/5
refactorize intervals (24: 15.3s, 48: 24.7s, 64: 15.6s, 100: 31.8s; 32
times out, hence the default interval move to 64 - scaling made higher
intervals 021-safe since b4ee1dc). expand() now handles all-columns-
eliminated results (nil X with incumbent, nil activity/price synthesis).

3. Local-cut effectiveness pruning (CBC prunes ineffective cuts): a local
batch must lift the node bound or it is retracted (rows truncated,
activations stripped, 2 strikes disables local cuts for the model);
accepted batches keep only the cuts binding at the new optimum. With
pruning CBC_LOCALCUTS=1 now PROVES 020 (57.3s, 1138 nodes - was a timeout)
but still loses to the 15.6s default, so it stays opt-in.

Robustness across CBC_MAXETAS {24,32,48,64,100}: 018 7-10 nodes/0.14-0.43s,
021 3 nodes every run, 020 proven 4/5. Full suite + PuLP green.
… cuts

1. substituteChains: implied-free continuous columns substituted out of
equality rows (CBC CglPreProcess / CoinPresolve): x = (b - rest)/a drops
the column and the row, folding the expression into every other row
containing x, with exact primal/dual postsolve (elimSub records: x back
from the pivot row, activities of rewritten rows restored per-record, the
dropped row's dual from stationarity, rc = 0; rowMap remaps the reduced
row space). Guards: continuous cols only, sparse stable pivots (<=8 nnz,
|a| >= 1% of row max), implied bounds must cover the column's own bounds.
Runs between dropRedundantRows and singleton elimination; reductions chain
(m.subRed then m.red on expand, both on MIPStart shrink). Default-on
(CBC_SUBST=0 disables). Main models substitute few columns (2-11: the evcc
bounds usually bind) but heuristic sub-MIP clones substitute hundreds
(e.g. 276 of 2421), and the perturbation removes 020's last bad basin:

  020 robustness: PROVEN 5/5 refactorize intervals, 10.1-26.2s
  (was 4/5, 15-32s + one timeout); defaults 020 12.6s / 774 nodes.
  018 7-10 nodes / 0.20-0.65s; 021 3 nodes every run / 2.7-5.7s.

2. probingCutsNode: CglProbing implication cuts derived under NODE bounds
(CBC probes at every node) - infeasible sides become local fixing rows -
emitted through the local-cut channel (NR rows + subtree activation) next
to node-basis GMI. Measured on 020: still net-negative (subtree-boundary
activation repair dominates), so CBC_LOCALCUTS stays opt-in.

Full suite + PuLP COIN_CMDTest green; all objectives exact.
…nd bound)

Five earlier attempts to demote faceWalk measured wall-negative; under the
substituted model the burst order no longer re-rolls the tree (020 node
counts identical per refactorize interval either way), so the CBC-shaped
ordering ships: RENS (integral-fix + rounding + node-capped sub-MIP) first,
faceWalk as the fallback. 020 pivot attribution: faceWalk 200k -> 41k,
strong-branch probes now the top consumer (82k of 205k).

Defaults unchanged: 018 0.35s/7 nodes, 021 3.4s/3, 020 13s/774 proven;
robustness 020 5/5 (10.5-26.5s), 018 %<1s all intervals, 021 3 nodes every
run. Full suite + PuLP green.
…t accounting

- CrunchProbe: strong-branch probes solve a row-subset LP with provably
  redundant rows dropped (Clp's crunch behind solveFromHotStart). Bounds
  are identical to full-LP probes; ~24% fewer rows on 020, probe pivots
  021 128->28. Opt-in: the changed probe roundoff re-rolls 020's tree
  lottery (CBC_MAXETAS=24 loses its proof), defaults stay byte-identical.
- probes skip Solution(): 312MB of the 020 alloc profile was probe-side
  x/rowAct/rc/price nobody read
- pivot counters include the DSE dual (Stats.Dual2): node/sb attribution
  was blind to dual2 work

Measured against real CBC 2.10.3 on 020 (-log 3): CBC runs 2032 strong
probes / 55266 iterations (27/probe, 56% of its total simplex work) vs
our 1022 probes / 82k pivots (80/probe, 50% of total) -- probe share is
at parity; the remaining wall gap is per-pivot engine constants.
Rejected by measurement: DSE/perturbed probes (13.2s vs 12.5s), probe
stall-exit (weak seeds, tree blowup to 31s), cap 300 (pure grind),
MarkHotStart refactorize (neutral).
…ic scheduling)

CBC runs diving heuristics only while the gap is open. cbcgo bursted at
node 1 and every 256 nodes unconditionally; on 020 the lone mid-tree burst
fired with the incumbent already within 0.02 of the proven optimum, ran the
full rens->faceWalk->fpump->dive fallback chain, found nothing, and cost
~26k pivots. Gating the burst on !closeGap (incumbent >10% from the node
bound) skips it:

  020  208614 -> 182619 pivots, 12.9 -> 11.6s, tree IDENTICAL (774 nodes,
       same objective); robustness 5/5 proven across maxEtas 24..100
  018/021  byte-identical (their bursts already gated by closeGap at root)

Root burst still always runs (no incumbent yet, closeGap false). Measured
alternatives rejected: faceWalk internal pivot budget (16x/24x rows no-op,
8x loses the root incumbent -> tree blows to timeout — the root walk needs
its full 37k pivots); burst-failure backoff (the expensive burst is the
FIRST mid one, nothing later to disable). PuLP suite green.
propagate allocated inQ+queue per call and used a reslice-front + append
FIFO that reallocated the backing ~O(64) times per call. Rewrote the queue
as a pooled cap-nr ring buffer (inQ dedups, so at most nr rows are ever
queued -> the ring is exact); the per-node propagatedChild path now reuses
one Model-scoped scratch. FIFO dequeue order is byte-preserved, so results
are identical: 020 774 nodes / 182619 pivots / same objective, 018/021
unchanged. 020 total allocs 1878MB -> 1625MB (propagate 192 -> 94MB; the
residual is one-time presolve probe callers, which pass nil). Wall is flat
on the golden cases (GC ran concurrently, off the critical path) — this is
a memory/GC-pressure reduction, not a speedup. All tests + PuLP green.
Reduced-model census vs real CBC 2.10.3: our 020 presolve leaves 2032 rows
/ 2421 cols where CBC reaches 1375 / 1399. CBC prints 0 substitutions and
the structural census finds 0 duplicate rows, 0 duplicate cols, 0 equality
doubletons — so the ~1000-col gap is CoinPresolve column elimination, not
dedup/doubletons. It splits into 260 free columns (no row entries) + 721
continuous singletons in the rowBlocks&&colBlocks case eliminateSingletons
leaves.

This adds the free-column reduction: a column with no row entries is decided
by the objective alone, so fix it at the cost-optimal finite bound and drop
(exact elimConst postsolve; empty rec.rows). Optimum-preserving.

Opt-in (CBC_FREECOL=1) because — like crunch / local cuts / FT — the
perturbed model re-rolls 020's proof-fragile tree: 774 -> 3400 nodes / 96s,
still proven but far slower, while 018 gets ~2x faster (31746 -> 13917
pivots, 314 -> 163ms, objective identical to 8 digits). Defaults stay
byte-identical: 018 31746 / 020 774 / 021 62278 pivots unchanged.

Fixing (105) and big-M coefficient strengthening (301, Cgl0010I-style, see
presolvePass) already match CBC's counts — that presolve parity ships on by
default. All tests + PuLP green.
…lice reflection)

The Clp long-step / DSE ratio test sorts entering candidates by ascending
ratio every dual pivot. sort.Slice does this via reflection (a per-element
reflect-based swap); on 020 it was 740ms / 6.8% of the solve. Replaced with
a concrete sort.Interface (dualCandByRatio / dual2CandByRatio) — the same
pdqsort, same comparator, so the order is byte-identical and results are
unchanged: 020 774 nodes / 182619 pivots / same objective, 018/021 identical.
Interleaved timing (controls for thermal drift): ~11.55s vs ~11.79s, a
consistent ~2% on 020 (new faster in all 5 paired runs). Numerics-neutral —
no tree re-roll. All tests + PuLP green.
Follow-up to e9d9386. Microbenchmark (200-elem tie-heavy sort, the ratio
test's shape): sort.Slice 6743ns/3allocs, sort.Sort-Interface 3269ns/1alloc,
slices.SortFunc 3080ns/0allocs. slices.SortFunc is 2.2x over the original
sort.Slice and 6%-faster + zero-alloc over the concrete sort.Interface (whose
1 alloc was interface boxing, x82k+ sorts). Order is byte-identical to
sort.Sort — verified across 200 tie-heavy seeds (a throwaway test) and by 020
staying at 774 nodes / 182619 pivots. Drops the concrete sort types. All
tests + PuLP green.
…, not barren mid-tree)

faceWalk fires ONCE at the root (ok=true), spending all 37711 pivots to find
the first incumbent — it is the root incumbent finder, not barren mid-tree
dives (prior wording was wrong). CBC's DiveCoefficient does the same job in 706
iterations; the 53x gap is per-variable re-solves forced by the model (RENS
batch-rounding fails on 020). No code change.
…48 vs 55266, 1.5x)

The prior '40/probe vs 27' compared our per-side rate to CBC's per-variable
count (apples to oranges). Replaced with total strong-branch simplex work:
81748 pivots vs CBC's 55266 iterations = 1.5x. No code change.
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.

1 participant