Skip to main content

logicaffeine_verify/
ic3.rs

1//! IC3/PDR (Property-Directed Reachability)
2//!
3//! The gold standard for unbounded safety verification (Bradley 2011).
4//!
5//! Maintains frame sequence F_0, F_1, ..., F_k where each frame over-approximates
6//! reachable states at step i. Converges when F_i = F_{i+1} (fixpoint = inductive invariant).
7//!
8//! Core operations:
9//! 1. Counterexample to Induction (CTI): Find state in F_i AND T AND NOT P
10//! 2. Blocking: Add clause to prevent CTI state (generalized)
11//! 3. Propagation: Push clauses forward through frames
12//! 4. Convergence: Check if F_i == F_{i+1}
13
14use crate::ir::VerifyExpr;
15use crate::equivalence::{Trace, CycleState, SignalValue};
16use crate::kinduction;
17use std::collections::{HashMap, HashSet};
18use z3::{ast::Ast, ast::Bool, ast::Int, SatResult};
19
20/// Result of IC3/PDR verification.
21#[derive(Debug)]
22pub enum Ic3Result {
23    /// Property holds — inductive invariant found.
24    Safe { invariant: VerifyExpr },
25    /// Property violated — counterexample trace.
26    Unsafe { trace: Trace },
27    /// Could not determine within resource limits.
28    Unknown,
29}
30
31/// A frame in the IC3 frame sequence.
32/// Each frame is a conjunction of clauses that over-approximates reachable states.
33#[derive(Clone)]
34struct Frame {
35    clauses: Vec<VerifyExpr>,
36}
37
38impl Frame {
39    fn new() -> Self {
40        Frame { clauses: Vec::new() }
41    }
42
43    fn add_clause(&mut self, clause: VerifyExpr) {
44        // Avoid exact duplicates
45        let dbg = format!("{:?}", clause);
46        for existing in &self.clauses {
47            if format!("{:?}", existing) == dbg {
48                return;
49            }
50        }
51        self.clauses.push(clause);
52    }
53
54    fn to_expr(&self) -> VerifyExpr {
55        if self.clauses.is_empty() {
56            return VerifyExpr::bool(true);
57        }
58        let mut expr = self.clauses[0].clone();
59        for clause in &self.clauses[1..] {
60            expr = VerifyExpr::and(expr, clause.clone());
61        }
62        expr
63    }
64}
65
66/// Run IC3/PDR on a safety property.
67///
68/// - `init`: Initial state predicate (using @0 variables)
69/// - `transition`: Transition relation (using @t and @t1 variables)
70/// - `property`: Safety property to verify (using @t variables)
71/// - `max_frames`: Maximum number of frames before giving up
72pub fn ic3(
73    init: &VerifyExpr,
74    transition: &VerifyExpr,
75    property: &VerifyExpr,
76    max_frames: u32,
77) -> Ic3Result {
78
79    // Collect all variable names for model extraction
80    let mut all_vars = HashSet::new();
81    collect_all_vars(init, &mut all_vars);
82    collect_all_vars(transition, &mut all_vars);
83    collect_all_vars(property, &mut all_vars);
84    // Extract base signal names (strip @0, @t, @t1 suffixes)
85    let signal_names: Vec<String> = extract_signal_names(&all_vars);
86
87    // Phase 0: Check if init violates property
88    let init_violation = VerifyExpr::and(
89        kinduction::instantiate_at(init, 0),
90        VerifyExpr::not(kinduction::instantiate_at(property, 0)),
91    );
92    if is_sat(&init_violation) {
93        let trace = extract_trace_from_bmc(init, transition, property, 1, &signal_names);
94        return Ic3Result::Unsafe { trace };
95    }
96
97    // Phase 1: BMC — check for counterexamples up to max_frames depth
98    for k in 1..max_frames {
99        let bmc_check = build_bmc_check(init, transition, property, k);
100        if is_sat(&bmc_check) {
101            let trace = extract_trace_from_bmc(init, transition, property, k, &signal_names);
102            return Ic3Result::Unsafe { trace };
103        }
104    }
105
106    // Phase 2: IC3 proper — frame-based backward reachability
107    // Frame[0] = init, Frame[i] overapproximates states reachable in <= i steps
108    let mut frames: Vec<Frame> = vec![Frame::new()];
109    frames[0].add_clause(init.clone());
110    frames[0].add_clause(property.clone());
111
112    for _iteration in 0..max_frames {
113        // Add a new frame initialized with the property
114        let mut new_frame = Frame::new();
115        new_frame.add_clause(property.clone());
116        frames.push(new_frame);
117        let k = frames.len() - 1;
118
119        // Block CTIs at this frame level
120        let mut blocked = true;
121        for _block_attempt in 0..50 {
122            // CTI check: is there a state satisfying F_{k-1} AND T that reaches NOT P?
123            let frame_expr = frames[k - 1].to_expr();
124            let cti_formula = VerifyExpr::and(
125                kinduction::instantiate_at(&frame_expr, 0),
126                VerifyExpr::and(
127                    kinduction::instantiate_transition(transition, 0),
128                    VerifyExpr::not(kinduction::instantiate_at(property, 1)),
129                ),
130            );
131
132            if !is_sat(&cti_formula) {
133                // No CTI — this frame is OK
134                blocked = true;
135                break;
136            }
137
138            // CTI found — extract the bad predecessor state and block it
139            let bad_state = extract_cti_state(&cti_formula, &signal_names);
140
141            // Check if the bad state is reachable from init (recursively)
142            if is_reachable_from_init(init, transition, &bad_state, k as u32, &signal_names) {
143                // Real counterexample — extract full trace
144                let trace = extract_trace_from_bmc(
145                    init, transition, property, k as u32, &signal_names,
146                );
147                return Ic3Result::Unsafe { trace };
148            }
149
150            // Block the bad state: add its negation as a clause
151            // Generalize: try to drop literals from the blocking clause
152            let blocking_clause = generalize_blocking_clause(
153                transition, property, &bad_state, &frames[k - 1],
154            );
155            // Add blocking clause to frame k-1 and all earlier frames (down to 1)
156            for fi in 1..k {
157                frames[fi].add_clause(blocking_clause.clone());
158            }
159            blocked = false;
160        }
161
162        // Propagate clauses forward
163        propagate_clauses(transition, &mut frames, k);
164
165        // Convergence check: does F_k == F_{k-1}?
166        if check_convergence(&frames, k) {
167            let invariant = frames[k].to_expr();
168            return Ic3Result::Safe { invariant };
169        }
170    }
171
172    // Exhausted frames — try k-induction as fallback
173    let kind_result = kinduction::k_induction(init, transition, property, &[], max_frames);
174    match kind_result {
175        kinduction::KInductionResult::Proven { .. } => {
176            // k-induction proved it — but we should still return a proper invariant
177            // Use the last frame as the invariant (it's at least as strong as property)
178            let inv = frames.last().map(|f| f.to_expr()).unwrap_or_else(|| property.clone());
179            Ic3Result::Safe { invariant: inv }
180        }
181        kinduction::KInductionResult::Counterexample { trace, .. } => Ic3Result::Unsafe { trace },
182        _ => Ic3Result::Unknown,
183    }
184}
185
186/// Extract base signal names from variable names (strip @0, @t, @t1 suffixes).
187fn extract_signal_names(all_vars: &HashSet<String>) -> Vec<String> {
188    let mut signals = HashSet::new();
189    for v in all_vars {
190        let base = v.replace("@0", "").replace("@t1", "").replace("@t", "");
191        if !base.is_empty() {
192            signals.insert(base);
193        }
194    }
195    signals.into_iter().collect()
196}
197
198/// Collect all variable names from an expression.
199fn collect_all_vars(expr: &VerifyExpr, vars: &mut HashSet<String>) {
200    match expr {
201        VerifyExpr::Var(name) => { vars.insert(name.clone()); }
202        VerifyExpr::Binary { left, right, .. } => {
203            collect_all_vars(left, vars);
204            collect_all_vars(right, vars);
205        }
206        VerifyExpr::Not(inner) => collect_all_vars(inner, vars),
207        VerifyExpr::Iff(l, r) => {
208            collect_all_vars(l, vars);
209            collect_all_vars(r, vars);
210        }
211        VerifyExpr::ForAll { body, .. } | VerifyExpr::Exists { body, .. } => {
212            collect_all_vars(body, vars);
213        }
214        _ => {}
215    }
216}
217
218/// Build a BMC check formula: init(0) AND T(0,1) AND ... AND T(k-2,k-1) AND NOT P(i) for some i.
219fn build_bmc_check(
220    init: &VerifyExpr,
221    transition: &VerifyExpr,
222    property: &VerifyExpr,
223    k: u32,
224) -> VerifyExpr {
225    let mut formula = kinduction::instantiate_at(init, 0);
226    for t in 0..k {
227        let trans = kinduction::instantiate_transition(transition, t);
228        formula = VerifyExpr::and(formula, trans);
229    }
230    // Property must fail at step k
231    let not_prop_k = VerifyExpr::not(kinduction::instantiate_at(property, k));
232    VerifyExpr::and(formula, not_prop_k)
233}
234
235/// Extract a CTI (counterexample to induction) state from a SAT formula.
236/// Returns a conjunction of literals describing the bad predecessor state.
237fn extract_cti_state(
238    formula: &VerifyExpr,
239    signal_names: &[String],
240) -> VerifyExpr {
241    let solver = crate::solver::new_solver();
242    let encoded = encode_bool(formula);
243    solver.assert(&encoded);
244
245    if !matches!(solver.check(), SatResult::Sat) {
246        return VerifyExpr::bool(false);
247    }
248    let model = solver.get_model().unwrap();
249
250    // Extract values at step 0 (the predecessor state)
251    let mut literals = Vec::new();
252    for sig in signal_names {
253        let var_name = format!("{}@0", sig);
254        // Try boolean
255        let bool_var = Bool::new_const(var_name.as_str());
256        if let Some(val) = model.eval(&bool_var, true) {
257            if let Some(b) = val.as_bool() {
258                if b {
259                    literals.push(VerifyExpr::var(&format!("{}@t", sig)));
260                } else {
261                    literals.push(VerifyExpr::not(VerifyExpr::var(&format!("{}@t", sig))));
262                }
263            }
264        }
265        // Try integer
266        let int_var = Int::new_const(var_name.as_str());
267        if let Some(val) = model.eval(&int_var, true) {
268            if let Some(n) = val.as_i64() {
269                literals.push(VerifyExpr::eq(
270                    VerifyExpr::var(&format!("{}@t", sig)),
271                    VerifyExpr::int(n),
272                ));
273            }
274        }
275    }
276
277    if literals.is_empty() {
278        VerifyExpr::bool(true)
279    } else {
280        let mut conj = literals[0].clone();
281        for lit in &literals[1..] {
282            conj = VerifyExpr::and(conj, lit.clone());
283        }
284        conj
285    }
286}
287
288/// Generalize a blocking clause by trying to drop literals.
289/// A literal can be dropped if the remaining clause still blocks the CTI
290/// (i.e., the clause is still inductive relative to the frame).
291fn generalize_blocking_clause(
292    transition: &VerifyExpr,
293    property: &VerifyExpr,
294    bad_state: &VerifyExpr,
295    _frame: &Frame,
296) -> VerifyExpr {
297    // Start with NOT(bad_state) — the full blocking clause
298    let full_clause = VerifyExpr::not(bad_state.clone());
299
300    // Extract individual literals from the bad state
301    let literals = extract_literals(bad_state);
302    if literals.len() <= 1 {
303        return full_clause;
304    }
305
306    // Try dropping each literal — keep the clause if it's still valid
307    let mut kept_literals = literals.clone();
308    for i in 0..literals.len() {
309        if kept_literals.len() <= 1 {
310            break;
311        }
312        // Try without literal i
313        let mut candidate: Vec<VerifyExpr> = Vec::new();
314        for (j, lit) in kept_literals.iter().enumerate() {
315            if j != i {
316                candidate.push(lit.clone());
317            }
318        }
319
320        // Build the negated candidate (the state we're blocking)
321        let candidate_state = conjoin(&candidate);
322        let _candidate_clause = VerifyExpr::not(candidate_state.clone());
323
324        // Check: is the candidate clause still consistent with the frame?
325        // (frame AND candidate_state AND T AND NOT P) should still be UNSAT
326        // after adding the clause to the frame
327        let check = VerifyExpr::and(
328            kinduction::instantiate_at(&candidate_state, 0),
329            VerifyExpr::and(
330                kinduction::instantiate_transition(transition, 0),
331                VerifyExpr::not(kinduction::instantiate_at(property, 1)),
332            ),
333        );
334        // If the weakened state can still reach NOT P, we need this literal
335        if is_sat(&check) {
336            // Can't drop literal i — keep it
337        } else {
338            // Literal i is redundant — drop it
339            kept_literals = candidate;
340        }
341    }
342
343    if kept_literals.is_empty() {
344        full_clause
345    } else {
346        VerifyExpr::not(conjoin(&kept_literals))
347    }
348}
349
350/// Extract top-level AND literals from an expression.
351fn extract_literals(expr: &VerifyExpr) -> Vec<VerifyExpr> {
352    match expr {
353        VerifyExpr::Binary { op: crate::ir::VerifyOp::And, left, right } => {
354            let mut lits = extract_literals(left);
355            lits.extend(extract_literals(right));
356            lits
357        }
358        _ => vec![expr.clone()],
359    }
360}
361
362/// Conjoin a list of expressions.
363fn conjoin(exprs: &[VerifyExpr]) -> VerifyExpr {
364    if exprs.is_empty() {
365        return VerifyExpr::bool(true);
366    }
367    let mut result = exprs[0].clone();
368    for e in &exprs[1..] {
369        result = VerifyExpr::and(result, e.clone());
370    }
371    result
372}
373
374/// Check if a bad state is reachable from init within k steps.
375fn is_reachable_from_init(
376    init: &VerifyExpr,
377    transition: &VerifyExpr,
378    bad_state: &VerifyExpr,
379    k: u32,
380    _signal_names: &[String],
381) -> bool {
382    for depth in 0..k {
383        let mut formula = kinduction::instantiate_at(init, 0);
384        for t in 0..depth {
385            formula = VerifyExpr::and(formula, kinduction::instantiate_transition(transition, t));
386        }
387        // Check if bad_state is reachable at step `depth`
388        let bad_at_depth = kinduction::instantiate_at(bad_state, depth);
389        let check = VerifyExpr::and(formula, bad_at_depth);
390        if is_sat(&check) {
391            return true;
392        }
393    }
394    false
395}
396
397/// Propagate clauses from frame[i] to frame[i+1] where they are inductive.
398fn propagate_clauses(
399    transition: &VerifyExpr,
400    frames: &mut Vec<Frame>,
401    k: usize,
402) {
403    if k == 0 { return; }
404    let clauses_to_try: Vec<VerifyExpr> = frames[k - 1].clauses.clone();
405
406    for clause in clauses_to_try {
407        // Check if clause is inductive relative to frame[k]:
408        // frame[k] AND clause AND T AND NOT clause' is UNSAT?
409        let frame_k_expr = frames[k].to_expr();
410        let check = VerifyExpr::and(
411            kinduction::instantiate_at(&frame_k_expr, 0),
412            VerifyExpr::and(
413                kinduction::instantiate_at(&clause, 0),
414                VerifyExpr::and(
415                    kinduction::instantiate_transition(transition, 0),
416                    VerifyExpr::not(kinduction::instantiate_at(&clause, 1)),
417                ),
418            ),
419        );
420        if !is_sat(&check) {
421            frames[k].add_clause(clause);
422        }
423    }
424}
425
426/// Check if frames[k-1] and frames[k] have converged (same clause set modulo entailment).
427fn check_convergence(frames: &[Frame], k: usize) -> bool {
428    if k == 0 { return false; }
429
430    let fk = frames[k].to_expr();
431    let fk_prev = frames[k - 1].to_expr();
432
433    // Check F_{k-1} => F_k (i.e., F_{k-1} AND NOT F_k is UNSAT)
434    let fwd = VerifyExpr::and(
435        kinduction::instantiate_at(&fk_prev, 0),
436        VerifyExpr::not(kinduction::instantiate_at(&fk, 0)),
437    );
438    if is_sat(&fwd) {
439        return false;
440    }
441
442    // Check F_k => F_{k-1}
443    let bwd = VerifyExpr::and(
444        kinduction::instantiate_at(&fk, 0),
445        VerifyExpr::not(kinduction::instantiate_at(&fk_prev, 0)),
446    );
447    !is_sat(&bwd)
448}
449
450/// Extract a concrete counterexample trace from a BMC check.
451fn extract_trace_from_bmc(
452    init: &VerifyExpr,
453    transition: &VerifyExpr,
454    property: &VerifyExpr,
455    depth: u32,
456    signal_names: &[String],
457) -> Trace {
458    let solver = crate::solver::new_solver();
459
460    // Build BMC formula
461    let init_0 = kinduction::instantiate_at(init, 0);
462    solver.assert(&encode_bool(&init_0));
463
464    for t in 0..depth {
465        let trans = kinduction::instantiate_transition(transition, t);
466        solver.assert(&encode_bool(&trans));
467    }
468
469    // Assert NOT P at depth
470    let not_prop = VerifyExpr::not(kinduction::instantiate_at(property, depth));
471    solver.assert(&encode_bool(&not_prop));
472
473    if !matches!(solver.check(), SatResult::Sat) {
474        return Trace { cycles: vec![] };
475    }
476    let model = solver.get_model().unwrap();
477
478    // Extract signal values at each timestep
479    let mut cycles = Vec::new();
480    for step in 0..=depth {
481        let mut signals = HashMap::new();
482        for sig in signal_names {
483            let var_name = format!("{}@{}", sig, step);
484
485            // Try boolean
486            let bool_var = Bool::new_const(var_name.as_str());
487            if let Some(val) = model.eval(&bool_var, true) {
488                if let Some(b) = val.as_bool() {
489                    signals.insert(sig.clone(), SignalValue::Bool(b));
490                    continue;
491                }
492            }
493
494            // Try integer
495            let int_var = Int::new_const(var_name.as_str());
496            if let Some(val) = model.eval(&int_var, true) {
497                if let Some(n) = val.as_i64() {
498                    signals.insert(sig.clone(), SignalValue::Int(n));
499                    continue;
500                }
501            }
502        }
503        if !signals.is_empty() {
504            cycles.push(CycleState { cycle: step as usize, signals });
505        }
506    }
507
508    // If we couldn't extract typed values, at least provide boolean interpretation
509    if cycles.is_empty() {
510        let mut signals = HashMap::new();
511        for sig in signal_names {
512            signals.insert(sig.clone(), SignalValue::Unknown);
513        }
514        cycles.push(CycleState { cycle: 0, signals });
515    }
516
517    Trace { cycles }
518}
519
520/// Check if a formula is satisfiable (internal, takes pre-built context).
521fn is_sat(expr: &VerifyExpr) -> bool {
522    let solver = crate::solver::new_solver();
523    let encoded = encode_bool(expr);
524    solver.assert(&encoded);
525    matches!(solver.check(), z3::SatResult::Sat)
526}
527
528/// Check if a formula is satisfiable (public, creates its own Z3 context).
529pub fn check_sat(expr: &VerifyExpr) -> bool {
530    is_sat(expr)
531}
532
533fn encode_bool(expr: &VerifyExpr) -> z3::ast::Bool {
534    let mut bool_vars = HashMap::new();
535    let mut int_vars = HashMap::new();
536    let mut all_vars = std::collections::HashSet::new();
537    crate::equivalence::collect_vars_pub(expr, &mut all_vars);
538    for name in &all_vars {
539        bool_vars.insert(name.clone(), z3::ast::Bool::new_const(name.as_str()));
540    }
541    crate::equivalence::collect_int_vars_pub(expr, &mut int_vars);
542    kinduction::encode_expr_bool(expr, &bool_vars, &int_vars)
543}