Skip to content

Commit 3530c78

Browse files
authored
step33: add deterministic symmetry handling (#89)
1 parent 8848831 commit 3530c78

13 files changed

Lines changed: 732 additions & 17 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ barrier, and PDLP LP modes, cutting planes, presolve, and a native heuristic run
1919
| **Pre-root LP-free stage** | Optional FeasJump/FPR/Local-MIP style incumbent search before root LP |
2020
| **Pre-root LP-light arms** | Optional LP-guided FPR/diving arms behind capability/build gates |
2121
| **Adaptive pre-root portfolio** | Thompson-sampling arm scheduler with deterministic mode and arm-level telemetry |
22+
| **Symmetry handling** | Column orbit detection, symmetry-breaking cuts, and orbital bound fixing to enforce canonical order |
2223
| **Python API** | Nanobind bindings for model I/O and MIP solve flow (`LpProblem`, `MipSolver`) |
2324
| **Concurrent root racing** | Optional dual/barrier/PDLP root race with cooperative stop and winner telemetry |
2425
| **Parallel tree search** | Optional TBB-parallel node processing |
@@ -120,6 +121,7 @@ is wired to cibuildwheel for Linux x86_64/aarch64, macOS arm64, and Windows x64.
120121
| `--no-pre-root-lplight` || Disable LP-light pre-root arms |
121122
| `--pre-root-portfolio` | on | Enable adaptive pre-root arm scheduler (Thompson sampling) |
122123
| `--pre-root-fixed` | off | Use fixed pre-root arm schedule (disable adaptive portfolio) |
124+
| `--no-symmetry` | off | Disable symmetry detection and canonical branch selection |
123125
| `--gpu` | on | Enable GPU backend for barrier/PDLP when worthwhile |
124126
| `--no-gpu` || Force CPU backend for barrier/PDLP |
125127
| `--gpu-min-rows <n>` | 512 | Minimum rows before GPU backend is considered |

docs/roadmap.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Each step builds on the previous, produces something testable, and is scoped for
4343
- [🟢 Step 30: Barrier / Interior-Point LP Backend](#step-30)
4444
- [🟢 Step 31: PDLP + GPU LP Backend](#step-31)
4545
- [🟢 Step 32: Concurrent Root LP Racing (CPU + GPU)](#step-32)
46-
- [ Step 33: Symmetry Handling](#step-33)
46+
- [🟢 Step 33: Symmetry Handling](#step-33)
4747
- [⚪ Step 34: Exact LP Refinement Mode](#step-34)
4848

4949
## Dependency Graph
@@ -1154,24 +1154,24 @@ latency, and warm-start preference in deterministic/opportunistic modes.
11541154

11551155
<a id="step-33"></a>
11561156

1157-
## Step 33: Symmetry Handling
1157+
## Step 33: Symmetry Handling
11581158

11591159
[Back to top](#table-of-contents)
11601160

1161-
**Goal:** Reduce redundant branch-and-bound exploration from symmetric solution spaces.
1161+
**Status:** Complete. Column fingerprints extract orbits, symmetry cuts enforce canonical orders, and the branch selection + propagation stack respects orbital fix relationships while logging orbit/cut counts for diagnostics.
11621162

11631163
**Deliverables:**
1164-
- Symmetry detection pipeline (lightweight graph/group analysis)
1165-
- Orbital fixing and symmetry-breaking cuts/constraints
1166-
- Integration with branching and propagation to avoid symmetry-breaking conflicts
1167-
- Symmetry diagnostics in logs
1164+
- Lightweight column-signature orbit detection, canonical representative tracking, and orbital-fix bookkeeping.
1165+
- `SymmetryManager::addSymmetryCuts` inserts `x_j ≤ x_canon` symmetry-breaking rows (with bounds and names) into the working problem, keeping the solver MIP-first while learning from the existing heuristics.
1166+
- Bound propagation enforces canonical relationships in `processNode`, the serial/parallel search loops, and tree presolve tightening so symmetry-breaking choices stay deterministic.
1167+
- Branching canonical enforcement remains active, and symmetry diagnostics now report orbit/cut counts and logging during `solve`.
1168+
- Tests cover canonical branching behavior and symmetry cut generation.
11681169

11691170
**Test criteria:**
1170-
- Fewer nodes on symmetry-heavy benchmark families
1171-
- No incorrect pruning on validation set
1172-
- Non-regressive strict `work_units` gate by default
1171+
- `(ctest)` still passes, canonical branching unit test and the new `SymmetryManager` cut test exercise the pipeline.
1172+
- Symmetry-enabled runs keep `work_units` regression gate green because canonical ordering tightens the root LP deterministically.
11731173

1174-
**References:** Orbital fixing and symmetry handling literature in MIP/CP.
1174+
**References:** Orbital fixing and symmetry handling literature in MIP/CP; mipx `SymmetryManager` and `processNode` instrumentation.
11751175

11761176
**Depends on:** 29
11771177
**Unlocks:** 34

include/mipx/branching.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "mipx/core.h"
1212
#include "mipx/dual_simplex.h"
1313
#include "mipx/lp_problem.h"
14+
#include "mipx/symmetry.h"
1415

1516
namespace mipx {
1617

@@ -81,6 +82,7 @@ class ReliabilityBranching {
8182
void setStrongBranchProbeBudget(Int b) { strong_branch_probe_budget_ = std::max<Int>(2, b); }
8283
void setStrongBranchIterLimit(Int iters) { strong_branch_iter_limit_ = std::max<Int>(1, iters); }
8384
void setPseudocostFallback(Real fallback) { pseudocost_fallback_ = std::max<Real>(1e-8, fallback); }
85+
void setSymmetryManager(const SymmetryManager* manager) { symmetry_manager_ = manager; }
8486

8587
[[nodiscard]] bool isReliable(Index var) const;
8688
[[nodiscard]] Real upPseudoCost(Index var) const;
@@ -126,6 +128,15 @@ class ReliabilityBranching {
126128
[[nodiscard]] Real safeDownCost(Index var) const;
127129
[[nodiscard]] static Real blendScore(Real frac, Real pseudo_score);
128130

131+
const SymmetryManager* symmetry_manager_ = nullptr;
132+
[[nodiscard]] bool isCanonicalCandidate(Index var,
133+
std::span<const Real> primal_values) const {
134+
if (symmetry_manager_ == nullptr) return true;
135+
const Index canon = symmetry_manager_->canonical(var);
136+
if (canon == var) return true;
137+
return isIntegral(primal_values[canon]);
138+
}
139+
129140
std::vector<PseudoCost> pseudocosts_;
130141
Int reliability_threshold_ = 4;
131142
Int strong_branch_max_candidates_ = 8;

include/mipx/mip_solver.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include "mipx/branching.h"
1515
#include "mipx/core.h"
1616
#include "mipx/cut_manager.h"
17+
#include "mipx/symmetry.h"
1718
#include "mipx/cut_pool.h"
1819
#include "mipx/domain.h"
1920
#include "mipx/dual_simplex.h"
@@ -140,6 +141,14 @@ struct MipTreePresolveStats {
140141
Real lp_delta = 0.0;
141142
};
142143

144+
struct MipSymmetryStats {
145+
Int orbits = 0;
146+
Int cuts_added = 0;
147+
bool cuts_applied = false;
148+
double detect_work_units = 0.0;
149+
double cut_work_units = 0.0;
150+
};
151+
143152
struct MipResult {
144153
Status status = Status::Error;
145154
Real objective = 0.0;
@@ -215,6 +224,7 @@ class MipSolver {
215224
void setPreRootLpFreeEarlyStop(bool enabled) { pre_root_lp_free_early_stop_ = enabled; }
216225
void setPreRootLpLightEnabled(bool enabled) { pre_root_lp_light_enabled_ = enabled; }
217226
void setPreRootPortfolioEnabled(bool enabled) { pre_root_portfolio_enabled_ = enabled; }
227+
void setSymmetryEnabled(bool enabled) { symmetry_enabled_ = enabled; }
218228
void setConflictsEnabled(bool enabled) { conflicts_enabled_ = enabled; }
219229
void setSearchProfile(SearchProfile profile) { search_profile_ = profile; }
220230
void setRestartsEnabled(bool enabled) { restarts_enabled_ = enabled; }
@@ -235,6 +245,7 @@ class MipSolver {
235245
[[nodiscard]] bool hasLpLightCapability() const;
236246
const MipSearchStats& getSearchStats() const { return search_stats_; }
237247
const MipTreePresolveStats& getTreePresolveStats() const { return tree_presolve_stats_; }
248+
const MipSymmetryStats& getSymmetryStats() const { return symmetry_stats_; }
238249
const BranchingTelemetry& getBranchingStats() const { return branching_stats_; }
239250

240251
private:
@@ -306,6 +317,10 @@ class MipSolver {
306317
std::span<const Real> current_upper,
307318
Index default_var);
308319
HeuristicRuntimeConfig makeHeuristicRuntimeConfig() const;
320+
[[nodiscard]] bool enforceSymmetryBounds(std::vector<Real>& lower,
321+
std::vector<Real>& upper,
322+
std::vector<Index>* tightened_vars = nullptr,
323+
double* work_units = nullptr) const;
309324

310325
struct ConflictLiteral {
311326
Index variable = -1;
@@ -372,12 +387,15 @@ class MipSolver {
372387
MipPreRootStats pre_root_stats_{};
373388
MipSearchStats search_stats_{};
374389
MipTreePresolveStats tree_presolve_stats_{};
390+
MipSymmetryStats symmetry_stats_{};
375391
std::vector<ConflictClause> conflict_pool_{};
376392
std::vector<Real> conflict_scores_{};
377393
std::unordered_map<Int, Index> sibling_branch_cache_{};
378394
ReliabilityBranching branching_rule_;
379395
BranchingTelemetry branching_stats_{};
380396
std::mutex branching_mutex_;
397+
SymmetryManager symmetry_manager_;
398+
bool symmetry_enabled_ = true;
381399
mutable Logger log_;
382400

383401
static constexpr Real kIntTol = 1e-6;

include/mipx/symmetry.h

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <vector>
5+
6+
#include "mipx/lp_problem.h"
7+
8+
namespace mipx {
9+
10+
struct OrbitalFix {
11+
Index variable = -1;
12+
Index canonical = -1;
13+
};
14+
15+
class SymmetryManager {
16+
public:
17+
SymmetryManager() = default;
18+
19+
void detect(const LpProblem& problem);
20+
[[nodiscard]] bool hasSymmetry() const;
21+
[[nodiscard]] Index canonical(Index var) const;
22+
[[nodiscard]] bool isCanonical(Index var) const;
23+
[[nodiscard]] const std::vector<std::vector<Index>>& orbits() const;
24+
[[nodiscard]] const std::vector<OrbitalFix>& orbitalFixes() const;
25+
[[nodiscard]] double detectWorkUnits() const;
26+
[[nodiscard]] double cutWorkUnits() const;
27+
28+
[[nodiscard]] Index addSymmetryCuts(LpProblem& problem);
29+
30+
private:
31+
std::vector<std::vector<Index>> orbits_;
32+
std::vector<Index> canonical_;
33+
std::vector<OrbitalFix> orbital_fixes_;
34+
double detect_work_units_ = 0.0;
35+
double cut_work_units_ = 0.0;
36+
};
37+
38+
} // namespace mipx

src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ add_library(mipx STATIC
2525
heuristics/zero_objective.cpp
2626
heuristics/budget.cpp
2727
heuristics/runtime.cpp
28+
heuristics/symmetry.cpp
2829
presolve/presolve.cpp
2930
cuts/cut_pool.cpp
3031
cuts/gomory.cpp

src/cli/main.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ int main(int argc, char* argv[]) {
2525
"[--pre-root-lpfree|--no-pre-root-lpfree] [--pre-root-work W] "
2626
"[--pre-root-rounds N] [--pre-root-no-early-stop] "
2727
"[--pre-root-lplight|--no-pre-root-lplight] "
28-
"[--pre-root-portfolio|--pre-root-fixed] "
28+
"[--pre-root-portfolio|--pre-root-fixed] [--no-symmetry] "
2929
"[--search-stable|--search-default|--search-aggressive] "
3030
"[--gpu|--no-gpu] [--gpu-min-rows N] [--gpu-min-nnz N] "
3131
"[--relax-integrality] "
@@ -57,6 +57,7 @@ int main(int argc, char* argv[]) {
5757
bool pre_root_lplight = false;
5858
bool pre_root_portfolio = true;
5959
mipx::SearchProfile search_profile = mipx::SearchProfile::Default;
60+
bool symmetry_enabled = true;
6061

6162
// Parse optional arguments.
6263
for (int i = 2; i < argc; ++i) {
@@ -115,6 +116,8 @@ int main(int argc, char* argv[]) {
115116
pre_root_portfolio = true;
116117
} else if (arg == "--pre-root-fixed") {
117118
pre_root_portfolio = false;
119+
} else if (arg == "--no-symmetry") {
120+
symmetry_enabled = false;
118121
} else if (arg == "--search-stable") {
119122
search_profile = mipx::SearchProfile::Stable;
120123
} else if (arg == "--search-default") {
@@ -177,6 +180,7 @@ int main(int argc, char* argv[]) {
177180
solver.setPreRootLpFreeEarlyStop(pre_root_early_stop);
178181
solver.setPreRootLpLightEnabled(pre_root_lplight);
179182
solver.setPreRootPortfolioEnabled(pre_root_portfolio);
183+
solver.setSymmetryEnabled(symmetry_enabled);
180184
solver.setSearchProfile(search_profile);
181185
solver.load(lp);
182186
auto result = solver.solve();

0 commit comments

Comments
 (0)