Skip to main content

logicaffeine_proof/
cardinality.rs

1//! Cardinality constraints over boolean [`ProofExpr`] atoms — the missing primitive that lets the
2//! certified solver answer "at most / at least / exactly `k` of these are true". Encoded with
3//! Sinz's sequential counter (linear in `n·k`, fresh auxiliary atoms under a caller-chosen
4//! prefix), so the result is an ordinary boolean obligation the existing CNF/CDCL/RUP pipeline
5//! discharges. This is the building block for SAT-based optimization (`crate::optimize`).
6//!
7//! Correctness is pinned **exhaustively** against a brute-force oracle (every assignment, small n).
8
9use crate::ProofExpr;
10
11fn atom(s: String) -> ProofExpr {
12    ProofExpr::Atom(s)
13}
14fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
15    ProofExpr::And(Box::new(a), Box::new(b))
16}
17fn not(a: ProofExpr) -> ProofExpr {
18    ProofExpr::Not(Box::new(a))
19}
20fn implies(a: ProofExpr, b: ProofExpr) -> ProofExpr {
21    ProofExpr::Implies(Box::new(a), Box::new(b))
22}
23fn tautology() -> ProofExpr {
24    let t = atom("__card_true".to_string());
25    ProofExpr::Or(Box::new(t.clone()), Box::new(not(t)))
26}
27fn contradiction() -> ProofExpr {
28    let f = atom("__card_false".to_string());
29    and(f.clone(), not(f))
30}
31fn conj(parts: Vec<ProofExpr>) -> ProofExpr {
32    let mut it = parts.into_iter();
33    match it.next() {
34        None => tautology(),
35        Some(first) => it.fold(first, and),
36    }
37}
38
39/// "At most `k` of `vars` are true." Auxiliary atoms are named `{aux}_{i}_{j}`, so distinct
40/// constraints in one formula must use distinct `aux` prefixes.
41pub fn at_most(vars: &[ProofExpr], k: usize, aux: &str) -> ProofExpr {
42    let n = vars.len();
43    if k >= n {
44        return tautology();
45    }
46    if k == 0 {
47        return conj(vars.iter().map(|v| not(v.clone())).collect());
48    }
49    // s(i, j) ≙ "at least j of x_1..x_i are true" (1-based).
50    let s = |i: usize, j: usize| atom(format!("{aux}_{i}_{j}"));
51    let mut clauses: Vec<ProofExpr> = Vec::new();
52
53    clauses.push(implies(vars[0].clone(), s(1, 1)));
54    for j in 2..=k {
55        clauses.push(not(s(1, j)));
56    }
57    for i in 2..=n {
58        let xi = vars[i - 1].clone();
59        clauses.push(implies(xi.clone(), s(i, 1)));
60        clauses.push(implies(s(i - 1, 1), s(i, 1)));
61        for j in 2..=k {
62            clauses.push(implies(and(xi.clone(), s(i - 1, j - 1)), s(i, j)));
63            clauses.push(implies(s(i - 1, j), s(i, j)));
64        }
65        // Already k counted and another true ⇒ would exceed k: forbidden.
66        clauses.push(not(and(xi, s(i - 1, k))));
67    }
68    conj(clauses)
69}
70
71/// "At least `k` of `vars` are true" — i.e. at most `n−k` are false.
72pub fn at_least(vars: &[ProofExpr], k: usize, aux: &str) -> ProofExpr {
73    let n = vars.len();
74    if k == 0 {
75        return tautology();
76    }
77    if k > n {
78        return contradiction();
79    }
80    let negated: Vec<ProofExpr> = vars.iter().map(|v| not(v.clone())).collect();
81    at_most(&negated, n - k, aux)
82}
83
84/// "Exactly `k` of `vars` are true."
85pub fn exactly(vars: &[ProofExpr], k: usize, aux: &str) -> ProofExpr {
86    and(
87        at_least(vars, k, &format!("{aux}_ge")),
88        at_most(vars, k, &format!("{aux}_le")),
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::sat::{find_model, ModelOutcome};
96
97    fn vars(n: usize) -> Vec<ProofExpr> {
98        (0..n).map(|i| atom(format!("x{i}"))).collect()
99    }
100
101    /// Is `formula` satisfiable once the `vars` are pinned to `assignment`? (Our solver decides;
102    /// the auxiliary atoms are free.)
103    fn sat_under(formula: &ProofExpr, vars: &[ProofExpr], assignment: &[bool]) -> bool {
104        let mut f = formula.clone();
105        for (v, &b) in vars.iter().zip(assignment) {
106            let unit = if b { v.clone() } else { not(v.clone()) };
107            f = and(f, unit);
108        }
109        matches!(find_model(&f), ModelOutcome::Sat(_))
110    }
111
112    fn for_each_assignment(n: usize, mut f: impl FnMut(&[bool], usize)) {
113        for mask in 0..(1u32 << n) {
114            let asg: Vec<bool> = (0..n).map(|i| (mask >> i) & 1 == 1).collect();
115            let ones = asg.iter().filter(|b| **b).count();
116            f(&asg, ones);
117        }
118    }
119
120    #[test]
121    fn at_most_matches_brute_force() {
122        for n in 1..=6 {
123            let xs = vars(n);
124            for k in 0..=n {
125                let formula = at_most(&xs, k, "c");
126                for_each_assignment(n, |asg, ones| {
127                    assert_eq!(
128                        sat_under(&formula, &xs, asg),
129                        ones <= k,
130                        "at_most n={n} k={k} ones={ones} asg={asg:?}"
131                    );
132                });
133            }
134        }
135    }
136
137    #[test]
138    fn at_least_matches_brute_force() {
139        for n in 1..=6 {
140            let xs = vars(n);
141            for k in 0..=(n + 1) {
142                let formula = at_least(&xs, k, "c");
143                for_each_assignment(n, |asg, ones| {
144                    assert_eq!(
145                        sat_under(&formula, &xs, asg),
146                        ones >= k,
147                        "at_least n={n} k={k} ones={ones} asg={asg:?}"
148                    );
149                });
150            }
151        }
152    }
153
154    #[test]
155    fn exactly_matches_brute_force() {
156        for n in 1..=5 {
157            let xs = vars(n);
158            for k in 0..=n {
159                let formula = exactly(&xs, k, "c");
160                for_each_assignment(n, |asg, ones| {
161                    assert_eq!(
162                        sat_under(&formula, &xs, asg),
163                        ones == k,
164                        "exactly n={n} k={k} ones={ones} asg={asg:?}"
165                    );
166                });
167            }
168        }
169    }
170
171    #[test]
172    fn empty_vars_are_handled() {
173        let none: Vec<ProofExpr> = vec![];
174        for k in 0..=3 {
175            assert!(
176                matches!(find_model(&at_most(&none, k, "e")), ModelOutcome::Sat(_)),
177                "at_most over [] with k={k} is trivially satisfiable"
178            );
179        }
180        assert!(matches!(find_model(&at_least(&none, 0, "e")), ModelOutcome::Sat(_)));
181        assert!(
182            matches!(find_model(&at_least(&none, 1, "e")), ModelOutcome::Unsat),
183            "at_least 1 of nothing is impossible"
184        );
185    }
186
187    #[test]
188    fn larger_n_spot_check_beyond_exhaustive() {
189        // n=8 is past the exhaustive sweep; check a couple of representative points.
190        let xs = vars(8);
191        let f = at_most(&xs, 3, "c");
192        let mut three = vec![false; 8];
193        three[0] = true;
194        three[2] = true;
195        three[5] = true;
196        assert!(sat_under(&f, &xs, &three), "exactly 3 ≤ 3 must be SAT");
197        let mut four = vec![false; 8];
198        for b in four.iter_mut().take(4) {
199            *b = true;
200        }
201        assert!(!sat_under(&f, &xs, &four), "4 > 3 must be UNSAT");
202    }
203}