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}