Skip to content

crossover:v0; first simple version for feedback#56

Draft
charles-vzf wants to merge 4 commits into
JuliaDecisionFocusedLearning:mainfrom
charles-vzf:crossover-13
Draft

crossover:v0; first simple version for feedback#56
charles-vzf wants to merge 4 commits into
JuliaDecisionFocusedLearning:mainfrom
charles-vzf:crossover-13

Conversation

@charles-vzf

@charles-vzf charles-vzf commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Add V1 post-solve crossover (threshold snap + implied bounds) Refs #13

Post-OPTIMAL crossover for PDLP: snap primal coordinates near finite box bounds, tighten implied bounds from equality rows, rollback if KKT residuals regress. Controlled by CrossoverParameters on Algorithm (enabled by default).

V1 is not a basic-vertex crossover (unlike cuOpt): it helps near-bound / implied-bound cases (e.g. toy_lp) but is usually a no-op when variables stay strictly interior. Lightweight first step toward MIP-ready solutions.

Also realized a small bench compared to cuopt:
speed_comparison
I am planning a more advanced version cuopt-like after feedbacks

@codecov

codecov Bot commented Jun 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.53125% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/components/crossover.jl 93.54% 6 Missing ⚠️
src/algorithms/common.jl 96.55% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/CoolPDLP.jl 100.00% <ø> (ø)
src/components/termination.jl 80.00% <100.00%> (+15.00%) ⬆️
src/algorithms/common.jl 96.10% <96.55%> (+2.22%) ⬆️
src/components/crossover.jl 93.54% <93.54%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gdalle gdalle marked this pull request as draft June 8, 2026 08:40
@gdalle gdalle self-requested a review June 8, 2026 08:40
@charles-vzf charles-vzf force-pushed the crossover-13 branch 2 times, most recently from b89002c to af49ead Compare June 22, 2026 07:56

@gdalle gdalle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for giving this a shot! Of course there are lots of comments, but as you'll learn if you continue to contribute to open-source, this is rather a good sign :) If you ever get a review of a 500-LOC PR with a single comment, the maintainer didn't read your code.
My main doubts are related to the algorithm you picked: is it even called a crossover? Why not go for the full Megiddo approach with simplex pivot steps?

Comment thread src/algorithms/common.jl
Comment on lines +52 to +56
crossover_threshold = 1.0e-6,
crossover_fixed_tol = 1.0e-8,
crossover_rollback_on_kkt_regression = true,
crossover_kkt_rtol = 0.0,
crossover_use_effective_bounds = true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do these default values come from?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pragmatic V1 knobs, not from a single paper constant:

  • 1e-6 / 1e-8: absolute snap tolerances (same order as PDLP residual scales and cuPDLPx-style "near bound" heuristics).
  • rollback_on_kkt_regression = true, kkt_rtol = 0: conservative default —
    keep crossover only if KKT does not worsen.
  • use_effective_bounds = true: enables the equality-row implied-bound pass before snapping.
    These are defaults only; the user can override them via PDLP / PDHG kwargs

