Skip to main content

concolic_core/
lib.rs

1//! The architecture-neutral half of the concolic pipeline.
2//!
3//! Every item in this crate is stated over **traces, decisions and reports**.
4//! None names an instruction set, a register, a syscall number, or a cell
5//! layout. A frontend (an ISA, a bytecode VM, a symbolic runtime, ...) supplies:
6//!
7//! - a deterministic concrete reference run ([`Frontend::run`]),
8//! - a symbolic walk forced along a concrete branch log ([`Frontend::walk`]),
9//! - the flip query at one decision ([`Frontend::flip`]),
10//! - the symbolic surface's names in a model, and how to apply them
11//!   ([`Frontend::model_inputs`], [`Frontend::apply_inputs`]).
12//!
13//! Everything else - the frontier, the budgets, the verification predicate
14//! ("same sites, same directions, opposite at the flipped site"), the seven
15//! flip outcomes and the report - is fixed here. A path only counts when the
16//! frontend's own reference engine re-ran the model and agreed; a `Sat` model
17//! alone is never a witness.
18
19use std::collections::BTreeSet;
20
21use smtlite::{Model, Solution};
22
23/// One decision in a concrete trace: the site and the direction taken.
24///
25/// Operand **values** are deliberately absent. A changed input necessarily
26/// changes the operands of earlier comparisons that still go the same way, so a
27/// verification predicate that compared whole records would reject genuine
28/// flips. Sites and directions are the path.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct BranchRec {
31    /// The decision site (an address in every frontend so far).
32    pub site: u64,
33    /// Whether the branch was taken.
34    pub taken: bool,
35}
36
37/// What an attempted flip actually reached.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
39pub enum FlipOutcome {
40    /// The reference engine took the opposite side at the same site, with the
41    /// same prefix decisions, and the resulting path was new.
42    Verified,
43    /// The flip query was UNSAT under the path prefix: no input flips it here.
44    ProvenUnflippable,
45    /// The solver hit a bound before a verdict.
46    SolverUnknown,
47    /// No verification run was spent, or no query was built. The loop records a
48    /// walk that cannot replay the path as a gap instead of inventing a
49    /// per-decision record; this value is the honest default for a consumer
50    /// that records an attempt it could not pose.
51    #[default]
52    NotPosed,
53    /// The flipped run failed at the engine level.
54    RunFailed,
55    /// The run failed to reproduce the walked prefix and take the opposite
56    /// side at this decision: its log was too short, diverged before the
57    /// decision, reached a different site, or took the same side. The walker
58    /// and the engine disagree.
59    Unconfirmed,
60    /// The run did take the opposite side, but the path was already seen.
61    AlreadyExplored,
62}
63
64/// One flip attempt, in decision order.
65#[derive(Clone, Debug, Default)]
66pub struct FlipRec {
67    /// The site the attempt was posed at.
68    pub at: u64,
69    /// The model's bindings over the frontend's symbolic surface, as opaque
70    /// name/value pairs.
71    pub input: Vec<(String, u64)>,
72    /// Whether the reference engine verified the flip onto a new path.
73    pub verified: bool,
74    /// The structured outcome.
75    pub outcome: FlipOutcome,
76}
77
78/// A property query's outcome (the same algebra every frontend's engine uses).
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
80pub enum PropertyOutcome {
81    /// Sat model, applied and re-run: the concrete readback violates the invariant.
82    Confirmed,
83    /// UNSAT under the path formula: no reaching input violates it.
84    Proven,
85    /// The solver hit a bound.
86    SolverUnknown,
87    /// No verification run was spent, or no query was built.
88    #[default]
89    NotPosed,
90    /// The verification re-run trapped, or failed at the engine level, so there
91    /// is no post-state to judge.
92    RunFailed,
93    /// The model was applied, the run completed, and it did not violate.
94    Unreproduced,
95}
96
97/// One property-directed query on one walked path.
98#[derive(Clone, Debug, Default)]
99pub struct PropertyRec {
100    /// The assertion's label.
101    pub label: String,
102    /// What the query reached.
103    pub outcome: PropertyOutcome,
104    /// The observed operands on a confirmed violation.
105    pub violation: Option<(u64, u64)>,
106    /// The applied input, when a model was re-run.
107    pub input: Vec<(String, u64)>,
108}
109
110/// A sink reached on a walked path. The `kind` and argument names are the
111/// frontend's vocabulary; the ledger treats them as opaque labels.
112#[derive(Clone, Debug, Default)]
113pub struct SinkRec {
114    /// The call site.
115    pub site: u64,
116    /// The sink class. The frontend chooses these labels; the ledger treats
117    /// them as opaque strings (e.g. `transfer`, `log`, `call`).
118    pub kind: String,
119    /// Argument names and their concrete values on the walked path.
120    pub args: Vec<(String, u64)>,
121}
122
123/// The outcome of one concrete reference run.
124#[derive(Clone, Debug, Default)]
125pub struct RunResult {
126    /// The program's own return value, when it returned.
127    pub result: Option<i64>,
128    /// The trap text, when the run trapped instead of returning.
129    pub trapped: Option<String>,
130    /// The decisions the run made, in order.
131    pub log: Vec<BranchRec>,
132    /// Sinks reached. The loop does not read this field; it reports the sinks
133    /// its own walks found through [`Frontend::walk_sinks`].
134    pub sinks: Vec<SinkRec>,
135}
136
137/// The session report - the architecture-neutral subset of the engine report
138/// that a consumer (a diagnosis layer, a CI gate, a notebook) reads.
139#[derive(Clone, Debug, Default)]
140pub struct Report {
141    /// The base run's result, when it returned.
142    pub result: Option<i64>,
143    /// Concrete runs attempted, including attempts that failed at the engine
144    /// level.
145    pub runs: usize,
146    /// Flips the reference engine verified.
147    pub flips_verified: usize,
148    /// Sinks reached.
149    pub sinks: Vec<SinkRec>,
150    /// Property queries posed.
151    pub properties: Vec<PropertyRec>,
152    /// Every flip attempt.
153    pub flips: Vec<FlipRec>,
154    /// Gap notes, verbatim: the frontend's own walk notes plus the loop's own
155    /// limit notes (`run failed: ...`, `run trapped: ...`, `walk could not
156    /// replay ...`, `solver Unknown at ...`).
157    pub gaps: Vec<String>,
158    /// The frontier emptied with no work left undone. A session whose budget
159    /// cut the decision loop short, refused a property verification, or could
160    /// not walk a discovered path is NOT exhausted, even if every path it did
161    /// process was processed to the end.
162    pub exhausted: bool,
163}
164
165/// The budgets a session ran under.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub struct Opts {
168    /// Cap on concrete runs. The base run is always attempted (a report always
169    /// says what it tried); every later attempt is refused once `runs` reaches
170    /// this value.
171    pub max_runs: usize,
172    /// Cap on verified flips. Reaching it ends the search.
173    pub max_flips: usize,
174}
175
176impl Default for Opts {
177    fn default() -> Self {
178        Self {
179            max_runs: 64,
180            max_flips: 32,
181        }
182    }
183}
184
185/// What a frontend must provide for the loop to run.
186///
187/// The obligations are the soundness contract: `run` is deterministic, `walk`
188/// is a sound over-approximation of `run`'s prefix, `flip` poses the query at a
189/// real decision and `site` names it, and `model_inputs`/`apply_inputs`
190/// round-trip a model onto a runnable seed.
191///
192/// Two further preconditions the loop relies on but cannot check:
193///
194/// - a `Some(walk)` must expose at least `log.len()` decisions: the loop
195///   indexes `site` and `flip` over `0..log.len()`, so a shorter walk is a
196///   frontend bug that will surface as a panic inside the frontend, not as a
197///   gap;
198/// - every seed in one session must share the same symbolic surface, because
199///   a path is identified by its `(site, taken)` decisions alone. Across
200///   different frontends or different input surfaces, call [`explore`]
201///   separately.
202pub trait Frontend {
203    /// The seed: the initial state, with its base concrete assignment.
204    type Seed: Clone;
205    /// The symbolic walk, owning whatever solver state the frontend keeps.
206    type Walk;
207
208    /// Run the reference engine concretely. Determinism is the frontend's
209    /// obligation; an engine-level failure is `Err`, not a trap.
210    fn run(&self, seed: &Self::Seed) -> Result<RunResult, String>;
211
212    /// Replay the concrete log symbolically. `None` when the walk cannot force
213    /// the path (a replay or frontend limitation); the caller records that fact.
214    fn walk(&self, seed: &Self::Seed, log: &[BranchRec]) -> Option<Self::Walk>;
215
216    /// Pose the flip query at decision `i` of the forced walk.
217    fn flip(&self, walk: &mut Self::Walk, i: usize) -> Solution;
218
219    /// The decision site at index `i` of the walk.
220    fn site(&self, walk: &Self::Walk, i: usize) -> u64;
221
222    /// The model's bindings over the symbolic surface.
223    fn model_inputs(&self, model: &Model, walk: &Self::Walk) -> Vec<(String, u64)>;
224
225    /// Apply those bindings to a child seed.
226    fn apply_inputs(&self, seed: &Self::Seed, inputs: &[(String, u64)]) -> Self::Seed;
227
228    /// Gap notes the walk itself recorded (unsupported instruction, replay
229    /// drift, ...).
230    fn walk_gaps(&self, _walk: &Self::Walk) -> Vec<String> {
231        Vec::new()
232    }
233
234    /// Sinks reached on the walked path.
235    fn walk_sinks(&self, _walk: &Self::Walk) -> Vec<SinkRec> {
236        Vec::new()
237    }
238
239    // -- optional property surface -------------------------------------------
240
241    /// The property-query labels this frontend poses at the end of a walked
242    /// path. Empty by default.
243    ///
244    /// A property query asks the end-state question `path AND NOT invariant`,
245    /// so a violation on a straight-line path with no decision to flip is
246    /// still expressible. A frontend with no property surface leaves this
247    /// empty and its property ledger stays empty.
248    ///
249    /// Queries are posed only on paths whose reference run completed. When
250    /// the run trapped, the loop records every declared label as
251    /// [`PropertyOutcome::NotPosed`]: there is no post-state to read, so the
252    /// question is vacuous rather than refused, and the ledger still shows
253    /// that the frontend declared it.
254    fn properties(&self, _walk: &Self::Walk) -> Vec<String> {
255        Vec::new()
256    }
257
258    /// Pose `label` over the walked path: `path AND NOT invariant`.
259    ///
260    /// `Unsat` is a local negative under this path's assumptions. `Sat` is a
261    /// candidate, never a finding - the loop re-runs its model and asks
262    /// [`Frontend::property_violated`] before believing it.
263    fn check_property(&self, _walk: &mut Self::Walk, _label: &str) -> Solution {
264        Solution::Unknown
265    }
266
267    /// Read the verification re-run's post-state for `label`. `Some((observed,
268    /// required))` only when the run completed and its concrete readback
269    /// violates the property. Called only on a run that did not trap.
270    fn property_violated(&self, _run: &RunResult, _label: &str) -> Option<(u64, u64)> {
271        None
272    }
273}
274
275/// A path's identity: every decision's site and direction, in order.
276///
277/// Directions alone are not an identity. Two runs can take the same side of
278/// the same number of decisions and still be different paths when the
279/// decision *sites* differ (an indirect branch, a dispatch table, a virtual
280/// call), so a key made of `taken` bits alone would discard the second path
281/// as already explored.
282type PathKey = Vec<(u64, bool)>;
283
284fn path_key(log: &[BranchRec]) -> PathKey {
285    log.iter().map(|b| (b.site, b.taken)).collect()
286}
287
288struct Pending<S> {
289    seed: S,
290    run: RunResult,
291}
292
293struct Attempt<S> {
294    rec: FlipRec,
295    verified: bool,
296    ran: bool,
297    gap: Option<String>,
298    child: Option<Pending<S>>,
299}
300
301/// Explore the frontend's entry point from `base` inside the budgets.
302///
303/// The loop is the DART/CUTE shape: run, replay the path symbolically, pose a
304/// flip query at every decision (deepest first), apply a model, re-run, verify.
305/// A verified flip pushes the child path onto the frontier; the frontier
306/// emptying inside the budgets sets `exhausted`.
307#[must_use]
308pub fn explore<F: Frontend>(frontend: &F, base: &F::Seed, opts: &Opts) -> Report {
309    let mut report = Report::default();
310    let mut seen: BTreeSet<PathKey> = BTreeSet::new();
311
312    // `runs` counts attempts, not successes: a report always says what it
313    // tried, including a base run the engine could not perform.
314    report.runs = 1;
315    let first = match frontend.run(base) {
316        Ok(r) => r,
317        Err(e) => {
318            report.gaps.push(format!("run failed: {e}"));
319            return report;
320        }
321    };
322    // A trapped run has no return value; the trap itself is the observation.
323    if first.trapped.is_none() {
324        report.result = first.result;
325    }
326    // `seen` holds every path the search has DISCOVERED - the base and every
327    // verified child, whether or not it has been popped. A path sitting on the
328    // frontier is already accounted for: checking only processed paths would
329    // let a later flip claim the same child as new, enqueue it twice, and
330    // inflate `flips_verified`.
331    seen.insert(path_key(&first.log));
332    let mut frontier = vec![Pending { seed: base.clone(), run: first }];
333
334    // Set when work the search was supposed to do could not be done: a budget
335    // refused a decision or a property verification, or a discovered path
336    // could not be walked at all. The frontier can empty in that state, but
337    // it did not empty *by completing the search*: remaining work was
338    // refused or unanalysable, so the session is not exhausted.
339    let mut incomplete = false;
340
341    while let Some(item) = frontier.pop() {
342        if report.runs >= opts.max_runs || report.flips_verified >= opts.max_flips {
343            return report;
344        }
345        if let Some(t) = &item.run.trapped {
346            report.gaps.push(format!("run trapped: {t}"));
347        }
348        let Some(mut walk) = frontend.walk(&item.seed, &item.run.log) else {
349            // A discovered path was dropped unanalysed. That is recorded as a
350            // gap AND it means the search did not complete: the frontier will
351            // empty with this path's decisions never posed.
352            report
353                .gaps
354                .push("walk could not replay the concrete path".to_string());
355            incomplete = true;
356            continue;
357        };
358        report.sinks.extend(frontend.walk_sinks(&walk));
359        report.gaps.extend(frontend.walk_gaps(&walk));
360
361        // Deepest decision first, with the budgets checked against what this
362        // round has already spent - the same exact-cap discipline the
363        // reference engines record.
364        let (mut spent, mut flipped) = (0usize, 0usize);
365
366        // Property-directed queries: the end-state question, posed on every
367        // path the walker replayed. A verification run spent here counts
368        // against the same budget as a flip's.
369        for label in frontend.properties(&walk) {
370            if item.run.trapped.is_some() {
371                // There is no post-state to ask about, so the query is
372                // vacuous rather than refused. Record the label as `NotPosed`
373                // rather than dropping it: a ledger that omits a declared
374                // query reads as "no property surface", which is a different
375                // fact.
376                report.properties.push(PropertyRec {
377                    label,
378                    ..PropertyRec::default()
379                });
380                continue;
381            }
382            let (rec, ran, skipped) = pose_property(
383                frontend,
384                &item.seed,
385                &mut walk,
386                &label,
387                report.runs,
388                opts,
389            );
390            report.runs += usize::from(ran);
391            incomplete |= skipped;
392            report.properties.push(rec);
393        }
394        let mut attempts = Vec::new();
395        for i in (0..item.run.log.len()).rev() {
396            if report.runs + spent >= opts.max_runs
397                || report.flips_verified + flipped >= opts.max_flips
398            {
399                // Decisions remain unposed. The frontier may empty later, but
400                // the search was cut short, not completed.
401                incomplete = true;
402                break;
403            }
404            let a = attempt(frontend, &item.seed, &item.run.log, &mut walk, i, &seen);
405            spent += usize::from(a.ran);
406            flipped += usize::from(a.verified);
407            // Record the child's path immediately: a later attempt in this
408            // same round must see it, or two flips can both call it new.
409            if a.verified {
410                if let Some(child) = &a.child {
411                    seen.insert(path_key(&child.run.log));
412                }
413            }
414            attempts.push(a);
415        }
416
417        for a in attempts {
418            report.runs += usize::from(a.ran);
419            if let Some(g) = a.gap {
420                report.gaps.push(g);
421            }
422            report.flips.push(a.rec);
423            if a.verified {
424                report.flips_verified += 1;
425                if let Some(child) = a.child {
426                    frontier.push(child);
427                }
428            }
429        }
430    }
431    report.exhausted = !incomplete;
432    report
433}
434
435/// Pose and discharge one property query.
436///
437/// Returns `(rec, ran, skipped)`: `ran` says whether a concrete verification
438/// run was spent, and `skipped` says a satisfiable query went unanswered only
439/// because the run budget refused its verification run.
440///
441/// The witness law is the same one the flip search obeys: a `Sat` model is a
442/// candidate, and only the frontend's own re-run readback can confirm it. A
443/// `Sat` model with no budget left for the re-run is `NotPosed`, not a
444/// finding.
445fn pose_property<F: Frontend>(
446    frontend: &F,
447    seed: &F::Seed,
448    walk: &mut F::Walk,
449    label: &str,
450    runs_spent: usize,
451    opts: &Opts,
452) -> (PropertyRec, bool, bool) {
453    let mut rec = PropertyRec {
454        label: label.to_string(),
455        outcome: PropertyOutcome::NotPosed,
456        violation: None,
457        input: Vec::new(),
458    };
459    match frontend.check_property(walk, label) {
460        Solution::Unsat => rec.outcome = PropertyOutcome::Proven,
461        Solution::Unknown => rec.outcome = PropertyOutcome::SolverUnknown,
462        Solution::Sat(model) => {
463            if runs_spent >= opts.max_runs {
464                return (rec, false, true);
465            }
466            let inputs = frontend.model_inputs(&model, walk);
467            let child_seed = frontend.apply_inputs(seed, &inputs);
468            rec.input = inputs;
469            // A run that trapped has no post-state for the invariant to be
470            // read off; that is a failed verification, not a violation.
471            match frontend.run(&child_seed) {
472                Ok(run) if run.trapped.is_none() => {
473                    match frontend.property_violated(&run, label) {
474                        Some(violation) => {
475                            rec.outcome = PropertyOutcome::Confirmed;
476                            rec.violation = Some(violation);
477                        }
478                        None => rec.outcome = PropertyOutcome::Unreproduced,
479                    }
480                }
481                _ => rec.outcome = PropertyOutcome::RunFailed,
482            }
483            return (rec, true, false);
484        }
485    }
486    (rec, false, false)
487}
488
489fn attempt<F: Frontend>(
490    frontend: &F,
491    seed: &F::Seed,
492    log: &[BranchRec],
493    walk: &mut F::Walk,
494    i: usize,
495    seen: &BTreeSet<PathKey>,
496) -> Attempt<F::Seed> {
497    let at = frontend.site(walk, i);
498    let solution = frontend.flip(walk, i);
499    let Solution::Sat(m) = &solution else {
500        let outcome = match solution {
501            Solution::Unsat => FlipOutcome::ProvenUnflippable,
502            Solution::Unknown => FlipOutcome::SolverUnknown,
503            _ => FlipOutcome::NotPosed,
504        };
505        let gap = (outcome == FlipOutcome::SolverUnknown)
506            .then(|| format!("solver Unknown at {at:#x}"));
507        return Attempt {
508            rec: FlipRec { at, input: Vec::new(), verified: false, outcome },
509            verified: false,
510            ran: false,
511            gap,
512            child: None,
513        };
514    };
515
516    let inputs = frontend.model_inputs(m, walk);
517    let child_seed = frontend.apply_inputs(seed, &inputs);
518    let child_run = match frontend.run(&child_seed) {
519        Ok(r) => r,
520        Err(_) => {
521            return Attempt {
522                rec: FlipRec {
523                    at,
524                    input: inputs,
525                    verified: false,
526                    outcome: FlipOutcome::RunFailed,
527                },
528                verified: false,
529                ran: true,
530                gap: None,
531                child: None,
532            };
533        }
534    };
535
536    // Verification: the SAME sites in the SAME directions up to decision `i`,
537    // and the opposite direction at `i`. Operand values are not compared.
538    let prefix_ok = child_run.log.len() > i
539        && child_run.log[..i]
540            .iter()
541            .zip(&log[..i])
542            .all(|(c, p)| c.site == p.site && c.taken == p.taken);
543    let flipped =
544        prefix_ok && child_run.log[i].site == log[i].site && child_run.log[i].taken != log[i].taken;
545
546    let child_path = path_key(&child_run.log);
547    let verified = flipped && !seen.contains(&child_path);
548    let outcome = if verified {
549        FlipOutcome::Verified
550    } else if flipped {
551        FlipOutcome::AlreadyExplored
552    } else {
553        FlipOutcome::Unconfirmed
554    };
555
556    Attempt {
557        rec: FlipRec { at, input: inputs, verified, outcome },
558        verified,
559        ran: true,
560        gap: None,
561        child: verified.then_some(Pending { seed: child_seed, run: child_run }),
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    /// A toy frontend: one symbolic byte `x`. The program branches on `x < 0x10`
570    /// at site 0x100, then on `x == 0x42` at site 0x200. It exists to prove the
571    /// loop and the verification predicate are independent of any real ISA.
572    struct Toy {
573        claim: Claim,
574    }
575
576    /// How the toy's property surface behaves.
577    #[derive(Clone, Copy)]
578    enum Claim {
579        /// No property surface at all.
580        None,
581        /// The property `x != 3`, confirmed only by a re-run reading back 3.
582        Witnessed,
583        /// The property `x != 3`, but the frontend's readback always reports
584        /// it as satisfied - the walker's term and the run disagree.
585        Denied,
586        /// Two queries on every completed path, so the second one meets the
587        /// run budget edge if the first spends the last run.
588        Pair,
589    }
590
591    #[derive(Clone)]
592    struct Seed {
593        x: u8,
594    }
595
596    struct Walk {
597        solver: smtlite::Solver,
598        x: smtlite::Bv,
599        conds: Vec<smtlite::Bv>,
600        taken: Vec<bool>,
601        sites: Vec<u64>,
602        path: Vec<smtlite::Bv>,
603    }
604
605    impl Frontend for Toy {
606        type Seed = Seed;
607        type Walk = Walk;
608
609        fn run(&self, seed: &Self::Seed) -> Result<RunResult, String> {
610            let mut log = Vec::new();
611            let x = seed.x;
612            log.push(BranchRec { site: 0x100, taken: x < 0x10 });
613            if x < 0x10 {
614                log.push(BranchRec { site: 0x200, taken: x == 0x42 });
615            }
616            Ok(RunResult { result: Some(i64::from(x)), trapped: None, log, sinks: Vec::new() })
617        }
618
619        fn walk(&self, _seed: &Self::Seed, log: &[BranchRec]) -> Option<Self::Walk> {
620            let mut solver = smtlite::Solver::new();
621            let x = solver.var("x", 8);
622            let mut conds = Vec::new();
623            let mut taken = Vec::new();
624            let mut sites = Vec::new();
625            let mut path = Vec::new();
626            for b in log {
627                let cond = match b.site {
628                    0x100 => x.ult(&smtlite::Bv::val(0x10, 8)),
629                    0x200 => x.eq(&smtlite::Bv::val(0x42, 8)),
630                    _ => return None,
631                };
632                path.push(if b.taken { cond.clone() } else { cond.not() });
633                conds.push(cond);
634                taken.push(b.taken);
635                sites.push(b.site);
636            }
637            Some(Walk { solver, x, conds, taken, sites, path })
638        }
639
640        fn flip(&self, walk: &mut Self::Walk, i: usize) -> Solution {
641            // Q_i = Phi_i AND NOT g_i: the prefix held, the branch forced the
642            // other way. `g_i` is the direction the concrete run took.
643            let g_i = if walk.taken[i] {
644                walk.conds[i].not()
645            } else {
646                walk.conds[i].clone()
647            };
648            let mut assumptions = walk.path[..i].to_vec();
649            assumptions.push(g_i);
650            walk.solver.check_all(&assumptions)
651        }
652
653        fn site(&self, walk: &Self::Walk, i: usize) -> u64 {
654            walk.sites[i]
655        }
656
657        fn model_inputs(&self, model: &Model, _walk: &Self::Walk) -> Vec<(String, u64)> {
658            model.get("x").map(|v| vec![("x".to_string(), v)]).unwrap_or_default()
659        }
660
661        fn apply_inputs(&self, _seed: &Self::Seed, inputs: &[(String, u64)]) -> Self::Seed {
662            Seed { x: inputs.first().map_or(0, |(_, v)| *v as u8) }
663        }
664
665        fn properties(&self, _walk: &Self::Walk) -> Vec<String> {
666            match self.claim {
667                Claim::None => Vec::new(),
668                Claim::Witnessed | Claim::Denied => vec!["x != 3".to_string()],
669                Claim::Pair => {
670                    vec!["x != 3".to_string(), "x != 3 (second)".to_string()]
671                }
672            }
673        }
674
675        fn check_property(&self, walk: &mut Self::Walk, _label: &str) -> Solution {
676            // `path AND NOT (x != 3)` - the end-state question.
677            let mut assumptions = walk.path.clone();
678            assumptions.push(walk.x.eq(&smtlite::Bv::val(3, 8)));
679            walk.solver.check_all(&assumptions)
680        }
681
682        fn property_violated(&self, run: &RunResult, _label: &str) -> Option<(u64, u64)> {
683            match self.claim {
684                Claim::None | Claim::Denied => None,
685                Claim::Witnessed | Claim::Pair => (run.result == Some(3)).then_some((3, 3)),
686            }
687        }
688    }
689
690    #[test]
691    fn the_loop_verifies_a_real_flip_and_proves_the_unreachable_one() {
692        let r = explore(
693            &Toy { claim: Claim::None },
694            &Seed { x: 0 },
695            &Opts { max_runs: 64, max_flips: 32 },
696        );
697        // Base path: 0x100 taken, 0x200 not taken. The attempt at 0x200 asks
698        // whether x < 0x10 AND x == 0x42 - UNSAT, a local negative. The
699        // attempt at 0x100 is satisfiable, applied and re-run, and the
700        // reference engine takes the other side: Verified. The child path's
701        // own flip lands back on the base path: AlreadyExplored.
702        assert_eq!(r.runs, 3, "flips={:?}", r.flips);
703        assert_eq!(r.flips_verified, 1, "flips={:?}", r.flips);
704        assert_eq!(
705            r.flips.iter().map(|f| (f.at, f.outcome)).collect::<Vec<_>>(),
706            vec![
707                (0x200, FlipOutcome::ProvenUnflippable),
708                (0x100, FlipOutcome::Verified),
709                (0x100, FlipOutcome::AlreadyExplored),
710            ]
711        );
712        assert!(r.exhausted);
713    }
714
715    /// Three independent decisions, one per symbolic byte: sites 0x100, 0x200
716    /// and 0x300 each ask whether a distinct byte is below 0x10.
717    ///
718    /// A flip query constrains only the prefix and the flipped decision, so
719    /// every later decision is free. A real model answers those with whatever
720    /// bit pattern satisfies the solver first; this frontend pins them to
721    /// 0x10 (`false`) so the regression is about the loop's bookkeeping, not
722    /// about which completion a solver happens to choose.
723    #[derive(Clone, Copy, Default)]
724    struct Siblings {
725        /// Every flip query hits a solver bound.
726        stuck: bool,
727        /// A run with `x >= 0x10` fails at the engine level.
728        fail_high_x: bool,
729        /// A run with `z >= 0x10` fails at the engine level. Because `z` is
730        /// the last decision's byte, a failed deep flip leaves no child.
731        fail_high_z: bool,
732        /// The symbolic walk cannot replay any concrete path.
733        walk_fails: bool,
734    }
735
736    #[derive(Clone)]
737    struct SSeed {
738        x: u8,
739        y: u8,
740        z: u8,
741    }
742
743    struct SWalk {
744        solver: smtlite::Solver,
745        conds: Vec<smtlite::Bv>,
746        taken: Vec<bool>,
747        sites: Vec<u64>,
748        path: Vec<smtlite::Bv>,
749        /// The index `flip` was last asked about, so `model_inputs` knows
750        /// which decisions the query did not constrain.
751        last_flip: usize,
752    }
753
754    const SITE_VAR: [(u64, usize); 3] = [(0x100, 0), (0x200, 1), (0x300, 2)];
755
756    impl Frontend for Siblings {
757        type Seed = SSeed;
758        type Walk = SWalk;
759
760        fn run(&self, seed: &Self::Seed) -> Result<RunResult, String> {
761            if (self.fail_high_x && seed.x >= 0x10)
762                || (self.fail_high_z && seed.z >= 0x10)
763            {
764                return Err("the frontend refused this seed".to_string());
765            }
766            let log = vec![
767                BranchRec { site: 0x100, taken: seed.x < 0x10 },
768                BranchRec { site: 0x200, taken: seed.y < 0x10 },
769                BranchRec { site: 0x300, taken: seed.z < 0x10 },
770            ];
771            Ok(RunResult { result: Some(0), trapped: None, log, sinks: Vec::new() })
772        }
773
774        fn walk(&self, _seed: &Self::Seed, log: &[BranchRec]) -> Option<Self::Walk> {
775            if self.walk_fails {
776                return None;
777            }
778            let mut solver = smtlite::Solver::new();
779            let vars = [
780                solver.var("x", 8),
781                solver.var("y", 8),
782                solver.var("z", 8),
783            ];
784            let mut conds = Vec::new();
785            let mut taken = Vec::new();
786            let mut sites = Vec::new();
787            let mut path = Vec::new();
788            for b in log {
789                let k = SITE_VAR.iter().find(|(site, _)| *site == b.site)?.1;
790                let cond = vars[k].ult(&smtlite::Bv::val(0x10, 8));
791                path.push(if b.taken { cond.clone() } else { cond.not() });
792                conds.push(cond);
793                taken.push(b.taken);
794                sites.push(b.site);
795            }
796            Some(SWalk { solver, conds, taken, sites, path, last_flip: 0 })
797        }
798
799        fn flip(&self, walk: &mut Self::Walk, i: usize) -> Solution {
800            walk.last_flip = i;
801            if self.stuck {
802                return Solution::Unknown;
803            }
804            let g_i = if walk.taken[i] {
805                walk.conds[i].not()
806            } else {
807                walk.conds[i].clone()
808            };
809            let mut assumptions = walk.path[..i].to_vec();
810            assumptions.push(g_i);
811            walk.solver.check_all(&assumptions)
812        }
813
814        fn site(&self, walk: &Self::Walk, i: usize) -> u64 {
815            walk.sites[i]
816        }
817
818        fn model_inputs(&self, model: &Model, walk: &Self::Walk) -> Vec<(String, u64)> {
819            // Decisions after the flipped one were not in the query. Fix them
820            // to a legal completion (0x10) so the child's path is a pure
821            // function of the query, not of the solver's assignment order.
822            ["x", "y", "z"]
823                .iter()
824                .enumerate()
825                .map(|(k, name)| {
826                    let v = if k > walk.last_flip {
827                        0x10
828                    } else {
829                        model.get(name).unwrap_or(0x10)
830                    };
831                    ((*name).to_string(), v)
832                })
833                .collect()
834        }
835
836        fn apply_inputs(&self, _seed: &Self::Seed, inputs: &[(String, u64)]) -> Self::Seed {
837            let v = |name: &str| {
838                inputs
839                    .iter()
840                    .find(|(n, _)| n == name)
841                    .map_or(0, |(_, v)| *v as u8)
842            };
843            SSeed { x: v("x"), y: v("y"), z: v("z") }
844        }
845    }
846
847    #[test]
848    fn a_flip_onto_a_path_still_on_the_frontier_is_not_a_new_flip() {
849        // The search discovers exactly eight paths: the base and seven
850        // children. Some flips land on a path that is already discovered but
851        // has not been popped yet; `verified` must mean "new path", so those
852        // attempts are `AlreadyExplored`, not extra verifications. Tracking
853        // only popped paths lets a later round verify the same child again
854        // and inflate `flips_verified`.
855        let r = explore(&Siblings::default(), &SSeed { x: 0, y: 0, z: 0 }, &Opts::default());
856        assert!(r.exhausted);
857        assert_eq!(7, r.flips_verified, "flips={:?}", r.flips);
858        assert_eq!(
859            7,
860            r.flips.iter().filter(|f| f.verified).count(),
861            "every counted verification must also be a verified record"
862        );
863    }
864
865    #[test]
866    fn a_budget_that_cuts_the_decision_loop_is_not_exhaustion() {
867        // The base run and one verification exhaust `max_runs`; the shallower
868        // decisions are never posed. The frontier will empty, but the search
869        // was cut short, so `exhausted` must be false. A consumer that reads
870        // `exhausted` alone must never see coverage that the budget refused.
871        let r = explore(
872            &Siblings::default(),
873            &SSeed { x: 0, y: 0, z: 0 },
874            &Opts { max_runs: 2, max_flips: 32 },
875        );
876        assert_eq!(r.runs, 2);
877        assert_eq!(1, r.flips.len(), "flips={:?}", r.flips);
878        assert!(!r.exhausted);
879    }
880
881    #[test]
882    fn a_solver_bound_is_a_gap_and_an_unresolved_outcome() {
883        // No verdict is not silence: every decision is recorded as
884        // `SolverUnknown`, and the loop's own note is a gap. The frontier did
885        // empty inside the budgets - the ledger, not the stop, carries the
886        // unresolved fact.
887        let r = explore(
888            &Siblings { stuck: true, ..Siblings::default() },
889            &SSeed { x: 0, y: 0, z: 0 },
890            &Opts::default(),
891        );
892        assert_eq!(3, r.flips.len());
893        assert!(r.flips.iter().all(|f| f.outcome == FlipOutcome::SolverUnknown));
894        assert_eq!(3, r.gaps.iter().filter(|g| g.starts_with("solver Unknown at")).count());
895        assert!(r.exhausted);
896    }
897
898    #[test]
899    fn a_failed_verification_run_is_recorded_and_counted() {
900        // The model is applied and the reference engine fails. That spends a
901        // run and is `RunFailed`; it is never folded into a flip.
902        let r = explore(
903            &Siblings { fail_high_x: true, ..Siblings::default() },
904            &SSeed { x: 0, y: 0, z: 0 },
905            &Opts::default(),
906        );
907        assert!(
908            r.flips
909                .iter()
910                .any(|f| f.at == 0x100 && f.outcome == FlipOutcome::RunFailed && !f.verified),
911            "flips={:?}",
912            r.flips
913        );
914        assert_eq!(
915            0,
916            r.flips.iter().filter(|f| f.verified && f.at == 0x100).count(),
917            "a run that failed at the engine level is not a verified flip"
918        );
919        assert!(r.exhausted);
920    }
921
922    #[test]
923    fn a_budget_break_that_leaves_the_frontier_empty_is_not_exhaustion() {
924        // The deepest flip spends the last run and FAILS, so it creates no
925        // child. The shallower decision is never posed, the frontier is empty,
926        // and only the inner-loop flag can record that the search was cut
927        // short. Without it this report would claim exhaustion.
928        let r = explore(
929            &Siblings { fail_high_z: true, ..Siblings::default() },
930            &SSeed { x: 0, y: 0, z: 0 },
931            &Opts { max_runs: 2, max_flips: 32 },
932        );
933        assert_eq!(r.runs, 2);
934        assert_eq!(1, r.flips.len(), "flips={:?}", r.flips);
935        assert_eq!(FlipOutcome::RunFailed, r.flips[0].outcome);
936        assert!(!r.exhausted);
937    }
938
939    /// A frontend with no decisions at all, but a property surface. It exists
940    /// to isolate the property-budget precondition: with no decisions there is
941    /// no attempt loop that could set the incomplete flag.
942    struct NoDecisions;
943
944    struct NWalk {
945        solver: smtlite::Solver,
946        x: smtlite::Bv,
947    }
948
949    impl Frontend for NoDecisions {
950        type Seed = u8;
951        type Walk = NWalk;
952
953        fn run(&self, seed: &Self::Seed) -> Result<RunResult, String> {
954            Ok(RunResult {
955                result: Some(i64::from(*seed)),
956                trapped: None,
957                log: Vec::new(),
958                sinks: Vec::new(),
959            })
960        }
961
962        fn walk(&self, _seed: &Self::Seed, _log: &[BranchRec]) -> Option<Self::Walk> {
963            let mut solver = smtlite::Solver::new();
964            let x = solver.var("value", 8);
965            Some(NWalk { solver, x })
966        }
967
968        fn flip(&self, _walk: &mut Self::Walk, _i: usize) -> Solution {
969            Solution::Unknown
970        }
971
972        fn site(&self, _walk: &Self::Walk, _i: usize) -> u64 {
973            0
974        }
975
976        fn model_inputs(&self, model: &Model, _walk: &Self::Walk) -> Vec<(String, u64)> {
977            model
978                .get("value")
979                .map(|v| vec![("value".to_string(), v)])
980                .unwrap_or_default()
981        }
982
983        fn apply_inputs(&self, _seed: &Self::Seed, inputs: &[(String, u64)]) -> Self::Seed {
984            inputs.first().map_or(0, |(_, v)| *v as u8)
985        }
986
987        fn properties(&self, _walk: &Self::Walk) -> Vec<String> {
988            vec!["p1".to_string(), "p2".to_string()]
989        }
990
991        fn check_property(&self, walk: &mut Self::Walk, _label: &str) -> Solution {
992            let q = walk.x.eq(&smtlite::Bv::val(0, 8));
993            walk.solver.check_all(&[q])
994        }
995
996        fn property_violated(&self, _run: &RunResult, _label: &str) -> Option<(u64, u64)> {
997            None
998        }
999    }
1000
1001    #[test]
1002    fn a_property_refused_its_verification_run_is_not_exhaustion() {
1003        // Two satisfiable queries, one run left after the base run. The first
1004        // query spends it; the second is recorded `NotPosed`. No decision was
1005        // even posed, so the frontier is empty - but a query went unanswered
1006        // for want of budget, and the report must not call that exhausted.
1007        let r = explore(&NoDecisions, &0, &Opts { max_runs: 2, max_flips: 32 });
1008        assert_eq!(r.runs, 2);
1009        assert_eq!(2, r.properties.len());
1010        assert_eq!(PropertyOutcome::Unreproduced, r.properties[0].outcome);
1011        assert_eq!(PropertyOutcome::NotPosed, r.properties[1].outcome);
1012        assert!(!r.exhausted);
1013    }
1014
1015    #[test]
1016    fn a_path_whose_walk_fails_is_not_exhaustion() {
1017        // The path was discovered and its run completed, but it was dropped
1018        // unanalysed. The gap records the limitation; the stop must not claim
1019        // the search completed.
1020        let r = explore(
1021            &Siblings { walk_fails: true, ..Siblings::default() },
1022            &SSeed { x: 0, y: 0, z: 0 },
1023            &Opts::default(),
1024        );
1025        assert_eq!(r.runs, 1);
1026        assert!(r.flips.is_empty());
1027        assert_eq!(
1028            vec!["walk could not replay the concrete path".to_string()],
1029            r.gaps
1030        );
1031        assert!(!r.exhausted);
1032    }
1033
1034    /// A frontend that traps on every run and declares one property label.
1035    struct Trapped;
1036
1037    impl Frontend for Trapped {
1038        type Seed = u8;
1039        type Walk = smtlite::Solver;
1040
1041        fn run(&self, _seed: &Self::Seed) -> Result<RunResult, String> {
1042            Ok(RunResult {
1043                result: None,
1044                trapped: Some("out of bounds memory access".to_string()),
1045                log: Vec::new(),
1046                sinks: Vec::new(),
1047            })
1048        }
1049
1050        fn walk(&self, _seed: &Self::Seed, _log: &[BranchRec]) -> Option<Self::Walk> {
1051            Some(smtlite::Solver::new())
1052        }
1053
1054        fn flip(&self, _walk: &mut Self::Walk, _i: usize) -> Solution {
1055            Solution::Unknown
1056        }
1057
1058        fn site(&self, _walk: &Self::Walk, _i: usize) -> u64 {
1059            0
1060        }
1061
1062        fn model_inputs(&self, _model: &Model, _walk: &Self::Walk) -> Vec<(String, u64)> {
1063            Vec::new()
1064        }
1065
1066        fn apply_inputs(&self, _seed: &Self::Seed, _inputs: &[(String, u64)]) -> Self::Seed {
1067            0
1068        }
1069
1070        fn properties(&self, _walk: &Self::Walk) -> Vec<String> {
1071            vec!["out <= 0x10".to_string()]
1072        }
1073
1074        fn check_property(&self, _walk: &mut Self::Walk, _label: &str) -> Solution {
1075            Solution::Unknown
1076        }
1077    }
1078
1079    #[test]
1080    fn a_trapped_path_declares_its_properties_as_not_posed() {
1081        // There is no post-state to ask an end-state question about, so the
1082        // query is vacuous. It must still appear in the ledger: dropping it
1083        // would make "declared but unaskable" indistinguishable from "the
1084        // frontend has no property surface".
1085        let r = explore(&Trapped, &0, &Opts::default());
1086        assert_eq!(1, r.properties.len());
1087        assert_eq!("out <= 0x10", r.properties[0].label);
1088        assert_eq!(PropertyOutcome::NotPosed, r.properties[0].outcome);
1089        assert_eq!(vec!["run trapped: out of bounds memory access".to_string()], r.gaps);
1090        // The trap is the program's observed behavior and every discovered
1091        // path was walked; refusing to ask a vacuous question is not skipped
1092        // work.
1093        assert!(r.exhausted);
1094    }
1095
1096    #[test]
1097    fn path_identity_counts_sites_not_only_directions() {
1098        // An indirect branch: the same two directions, two different targets.
1099        let direct = [
1100            BranchRec { site: 0x100, taken: true },
1101            BranchRec { site: 0x200, taken: false },
1102        ];
1103        let indirect = [
1104            BranchRec { site: 0x100, taken: true },
1105            BranchRec { site: 0x300, taken: false },
1106        ];
1107        assert_eq!(
1108            direct.iter().map(|b| b.taken).collect::<Vec<_>>(),
1109            indirect.iter().map(|b| b.taken).collect::<Vec<_>>(),
1110            "the two runs take the same directions"
1111        );
1112        assert_ne!(
1113            path_key(&direct),
1114            path_key(&indirect),
1115            "but they are not the same path"
1116        );
1117    }
1118
1119    #[test]
1120    fn a_property_is_confirmed_only_by_the_re_run() {
1121        let r = explore(
1122            &Toy { claim: Claim::Witnessed },
1123            &Seed { x: 0 },
1124            &Opts { max_runs: 64, max_flips: 32 },
1125        );
1126        // On the base path the solver finds x = 3 and the reference engine
1127        // re-runs that model; the readback is the witness, not the Sat answer.
1128        let confirmed = &r.properties[0];
1129        assert_eq!(confirmed.label, "x != 3");
1130        assert_eq!(confirmed.outcome, PropertyOutcome::Confirmed);
1131        assert_eq!(confirmed.violation, Some((3, 3)));
1132        assert_eq!(confirmed.input, vec![("x".to_string(), 3)]);
1133        // The flipped child path (x >= 0x10) is UNSAT for x == 3: a local
1134        // negative under its own assumptions, and no run is spent on it.
1135        assert_eq!(r.properties.len(), 2);
1136        assert_eq!(r.properties[1].outcome, PropertyOutcome::Proven);
1137        // base + property witness + verified flip + the child's own flip.
1138        assert_eq!(r.runs, 4, "flips={:?}", r.flips);
1139        assert_eq!(r.flips_verified, 1, "flips={:?}", r.flips);
1140        assert!(r.exhausted);
1141    }
1142
1143    #[test]
1144    fn a_sat_model_without_a_violating_readback_is_unreproduced() {
1145        // The solver satisfies `x == 3`, the model is applied and re-run, but
1146        // this frontend's readback never reports the violation. The Sat answer
1147        // alone is not a finding: the ledger records the disagreement.
1148        let r = explore(
1149            &Toy { claim: Claim::Denied },
1150            &Seed { x: 0 },
1151            &Opts { max_runs: 64, max_flips: 32 },
1152        );
1153        assert_eq!(r.properties[0].outcome, PropertyOutcome::Unreproduced);
1154        assert_eq!(r.properties[0].violation, None);
1155        assert_eq!(r.properties[0].input, vec![("x".to_string(), 3)]);
1156    }
1157
1158    #[test]
1159    fn a_sat_property_with_no_run_budget_is_not_posed() {
1160        // The first query spends the last remaining run; the second is
1161        // satisfiable but has no verification run behind it, so the witness
1162        // law records `NotPosed`, not a finding.
1163        let r = explore(
1164            &Toy { claim: Claim::Pair },
1165            &Seed { x: 0 },
1166            &Opts { max_runs: 2, max_flips: 32 },
1167        );
1168        assert_eq!(r.properties.len(), 2);
1169        assert_eq!(r.properties[0].outcome, PropertyOutcome::Confirmed);
1170        assert_eq!(r.properties[1].outcome, PropertyOutcome::NotPosed);
1171        assert_eq!(r.runs, 2, "the budget is a hard cap, not a target");
1172        // The frontier may be empty, but a satisfiable query was refused its
1173        // verification run. The search was cut short, so it is NOT exhausted.
1174        assert!(!r.exhausted);
1175    }
1176}