Skip to main content

conjure_cp_cli/utils/
conjure.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fmt::Write as _;
3use std::fs;
4use std::path::PathBuf;
5use std::string::ToString;
6use std::sync::{Arc, Mutex, RwLock};
7use std::time::Instant;
8
9use conjure_cp::ast::categories::{Category, CategoryOf};
10use conjure_cp::ast::{Atom, DeclarationPtr, Expression, GroundDomain, Literal, Metadata, Name};
11use conjure_cp::bug_assert;
12use conjure_cp::context::Context;
13use conjure_cp::settings::{configured_rule_trace_enabled, set_rule_trace_enabled};
14
15use serde_json::{Map, Value as JsonValue};
16
17use itertools::Itertools as _;
18use tempfile::tempdir;
19
20use crate::utils::json::sort_json_object;
21use crate::utils::simplified_json::{
22    domains_from_model, param_model_from_assignments, params_from_simplified_json_str,
23    solutions_from_simplified_json_str,
24};
25use conjure_cp::Model;
26use conjure_cp::instantiate::instantiate_model;
27use conjure_cp::parse::tree_sitter::parse_essence_file_native;
28use conjure_cp::representation::util::try_up;
29use conjure_cp::solver::{SearchStatus, Solver};
30
31use glob::glob;
32
33use uniplate::Uniplate;
34
35#[derive(Clone, Copy, Debug, Default)]
36pub struct ConjureRunTimings {
37    pub wall_clock_time_s: f64,
38    pub translation_time_s: f64,
39    pub conjure_translation_time_s: f64,
40    pub savilerow_translation_time_s: f64,
41    pub solve_time_s: f64,
42}
43
44#[derive(Debug)]
45pub struct ConjureSolutions {
46    pub solutions: Vec<BTreeMap<Name, Literal>>,
47    pub timings: Option<ConjureRunTimings>,
48}
49
50/// Coerces a literal into the type expected by a reference domain when possible.
51///
52/// This is currently used to turn `0`/`1` solver outputs back into boolean
53/// literals before substituting them into dominance expressions.
54fn literal_for_reference_domain(
55    reference_domain: Option<GroundDomain>,
56    value: &Literal,
57) -> Option<Literal> {
58    if matches!(reference_domain, Some(GroundDomain::Bool)) {
59        return match value {
60            Literal::Bool(x) => Some(Literal::Bool(*x)),
61            Literal::Int(1) => Some(Literal::Bool(true)),
62            Literal::Int(0) => Some(Literal::Bool(false)),
63            _ => None,
64        };
65    }
66
67    Some(value.clone())
68}
69
70/// Replaces `fromSolution(x)` occurrences with the value of `x` from a previous solution.
71fn substitute_from_solution(
72    expr: &Expression,
73    previous_solution: &BTreeMap<Name, Literal>,
74) -> Option<Expression> {
75    match expr {
76        Expression::FromSolution(_, atom_expr) => {
77            let Atom::Reference(reference) = atom_expr.as_ref() else {
78                return Some(expr.clone());
79            };
80
81            let name = reference.name();
82            let value = previous_solution.get(&name)?;
83            let reference_domain = reference.resolved_domain().map(|x| x.as_ref().clone());
84            let value = literal_for_reference_domain(reference_domain, value)?;
85            Some(Expression::Atomic(Metadata::new(), Atom::Literal(value)))
86        }
87        _ => Some(expr.clone()),
88    }
89}
90
91/// Replaces direct variable references with values from the candidate solution.
92fn substitute_current_solution_refs(
93    expr: &Expression,
94    candidate_solution: &BTreeMap<Name, Literal>,
95) -> Option<Expression> {
96    match expr {
97        Expression::Atomic(_, Atom::Reference(reference)) => {
98            let name = reference.name();
99            let value = candidate_solution.get(&name)?;
100            let reference_domain = reference.resolved_domain().map(|x| x.as_ref().clone());
101            let value = literal_for_reference_domain(reference_domain, value)?;
102            Some(Expression::Atomic(Metadata::new(), Atom::Literal(value)))
103        }
104        _ => Some(expr.clone()),
105    }
106}
107
108/// Evaluates whether `candidate_solution` dominates `previous_solution`.
109///
110/// The dominance expression is instantiated with values from both solutions and
111/// then constant-folded to a boolean result.
112fn does_solution_dominate(
113    dominance_expression: &Expression,
114    candidate_solution: &BTreeMap<Name, Literal>,
115    previous_solution: &BTreeMap<Name, Literal>,
116) -> bool {
117    let expr = dominance_expression
118        .rewrite(&|e| substitute_from_solution(&e, previous_solution))
119        .rewrite(&|e| substitute_current_solution_refs(&e, candidate_solution));
120
121    matches!(
122        conjure_cp::ast::eval::eval_constant(&expr),
123        Some(Literal::Bool(true))
124    )
125}
126
127/// Removes solutions that are dominated by another solution in the result set.
128fn retroactively_prune_dominated(
129    solutions: Vec<BTreeMap<Name, Literal>>,
130    dominance_expression: &Expression,
131) -> Vec<BTreeMap<Name, Literal>> {
132    solutions
133        .iter()
134        .enumerate()
135        .filter_map(|(i, solution)| {
136            let dominated = solutions.iter().enumerate().any(|(j, candidate)| {
137                i != j && does_solution_dominate(dominance_expression, candidate, solution)
138            });
139
140            if dominated {
141                None
142            } else {
143                Some(solution.clone())
144            }
145        })
146        .collect()
147}
148
149fn validate_solution_collection_options(model: &Model, num_sols: i32) -> Result<(), anyhow::Error> {
150    if model.objective.is_some() && num_sols != 1 {
151        let got = if num_sols == 0 {
152            "all".to_string()
153        } else {
154            num_sols.to_string()
155        };
156        return Err(anyhow::anyhow!(
157            "optimisation problems require number-of-solutions=1 (got {got})"
158        ));
159    }
160    Ok(())
161}
162
163/// Wall-clock phases of collecting solutions from a compiled model.
164#[derive(Clone, Copy, Debug, Default)]
165pub struct SolutionCollectionTimings {
166    pub backend_time_s: f64,
167    /// Time inside the solver call, including solution callbacks and solver-time rewrites.
168    pub search_time_s: f64,
169    pub solution_processing_time_s: f64,
170}
171
172/// Collect and reconstruct model solutions.
173pub fn get_solutions(
174    solver: Solver,
175    model: Model,
176    num_sols: i32,
177    keep_intermediate_solutions: bool,
178    solver_input_file: &Option<PathBuf>,
179    rule_trace_cdp: bool,
180) -> Result<Vec<BTreeMap<Name, Literal>>, anyhow::Error> {
181    get_solutions_with_timings(
182        solver,
183        model,
184        num_sols,
185        keep_intermediate_solutions,
186        solver_input_file,
187        rule_trace_cdp,
188    )
189    .map(|(solutions, _)| solutions)
190}
191
192/// Collect solutions together with measured backend, search and reconstruction times.
193pub fn get_solutions_with_timings(
194    solver: Solver,
195    model: Model,
196    num_sols: i32,
197    keep_intermediate_solutions: bool,
198    solver_input_file: &Option<PathBuf>,
199    rule_trace_cdp: bool,
200) -> Result<(Vec<BTreeMap<Name, Literal>>, SolutionCollectionTimings), anyhow::Error> {
201    set_rule_trace_enabled(rule_trace_cdp && configured_rule_trace_enabled());
202
203    validate_solution_collection_options(&model, num_sols)?;
204
205    let is_optimisation = model.objective.is_some();
206
207    let dominance_expression = model.dominance.as_ref().map(|expr| match expr {
208        Expression::DominanceRelation(_, inner) => inner.as_ref().clone(),
209        _ => expr.clone(),
210    });
211
212    let adaptor_name = solver.get_name();
213
214    tracing::info!(target: "conjure::stage", adaptor = adaptor_name, "building solver model");
215
216    // Create for later since we consume the model when loading it
217    let symbols_ptr = model.symbols_ptr_unchecked().clone();
218
219    let backend_started_at = Instant::now();
220    let solver = solver.load_model(model)?;
221    let backend_time_s = backend_started_at.elapsed().as_secs_f64();
222
223    if let Some(solver_input_file) = solver_input_file {
224        tracing::info!(target: "conjure::stage",
225            "Writing solver input file to {}",
226            solver_input_file.display()
227        );
228        let file = Box::new(std::fs::File::create(solver_input_file)?);
229        solver.write_solver_input_file(&mut (file as Box<dyn std::io::Write>))?;
230    }
231
232    tracing::info!(target: "conjure::stage", adaptor = adaptor_name, "running solver");
233
234    // Create two arcs, one to pass into the solver callback, one to get solutions out later
235    let all_solutions_ref = Arc::new(Mutex::<Vec<BTreeMap<Name, Literal>>>::new(vec![]));
236    let all_solutions_ref_2 = all_solutions_ref.clone();
237
238    let search_started_at = Instant::now();
239    let solver = if is_optimisation {
240        solver
241            .solve(Box::new(move |sols| {
242                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
243                let solution = sols.into_iter().collect();
244                if keep_intermediate_solutions {
245                    all_solutions.push(solution);
246                } else {
247                    all_solutions.clear();
248                    all_solutions.push(solution);
249                }
250                true
251            }))
252            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
253    } else if num_sols > 0 {
254        // Get num_sols solutions
255        let sols_left = Mutex::new(num_sols);
256
257        solver
258            .solve(Box::new(move |sols| {
259                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
260                all_solutions.push(sols.into_iter().collect());
261                let mut sols_left = sols_left.lock().unwrap();
262                *sols_left -= 1;
263
264                *sols_left != 0
265            }))
266            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
267    } else {
268        // Get all solutions
269        solver
270            .solve(Box::new(move |sols| {
271                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
272                all_solutions.push(sols.into_iter().collect());
273                true
274            }))
275            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
276    };
277
278    let search_time_s = search_started_at.elapsed().as_secs_f64();
279    let processing_started_at = Instant::now();
280    let search_status = solver.search_status();
281    solver.save_stats_to_context();
282
283    // Get the collections of solutions and model symbols
284    #[allow(clippy::unwrap_used)]
285    let mut sols_guard = (*all_solutions_ref).lock().unwrap();
286    let sols = &mut *sols_guard;
287
288    // Stopping after a requested finite number of satisfaction solutions is intentional. Every
289    // other incomplete search -- notably a solver timeout or interrupt while proving UNSAT -- must
290    // not be accepted as a complete (possibly empty) solution set.
291    let requested_limit_reached = !is_optimisation
292        && num_sols > 0
293        && sols.len() >= usize::try_from(num_sols).unwrap_or(usize::MAX);
294    if let SearchStatus::Incomplete(reason) = search_status
295        && !requested_limit_reached
296    {
297        return Err(anyhow::anyhow!("solver search incomplete: {reason:?}"));
298    }
299
300    let symbols = symbols_ptr.read();
301
302    // Get the representations for each variable by name, since some variables are
303    // divided into multiple auxiliary variables(see crate::representation::Representation)
304    let names = symbols.clone().into_iter().map(|x| x.0).collect_vec();
305    let representations = names
306        .into_iter()
307        .filter_map(|x| symbols.representations_for(&x).map(|repr| (x, repr)))
308        .filter_map(|(name, reprs)| {
309            if reprs.is_empty() {
310                return None;
311            }
312            bug_assert!(
313                reprs.len() <= 1,
314                "multiple representations for a variable is not yet implemented"
315            );
316
317            assert_eq!(
318                reprs[0].len(),
319                1,
320                "nested representations are not yet implemented"
321            );
322            Some((name, reprs[0][0].clone()))
323        })
324        .collect_vec();
325
326    let structured_declarations: Vec<DeclarationPtr> = symbols
327        .iter_local()
328        .filter(|(name, declaration)| {
329            // `category_of` follows a value letting into its body, and from there into every
330            // letting that body names. Those declarations have no representations, so skip them
331            // before walking the chain.
332            matches!(name, Name::User(_))
333                && !declaration.reprs().is_empty()
334                && declaration.category_of() >= Category::Decision
335        })
336        .map(|(_, declaration)| declaration.clone())
337        .collect();
338
339    for sol in sols.iter_mut() {
340        // Get the value of complex variables using their auxiliary variables
341        for (name, representation) in representations.iter() {
342            if sol.contains_key(name) {
343                continue;
344            }
345
346            let value = representation.value_up(sol).map_err(|err| {
347                anyhow::anyhow!(
348                    "failed to reconstruct value for variable {name} from solver solution: {err}"
349                )
350            })?;
351            sol.insert(name.clone(), value);
352        }
353
354        let raw_assignment: HashMap<Name, Literal> = sol.clone().into_iter().collect();
355        for declaration in &structured_declarations {
356            let value = try_up(declaration.clone(), &raw_assignment).map_err(|err| {
357                anyhow::anyhow!(
358                    "failed to reconstruct value for variable {}: {err}",
359                    declaration.name()
360                )
361            })?;
362            sol.insert(declaration.name().clone(), value);
363        }
364
365        // Remove auxiliary variables since we've found the value of the
366        // variable they represent
367        *sol = sol
368            .clone()
369            .into_iter()
370            .filter(|(name, _)| {
371                !matches!(name, Name::Represented(_)) && !matches!(name, Name::Machine(_))
372            })
373            .collect();
374    }
375
376    sols.retain(|x| !x.is_empty());
377    if let Some(dominance_expression) = dominance_expression.as_ref() {
378        let pre_prune_len = sols.len();
379        let pruned = retroactively_prune_dominated(sols.clone(), dominance_expression);
380        let post_prune_len = pruned.len();
381
382        tracing::info!(
383            target: "conjure::stage",
384            retained = post_prune_len,
385            considered = pre_prune_len,
386            "pruned dominated solutions"
387        );
388
389        *sols = pruned;
390    }
391
392    let solutions = sols.clone();
393    Ok((
394        solutions,
395        SolutionCollectionTimings {
396            backend_time_s,
397            search_time_s,
398            solution_processing_time_s: processing_started_at.elapsed().as_secs_f64(),
399        },
400    ))
401}
402
403#[derive(Clone, Debug, Default)]
404pub struct ConjureSolveCaptureOptions {
405    /// When set, `conjure solve -o` writes models and Minion files here instead of a temp dir.
406    pub artifact_dir: Option<PathBuf>,
407    /// Passed to `conjure solve --savilerow-options` (e.g. `-O0`).
408    pub savilerow_options: Option<String>,
409}
410
411#[allow(clippy::unwrap_used)]
412pub fn get_solutions_from_conjure(
413    essence_file: &str,
414    param_file: Option<&str>,
415    context: Arc<RwLock<Context<'static>>>,
416) -> Result<Vec<BTreeMap<Name, Literal>>, anyhow::Error> {
417    Ok(get_solutions_from_conjure_with_stats(
418        essence_file,
419        param_file,
420        context,
421        0,
422        ConjureSolveCaptureOptions::default(),
423    )?
424    .solutions)
425}
426
427#[allow(clippy::unwrap_used)]
428pub fn get_solutions_from_conjure_with_stats(
429    essence_file: &str,
430    param_file: Option<&str>,
431    context: Arc<RwLock<Context<'static>>>,
432    number_of_solutions: i32,
433    capture_options: ConjureSolveCaptureOptions,
434) -> Result<ConjureSolutions, anyhow::Error> {
435    enum ConjureOutputDir {
436        Temp(tempfile::TempDir),
437        Fixed(PathBuf),
438    }
439
440    impl ConjureOutputDir {
441        fn path(&self) -> &std::path::Path {
442            match self {
443                Self::Temp(dir) => dir.path(),
444                Self::Fixed(path) => path,
445            }
446        }
447    }
448
449    let output_dir = match &capture_options.artifact_dir {
450        Some(path) => {
451            // A failed run leaves its diagnostics behind. Conjure may reuse files in an existing
452            // output directory, and the glob below would also read stale solution files, so each
453            // invocation must start with an empty artifact directory.
454            if path.exists() {
455                fs::remove_dir_all(path)?;
456            }
457            fs::create_dir_all(path)?;
458            ConjureOutputDir::Fixed(path.clone())
459        }
460        None => ConjureOutputDir::Temp(tempdir()?),
461    };
462
463    let mut cmd = std::process::Command::new("conjure");
464    let number_of_solutions_arg = if number_of_solutions == 0 {
465        "all".to_string()
466    } else {
467        number_of_solutions.to_string()
468    };
469
470    cmd.arg("solve")
471        .arg(format!("--number-of-solutions={number_of_solutions_arg}"))
472        .arg("--copy-solutions=no")
473        .arg("--solutions-in-one-file")
474        .arg("--output-format=json")
475        .arg("-o")
476        .arg(output_dir.path());
477
478    if let Some(options) = &capture_options.savilerow_options {
479        cmd.arg(format!("--savilerow-options={options}"));
480    }
481
482    cmd.arg(essence_file);
483
484    if let Some(file) = param_file {
485        cmd.arg(file);
486    }
487
488    let conjure_solve_start = Instant::now();
489    let output = cmd.output()?;
490    let conjure_solve_wall_time_s = conjure_solve_start.elapsed().as_secs_f64();
491
492    if !output.status.success() {
493        let stderr =
494            String::from_utf8(output.stderr).unwrap_or_else(|e| e.utf8_error().to_string());
495        return Err(anyhow::Error::msg(format!(
496            "Error: `conjure solve` exited with code {}; stderr: {}",
497            output.status, stderr
498        )));
499    }
500
501    let domains = domains_for_conjure_solutions(essence_file, param_file, Arc::clone(&context))?;
502
503    let solutions_files: Vec<_> =
504        glob(&format!("{}/*.solutions.json", output_dir.path().display()))?.collect();
505    if solutions_files.is_empty() {
506        // Unsatisfiable / no solutions: Conjure may omit the solutions file.
507        let timings = read_conjure_timings(output_dir.path(), conjure_solve_wall_time_s)?;
508        return Ok(ConjureSolutions {
509            solutions: Vec::new(),
510            timings,
511        });
512    }
513
514    let mut solutions_set = Vec::new();
515    for solutions_file in solutions_files {
516        let solutions_file = solutions_file?;
517        let text = fs::read_to_string(&solutions_file)?;
518        solutions_set.extend(solutions_from_simplified_json_str(&text, &domains)?);
519    }
520
521    let timings = read_conjure_timings(output_dir.path(), conjure_solve_wall_time_s)?;
522
523    Ok(ConjureSolutions {
524        solutions: solutions_set
525            .into_iter()
526            .filter(|x| !x.is_empty())
527            .collect(),
528        timings,
529    })
530}
531
532fn domains_for_conjure_solutions(
533    essence_file: &str,
534    param_file: Option<&str>,
535    context: Arc<RwLock<Context<'static>>>,
536) -> Result<BTreeMap<Name, conjure_cp::ast::DomainPtr>, anyhow::Error> {
537    let problem = parse_essence_file_native(essence_file, Arc::clone(&context))?;
538    let unified = match param_file {
539        Some(param_path) if param_path.ends_with(".json") => {
540            let given_domains = domains_from_model(&problem);
541            let params =
542                params_from_simplified_json_str(&fs::read_to_string(param_path)?, &given_domains)?;
543            let param_model =
544                param_model_from_assignments(params, &given_domains, Arc::clone(&context));
545            instantiate_model(problem, param_model)?
546        }
547        Some(param_path) => {
548            let param_model = parse_essence_file_native(param_path, Arc::clone(&context))?;
549            instantiate_model(problem, param_model)?
550        }
551        None => problem,
552    };
553    Ok(domains_from_model(&unified))
554}
555
556fn read_conjure_timings(
557    path: &std::path::Path,
558    conjure_solve_wall_time_s: f64,
559) -> Result<Option<ConjureRunTimings>, anyhow::Error> {
560    let stats_files: Vec<_> = glob(&format!("{}/*.stats.json", path.display()))?.collect();
561    if stats_files.is_empty() {
562        return Ok(None);
563    }
564
565    let mut timings = ConjureRunTimings {
566        wall_clock_time_s: conjure_solve_wall_time_s,
567        ..Default::default()
568    };
569    for stats_file in stats_files {
570        let stats_file = stats_file?;
571        let stats: JsonValue = serde_json::from_str(&fs::read_to_string(&stats_file)?)?;
572        let savilerow_total_time = stats
573            .pointer("/savilerowInfo/SavileRowTotalTime")
574            .and_then(JsonValue::as_str)
575            .and_then(|value| value.parse::<f64>().ok())
576            .unwrap_or_default();
577        let solve_time = stats
578            .pointer("/savilerowInfo/SolverTotalTime")
579            .and_then(JsonValue::as_str)
580            .and_then(|value| value.parse::<f64>().ok())
581            .or_else(|| {
582                stats
583                    .pointer("/savilerowInfo/SolverSolveTime")
584                    .and_then(JsonValue::as_str)
585                    .and_then(|value| value.parse::<f64>().ok())
586            })
587            .unwrap_or_default();
588
589        timings.savilerow_translation_time_s += savilerow_total_time;
590        timings.solve_time_s += solve_time;
591    }
592
593    timings.conjure_translation_time_s =
594        (conjure_solve_wall_time_s - timings.savilerow_translation_time_s - timings.solve_time_s)
595            .max(0.0);
596    timings.translation_time_s =
597        timings.conjure_translation_time_s + timings.savilerow_translation_time_s;
598
599    Ok(Some(timings))
600}
601
602pub fn solutions_to_json(solutions: &[BTreeMap<Name, Literal>]) -> JsonValue {
603    let json_solutions = solutions.iter().map(solution_to_json).collect();
604    let ans = JsonValue::Array(json_solutions);
605    sort_json_object(&ans, true)
606}
607
608/// Render solutions in the format produced by Conjure's `--solutions-in-one-file` option.
609pub fn solutions_to_essence(solutions: &[BTreeMap<Name, Literal>]) -> String {
610    let mut solutions = solutions.iter().collect::<Vec<_>>();
611    solutions.sort_by(|lhs, rhs| solution_essence_cmp(lhs, rhs));
612
613    let mut output = String::new();
614    for (index, solution) in solutions.iter().enumerate() {
615        writeln!(output, "$ Solution: {:06}", index + 1).unwrap();
616        writeln!(output, "language Essence 1.3\n").unwrap();
617        let mut unnamed_types = BTreeMap::new();
618        for value in solution.values() {
619            for literal in value.universe() {
620                if let Literal::Unnamed(value) = literal {
621                    unnamed_types.insert(value.ty.name.clone(), value.ty.size);
622                }
623            }
624        }
625        for (name, size) in unnamed_types {
626            let members = (1..=size).map(|index| format!("{name}_{index}")).join(", ");
627            writeln!(output, "letting {name} be new type enum {{{members}}}").unwrap();
628        }
629        for (name, value) in *solution {
630            writeln!(output, "letting {name} be {value}").unwrap();
631        }
632        output.push_str("\n\n");
633    }
634    output
635}
636
637fn solution_essence_cmp(
638    lhs: &BTreeMap<Name, Literal>,
639    rhs: &BTreeMap<Name, Literal>,
640) -> std::cmp::Ordering {
641    lhs.iter()
642        .zip(rhs)
643        .find_map(|((lhs_name, lhs_value), (rhs_name, rhs_value))| {
644            let ordering = lhs_name.cmp(rhs_name);
645            (ordering != std::cmp::Ordering::Equal)
646                .then_some(ordering)
647                .or_else(|| {
648                    let ordering = lhs_value.essence_cmp(rhs_value);
649                    (ordering != std::cmp::Ordering::Equal).then_some(ordering)
650                })
651        })
652        .unwrap_or_else(|| lhs.len().cmp(&rhs.len()))
653}
654
655fn solution_to_json(solution: &BTreeMap<Name, Literal>) -> JsonValue {
656    let mut json_solution = Map::new();
657    for (var_name, constant) in solution {
658        let serialized_constant = serde_json::to_value(constant).unwrap();
659        json_solution.insert(var_name.to_string(), serialized_constant);
660    }
661    sort_json_object(&JsonValue::Object(json_solution), false)
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use conjure_cp::ast::{DeclarationPtr, Domain, Moo, Reference};
668
669    #[test]
670    fn renders_conjure_multi_solution_essence_format() {
671        let mut first = BTreeMap::new();
672        first.insert(Name::User("b".into()), Literal::Bool(false));
673        first.insert(Name::User("x".into()), Literal::Int(1));
674        let mut second = BTreeMap::new();
675        second.insert(Name::User("b".into()), Literal::Bool(true));
676        second.insert(Name::User("x".into()), Literal::Int(2));
677
678        assert_eq!(
679            solutions_to_essence(&[first, second]),
680            concat!(
681                "$ Solution: 000001\n",
682                "language Essence 1.3\n\n",
683                "letting b be false\n",
684                "letting x be 1\n\n\n",
685                "$ Solution: 000002\n",
686                "language Essence 1.3\n\n",
687                "letting b be true\n",
688                "letting x be 2\n\n\n",
689            )
690        );
691    }
692
693    #[test]
694    fn retroactive_pruning_removes_dominated_prior_solution() {
695        let x = Name::User("x".into());
696        let x_ref = Expression::Atomic(
697            Metadata::new(),
698            Atom::Reference(Reference::new(DeclarationPtr::new_find(
699                x.clone(),
700                Domain::bool(),
701            ))),
702        );
703        let dominance_expression = Expression::Imply(
704            Metadata::new(),
705            Moo::new(x_ref),
706            Moo::new(Expression::FromSolution(
707                Metadata::new(),
708                Moo::new(Atom::Reference(Reference::new(DeclarationPtr::new_find(
709                    x.clone(),
710                    Domain::bool(),
711                )))),
712            )),
713        );
714
715        let mut sol_true = BTreeMap::new();
716        sol_true.insert(x.clone(), Literal::Int(1));
717        let mut sol_false = BTreeMap::new();
718        sol_false.insert(x, Literal::Int(0));
719
720        let pruned =
721            retroactively_prune_dominated(vec![sol_true, sol_false.clone()], &dominance_expression);
722
723        assert_eq!(pruned, vec![sol_false]);
724    }
725}