Skip to main content

foundry_evm_symbolic/runtime/solver/
mod.rs

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