Skip to main content

logicaffeine_proof/
optimize.rs

1//! Certified SAT-based optimization. We have no native MaxSAT/ILP solver, so we minimise an
2//! integer cost the honest way: binary-search the smallest feasible cost bound, where each query
3//! is a certified decision (`prove_unsat` → `Sat` witness or RUP-`Refuted`). The result is a
4//! **certified optimum** — a witness at the optimum and a refutation at optimum−1 — not merely a
5//! value we happened to find.
6//!
7//! `feasible_at(bound)` must be MONOTONE: feasible at `b` ⇒ feasible at `b+1` (e.g. "cost ≤ b" via
8//! [`crate::cardinality::at_most`]).
9
10use crate::sat::{prove_unsat, UnsatOutcome};
11use crate::ProofExpr;
12
13/// The outcome of a certified minimization.
14#[derive(Clone, Debug, PartialEq)]
15pub struct MinResult {
16    /// The smallest feasible cost bound.
17    pub optimum: i64,
18    /// A satisfying assignment at the optimum (atom → value).
19    pub witness: Vec<(String, bool)>,
20    /// `true` when `optimum-1` was RUP-`Refuted` (so the optimum is provably minimal), or when the
21    /// optimum equals the search floor.
22    pub minimal_certified: bool,
23}
24
25/// Binary-search `[lo, hi]` for the least `bound` with `feasible_at(bound)` satisfiable.
26/// Returns `None` if even `hi` is infeasible or a query leaves the supported fragment.
27pub fn minimize_certified(
28    feasible_at: impl Fn(i64) -> ProofExpr,
29    lo: i64,
30    hi: i64,
31) -> Option<MinResult> {
32    if lo > hi {
33        return None;
34    }
35    // The top of the range must be feasible, else there is no solution at all.
36    if !matches!(prove_unsat(&feasible_at(hi)), UnsatOutcome::Sat(_)) {
37        return None;
38    }
39    let mut l = lo;
40    let mut h = hi;
41    while l < h {
42        let mid = l + (h - l) / 2;
43        match prove_unsat(&feasible_at(mid)) {
44            UnsatOutcome::Sat(_) => h = mid,
45            UnsatOutcome::Refuted => l = mid + 1,
46            UnsatOutcome::Unsupported => return None,
47        }
48    }
49    let optimum = l;
50    let witness = match prove_unsat(&feasible_at(optimum)) {
51        UnsatOutcome::Sat(m) => m,
52        _ => return None,
53    };
54    let minimal_certified = optimum <= lo
55        || matches!(prove_unsat(&feasible_at(optimum - 1)), UnsatOutcome::Refuted);
56    Some(MinResult { optimum, witness, minimal_certified })
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::cardinality::at_most;
63
64    fn atom(s: &str) -> ProofExpr {
65        ProofExpr::Atom(s.to_string())
66    }
67    fn or(a: ProofExpr, b: ProofExpr) -> ProofExpr {
68        ProofExpr::Or(Box::new(a), Box::new(b))
69    }
70    fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
71        ProofExpr::And(Box::new(a), Box::new(b))
72    }
73    fn contradiction() -> ProofExpr {
74        let f = atom("__f");
75        and(f.clone(), ProofExpr::Not(Box::new(f)))
76    }
77
78    /// Minimum number of `true` atoms satisfying a set of clauses, found and CERTIFIED.
79    #[test]
80    fn finds_certified_minimum_hitting_count() {
81        // (a∨b) ∧ (b∨c) ∧ (a∨c): every assignment with <2 trues leaves a clause unsatisfied,
82        // so the minimum is exactly 2.
83        let (a, b, c) = (atom("a"), atom("b"), atom("c"));
84        let vars = vec![a.clone(), b.clone(), c.clone()];
85        let clauses = and(
86            and(or(a.clone(), b.clone()), or(b.clone(), c.clone())),
87            or(a.clone(), c.clone()),
88        );
89        let feasible_at = |bound: i64| {
90            if bound < 0 {
91                contradiction()
92            } else {
93                and(clauses.clone(), at_most(&vars, bound as usize, "cost"))
94            }
95        };
96        let res = minimize_certified(feasible_at, 0, 3).expect("feasible at 3");
97        assert_eq!(res.optimum, 2, "minimum hitting count is 2");
98        assert!(res.minimal_certified, "a 1-true solution must be RUP-refuted");
99        // Sanity: the witness really turns on at least 2 of a/b/c.
100        let ons = res
101            .witness
102            .iter()
103            .filter(|(n, v)| *v && matches!(n.as_str(), "a" | "b" | "c"))
104            .count();
105        assert!(ons >= 2, "witness must satisfy the clauses: {:?}", res.witness);
106    }
107
108    #[test]
109    fn zero_cost_optimum_when_unconstrained() {
110        // No constraints → 0 trues suffices; optimum is the floor and is trivially minimal.
111        let vars = vec![atom("a"), atom("b")];
112        let feasible_at = |bound: i64| at_most(&vars, bound.max(0) as usize, "c");
113        let res = minimize_certified(feasible_at, 0, 2).unwrap();
114        assert_eq!(res.optimum, 0);
115        assert!(res.minimal_certified);
116    }
117
118    #[test]
119    fn infeasible_range_returns_none() {
120        // "cost ≤ 1" can never satisfy a clause needing 2 trues → no solution in [0, 1].
121        let (a, b, c) = (atom("a"), atom("b"), atom("c"));
122        let vars = vec![a.clone(), b.clone(), c.clone()];
123        let clauses = and(
124            and(or(a.clone(), b.clone()), or(b.clone(), c.clone())),
125            or(a.clone(), c.clone()),
126        );
127        let feasible_at =
128            |bound: i64| and(clauses.clone(), at_most(&vars, bound.max(0) as usize, "cost"));
129        assert!(minimize_certified(feasible_at, 0, 1).is_none());
130    }
131
132    #[test]
133    fn larger_cover_minimum_is_three() {
134        // Five clauses forcing at least 3 of {a,b,c,d}: pairwise-ish coverage that 2 can't hit.
135        // K4 edges as clauses (each edge needs an endpoint) only forces a vertex cover; instead
136        // force exactly: require a, and (b∨c), and (c∨d), and (b∨d) → need b,c,d cover {bc,cd,bd}
137        // = at least 2 of b,c,d, plus a ⇒ min 3.
138        let (a, b, c, d) = (atom("a"), atom("b"), atom("c"), atom("d"));
139        let vars = vec![a.clone(), b.clone(), c.clone(), d.clone()];
140        let clauses = and(
141            and(a.clone(), or(b.clone(), c.clone())),
142            and(or(c.clone(), d.clone()), or(b.clone(), d.clone())),
143        );
144        let feasible_at =
145            |bound: i64| and(clauses.clone(), at_most(&vars, bound.max(0) as usize, "cost"));
146        let res = minimize_certified(feasible_at, 0, 4).unwrap();
147        assert_eq!(res.optimum, 3);
148        assert!(res.minimal_certified);
149    }
150
151    #[test]
152    fn singleton_range_returns_that_bound() {
153        // Force both a and b true (cost 2); search the singleton range [2, 2].
154        let vars = vec![atom("a"), atom("b")];
155        let clauses = and(atom("a"), atom("b"));
156        let feasible_at =
157            |bound: i64| and(clauses.clone(), at_most(&vars, bound.max(0) as usize, "c"));
158        let res = minimize_certified(feasible_at, 2, 2).unwrap();
159        assert_eq!(res.optimum, 2);
160        assert!(res.minimal_certified, "optimum == lo is trivially minimal");
161    }
162
163    #[test]
164    fn lo_greater_than_hi_is_none() {
165        let vars = vec![atom("a")];
166        let feasible_at = |bound: i64| at_most(&vars, bound.max(0) as usize, "c");
167        assert!(minimize_certified(feasible_at, 5, 2).is_none());
168    }
169}