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