Comment thread src/algorithms/common.jl
reltol_T = _T(termination_reltol)
crossover_params = CrossoverParameters(;
enabled = crossover,
threshold = max(_T(crossover_threshold), reltol_T / 20),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this / 20 come from?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heuristic floor: effective snap threshold = max(crossover_threshold, termination_reltol / 20).

With a loose reltol (e.g. 1e-3), PDLP can stop as OPTIMAL while some coords are still O(reltol) from bounds; the default 1e-6 snap would barely fire. The floor raises the snap radius to at least reltol/20 (here 5e-5)
so near-bound coords at the solve-tolerance scale actually get snapped.
Factor 20 is arbitrary, i can remove this coupling entirely and just use crossover_threshold as
given (user sets it explicitly if they want a looser snap), or expose it as a named parameter (crossover_reltol_fraction) if you prefer to keep the link.

Comment thread src/algorithms/common.jl
end
(; termination_reltol) = algo.termination
err_before = stats.err
x_backup = copy(state.sol.x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to overwrite some field from the scratch space to avoid allocating? It doesn't matter much though, since we only apply crossover once

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot reuse scratch.x / y / z for the backup:
kkt_errors! overwrites all three when checking post-crossover residuals.

Comment on lines +19 to +20
"revert to the pre-crossover primal if KKT errors regress beyond tolerances"
rollback_on_kkt_regression::Bool = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this boolean parameter necessary? To simulate rollback_on_kkt_regression = false, one could also set kkt_rtol = Inf?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not completely equivalent :)
rollback_on_kkt_regression = false skips all rollback checks
(crossover_kkt_acceptable returns true immediately). kkt_rtol = Inf only disables the relative increase test; we still reject if relative(err_after) > termination_reltol. Tests rely on rollback=false accepting a worsened iterate. Keeping the bool for clarity, but it is still possible to remove it and have +inf instead, as you want.

elseif crossover_applied
"crossover applied ($crossover_n_snapped coords)"
else
"crossover not applied"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this case mean?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That else branch is the catch-all message when crossover did not stick:
disabled (crossover=false), enabled but nothing snapped (n_changed == 0),
or we never kept a post-crossover primal. show only has three strings total
— rolled back / applied / not applied — and this is the third.

Comment thread src/components/crossover.jl Outdated
push!(free, j)
end
end
length(free) == 1 || continue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How often do we have length(free) = 1 on realistic cases, e.g. from MathOptBenchmarkInstances.jl?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pass only tightens when an equality has exactly one free (not-at-box) variable — if that is rare on realistic LPs, the feature buys little.
Local tests (unit + toy LPs) show that case does occur and enables snaps
when PDLP already puts vars on bounds; on larger/random MOBI-style
instances it is often a no-op, and cheap (continue).

end

function crossover_n_changed(x_after, x_before)
return sum(x_after .!= x_before)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version is non-allocating:

count(!=, x_after, x_before)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count(!=, a, b) is not defined for two AbstractVectors
on Julia 1.10–1.12 (MethodError). CPU path: explicit loop (non-allocating);
GPU path: sum(x_after .!= x_before) (broadcast).

Comment thread src/components/crossover.jl Outdated
if at_box_cpu[j]
slack -= aij * x_cpu[j]
else
push!(free, j)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Judging by what comes below, this loop might be interrupted early as soon as we find two free variables. Thus, the free vector might not even be necessary?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INdeed - replaced free::Vector{Int} with (n_free, free_j, free_aij) counters
and break when n_free > 1.

Comment thread src/components/crossover.jl Outdated
Comment on lines +153 to +157
if !isfinite(uv_cpu[j])
uv_cpu[j] = implied
else
uv_cpu[j] = min(uv_cpu[j], implied)
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else actually works for both cases

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merci - simplified to uv_cpu[j] = min(uv_cpu[j], slack/aij) and
lv_cpu[j] = max(lv_cpu[j], slack/aij) (min/max with ±Inf handle both
cases).

end

"""
fraction_at_bounds(x, milp; atol=1e-12)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this used for?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed for the apply/rollback gate (n_changed > 0), not only display. The
exact integer is also stored as stats.crossover_n_snapped and shown in
ConvergenceStats — useful for tests/debug, optional for end users. Happy to
keep only the bools (applied / rolled_back) and drop the count from the
public stats if you prefer a thinner API.

Charles Vielzeuf added 2 commits July 5, 2026 16:40
After OPTIMAL termination, optionally snap primal coordinates near finite
box bounds, tighten implied bounds from equality rows, and roll back if KKT
errors regress. Controlled by CrossoverParameters on Algorithm (on by default).

Effective bounds run on CPU once per solve and copy back to device. Not a
basic-vertex crossover (unlike cuOpt): useful when PDLP stops near bounds,
usually a no-op for strictly interior solutions.

Refs JuliaDecisionFocusedLearning#13
Enum values are not separate doc bindings; use inline code instead of @ref.
Charles Vielzeuf added 2 commits July 11, 2026 16:09
…er fixes.

Document V1 vs Megiddo, parametrize bound/eq atol, traverse At via nzrange,
drop @inbounds/free vector, simplify implied bounds, and cover ConvergenceStats show.
Mutate effective bounds in place after the caller materializes CPU
vectors, clarify the implied-bounds docstring, and remove review-only
comments.
@charles-vzf

Copy link
Copy Markdown
Collaborator Author

Thank you for your review and all the comments that were very instructive.
This PR is intentionally a V1 post-PDLP rounding step (threshold snap + implied
equality bounds + KKT rollback), not a Megiddo-style crossover with simplex
pivots.
I plan to use this V1 as a baseline while designing the basis-based version.
Happy to rename to postprocess / rounding in the API if you prefer less
loaded terminology before merge (if merge?) — I'll wait for your preference before renaming.

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.

2 participants