Skip to main content

foundry_evm_symbolic/runtime/
solver.rs

1use super::*;
2
3mod hard_arith_fallback;
4mod monotonic_product;
5mod normalize;
6
7use hard_arith_fallback::constraints_prefer_hard_arith_fallback_first;
8pub(crate) use hard_arith_fallback::hard_arith_fallback_model;
9#[cfg(test)]
10pub(crate) use hard_arith_fallback::{expr_contains_hard_arith, fallback_single_var_model};
11#[cfg(test)]
12pub(crate) use monotonic_product::product_monotonic_unsat;
13use monotonic_product::product_monotonic_unsat_normalized;
14pub(crate) use normalize::normalize_constraints_for_solver;
15#[cfg(test)]
16pub(crate) use normalize::{normalize_bool_for_solver, normalize_expr_for_solver};
17
18/// Errors that arise when parsing or constructing solver commands from configuration.
19#[derive(Debug, thiserror::Error)]
20pub(crate) enum SolverConfigError {
21    /// The command string parsed to an empty argv.
22    #[error("symbolic solver command is empty")]
23    EmptyCommand,
24    /// The command string contains an unterminated quote character.
25    #[error("unterminated {0} quote in symbolic solver command")]
26    UnterminatedQuote(char),
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
30pub(crate) enum SolverOutcome {
31    Cancelled,
32    Error,
33    NotStarted,
34    SatAfterWinner,
35    SatInvalid,
36    SatValid,
37    TimeoutOrUnknown,
38    Unknown,
39    UnknownAfterWinner,
40    Unsat,
41    UnsatAfterWinner,
42    Unexpected,
43}
44
45impl SolverOutcome {
46    /// Returns the diagnostic label for this solver outcome.
47    const fn as_str(self) -> &'static str {
48        match self {
49            Self::Cancelled => "cancelled",
50            Self::Error => "error",
51            Self::NotStarted => "not-started",
52            Self::SatAfterWinner => "sat-after-winner",
53            Self::SatInvalid => "sat-invalid",
54            Self::SatValid => "sat-valid",
55            Self::TimeoutOrUnknown => "timeout-or-unknown",
56            Self::Unknown => "unknown",
57            Self::UnknownAfterWinner => "unknown-after-winner",
58            Self::Unsat => "unsat",
59            Self::UnsatAfterWinner => "unsat-after-winner",
60            Self::Unexpected => "unexpected",
61        }
62    }
63}
64
65impl fmt::Display for SolverOutcome {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71pub(crate) type QueryObserver = Box<dyn Fn(usize) + Send + Sync + 'static>;
72
73/// Minimal solver backend interface used by the symbolic executor.
74///
75/// Implementations are responsible for translating accumulated symbolic constraints
76/// into solver queries, enforcing query budgets, and extracting concrete model values
77/// for counterexample replay. The trait is intentionally small so alternate SMT
78/// backends can be added without changing the executor entrypoints.
79pub(crate) trait SymbolicSolver {
80    /// Returns solver counters collected by this backend.
81    fn stats(&self) -> SymbolicStats;
82
83    /// Registers a callback invoked after each logical solver query is reserved.
84    fn set_query_observer(&mut self, observer: Option<QueryObserver>);
85
86    /// Returns aggregate staged-portfolio diagnostics collected by this backend.
87    fn portfolio_diagnostics(&self) -> Option<&PortfolioDiagnostics>;
88
89    /// Captures verbose diagnostics for later rendering instead of writing them live.
90    fn capture_diagnostics(&mut self);
91
92    /// Takes any captured verbose diagnostics collected by this backend.
93    fn take_diagnostics(&mut self) -> Option<String>;
94
95    /// Returns the number of satisfiable witnesses produced by local hard-arithmetic search.
96    fn heuristic_witnesses(&self) -> usize {
97        0
98    }
99
100    /// Verifies that the configured solver can be invoked before exploration starts.
101    ///
102    /// Backends should keep this check lightweight and return a [`SymbolicError`] with
103    /// a stable stop reason when the solver executable or service is unavailable.
104    fn check_available(&self) -> Result<(), SymbolicError>;
105
106    /// Returns whether the supplied path constraints are satisfiable.
107    ///
108    /// Implementations should count this as one solver query and map solver `unknown`
109    /// or timeout responses into [`SymbolicError::SolverUnknown`] or
110    /// [`SymbolicError::Solver`], as appropriate.
111    fn is_sat(&mut self, constraints: &[BoolExpr]) -> Result<bool, SymbolicError>;
112
113    /// Returns a concrete model for all symbolic variables constrained by the path.
114    ///
115    /// The executor uses the returned variable assignments to materialize ABI
116    /// arguments, calldata, and invariant sequences for concrete replay.
117    fn model(&mut self, constraints: &[BoolExpr]) -> Result<BTreeMap<String, U256>, SymbolicError>;
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub(crate) struct SolverCommand {
122    pub(crate) program: String,
123    pub(crate) args: Vec<String>,
124    pub(crate) display: String,
125    pub(crate) smt_timeout: bool,
126}
127
128impl SolverCommand {
129    /// Constructs a solver command from a program plus arguments.
130    pub(crate) fn new(parts: Vec<String>, smt_timeout: bool) -> Result<Self, SolverConfigError> {
131        let mut parts = parts.into_iter();
132        let Some(program) = parts.next().filter(|part| !part.is_empty()) else {
133            return Err(SolverConfigError::EmptyCommand);
134        };
135        let args = parts.collect::<Vec<_>>();
136        let display = std::iter::once(program.as_str())
137            .chain(args.iter().map(String::as_str))
138            .collect::<Vec<_>>()
139            .join(" ");
140        Ok(Self { program, args, display, smt_timeout })
141    }
142}
143
144pub(crate) struct SmtLibSubprocessSolver {
145    pub(crate) commands: Result<Vec<SolverCommand>, SolverConfigError>,
146    pub(crate) timeout: Option<u32>,
147    pub(crate) max_queries: usize,
148    pub(crate) queries: usize,
149    query_observer: Option<QueryObserver>,
150    pub(crate) dump_smt: bool,
151    portfolio_scheduler: PortfolioScheduler,
152    portfolio_diagnostics: PortfolioDiagnostics,
153    captured_diagnostics: Option<String>,
154    heuristic_witnesses: usize,
155    sat_cache: BTreeMap<Vec<BoolExpr>, bool>,
156    model_cache: BTreeMap<Vec<BoolExpr>, BTreeMap<String, U256>>,
157    sat_queries: usize,
158    model_queries: usize,
159    sat_cache_hits: usize,
160    model_cache_hits: usize,
161    smt_queries: usize,
162    solver_time: Duration,
163}
164
165impl SmtLibSubprocessSolver {
166    /// Constructs a new instance.
167    pub(crate) fn new(
168        commands: Result<Vec<SolverCommand>, SolverConfigError>,
169        timeout: Option<u32>,
170        max_queries: usize,
171        dump_smt: bool,
172    ) -> Self {
173        Self {
174            commands,
175            timeout,
176            max_queries,
177            queries: 0,
178            query_observer: None,
179            dump_smt,
180            portfolio_scheduler: PortfolioScheduler::default(),
181            portfolio_diagnostics: PortfolioDiagnostics::default(),
182            captured_diagnostics: None,
183            heuristic_witnesses: 0,
184            sat_cache: BTreeMap::new(),
185            model_cache: BTreeMap::new(),
186            sat_queries: 0,
187            model_queries: 0,
188            sat_cache_hits: 0,
189            model_cache_hits: 0,
190            smt_queries: 0,
191            solver_time: Duration::ZERO,
192        }
193    }
194
195    /// Constructs a subprocess solver from Foundry symbolic config.
196    pub(crate) fn from_config(config: &SymbolicConfig) -> Self {
197        Self::new(
198            solver_commands_for_config(config),
199            config.timeout,
200            config.max_solver_queries as usize,
201            config.dump_smt,
202        )
203    }
204}
205
206impl SymbolicSolver for SmtLibSubprocessSolver {
207    /// Implements the `stats` solver helper.
208    fn stats(&self) -> SymbolicStats {
209        SymbolicStats {
210            paths: 0,
211            solver_queries: self.queries,
212            smt_queries: self.smt_queries,
213            sat_queries: self.sat_queries,
214            model_queries: self.model_queries,
215            sat_cache_hits: self.sat_cache_hits,
216            model_cache_hits: self.model_cache_hits,
217            heuristic_witnesses: self.heuristic_witnesses,
218            solver_time_ms: self.solver_time.as_millis().try_into().unwrap_or(u64::MAX),
219        }
220    }
221
222    /// Registers a live query observer for progress rendering.
223    fn set_query_observer(&mut self, observer: Option<QueryObserver>) {
224        self.query_observer = observer;
225    }
226
227    /// Returns staged-portfolio diagnostics collected by this solver.
228    fn portfolio_diagnostics(&self) -> Option<&PortfolioDiagnostics> {
229        (!self.portfolio_diagnostics.is_empty()).then_some(&self.portfolio_diagnostics)
230    }
231
232    /// Enables deferred diagnostic rendering for verbose symbolic solver output.
233    fn capture_diagnostics(&mut self) {
234        self.captured_diagnostics.get_or_insert_with(String::new);
235    }
236
237    /// Returns and clears deferred diagnostic rendering output.
238    fn take_diagnostics(&mut self) -> Option<String> {
239        self.captured_diagnostics.take().filter(|diagnostics| !diagnostics.is_empty())
240    }
241
242    /// Returns how many validated local hard-arithmetic witnesses this solver used.
243    fn heuristic_witnesses(&self) -> usize {
244        self.heuristic_witnesses
245    }
246
247    /// Validates the `check_available` solver helper.
248    fn check_available(&self) -> Result<(), SymbolicError> {
249        let commands = self.commands()?;
250        let mut errors = Vec::new();
251        for command in commands {
252            let output = match Command::new(&command.program).arg("--version").output() {
253                Ok(output) => output,
254                Err(err) => {
255                    errors.push(format!("failed to execute `{}`: {err}", command.program));
256                    continue;
257                }
258            };
259            if output.status.success() {
260                return Ok(());
261            }
262            errors.push(format!("`{}` is not a usable SMT solver executable", command.program));
263        }
264        Err(SymbolicError::Solver(errors.join("; ")))
265    }
266
267    /// Returns whether `is_sat` holds.
268    fn is_sat(&mut self, constraints: &[BoolExpr]) -> Result<bool, SymbolicError> {
269        self.sat_queries += 1;
270        if constraints.iter().any(bool_contains_gasleft) {
271            return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled"));
272        }
273        let smt_constraints = normalize_constraints_for_solver(constraints);
274        let cache_key = constraint_cache_key(&smt_constraints);
275        if let Some(result) = self.sat_cache.get(&cache_key) {
276            self.sat_cache_hits += 1;
277            trace!(result, "is_sat: normalized cache hit");
278            return Ok(*result);
279        }
280        if self.has_cached_unsat_subset(&cache_key) {
281            self.sat_cache_hits += 1;
282            trace!("is_sat: normalized unsat subset cache hit");
283            self.cache_sat_result(cache_key, false);
284            return Ok(false);
285        }
286
287        self.reserve_query()?;
288        self.record_query();
289        let _span = trace_span!(
290            "solver_query",
291            query_id = self.queries,
292            constraint_count = constraints.len(),
293            kind = "is_sat"
294        )
295        .entered();
296        trace!(query_id = self.queries, constraint_count = constraints.len(), "solver is_sat");
297        if constraints_are_directly_unsat(&smt_constraints) {
298            trace!("is_sat: direct contradiction");
299            self.cache_sat_result(cache_key, false);
300            return Ok(false);
301        }
302        if product_monotonic_unsat_normalized(&smt_constraints) {
303            trace!("is_sat: monotonic product contradiction");
304            self.cache_sat_result(cache_key, false);
305            return Ok(false);
306        }
307        if constraints_prefer_hard_arith_fallback_first(&smt_constraints)
308            && validated_hard_arith_fallback_model(&smt_constraints, constraints).is_some()
309        {
310            self.heuristic_witnesses += 1;
311            trace!("is_sat: validated hard arithmetic fallback model before solver");
312            self.cache_sat_result(cache_key, true);
313            return Ok(true);
314        }
315        let output = match self.query_normalized(&smt_constraints, false, constraints) {
316            Ok(output) => output,
317            Err(SymbolicError::SolverUnknown) => {
318                if validated_hard_arith_fallback_model(&smt_constraints, constraints).is_some() {
319                    self.heuristic_witnesses += 1;
320                    trace!("is_sat: validated hard arithmetic fallback model after solver unknown");
321                    self.cache_sat_result(cache_key, true);
322                    return Ok(true);
323                }
324                return Err(SymbolicError::SolverUnknown);
325            }
326            Err(err) => return Err(err),
327        };
328        match output.lines().next().unwrap_or_default().trim() {
329            "sat" => {
330                self.cache_sat_result(cache_key, true);
331                Ok(true)
332            }
333            "unsat" => {
334                self.cache_sat_result(cache_key, false);
335                Ok(false)
336            }
337            "unknown" => {
338                if validated_hard_arith_fallback_model(&smt_constraints, constraints).is_some() {
339                    self.heuristic_witnesses += 1;
340                    self.cache_sat_result(cache_key, true);
341                    Ok(true)
342                } else {
343                    Err(SymbolicError::SolverUnknown)
344                }
345            }
346            other => Err(SymbolicError::Solver(format!("unexpected solver response `{other}`"))),
347        }
348    }
349
350    /// Implements the `model` solver helper.
351    fn model(&mut self, constraints: &[BoolExpr]) -> Result<BTreeMap<String, U256>, SymbolicError> {
352        self.model_queries += 1;
353        if constraints.iter().any(bool_contains_gasleft) {
354            return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled"));
355        }
356        let smt_constraints = normalize_constraints_for_solver(constraints);
357        let cache_key = constraint_cache_key(&smt_constraints);
358
359        if self.sat_cache.get(&cache_key) == Some(&false) {
360            self.model_cache.remove(&cache_key);
361            trace!("model: normalized sat cache says unsat");
362            return Err(SymbolicError::Solver("counterexample path became unsat".to_string()));
363        }
364        if self.has_cached_unsat_subset(&cache_key) {
365            self.cache_sat_result(cache_key.clone(), false);
366            self.model_cache.remove(&cache_key);
367            trace!("model: normalized unsat subset cache hit");
368            return Err(SymbolicError::Solver("counterexample path became unsat".to_string()));
369        }
370
371        if let Some(model) = self.model_cache.get(&cache_key) {
372            if model_satisfies_constraints(model, constraints) {
373                let model = model.clone();
374                self.model_cache_hits += 1;
375                trace!("model: normalized cache hit");
376                self.cache_sat_result(cache_key.clone(), true);
377                return Ok(model);
378            }
379            trace!("model: normalized cache hit failed validation");
380        }
381        if self.model_cache.remove(&cache_key).is_some() {
382            self.sat_cache.remove(&cache_key);
383        }
384
385        self.reserve_query()?;
386        self.record_query();
387        let _span = trace_span!(
388            "solver_query",
389            query_id = self.queries,
390            constraint_count = constraints.len(),
391            kind = "model"
392        )
393        .entered();
394        trace!(query_id = self.queries, constraint_count = constraints.len(), "solver model");
395        if constraints_prefer_hard_arith_fallback_first(&smt_constraints)
396            && let Some(model) = validated_hard_arith_fallback_model(&smt_constraints, constraints)
397        {
398            self.heuristic_witnesses += 1;
399            trace!("model: validated hard arithmetic fallback model before solver");
400            self.cache_sat_result(cache_key.clone(), true);
401            self.cache_model_result(cache_key, model.clone());
402            return Ok(model);
403        }
404        let output = match self.query_normalized(&smt_constraints, true, constraints) {
405            Ok(output) => output,
406            Err(SymbolicError::SolverUnknown) => {
407                if let Some(model) =
408                    validated_hard_arith_fallback_model(&smt_constraints, constraints)
409                {
410                    self.heuristic_witnesses += 1;
411                    trace!("model: validated hard arithmetic fallback model after solver unknown");
412                    self.cache_sat_result(cache_key.clone(), true);
413                    self.cache_model_result(cache_key, model.clone());
414                    return Ok(model);
415                }
416                return Err(SymbolicError::SolverUnknown);
417            }
418            Err(err) => return Err(err),
419        };
420        let mut lines = output.lines();
421        match lines.next().unwrap_or_default().trim() {
422            "sat" => {
423                let model = parse_and_validate_model(&output, constraints)?;
424                self.cache_sat_result(cache_key.clone(), true);
425                self.cache_model_result(cache_key, model.clone());
426                Ok(model)
427            }
428            "unsat" => {
429                self.model_cache.remove(&cache_key);
430                self.cache_sat_result(cache_key, false);
431                Err(SymbolicError::Solver("counterexample path became unsat".to_string()))
432            }
433            "unknown" => {
434                if let Some(model) =
435                    validated_hard_arith_fallback_model(&smt_constraints, constraints)
436                {
437                    self.heuristic_witnesses += 1;
438                    self.cache_sat_result(cache_key.clone(), true);
439                    self.cache_model_result(cache_key, model.clone());
440                    Ok(model)
441                } else {
442                    Err(SymbolicError::SolverUnknown)
443                }
444            }
445            other => Err(SymbolicError::Solver(format!("unexpected solver response `{other}`"))),
446        }
447    }
448}
449
450impl SmtLibSubprocessSolver {
451    /// Returns the resolved commands or the stored config error.
452    pub(crate) fn commands(&self) -> Result<&[SolverCommand], SymbolicError> {
453        self.commands
454            .as_ref()
455            .map(Vec::as_slice)
456            .map_err(|err| SymbolicError::Solver(err.to_string()))
457    }
458
459    /// Emits one verbose solver diagnostic either live or into the deferred buffer.
460    fn emit_diagnostic(&mut self, diagnostic: fmt::Arguments<'_>) {
461        if let Some(captured_diagnostics) = &mut self.captured_diagnostics {
462            let _ = captured_diagnostics.write_fmt(diagnostic);
463        } else {
464            let mut stderr = std::io::stderr().lock();
465            let _ = stderr.write_fmt(diagnostic);
466        }
467    }
468
469    /// Validates the `reserve_query` solver helper.
470    pub(crate) const fn reserve_query(&self) -> Result<(), SymbolicError> {
471        if self.queries >= self.max_queries {
472            return Err(SymbolicError::SolverQueryLimit(self.max_queries));
473        }
474        Ok(())
475    }
476
477    /// Records one logical solver query and notifies the live observer, if any.
478    fn record_query(&mut self) {
479        self.queries += 1;
480        if let Some(observer) = &self.query_observer {
481            observer(self.queries);
482        }
483    }
484
485    /// Caches a definitive normalized satisfiability result if the cache has room.
486    fn cache_sat_result(&mut self, key: Vec<BoolExpr>, result: bool) {
487        if self.sat_cache.contains_key(&key)
488            || self.sat_cache.len() < SYMBOLIC_SOLVER_SAT_CACHE_MAX_ENTRIES
489        {
490            self.sat_cache.insert(key, result);
491        }
492    }
493
494    /// Caches a validated normalized model result if the cache has room.
495    fn cache_model_result(&mut self, key: Vec<BoolExpr>, model: BTreeMap<String, U256>) {
496        if self.model_cache.contains_key(&key)
497            || self.model_cache.len() < SYMBOLIC_SOLVER_MODEL_CACHE_MAX_ENTRIES
498        {
499            self.model_cache.insert(key, model);
500        }
501    }
502
503    /// Returns whether an already-proved unsat constraint set is a subset of `key`.
504    fn has_cached_unsat_subset(&self, key: &[BoolExpr]) -> bool {
505        self.sat_cache
506            .iter()
507            .any(|(cached_key, result)| !*result && sorted_bool_exprs_are_subset(cached_key, key))
508    }
509
510    /// Sends already-normalized constraints to the configured solver portfolio.
511    pub(crate) fn query_normalized(
512        &mut self,
513        smt_constraints: &[BoolExpr],
514        model: bool,
515        model_constraints: &[BoolExpr],
516    ) -> Result<String, SymbolicError> {
517        self.smt_queries += 1;
518        let mut vars = BTreeSet::new();
519        for constraint in smt_constraints {
520            constraint.collect_vars(&mut vars);
521        }
522
523        let configured_commands = self.commands()?.to_vec();
524        let ordered_commands = self.portfolio_scheduler.ordered_commands(&configured_commands);
525        let commands =
526            ordered_commands.iter().map(|(_, command)| command.clone()).collect::<Vec<_>>();
527
528        let mut smt = String::with_capacity(256 + smt_constraints.len().saturating_mul(192));
529        smt.push_str("(set-logic QF_BV)\n");
530        if commands.iter().all(|command| command.smt_timeout)
531            && let Some(timeout) = self.timeout.filter(|timeout| *timeout > 0)
532        {
533            let _ = writeln!(smt, "(set-option :timeout {})", timeout.saturating_mul(1000));
534        }
535        for var in vars {
536            let _ = writeln!(smt, "(declare-fun {var} () (_ BitVec 256))");
537        }
538        for constraint in smt_constraints {
539            let _ = writeln!(smt, "(assert {})", constraint.smt());
540        }
541        smt.push_str("(check-sat)\n");
542        if model {
543            smt.push_str("(get-model)\n");
544        }
545        if self.dump_smt {
546            let query = self.queries;
547            self.emit_diagnostic(format_args!("--- symbolic SMT query {query} ---\n{smt}\n"));
548        }
549
550        let started = Instant::now();
551        let result =
552            run_solver_commands(&commands, &smt, self.timeout, model.then_some(model_constraints));
553        self.solver_time += started.elapsed();
554        self.portfolio_scheduler.record(&ordered_commands, &result.summaries);
555        if self.dump_smt {
556            self.portfolio_diagnostics.record(&result.summaries);
557            if !result.summaries.is_empty() {
558                self.emit_diagnostic(format_args!(
559                    "{}",
560                    format_solver_portfolio_summaries(&result.summaries)
561                ));
562            }
563        }
564        result.output
565    }
566}
567
568/// Returns a structural key for normalized solver cache lookups.
569fn constraint_cache_key(constraints: &[BoolExpr]) -> Vec<BoolExpr> {
570    let mut key = Vec::with_capacity(constraints.len());
571    for constraint in constraints.iter().cloned().map(cache_key_bool) {
572        collect_cache_key_conjunct(constraint, &mut key);
573    }
574    key.sort();
575    key.dedup();
576    key
577}
578
579/// Returns whether normalized conjunctive constraints contain a direct contradiction.
580fn constraints_are_directly_unsat(constraints: &[BoolExpr]) -> bool {
581    constraints.iter().any(|constraint| match constraint {
582        BoolExpr::Const(false) => true,
583        BoolExpr::Not(inner) => constraints.binary_search(inner.as_ref()).is_ok(),
584        constraint => constraints.binary_search(&constraint.clone().not()).is_ok(),
585    })
586}
587
588/// Returns whether every expression in sorted `subset` appears in sorted `superset`.
589fn sorted_bool_exprs_are_subset(subset: &[BoolExpr], superset: &[BoolExpr]) -> bool {
590    if subset.len() > superset.len() {
591        return false;
592    }
593
594    let mut superset = superset.iter();
595    for expected in subset {
596        loop {
597            match superset.next() {
598                Some(candidate) if candidate < expected => {}
599                Some(candidate) if candidate == expected => break,
600                _ => return false,
601            }
602        }
603    }
604    true
605}
606
607/// Returns a conservative canonical boolean expression for cache-key equality.
608fn cache_key_bool(expr: BoolExpr) -> BoolExpr {
609    match expr {
610        BoolExpr::Const(_) => expr,
611        BoolExpr::Not(value) => cache_key_bool(*value).not(),
612        BoolExpr::And(values) => {
613            let mut conjuncts = Vec::new();
614            for value in values.into_iter().map(cache_key_bool) {
615                collect_cache_key_conjunct(value, &mut conjuncts);
616            }
617            conjuncts.sort();
618            conjuncts.dedup();
619            BoolExpr::and(conjuncts)
620        }
621        BoolExpr::Eq(left, right) => {
622            let left = cache_key_expr(left);
623            let right = cache_key_expr(right);
624            if left <= right { BoolExpr::eq(left, right) } else { BoolExpr::eq(right, left) }
625        }
626        BoolExpr::Cmp(op, left, right) => {
627            cache_key_cmp(op, cache_key_expr(left), cache_key_expr(right))
628        }
629    }
630}
631
632/// Collects cache-key conjuncts, flattening conjunctions because path constraints are conjunctive.
633fn collect_cache_key_conjunct(expr: BoolExpr, out: &mut Vec<BoolExpr>) {
634    match expr {
635        BoolExpr::Const(true) => {}
636        BoolExpr::And(values) => {
637            for value in values {
638                collect_cache_key_conjunct(value, out);
639            }
640        }
641        value => out.push(value),
642    }
643}
644
645/// Returns a conservative canonical comparison for cache-key equality.
646fn cache_key_cmp(op: BoolExprOp, left: Expr, right: Expr) -> BoolExpr {
647    match op {
648        BoolExprOp::Ugt => BoolExpr::cmp(BoolExprOp::Ult, right, left),
649        BoolExprOp::Uge => BoolExpr::cmp(BoolExprOp::Ule, right, left),
650        BoolExprOp::Sgt => BoolExpr::cmp(BoolExprOp::Slt, right, left),
651        BoolExprOp::Ult | BoolExprOp::Ule | BoolExprOp::Slt => BoolExpr::cmp(op, left, right),
652    }
653}
654
655/// Returns a conservative canonical word expression for cache-key equality.
656fn cache_key_expr(expr: Expr) -> Expr {
657    match expr {
658        Expr::Const(_) | Expr::Var(_) | Expr::GasLeft(_) => expr,
659        Expr::Keccak { name, len, bytes } => Expr::Keccak {
660            name,
661            len: Box::new(cache_key_expr(*len)),
662            bytes: bytes.into_iter().map(cache_key_expr).collect(),
663        },
664        Expr::Hash { name, algorithm, bytes } => {
665            Expr::Hash { name, algorithm, bytes: bytes.into_iter().map(cache_key_expr).collect() }
666        }
667        Expr::Not(value) => Expr::Not(Box::new(cache_key_expr(*value))),
668        Expr::Op(op, left, right) => {
669            let left = cache_key_expr(*left);
670            let right = cache_key_expr(*right);
671            if expr_op_is_commutative(op) && right < left {
672                Expr::op(op, right, left)
673            } else {
674                Expr::op(op, left, right)
675            }
676        }
677        Expr::Ite(cond, left, right) => Expr::Ite(
678            Box::new(cache_key_bool(*cond)),
679            Box::new(cache_key_expr(*left)),
680            Box::new(cache_key_expr(*right)),
681        ),
682    }
683}
684
685/// Returns whether a word operation is safe to reorder for cache-key equality.
686const fn expr_op_is_commutative(op: ExprOp) -> bool {
687    matches!(op, ExprOp::Add | ExprOp::Mul | ExprOp::And | ExprOp::Or | ExprOp::Xor)
688}
689
690/// Returns a hard-arithmetic fallback model only after validating it against original constraints.
691fn validated_hard_arith_fallback_model(
692    normalized_constraints: &[BoolExpr],
693    original_constraints: &[BoolExpr],
694) -> Option<BTreeMap<String, U256>> {
695    let model = hard_arith_fallback_model(normalized_constraints)?;
696    model_satisfies_constraints(&model, original_constraints).then_some(model)
697}
698
699/// Returns whether a parsed model satisfies the current original constraints.
700fn model_satisfies_constraints(model: &BTreeMap<String, U256>, constraints: &[BoolExpr]) -> bool {
701    constraints.iter().all(|constraint| eval_bool_expr(constraint, model).unwrap_or(false))
702}
703
704#[derive(Clone, Debug, Default)]
705struct PortfolioScheduler {
706    history: Vec<VecDeque<PortfolioSchedulerSignal>>,
707}
708
709#[derive(Clone, Copy, Debug, PartialEq, Eq)]
710enum PortfolioSchedulerSignal {
711    Winner { speed_bonus: i64 },
712    InvalidModel,
713    Error,
714    Unknown,
715    Neutral,
716}
717
718impl PortfolioSchedulerSignal {
719    /// Returns the scheduler signal represented by one solver run summary.
720    fn from_summary(summary: &SolverRunSummary) -> Self {
721        let speed_bonus = PORTFOLIO_SCHEDULER_MAX_SPEED_BONUS.saturating_sub(
722            summary.elapsed.as_millis().min(PORTFOLIO_SCHEDULER_SPEED_BONUS_CAP_MS) as i64,
723        );
724        match (summary.winner, summary.outcome) {
725            (true, SolverOutcome::SatValid | SolverOutcome::Unsat) => Self::Winner { speed_bonus },
726            (_, SolverOutcome::SatInvalid) => Self::InvalidModel,
727            (_, SolverOutcome::Error | SolverOutcome::Unexpected) => Self::Error,
728            (_, SolverOutcome::Unknown | SolverOutcome::TimeoutOrUnknown) => Self::Unknown,
729            _ => Self::Neutral,
730        }
731    }
732
733    /// Returns whether this signal should affect later portfolio scheduling.
734    const fn is_neutral(self) -> bool {
735        matches!(self, Self::Neutral)
736    }
737
738    /// Returns the numeric score contribution for adaptive portfolio ordering.
739    const fn score(self) -> i64 {
740        match self {
741            Self::Winner { speed_bonus } => 1_000 + speed_bonus,
742            Self::InvalidModel => -1_000,
743            Self::Error => -750,
744            Self::Unknown => -250,
745            Self::Neutral => 0,
746        }
747    }
748}
749
750impl PortfolioScheduler {
751    /// Returns configured commands ordered by recent portfolio performance.
752    fn ordered_commands(&mut self, commands: &[SolverCommand]) -> Vec<(usize, SolverCommand)> {
753        self.ensure_len(commands.len());
754        let mut ordered = commands.iter().cloned().enumerate().collect::<Vec<_>>();
755        ordered.sort_by(|(left_index, _), (right_index, _)| {
756            self.score(*right_index)
757                .cmp(&self.score(*left_index))
758                .then_with(|| left_index.cmp(right_index))
759        });
760        ordered
761    }
762
763    /// Records one query's portfolio summaries against original configured solver indexes.
764    fn record(
765        &mut self,
766        ordered_commands: &[(usize, SolverCommand)],
767        summaries: &[SolverRunSummary],
768    ) {
769        for summary in summaries {
770            let Some(run_index) = summary.index else { continue };
771            let Some((configured_index, _)) = ordered_commands.get(run_index) else { continue };
772            let Some(history) = self.history.get_mut(*configured_index) else { continue };
773            let signal = PortfolioSchedulerSignal::from_summary(summary);
774            if signal.is_neutral() {
775                continue;
776            }
777            history.push_back(signal);
778            if history.len() > PORTFOLIO_SCHEDULER_HISTORY {
779                history.pop_front();
780            }
781        }
782    }
783
784    /// Ensures the scheduler has one history slot per configured solver.
785    fn ensure_len(&mut self, len: usize) {
786        self.history.resize_with(len, VecDeque::new);
787    }
788
789    /// Returns the recent-performance score for one configured solver index.
790    fn score(&self, index: usize) -> i64 {
791        self.history
792            .get(index)
793            .into_iter()
794            .flatten()
795            .rev()
796            .enumerate()
797            .map(|(age, signal)| {
798                let recency = PORTFOLIO_SCHEDULER_HISTORY
799                    .saturating_sub(age)
800                    .max(PORTFOLIO_SCHEDULER_MIN_RECENCY_WEIGHT as usize)
801                    as i64;
802                recency * signal.score()
803            })
804            .sum()
805    }
806}
807
808/// Returns the subprocess commands for the configured SMT solver setup.
809pub(crate) fn solver_commands_for_config(
810    config: &SymbolicConfig,
811) -> Result<Vec<SolverCommand>, SolverConfigError> {
812    if let Some(command) = config.solver_command.as_deref().filter(|command| !command.is_empty()) {
813        return Ok(vec![SolverCommand::new(split_solver_command(command)?, false)?]);
814    }
815
816    let portfolio = config
817        .solver_portfolio
818        .iter()
819        .map(|entry| entry.trim())
820        .filter(|entry| !entry.is_empty())
821        .collect::<Vec<_>>();
822    if !portfolio.is_empty() {
823        return portfolio.into_iter().map(solver_command_for_portfolio_entry).collect();
824    }
825
826    Ok(vec![named_solver_command(&config.solver)?])
827}
828
829/// Returns a warning when a configured portfolio will run with unavailable solver entries.
830pub(crate) fn solver_portfolio_availability_warning(config: &SymbolicConfig) -> Option<String> {
831    if config.solver_command.as_deref().is_some_and(|command| !command.trim().is_empty())
832        || config.solver_portfolio.iter().all(|entry| entry.trim().is_empty())
833    {
834        return None;
835    }
836
837    let commands = solver_commands_for_config(config).ok()?;
838    let unavailable = commands
839        .iter()
840        .filter_map(|command| {
841            solver_command_availability_error(command)
842                .map(|err| format!("`{}` ({err})", command.display))
843        })
844        .collect::<Vec<_>>();
845    if unavailable.is_empty() {
846        return None;
847    }
848
849    let suffix = if unavailable.len() == commands.len() {
850        "No configured portfolio entries are currently available."
851    } else {
852        "Available portfolio entries will still be used."
853    };
854    Some(format!(
855        "Symbolic solver portfolio is degraded; unavailable entries: {}. {suffix}",
856        unavailable.join("; ")
857    ))
858}
859
860/// Returns the default command for a known solver name.
861pub(crate) fn named_solver_command(solver: &str) -> Result<SolverCommand, SolverConfigError> {
862    let (parts, smt_timeout) = match solver {
863        "z3" => (vec!["z3", "-in", "-smt2"], true),
864        "yices" => (vec!["yices-smt2", "--bvconst-in-decimal"], false),
865        "cvc5" => (
866            vec![
867                "cvc5",
868                "--produce-models",
869                "--lang",
870                "smt2",
871                "--bv-print-consts-as-indexed-symbols",
872            ],
873            false,
874        ),
875        "cvc5-int" => (
876            vec![
877                "cvc5",
878                "--produce-models",
879                "--lang",
880                "smt2",
881                "--bv-print-consts-as-indexed-symbols",
882                "--solve-bv-as-int=iand",
883                "--iand-mode=bitwise",
884            ],
885            false,
886        ),
887        "bitwuzla" => (vec!["bitwuzla", "--produce-models"], false),
888        "bitwuzla-abs" => (vec!["bitwuzla", "--produce-models", "--abstraction"], false),
889        // Preserve existing behavior for custom z3-compatible executable names/paths.
890        custom => (vec![custom, "-in", "-smt2"], true),
891    };
892    let parts = parts.into_iter().map(str::to_string).collect::<Vec<_>>();
893    SolverCommand::new(parts, smt_timeout)
894}
895
896/// Returns the command for one configured portfolio entry.
897pub(crate) fn solver_command_for_portfolio_entry(
898    entry: &str,
899) -> Result<SolverCommand, SolverConfigError> {
900    if entry.chars().any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\')) {
901        SolverCommand::new(split_solver_command(entry)?, false)
902    } else {
903        named_solver_command(entry)
904    }
905}
906
907/// Splits a shell-like solver command into argv parts.
908pub(crate) fn split_solver_command(command: &str) -> Result<Vec<String>, SolverConfigError> {
909    let parts = split_quoted_args(command).map_err(SolverConfigError::UnterminatedQuote)?;
910    if parts.is_empty() {
911        return Err(SolverConfigError::EmptyCommand);
912    }
913
914    Ok(parts)
915}
916
917/// Returns why `command` is not currently executable as an SMT solver.
918fn solver_command_availability_error(command: &SolverCommand) -> Option<String> {
919    let output = match Command::new(&command.program).arg("--version").output() {
920        Ok(output) => output,
921        Err(err) => return Some(format!("failed to execute `{}`: {err}", command.program)),
922    };
923    (!output.status.success())
924        .then(|| format!("`{}` is not a usable SMT solver executable", command.program))
925}
926
927#[derive(Debug)]
928enum SolverProcessOutcome {
929    Output(String),
930    Unknown,
931    Cancelled,
932    Error(String),
933}
934
935#[derive(Debug)]
936struct SolverProcessResult {
937    index: usize,
938    display: String,
939    scheduled_after: Duration,
940    started_after: Duration,
941    elapsed: Duration,
942    outcome: SolverProcessOutcome,
943}
944
945#[derive(Debug)]
946struct ScheduledSolver {
947    index: usize,
948    command: SolverCommand,
949    launch_after: Duration,
950}
951
952#[derive(Debug)]
953struct SolverCommandRun {
954    output: Result<String, SymbolicError>,
955    summaries: Vec<SolverRunSummary>,
956}
957
958#[derive(Debug)]
959pub(crate) struct SolverRunSummary {
960    index: Option<usize>,
961    display: String,
962    scheduled_after: Option<Duration>,
963    started_after: Option<Duration>,
964    elapsed: Duration,
965    outcome: SolverOutcome,
966    detail: Option<String>,
967    winner: bool,
968}
969
970impl SolverRunSummary {
971    /// Builds a portfolio run summary with no detail or winner marker.
972    pub(crate) const fn new(display: String, elapsed: Duration, outcome: SolverOutcome) -> Self {
973        Self {
974            index: None,
975            display,
976            scheduled_after: None,
977            started_after: None,
978            elapsed,
979            outcome,
980            detail: None,
981            winner: false,
982        }
983    }
984
985    /// Attaches the configured portfolio order and launch delay to this summary.
986    pub(crate) const fn with_schedule(
987        mut self,
988        index: usize,
989        scheduled_after: Duration,
990        started_after: Option<Duration>,
991    ) -> Self {
992        self.index = Some(index);
993        self.scheduled_after = Some(scheduled_after);
994        self.started_after = started_after;
995        self
996    }
997
998    /// Attaches an additional diagnostic detail string to this summary.
999    fn with_detail(mut self, detail: impl Into<String>) -> Self {
1000        self.detail = Some(detail.into());
1001        self
1002    }
1003
1004    /// Marks this solver run as the portfolio result winner.
1005    pub(crate) const fn winner(mut self) -> Self {
1006        self.winner = true;
1007        self
1008    }
1009}
1010
1011#[derive(Clone, Debug, Default)]
1012pub struct PortfolioDiagnostics {
1013    pub(crate) queries: usize,
1014    pub(crate) solver_runs: usize,
1015    pub(crate) rescue_runs: usize,
1016    pub(crate) non_primary_wins: usize,
1017    pub(crate) rescue_wins: usize,
1018    pub(crate) not_started: usize,
1019    pub(crate) cancelled_after_winner: usize,
1020    pub(crate) invalid_models: usize,
1021    pub(crate) solver_errors: usize,
1022    pub(crate) winner_counts: BTreeMap<String, usize>,
1023    pub(crate) launch_counts: BTreeMap<String, usize>,
1024    pub(crate) outcome_counts: BTreeMap<SolverOutcome, usize>,
1025}
1026
1027impl PortfolioDiagnostics {
1028    /// Returns whether this diagnostic set is empty.
1029    pub const fn is_empty(&self) -> bool {
1030        self.queries == 0
1031    }
1032
1033    /// Records one portfolio query's per-solver summaries.
1034    pub(crate) fn record(&mut self, summaries: &[SolverRunSummary]) {
1035        if summaries.len() <= 1 {
1036            return;
1037        }
1038
1039        self.queries += 1;
1040        for summary in summaries {
1041            *self.outcome_counts.entry(summary.outcome).or_default() += 1;
1042            if summary.started_after.is_some() {
1043                self.solver_runs += 1;
1044                *self.launch_counts.entry(summary.display.clone()).or_default() += 1;
1045                if summary.index.is_some_and(|index| index >= 2) {
1046                    self.rescue_runs += 1;
1047                }
1048            }
1049
1050            match summary.outcome {
1051                SolverOutcome::NotStarted => self.not_started += 1,
1052                SolverOutcome::Cancelled
1053                | SolverOutcome::SatAfterWinner
1054                | SolverOutcome::UnsatAfterWinner
1055                | SolverOutcome::UnknownAfterWinner => self.cancelled_after_winner += 1,
1056                SolverOutcome::SatInvalid => self.invalid_models += 1,
1057                SolverOutcome::Error => self.solver_errors += 1,
1058                _ => {}
1059            }
1060
1061            if summary.winner {
1062                *self.winner_counts.entry(summary.display.clone()).or_default() += 1;
1063                if summary.index.is_some_and(|index| index > 0) {
1064                    self.non_primary_wins += 1;
1065                }
1066                if summary.index.is_some_and(|index| index >= 2) {
1067                    self.rescue_wins += 1;
1068                }
1069            }
1070        }
1071    }
1072
1073    /// Merges another aggregate portfolio summary into this one.
1074    pub fn merge(&mut self, other: &Self) {
1075        self.queries += other.queries;
1076        self.solver_runs += other.solver_runs;
1077        self.rescue_runs += other.rescue_runs;
1078        self.non_primary_wins += other.non_primary_wins;
1079        self.rescue_wins += other.rescue_wins;
1080        self.not_started += other.not_started;
1081        self.cancelled_after_winner += other.cancelled_after_winner;
1082        self.invalid_models += other.invalid_models;
1083        self.solver_errors += other.solver_errors;
1084        merge_counts(&mut self.winner_counts, &other.winner_counts);
1085        merge_counts(&mut self.launch_counts, &other.launch_counts);
1086        merge_counts(&mut self.outcome_counts, &other.outcome_counts);
1087    }
1088}
1089
1090impl fmt::Display for PortfolioDiagnostics {
1091    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1092        if self.is_empty() {
1093            return Ok(());
1094        }
1095
1096        writeln!(f, "--- symbolic solver portfolio summary ---")?;
1097        writeln!(f, "queries: {}", self.queries)?;
1098        writeln!(f, "solver runs: {}", self.solver_runs)?;
1099        writeln!(f, "rescue solver runs: {}", self.rescue_runs)?;
1100        writeln!(f, "not-started solver runs: {}", self.not_started)?;
1101        writeln!(f, "non-primary wins: {}", self.non_primary_wins)?;
1102        writeln!(f, "rescue wins: {}", self.rescue_wins)?;
1103        writeln!(f, "cancelled after winner: {}", self.cancelled_after_winner)?;
1104        writeln!(f, "invalid models: {}", self.invalid_models)?;
1105        writeln!(f, "solver errors: {}", self.solver_errors)?;
1106        if !self.winner_counts.is_empty() {
1107            writeln!(f, "winner counts:")?;
1108            for (solver, count) in &self.winner_counts {
1109                writeln!(f, "  {solver}: {count}")?;
1110            }
1111        }
1112        if !self.launch_counts.is_empty() {
1113            writeln!(f, "launch counts:")?;
1114            for (solver, count) in &self.launch_counts {
1115                writeln!(f, "  {solver}: {count}")?;
1116            }
1117        }
1118        writeln!(f, "outcome counts:")?;
1119        for (outcome, count) in &self.outcome_counts {
1120            writeln!(f, "  {outcome}: {count}")?;
1121        }
1122        Ok(())
1123    }
1124}
1125
1126fn merge_counts<K: Ord + Clone>(base: &mut BTreeMap<K, usize>, other: &BTreeMap<K, usize>) {
1127    for (key, count) in other {
1128        *base.entry(key.clone()).or_default() += count;
1129    }
1130}
1131
1132/// Runs one or more solver commands and returns the first decisive SMT-LIB response.
1133fn run_solver_commands(
1134    commands: &[SolverCommand],
1135    smt: &str,
1136    timeout: Option<u32>,
1137    model_constraints: Option<&[BoolExpr]>,
1138) -> SolverCommandRun {
1139    if commands.is_empty() {
1140        return SolverCommandRun {
1141            output: Err(SymbolicError::Solver("symbolic solver portfolio is empty".to_string())),
1142            summaries: Vec::new(),
1143        };
1144    }
1145    if commands.len() == 1 {
1146        let output = match run_solver_process(&commands[0], smt, timeout, &AtomicBool::new(false)) {
1147            SolverProcessOutcome::Output(output) => Ok(output),
1148            SolverProcessOutcome::Unknown => Err(SymbolicError::SolverUnknown),
1149            SolverProcessOutcome::Cancelled => {
1150                warn!("solver query was cancelled");
1151                Err(SymbolicError::Solver("solver query was cancelled".to_string()))
1152            }
1153            SolverProcessOutcome::Error(err) => Err(SymbolicError::Solver(err)),
1154        };
1155        return SolverCommandRun { output, summaries: Vec::new() };
1156    }
1157
1158    let cancel = Arc::new(AtomicBool::new(false));
1159    let (tx, rx) = mpsc::channel();
1160    thread::scope(|scope| {
1161        let started_at = Instant::now();
1162        let mut pending = scheduled_portfolio(commands);
1163        let mut running = 0usize;
1164
1165        let mut saw_unknown = false;
1166        let mut saw_unsat = false;
1167        let mut saw_invalid_sat_model = false;
1168        let mut errors = Vec::new();
1169        let mut decisive = None;
1170        let mut summaries = Vec::new();
1171
1172        while running > 0 || !pending.is_empty() {
1173            if decisive.is_none() {
1174                let now = started_at.elapsed();
1175                let mut launched = false;
1176                while pending
1177                    .front()
1178                    .is_some_and(|solver| solver.launch_after <= now || (running == 0 && !launched))
1179                {
1180                    let solver = pending.pop_front().expect("pending solver exists");
1181                    let tx = tx.clone();
1182                    let cancel = Arc::clone(&cancel);
1183                    let started_after = started_at.elapsed();
1184                    running += 1;
1185                    launched = true;
1186                    scope.spawn(move || {
1187                        let start = Instant::now();
1188                        let outcome = run_solver_process(&solver.command, smt, timeout, &cancel);
1189                        let _ = tx.send(SolverProcessResult {
1190                            index: solver.index,
1191                            display: solver.command.display,
1192                            scheduled_after: solver.launch_after,
1193                            started_after,
1194                            elapsed: start.elapsed(),
1195                            outcome,
1196                        });
1197                    });
1198                }
1199            }
1200
1201            if running == 0 {
1202                continue;
1203            }
1204
1205            let result = if decisive.is_none() {
1206                next_portfolio_launch_wait(started_at, &pending)
1207                    .map_or_else(|| rx.recv().ok(), |wait| rx.recv_timeout(wait).ok())
1208            } else {
1209                rx.recv().ok()
1210            };
1211            let Some(result) = result else {
1212                continue;
1213            };
1214            running = running.saturating_sub(1);
1215            let SolverProcessResult {
1216                index,
1217                display,
1218                scheduled_after,
1219                started_after,
1220                elapsed,
1221                outcome,
1222            } = result;
1223            if decisive.is_some() {
1224                summaries.push(summary_for_cancelled_solver_result(
1225                    index,
1226                    display,
1227                    scheduled_after,
1228                    started_after,
1229                    elapsed,
1230                    outcome,
1231                ));
1232                continue;
1233            }
1234            match outcome {
1235                SolverProcessOutcome::Output(output) if solver_output_is_sat(&output) => {
1236                    if let Some(constraints) = model_constraints
1237                        && let Err(err) = validate_solver_model_output(&output, constraints)
1238                    {
1239                        summaries.push(
1240                            SolverRunSummary::new(
1241                                display.clone(),
1242                                elapsed,
1243                                SolverOutcome::SatInvalid,
1244                            )
1245                            .with_schedule(index, scheduled_after, Some(started_after))
1246                            .with_detail(err.to_string()),
1247                        );
1248                        saw_invalid_sat_model = true;
1249                        errors.push(format!("{display}: {err}"));
1250                        continue;
1251                    }
1252                    summaries.push(
1253                        SolverRunSummary::new(display, elapsed, SolverOutcome::SatValid)
1254                            .with_schedule(index, scheduled_after, Some(started_after))
1255                            .winner(),
1256                    );
1257                    decisive = Some(output);
1258                    cancel.store(true, Ordering::SeqCst);
1259                    while let Some(solver) = pending.pop_front() {
1260                        summaries.push(summary_for_unstarted_solver(solver));
1261                    }
1262                }
1263                SolverProcessOutcome::Output(output) if solver_output_is_unsat(&output) => {
1264                    summaries.push(
1265                        SolverRunSummary::new(display, elapsed, SolverOutcome::Unsat)
1266                            .with_schedule(index, scheduled_after, Some(started_after)),
1267                    );
1268                    saw_unsat = true;
1269                }
1270                SolverProcessOutcome::Output(output) if solver_output_is_unknown(&output) => {
1271                    summaries.push(
1272                        SolverRunSummary::new(display, elapsed, SolverOutcome::Unknown)
1273                            .with_schedule(index, scheduled_after, Some(started_after)),
1274                    );
1275                    saw_unknown = true;
1276                }
1277                SolverProcessOutcome::Output(output) => {
1278                    let first_line = first_solver_line(&output).to_string();
1279                    summaries.push(
1280                        SolverRunSummary::new(display.clone(), elapsed, SolverOutcome::Unexpected)
1281                            .with_schedule(index, scheduled_after, Some(started_after))
1282                            .with_detail(first_line.clone()),
1283                    );
1284                    errors.push(format!("{display}: unexpected solver response `{first_line}`"));
1285                }
1286                SolverProcessOutcome::Unknown => {
1287                    summaries.push(
1288                        SolverRunSummary::new(display, elapsed, SolverOutcome::TimeoutOrUnknown)
1289                            .with_schedule(index, scheduled_after, Some(started_after)),
1290                    );
1291                    saw_unknown = true;
1292                }
1293                SolverProcessOutcome::Cancelled => {
1294                    summaries.push(
1295                        SolverRunSummary::new(display, elapsed, SolverOutcome::Cancelled)
1296                            .with_schedule(index, scheduled_after, Some(started_after)),
1297                    );
1298                }
1299                SolverProcessOutcome::Error(err) => {
1300                    summaries.push(
1301                        SolverRunSummary::new(display.clone(), elapsed, SolverOutcome::Error)
1302                            .with_schedule(index, scheduled_after, Some(started_after))
1303                            .with_detail(err.clone()),
1304                    );
1305                    errors.push(format!("{display}: {err}"));
1306                }
1307            }
1308        }
1309
1310        if decisive.is_none()
1311            && saw_unsat
1312            && let Some(summary) =
1313                summaries.iter_mut().find(|summary| summary.outcome == SolverOutcome::Unsat)
1314        {
1315            summary.winner = true;
1316        }
1317
1318        let output = if let Some(output) = decisive {
1319            Ok(output)
1320        } else if saw_invalid_sat_model {
1321            Err(SymbolicError::Solver(errors.join("; ")))
1322        } else if saw_unsat {
1323            Ok("unsat\n".to_string())
1324        } else if saw_unknown {
1325            Err(SymbolicError::SolverUnknown)
1326        } else {
1327            Err(SymbolicError::Solver(errors.join("; ")))
1328        };
1329
1330        SolverCommandRun { output, summaries }
1331    })
1332}
1333
1334/// Returns the staged launch plan for a configured portfolio.
1335fn scheduled_portfolio(commands: &[SolverCommand]) -> VecDeque<ScheduledSolver> {
1336    commands
1337        .iter()
1338        .cloned()
1339        .enumerate()
1340        .map(|(index, command)| ScheduledSolver {
1341            index,
1342            command,
1343            launch_after: portfolio_launch_delay(index),
1344        })
1345        .collect()
1346}
1347
1348/// Returns when the solver at `index` should be started, relative to query start.
1349const fn portfolio_launch_delay(index: usize) -> Duration {
1350    match index {
1351        0 => Duration::ZERO,
1352        1 => SECOND_PORTFOLIO_SOLVER_DELAY,
1353        index => RESCUE_PORTFOLIO_SOLVER_DELAY.saturating_mul(index.saturating_sub(1) as u32),
1354    }
1355}
1356
1357/// Returns how long the supervisor can wait before the next pending solver is due.
1358fn next_portfolio_launch_wait(
1359    started_at: Instant,
1360    pending: &VecDeque<ScheduledSolver>,
1361) -> Option<Duration> {
1362    pending.front().map(|solver| {
1363        solver.launch_after.checked_sub(started_at.elapsed()).unwrap_or(Duration::ZERO)
1364    })
1365}
1366
1367/// Summarizes a solver that was never launched because the portfolio already won.
1368fn summary_for_unstarted_solver(solver: ScheduledSolver) -> SolverRunSummary {
1369    SolverRunSummary::new(solver.command.display, Duration::ZERO, SolverOutcome::NotStarted)
1370        .with_schedule(solver.index, solver.launch_after, None)
1371}
1372
1373/// Summarizes a solver result received after a portfolio winner was chosen.
1374fn summary_for_cancelled_solver_result(
1375    index: usize,
1376    display: String,
1377    scheduled_after: Duration,
1378    started_after: Duration,
1379    elapsed: Duration,
1380    outcome: SolverProcessOutcome,
1381) -> SolverRunSummary {
1382    let summary = match outcome {
1383        SolverProcessOutcome::Output(output) if solver_output_is_sat(&output) => {
1384            SolverRunSummary::new(display, elapsed, SolverOutcome::SatAfterWinner)
1385        }
1386        SolverProcessOutcome::Output(output) if solver_output_is_unsat(&output) => {
1387            SolverRunSummary::new(display, elapsed, SolverOutcome::UnsatAfterWinner)
1388        }
1389        SolverProcessOutcome::Output(output) if solver_output_is_unknown(&output) => {
1390            SolverRunSummary::new(display, elapsed, SolverOutcome::UnknownAfterWinner)
1391        }
1392        SolverProcessOutcome::Output(output) => {
1393            SolverRunSummary::new(display, elapsed, SolverOutcome::Unexpected)
1394                .with_detail(first_solver_line(&output).to_string())
1395        }
1396        SolverProcessOutcome::Unknown => {
1397            SolverRunSummary::new(display, elapsed, SolverOutcome::TimeoutOrUnknown)
1398        }
1399        SolverProcessOutcome::Cancelled => {
1400            SolverRunSummary::new(display, elapsed, SolverOutcome::Cancelled)
1401        }
1402        SolverProcessOutcome::Error(err) => {
1403            SolverRunSummary::new(display, elapsed, SolverOutcome::Error).with_detail(err)
1404        }
1405    };
1406    summary.with_schedule(index, scheduled_after, Some(started_after))
1407}
1408
1409/// Formats solver portfolio outcome diagnostics.
1410fn format_solver_portfolio_summaries(summaries: &[SolverRunSummary]) -> String {
1411    let mut output = String::new();
1412    let _ = writeln!(output, "--- symbolic solver portfolio outcomes ---");
1413    for summary in summaries {
1414        let marker = if summary.winner { " winner" } else { "" };
1415        let schedule = summary.index.zip(summary.scheduled_after).map(|(index, delay)| {
1416            let started = summary
1417                .started_after
1418                .map(|started| format!(" started +{started:.3?}"))
1419                .unwrap_or_default();
1420            format!("#{} scheduled +{delay:.3?}{started} ", index + 1)
1421        });
1422        let _ = write!(
1423            output,
1424            "{}{}: {} in {:.3?}{}",
1425            schedule.as_deref().unwrap_or_default(),
1426            summary.display,
1427            summary.outcome,
1428            summary.elapsed,
1429            marker
1430        );
1431        if let Some(detail) = summary.detail.as_deref().filter(|detail| !detail.is_empty()) {
1432            let _ = write!(output, " ({detail})");
1433        }
1434        let _ = writeln!(output);
1435    }
1436    output
1437}
1438
1439/// Runs one solver process to completion, timeout, or cooperative cancellation.
1440fn run_solver_process(
1441    command: &SolverCommand,
1442    smt: &str,
1443    timeout: Option<u32>,
1444    cancel: &AtomicBool,
1445) -> SolverProcessOutcome {
1446    let mut child = match Command::new(&command.program)
1447        .args(&command.args)
1448        .stdin(Stdio::piped())
1449        .stdout(Stdio::piped())
1450        .stderr(Stdio::piped())
1451        .spawn()
1452    {
1453        Ok(child) => child,
1454        Err(err) => {
1455            return SolverProcessOutcome::Error(format!(
1456                "failed to spawn `{}`: {err}",
1457                command.display
1458            ));
1459        }
1460    };
1461    let stdout_reader = child.stdout.take().map(read_pipe_to_string);
1462    let stderr_reader = child.stderr.take().map(read_pipe_to_string);
1463
1464    if let Some(mut stdin) = child.stdin.take()
1465        && let Err(err) = stdin.write_all(smt.as_bytes())
1466    {
1467        kill_and_reap_solver_process(&mut child, stdout_reader, stderr_reader);
1468        return SolverProcessOutcome::Error(format!("failed to write solver query: {err}"));
1469    }
1470
1471    let deadline = timeout
1472        .filter(|seconds| *seconds > 0)
1473        .map(|seconds| Instant::now() + Duration::from_secs(seconds.into()));
1474    let mut backoff = INITIAL_SOLVER_POLL_BACKOFF;
1475    let status = loop {
1476        if cancel.load(Ordering::SeqCst) {
1477            kill_and_reap_solver_process(&mut child, stdout_reader, stderr_reader);
1478            return SolverProcessOutcome::Cancelled;
1479        }
1480        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
1481            kill_and_reap_solver_process(&mut child, stdout_reader, stderr_reader);
1482            return SolverProcessOutcome::Unknown;
1483        }
1484        match child.try_wait() {
1485            Ok(Some(status)) => break status,
1486            Ok(None) => {
1487                thread::sleep(backoff);
1488                backoff = (backoff * 2).min(MAX_SOLVER_POLL_BACKOFF);
1489            }
1490            Err(err) => {
1491                kill_and_reap_solver_process(&mut child, stdout_reader, stderr_reader);
1492                return SolverProcessOutcome::Error(format!("failed to read solver output: {err}"));
1493            }
1494        }
1495    };
1496
1497    let stdout = match join_pipe_output(stdout_reader, "stdout") {
1498        Ok(stdout) => stdout,
1499        Err(err) => return SolverProcessOutcome::Error(err),
1500    };
1501    let stderr = match join_pipe_output(stderr_reader, "stderr") {
1502        Ok(stderr) => stderr,
1503        Err(err) => return SolverProcessOutcome::Error(err),
1504    };
1505    if !status.success() {
1506        return SolverProcessOutcome::Error(solver_exit_error(command, status, &stdout, &stderr));
1507    }
1508    SolverProcessOutcome::Output(stdout)
1509}
1510
1511fn read_pipe_to_string<R>(mut pipe: R) -> thread::JoinHandle<Result<String, String>>
1512where
1513    R: Read + Send + 'static,
1514{
1515    thread::spawn(move || {
1516        let mut output = Vec::new();
1517        pipe.read_to_end(&mut output)
1518            .map_err(|err| format!("failed to read solver output: {err}"))?;
1519        Ok(String::from_utf8_lossy(&output).to_string())
1520    })
1521}
1522
1523fn join_pipe_output(
1524    reader: Option<thread::JoinHandle<Result<String, String>>>,
1525    stream: &str,
1526) -> Result<String, String> {
1527    match reader {
1528        Some(reader) => reader.join().map_err(|_| format!("solver {stream} reader panicked"))?,
1529        None => Ok(String::new()),
1530    }
1531}
1532
1533fn kill_and_reap_solver_process(
1534    child: &mut std::process::Child,
1535    stdout_reader: Option<thread::JoinHandle<Result<String, String>>>,
1536    stderr_reader: Option<thread::JoinHandle<Result<String, String>>>,
1537) {
1538    // This only terminates the direct child. Wrapper commands should forward termination and close
1539    // inherited pipes so descendant solver processes do not outlive cancelled queries.
1540    let _ = child.kill();
1541    let _ = child.wait();
1542    let _ = join_pipe_output(stdout_reader, "stdout");
1543    let _ = join_pipe_output(stderr_reader, "stderr");
1544}
1545
1546fn solver_exit_error(
1547    command: &SolverCommand,
1548    status: std::process::ExitStatus,
1549    stdout: &str,
1550    stderr: &str,
1551) -> String {
1552    let mut message = format!("`{}` exited with {status}", command.display);
1553    if !stderr.trim().is_empty() {
1554        message.push_str(": ");
1555        message.push_str(stderr.trim());
1556    }
1557    if !stdout.trim().is_empty() {
1558        message.push_str("; stdout: ");
1559        message.push_str(stdout.trim());
1560    }
1561    message
1562}
1563
1564fn solver_output_is_sat(output: &str) -> bool {
1565    first_solver_line(output) == "sat"
1566}
1567
1568fn solver_output_is_unsat(output: &str) -> bool {
1569    first_solver_line(output) == "unsat"
1570}
1571
1572fn solver_output_is_unknown(output: &str) -> bool {
1573    first_solver_line(output) == "unknown"
1574}
1575
1576fn first_solver_line(output: &str) -> &str {
1577    output.lines().next().unwrap_or_default().trim()
1578}
1579
1580pub(crate) fn parse_and_validate_model(
1581    output: &str,
1582    constraints: &[BoolExpr],
1583) -> Result<BTreeMap<String, U256>, SymbolicError> {
1584    let model = parse_model(output)?;
1585    if constraints.iter().all(|constraint| eval_bool_expr(constraint, &model).unwrap_or(false)) {
1586        Ok(model)
1587    } else {
1588        let reason = if constraints.iter().any(bool_contains_keccak) {
1589            "solver model does not satisfy path constraints involving symbolic Keccak heuristic"
1590        } else {
1591            "solver model does not satisfy path constraints"
1592        };
1593        debug!(
1594            constraint_count = constraints.len(),
1595            reason, "solver model does not satisfy path constraints"
1596        );
1597        Err(SymbolicError::Solver(reason.to_string()))
1598    }
1599}
1600
1601pub(crate) fn validate_solver_model_output(
1602    output: &str,
1603    constraints: &[BoolExpr],
1604) -> Result<(), SymbolicError> {
1605    parse_and_validate_model(output, constraints).map(|_| ())
1606}
1607
1608/// Returns the `parse_model` solver helper result.
1609pub(crate) fn parse_model(output: &str) -> Result<BTreeMap<String, U256>, SymbolicError> {
1610    let mut values = BTreeMap::new();
1611    let mut tokens = output
1612        .split(|c: char| c.is_whitespace() || matches!(c, '(' | ')'))
1613        .filter(|token| !token.is_empty());
1614    while let Some(token) = tokens.next() {
1615        if token == "define-fun" {
1616            let Some(name) = tokens.next() else { continue };
1617            while let Some(value) = tokens.next() {
1618                if let Some(hex) = value.strip_prefix("#x") {
1619                    if hex.len() > 64 {
1620                        return Err(SymbolicError::Solver(
1621                            "solver hex model value exceeds 256 bits".to_string(),
1622                        ));
1623                    }
1624                    let mut bytes = [0u8; 32];
1625                    let decoded = alloy_primitives::hex::decode(hex).map_err(|err| {
1626                        SymbolicError::Solver(format!("invalid solver hex model value: {err}"))
1627                    })?;
1628                    let start = 32usize.saturating_sub(decoded.len());
1629                    bytes[start..start + decoded.len()].copy_from_slice(&decoded);
1630                    values.insert(name.to_string(), U256::from_be_bytes(bytes));
1631                    break;
1632                }
1633                if let Some(binary) = value.strip_prefix("#b") {
1634                    if binary.len() > 256 {
1635                        return Err(SymbolicError::Solver(
1636                            "solver binary model value exceeds 256 bits".to_string(),
1637                        ));
1638                    }
1639                    let parsed = U256::from_str_radix(binary, 2).map_err(|err| {
1640                        SymbolicError::Solver(format!("invalid solver binary model value: {err}"))
1641                    })?;
1642                    values.insert(name.to_string(), parsed);
1643                    break;
1644                }
1645                if value == "_"
1646                    && let Some(bv) = tokens.next().and_then(|v| v.strip_prefix("bv"))
1647                {
1648                    let parsed = U256::from_str_radix(bv, 10).map_err(|err| {
1649                        SymbolicError::Solver(format!("invalid solver decimal model value: {err}"))
1650                    })?;
1651                    values.insert(name.to_string(), parsed);
1652                    break;
1653                }
1654            }
1655        }
1656    }
1657    Ok(values)
1658}