Skip to main content

foundry_evm_symbolic/runtime/
solver.rs

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