scirs2_optimize/integer/cdcl_branching.rs
1//! CDCL-Style (Conflict-Driven Clause Learning) MIP Branching
2//!
3//! This module adapts CDCL techniques from SAT solving to MIP branch-and-bound.
4//! When a sub-problem is found infeasible, the algorithm extracts a "nogood"
5//! clause (a conjunction of branching decisions that caused infeasibility) and
6//! stores it to prune future subproblems that replay the same partial assignment.
7//!
8//! # Algorithm Overview
9//!
10//! 1. **Branching**: select the fractional variable with the highest VSIDS
11//! activity score.
12//! 2. **Conflict analysis**: when a node is infeasible, record a learned clause
13//! `NOT(d_1) ∨ NOT(d_2) ∨ … ∨ NOT(d_k)` for each branching decision d_i
14//! on the path.
15//! 3. **Propagation**: before expanding a node, check all learned clauses; prune
16//! if any clause is violated.
17//! 4. **Activity decay**: periodically decay all activity scores to prioritise
18//! recent conflicts (VSIDS heuristic).
19//!
20//! # Integration
21//!
22//! The `CdclBranchingState` is designed to plug into `MilpBranchAndBound` via
23//! the `BranchingStrategy::Cdcl` variant (see [`BranchingStrategy`]). The
24//! host solver calls:
25//! - `select_branching_var` to pick which variable to branch on.
26//! - `record_conflict` when LP infeasibility is detected.
27//! - `apply_clauses` before expanding each child node.
28//! - `decay_activities` after each batch of conflicts.
29//!
30//! # References
31//! - Marques-Silva & Sakallah (1996). "GRASP—A new search algorithm for
32//! satisfiability." ICCAD.
33//! - Achterberg, T. (2007). "Conflict analysis in mixed integer programming."
34//! Discrete Optimization, 4(1), 4–20.
35
36use std::fmt::Debug;
37
38use scirs2_core::num_traits::{Float, FromPrimitive};
39
40use crate::error::{OptimizeError, OptimizeResult};
41
42// ─────────────────────────────────────────────────────────────────────────────
43// Configuration
44// ─────────────────────────────────────────────────────────────────────────────
45
46/// Configuration for the CDCL branching state.
47#[derive(Debug, Clone)]
48pub struct CdclConfig {
49 /// Maximum number of learned clauses to retain. Oldest clauses are evicted
50 /// when this limit is reached.
51 pub max_clauses: usize,
52 /// Maximum number of clauses learned per conflict resolution.
53 pub max_learned_per_conflict: usize,
54 /// VSIDS activity decay factor applied after each conflict batch.
55 /// Typical value: 0.95.
56 pub decay: f64,
57 /// Activity bump increment applied to variables involved in a conflict.
58 pub activity_bump: f64,
59 /// Minimum activity threshold below which a clause may be deleted.
60 pub min_activity_threshold: f64,
61}
62
63impl Default for CdclConfig {
64 fn default() -> Self {
65 Self {
66 max_clauses: 10_000,
67 max_learned_per_conflict: 3,
68 decay: 0.95,
69 activity_bump: 1.0,
70 min_activity_threshold: 1e-12,
71 }
72 }
73}
74
75// ─────────────────────────────────────────────────────────────────────────────
76// Core data structures
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// A single branching decision: set variable `var_index` to value `value`.
80///
81/// For binary variables `value` ∈ {0, 1}. For general integer variables,
82/// the clause uses the exact integer value.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct BranchingDecision {
85 /// Index of the branched variable.
86 pub var_index: usize,
87 /// Value assigned by this decision.
88 pub value: i32,
89}
90
91impl BranchingDecision {
92 /// Create a new branching decision.
93 pub fn new(var_index: usize, value: i32) -> Self {
94 Self { var_index, value }
95 }
96}
97
98/// A learned nogood clause: a disjunction of negated literals.
99///
100/// A clause `{(i₁, v₁), (i₂, v₂), …}` is violated (and triggers pruning) when
101/// every literal matches a current decision: `decision[iⱼ] == vⱼ` for all j.
102#[derive(Debug, Clone)]
103pub struct LearnedClause {
104 /// Literals `(var_index, value)`. The clause fires when all literals are
105 /// satisfied simultaneously by the current decision trail.
106 pub literals: Vec<(usize, i32)>,
107 /// Activity score of this clause (used for clause deletion).
108 pub activity: f64,
109}
110
111impl LearnedClause {
112 /// Create a new learned clause from a slice of variable-value pairs.
113 pub fn new(literals: Vec<(usize, i32)>) -> Self {
114 Self {
115 literals,
116 activity: 1.0,
117 }
118 }
119
120 /// Return the number of literals in the clause.
121 pub fn len(&self) -> usize {
122 self.literals.len()
123 }
124
125 /// Return true if the clause has no literals (tautology / empty).
126 pub fn is_empty(&self) -> bool {
127 self.literals.is_empty()
128 }
129
130 /// Check whether this clause subsumes `other`, i.e., every literal of
131 /// `self` also appears in `other`. A subsuming clause is strictly
132 /// stronger (shorter or equal).
133 pub fn subsumes(&self, other: &LearnedClause) -> bool {
134 self.literals.iter().all(|lit| other.literals.contains(lit))
135 }
136}
137
138// ─────────────────────────────────────────────────────────────────────────────
139// CDCL Branching State
140// ─────────────────────────────────────────────────────────────────────────────
141
142/// Mutable state for CDCL-style MIP branching.
143///
144/// Maintains a decision trail, a database of learned clauses, and VSIDS
145/// activity scores for heuristic variable selection.
146#[derive(Debug, Clone)]
147pub struct CdclBranchingState<F = f64> {
148 /// Number of (binary/integer) variables in the problem.
149 pub n_vars: usize,
150 /// Current decision trail (ordered stack).
151 pub decisions: Vec<BranchingDecision>,
152 /// Accumulated learned clauses (nogoods).
153 pub learned_clauses: Vec<LearnedClause>,
154 /// VSIDS activity scores per variable.
155 pub activity: Vec<F>,
156 /// Solver configuration.
157 pub config: CdclConfig,
158}
159
160impl<F> CdclBranchingState<F>
161where
162 F: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::MulAssign,
163{
164 /// Create a new CDCL branching state for a problem with `n_vars` variables.
165 pub fn new(n_vars: usize, config: CdclConfig) -> OptimizeResult<Self> {
166 if n_vars == 0 {
167 return Err(OptimizeError::InvalidInput(
168 "n_vars must be positive".into(),
169 ));
170 }
171 Ok(Self {
172 n_vars,
173 decisions: Vec::new(),
174 learned_clauses: Vec::new(),
175 activity: vec![F::zero(); n_vars],
176 config,
177 })
178 }
179
180 /// Select the variable to branch on.
181 ///
182 /// Among all variables that are **fractional** in the LP relaxation
183 /// (i.e., `0 < lp_solution[i] < 1`), return the index with the highest
184 /// activity score. If no fractional variable exists, return `None`.
185 ///
186 /// # Arguments
187 /// * `lp_solution` – current LP relaxation solution (length `n_vars`).
188 pub fn select_branching_var(&self, lp_solution: &[F]) -> Option<usize> {
189 if lp_solution.len() != self.n_vars {
190 return None;
191 }
192
193 let zero = F::zero();
194 let one = F::one();
195 let frac_tol = F::from_f64(1e-6).unwrap_or_else(|| F::from_f64(1e-6).unwrap_or(zero));
196
197 let mut best_idx: Option<usize> = None;
198 let mut best_activity = F::neg_infinity();
199
200 for (i, &val) in lp_solution.iter().enumerate() {
201 // Fractional: strictly between 0 and 1
202 if val > frac_tol && val < one - frac_tol {
203 let act = self.activity[i];
204 if act > best_activity {
205 best_activity = act;
206 best_idx = Some(i);
207 }
208 }
209 }
210
211 best_idx
212 }
213
214 /// Record a conflict: given the set of branching decisions that led to
215 /// infeasibility, extract a learned clause and update VSIDS activities.
216 ///
217 /// # Algorithm
218 /// 1. Build clause `{NOT d | d ∈ infeasible_decisions}` as the conjunction
219 /// of negated literals.
220 /// 2. Bump activity for all variables in the clause.
221 /// 3. Apply clause minimisation (remove literals subsumed by existing
222 /// shorter clauses).
223 /// 4. Evict oldest clauses if the database exceeds `max_clauses`.
224 ///
225 /// At most `max_learned_per_conflict` clauses are added per call.
226 pub fn record_conflict(&mut self, infeasible_decisions: &[BranchingDecision]) {
227 if infeasible_decisions.is_empty() {
228 return;
229 }
230
231 // Build learned clause from negated decisions
232 let literals: Vec<(usize, i32)> = infeasible_decisions
233 .iter()
234 .map(|d| (d.var_index, d.value))
235 .collect();
236
237 let clause = LearnedClause::new(literals);
238
239 // Bump activity for all variables in the clause
240 let bump = F::from_f64(self.config.activity_bump).unwrap_or(F::one());
241 for &(var_idx, _) in &clause.literals {
242 if var_idx < self.n_vars {
243 self.activity[var_idx] += bump;
244 }
245 }
246
247 // Only add if not subsumed by an existing clause
248 let already_covered = self
249 .learned_clauses
250 .iter()
251 .any(|existing| existing.subsumes(&clause));
252
253 if !already_covered {
254 self.learned_clauses.push(clause);
255 }
256
257 // Evict oldest clauses if limit exceeded
258 if self.learned_clauses.len() > self.config.max_clauses {
259 let excess = self.learned_clauses.len() - self.config.max_clauses;
260 self.learned_clauses.drain(0..excess);
261 }
262
263 // Decay activities periodically after recording a conflict
264 self.decay_activities();
265 }
266
267 /// Check whether any learned clause is violated by the current decisions.
268 ///
269 /// A clause is **violated** (fires) when every literal `(i, v)` in the
270 /// clause is matched by some decision `d` with `d.var_index == i` and
271 /// `d.value == v`. Firing indicates that the current node is provably
272 /// infeasible and should be pruned.
273 ///
274 /// Returns `true` if at least one clause fires (prune this node).
275 pub fn apply_clauses(&self, current_decisions: &[BranchingDecision]) -> bool {
276 'clause: for clause in &self.learned_clauses {
277 if clause.is_empty() {
278 continue;
279 }
280 // Check if every literal is satisfied by current_decisions
281 for &(var_idx, value) in &clause.literals {
282 let matched = current_decisions
283 .iter()
284 .any(|d| d.var_index == var_idx && d.value == value);
285 if !matched {
286 continue 'clause; // This clause does not fire
287 }
288 }
289 // All literals matched → prune
290 return true;
291 }
292 false
293 }
294
295 /// Decay all variable activity scores by multiplying by `config.decay`.
296 ///
297 /// This implements the VSIDS (Variable State Independent Decaying Sum)
298 /// heuristic, which prioritises variables that appeared in *recent*
299 /// conflicts.
300 pub fn decay_activities(&mut self) {
301 let decay = F::from_f64(self.config.decay).unwrap_or(F::one());
302 for act in &mut self.activity {
303 *act *= decay;
304 }
305 }
306
307 /// Push a new decision onto the decision trail.
308 pub fn push_decision(&mut self, decision: BranchingDecision) {
309 self.decisions.push(decision);
310 }
311
312 /// Pop the most recent decision from the trail (backtrack one level).
313 pub fn pop_decision(&mut self) -> Option<BranchingDecision> {
314 self.decisions.pop()
315 }
316
317 /// Return the number of learned clauses currently in the database.
318 pub fn n_learned_clauses(&self) -> usize {
319 self.learned_clauses.len()
320 }
321
322 /// Remove all learned clauses with activity below the configured threshold.
323 pub fn prune_inactive_clauses(&mut self) {
324 let threshold = self.config.min_activity_threshold;
325 self.learned_clauses.retain(|c| c.activity >= threshold);
326 }
327}
328
329// ─────────────────────────────────────────────────────────────────────────────
330// BranchingStrategy hook (interface documentation)
331// ─────────────────────────────────────────────────────────────────────────────
332
333/// Enumeration of branching strategies for use with `MilpBranchAndBound`.
334///
335/// This enum is intended to be incorporated into the `MilpBranchAndBound`
336/// solver to switch between branching heuristics at runtime.
337///
338/// # Integration with `MilpBranchAndBound`
339///
340/// ```text
341/// match strategy {
342/// BranchingStrategy::MostFractional => { /* existing code */ }
343/// BranchingStrategy::StrongBranching => { /* existing code */ }
344/// BranchingStrategy::Cdcl(ref mut state) => {
345/// let var = state.select_branching_var(lp_sol)?;
346/// // ... branch on `var`, then:
347/// if node_infeasible {
348/// state.record_conflict(&decisions_on_path);
349/// }
350/// if state.apply_clauses(¤t_decisions) {
351/// prune_node();
352/// }
353/// }
354/// }
355/// ```
356#[derive(Debug)]
357#[non_exhaustive]
358pub enum BranchingStrategy<F = f64> {
359 /// Most-fractional variable selection (standard heuristic).
360 MostFractional,
361 /// Strong branching: evaluate both child LP relaxations before committing.
362 StrongBranching,
363 /// CDCL-style branching with clause learning and VSIDS activity.
364 Cdcl(CdclBranchingState<F>),
365}
366
367// ─────────────────────────────────────────────────────────────────────────────
368// Tests
369// ─────────────────────────────────────────────────────────────────────────────
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 type F = f64;
376
377 fn make_state(n: usize) -> CdclBranchingState<F> {
378 CdclBranchingState::new(n, CdclConfig::default()).unwrap()
379 }
380
381 // ── Initialization ───────────────────────────────────────────────────────
382
383 #[test]
384 fn test_new_state_correct_size() {
385 let state = make_state(5);
386 assert_eq!(state.n_vars, 5);
387 assert_eq!(state.activity.len(), 5);
388 assert!(state.decisions.is_empty());
389 assert!(state.learned_clauses.is_empty());
390 }
391
392 #[test]
393 fn test_cdcl_config_defaults() {
394 let cfg = CdclConfig::default();
395 assert_eq!(cfg.max_clauses, 10_000);
396 assert_eq!(cfg.max_learned_per_conflict, 3);
397 assert!((cfg.decay - 0.95).abs() < 1e-12);
398 assert!((cfg.activity_bump - 1.0).abs() < 1e-12);
399 }
400
401 // ── Variable selection ───────────────────────────────────────────────────
402
403 #[test]
404 fn test_select_branching_var_picks_highest_activity() {
405 let mut state = make_state(4);
406 // Manually set activities: var 2 has highest
407 state.activity[0] = 0.1;
408 state.activity[1] = 0.5;
409 state.activity[2] = 1.0;
410 state.activity[3] = 0.3;
411 // All fractional
412 let lp_sol = vec![0.5, 0.7, 0.3, 0.6];
413 let selected = state.select_branching_var(&lp_sol);
414 assert_eq!(selected, Some(2), "should pick var 2 (highest activity)");
415 }
416
417 #[test]
418 fn test_select_branching_var_skips_integral() {
419 let mut state = make_state(3);
420 state.activity = vec![10.0, 0.5, 0.1];
421 // var 0 is integral (value 1.0), only vars 1 and 2 are fractional
422 let lp_sol = vec![1.0, 0.4, 0.6];
423 let selected = state.select_branching_var(&lp_sol);
424 // var 0 integral → should pick var 1 (next highest activity)
425 assert_eq!(selected, Some(1));
426 }
427
428 // ── Conflict recording ───────────────────────────────────────────────────
429
430 #[test]
431 fn test_record_conflict_creates_clause_correct_length() {
432 let mut state = make_state(4);
433 let decisions = vec![BranchingDecision::new(0, 1), BranchingDecision::new(2, 0)];
434 state.record_conflict(&decisions);
435 assert!(!state.learned_clauses.is_empty());
436 let clause = &state.learned_clauses[0];
437 assert_eq!(clause.len(), 2, "clause should have 2 literals");
438 }
439
440 #[test]
441 fn test_record_conflict_bumps_activity() {
442 let mut state = make_state(3);
443 let decisions = vec![BranchingDecision::new(0, 1), BranchingDecision::new(1, 0)];
444 state.record_conflict(&decisions);
445 // After bump and one decay: activity = bump * decay
446 let expected = 1.0 * 0.95;
447 assert!(
448 (state.activity[0] - expected).abs() < 1e-9,
449 "activity[0] = {}",
450 state.activity[0]
451 );
452 assert!(
453 (state.activity[1] - expected).abs() < 1e-9,
454 "activity[1] = {}",
455 state.activity[1]
456 );
457 // var 2 was not in conflict — only decayed
458 assert!(state.activity[2] < state.activity[0]);
459 }
460
461 // ── Clause application ───────────────────────────────────────────────────
462
463 #[test]
464 fn test_apply_clauses_no_violation() {
465 let mut state = make_state(3);
466 // Clause: (var 0 = 1) ∧ (var 1 = 0) — fires when both hold
467 state
468 .learned_clauses
469 .push(LearnedClause::new(vec![(0, 1), (1, 0)]));
470 // Current decisions match only partially
471 let current = vec![BranchingDecision::new(0, 1)]; // var 1 not set
472 assert!(!state.apply_clauses(¤t), "clause should not fire");
473 }
474
475 #[test]
476 fn test_apply_clauses_violation() {
477 let mut state = make_state(3);
478 state
479 .learned_clauses
480 .push(LearnedClause::new(vec![(0, 1), (1, 0)]));
481 // Both literals match → clause fires → prune
482 let current = vec![BranchingDecision::new(0, 1), BranchingDecision::new(1, 0)];
483 assert!(state.apply_clauses(¤t), "clause should fire");
484 }
485
486 #[test]
487 fn test_apply_clauses_empty_trail_no_violation() {
488 let mut state = make_state(3);
489 state.learned_clauses.push(LearnedClause::new(vec![(0, 1)]));
490 assert!(
491 !state.apply_clauses(&[]),
492 "empty trail cannot satisfy any clause"
493 );
494 }
495
496 // ── Activity decay ───────────────────────────────────────────────────────
497
498 #[test]
499 fn test_decay_activities_reduces_all() {
500 let mut state = make_state(3);
501 state.activity = vec![1.0, 2.0, 0.5];
502 let before: Vec<f64> = state.activity.clone();
503 state.decay_activities();
504 for (a, b) in before.iter().zip(state.activity.iter()) {
505 assert!(b < a || (*a == 0.0 && *b == 0.0));
506 }
507 }
508
509 // ── Clause subsumption ───────────────────────────────────────────────────
510
511 #[test]
512 fn test_learned_clause_subsumption() {
513 // Shorter clause {(0,1)} subsumes longer {(0,1), (1,0)}
514 let short = LearnedClause::new(vec![(0, 1)]);
515 let long = LearnedClause::new(vec![(0, 1), (1, 0)]);
516 assert!(
517 short.subsumes(&long),
518 "shorter clause should subsume longer"
519 );
520 assert!(
521 !long.subsumes(&short),
522 "longer clause should not subsume shorter"
523 );
524 }
525
526 #[test]
527 fn test_duplicate_clause_not_added() {
528 let mut state = make_state(3);
529 let decisions = vec![BranchingDecision::new(0, 1), BranchingDecision::new(1, 0)];
530 state.record_conflict(&decisions);
531 let count_before = state.learned_clauses.len();
532 // Record same conflict again — should not duplicate
533 // (re-creates same clause; subsumption check prevents duplicate)
534 let decisions2 = vec![
535 BranchingDecision::new(0, 1),
536 BranchingDecision::new(1, 0),
537 BranchingDecision::new(2, 1),
538 ];
539 state.record_conflict(&decisions2);
540 // The new (longer) clause is subsumed by the first — not added
541 assert_eq!(
542 state.learned_clauses.len(),
543 count_before,
544 "subsumed clause should not be added"
545 );
546 }
547}