Skip to main content

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(&current_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(&current), "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(&current), "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}