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
163pub fn get_solutions(
164    solver: Solver,
165    model: Model,
166    num_sols: i32,
167    keep_intermediate_solutions: bool,
168    solver_input_file: &Option<PathBuf>,
169    rule_trace_cdp: bool,
170) -> Result<Vec<BTreeMap<Name, Literal>>, anyhow::Error> {
171    set_rule_trace_enabled(rule_trace_cdp && configured_rule_trace_enabled());
172
173    validate_solution_collection_options(&model, num_sols)?;
174
175    let is_optimisation = model.objective.is_some();
176
177    let dominance_expression = model.dominance.as_ref().map(|expr| match expr {
178        Expression::DominanceRelation(_, inner) => inner.as_ref().clone(),
179        _ => expr.clone(),
180    });
181
182    let adaptor_name = solver.get_name();
183
184    tracing::info!(target: "conjure::stage", adaptor = adaptor_name, "building solver model");
185
186    // Create for later since we consume the model when loading it
187    let symbols_ptr = model.symbols_ptr_unchecked().clone();
188
189    let solver = solver.load_model(model)?;
190
191    if let Some(solver_input_file) = solver_input_file {
192        tracing::info!(target: "conjure::stage",
193            "Writing solver input file to {}",
194            solver_input_file.display()
195        );
196        let file = Box::new(std::fs::File::create(solver_input_file)?);
197        solver.write_solver_input_file(&mut (file as Box<dyn std::io::Write>))?;
198    }
199
200    tracing::info!(target: "conjure::stage", adaptor = adaptor_name, "running solver");
201
202    // Create two arcs, one to pass into the solver callback, one to get solutions out later
203    let all_solutions_ref = Arc::new(Mutex::<Vec<BTreeMap<Name, Literal>>>::new(vec![]));
204    let all_solutions_ref_2 = all_solutions_ref.clone();
205
206    let solver = if is_optimisation {
207        solver
208            .solve(Box::new(move |sols| {
209                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
210                let solution = sols.into_iter().collect();
211                if keep_intermediate_solutions {
212                    all_solutions.push(solution);
213                } else {
214                    all_solutions.clear();
215                    all_solutions.push(solution);
216                }
217                true
218            }))
219            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
220    } else if num_sols > 0 {
221        // Get num_sols solutions
222        let sols_left = Mutex::new(num_sols);
223
224        solver
225            .solve(Box::new(move |sols| {
226                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
227                all_solutions.push(sols.into_iter().collect());
228                let mut sols_left = sols_left.lock().unwrap();
229                *sols_left -= 1;
230
231                *sols_left != 0
232            }))
233            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
234    } else {
235        // Get all solutions
236        solver
237            .solve(Box::new(move |sols| {
238                let mut all_solutions = (*all_solutions_ref_2).lock().unwrap();
239                all_solutions.push(sols.into_iter().collect());
240                true
241            }))
242            .map_err(|err| anyhow::anyhow!("solver failed while collecting solutions: {err}"))?
243    };
244
245    let search_status = solver.search_status();
246    solver.save_stats_to_context();
247
248    // Get the collections of solutions and model symbols
249    #[allow(clippy::unwrap_used)]
250    let mut sols_guard = (*all_solutions_ref).lock().unwrap();
251    let sols = &mut *sols_guard;
252
253    // Stopping after a requested finite number of satisfaction solutions is intentional. Every
254    // other incomplete search -- notably a solver timeout or interrupt while proving UNSAT -- must
255    // not be accepted as a complete (possibly empty) solution set.
256    let requested_limit_reached = !is_optimisation
257        && num_sols > 0
258        && sols.len() >= usize::try_from(num_sols).unwrap_or(usize::MAX);
259    if let SearchStatus::Incomplete(reason) = search_status
260        && !requested_limit_reached
261    {
262        return Err(anyhow::anyhow!("solver search incomplete: {reason:?}"));
263    }
264
265    let symbols = symbols_ptr.read();
266
267    // Get the representations for each variable by name, since some variables are
268    // divided into multiple auxiliary variables(see crate::representation::Representation)
269    let names = symbols.clone().into_iter().map(|x| x.0).collect_vec();
270    let representations = names
271        .into_iter()
272        .filter_map(|x| symbols.representations_for(&x).map(|repr| (x, repr)))
273        .filter_map(|(name, reprs)| {
274            if reprs.is_empty() {
275                return None;
276            }
277            bug_assert!(
278                reprs.len() <= 1,
279                "multiple representations for a variable is not yet implemented"
280            );
281
282            assert_eq!(
283                reprs[0].len(),
284                1,
285                "nested representations are not yet implemented"
286            );
287            Some((name, reprs[0][0].clone()))
288        })
289        .collect_vec();
290
291    let structured_declarations: Vec<DeclarationPtr> = symbols
292        .iter_local()
293        .filter(|(name, declaration)| {
294            matches!(name, Name::User(_))
295                && declaration.category_of() >= Category::Decision
296                && !declaration.reprs().is_empty()
297        })
298        .map(|(_, declaration)| declaration.clone())
299        .collect();
300
301    for sol in sols.iter_mut() {
302        // Get the value of complex variables using their auxiliary variables
303        for (name, representation) in representations.iter() {
304            if sol.contains_key(name) {
305                continue;
306            }
307
308            let value = representation.value_up(sol).map_err(|err| {
309                anyhow::anyhow!(
310                    "failed to reconstruct value for variable {name} from solver solution: {err}"
311                )
312            })?;
313            sol.insert(name.clone(), value);
314        }
315
316        let raw_assignment: HashMap<Name, Literal> = sol.clone().into_iter().collect();
317        for declaration in &structured_declarations {
318            let value = try_up(declaration.clone(), &raw_assignment).map_err(|err| {
319                anyhow::anyhow!(
320                    "failed to reconstruct value for variable {}: {err}",
321                    declaration.name()
322                )
323            })?;
324            sol.insert(declaration.name().clone(), value);
325        }
326
327        // Remove auxiliary variables since we've found the value of the
328        // variable they represent
329        *sol = sol
330            .clone()
331            .into_iter()
332            .filter(|(name, _)| {
333                !matches!(name, Name::Represented(_)) && !matches!(name, Name::Machine(_))
334            })
335            .collect();
336    }
337
338    sols.retain(|x| !x.is_empty());
339    if let Some(dominance_expression) = dominance_expression.as_ref() {
340        let pre_prune_len = sols.len();
341        let pruned = retroactively_prune_dominated(sols.clone(), dominance_expression);
342        let post_prune_len = pruned.len();
343
344        tracing::info!(
345            target: "conjure::stage",
346            retained = post_prune_len,
347            considered = pre_prune_len,
348            "pruned dominated solutions"
349        );
350
351        *sols = pruned;
352    }
353
354    Ok(sols.clone())
355}
356
357#[derive(Clone, Debug, Default)]
358pub struct ConjureSolveCaptureOptions {
359    /// When set, `conjure solve -o` writes models and Minion files here instead of a temp dir.
360    pub artifact_dir: Option<PathBuf>,
361    /// Passed to `conjure solve --savilerow-options` (e.g. `-O0`).
362    pub savilerow_options: Option<String>,
363}
364
365#[allow(clippy::unwrap_used)]
366pub fn get_solutions_from_conjure(
367    essence_file: &str,
368    param_file: Option<&str>,
369    context: Arc<RwLock<Context<'static>>>,
370) -> Result<Vec<BTreeMap<Name, Literal>>, anyhow::Error> {
371    Ok(get_solutions_from_conjure_with_stats(
372        essence_file,
373        param_file,
374        context,
375        0,
376        ConjureSolveCaptureOptions::default(),
377    )?
378    .solutions)
379}
380
381#[allow(clippy::unwrap_used)]
382pub fn get_solutions_from_conjure_with_stats(
383    essence_file: &str,
384    param_file: Option<&str>,
385    context: Arc<RwLock<Context<'static>>>,
386    number_of_solutions: i32,
387    capture_options: ConjureSolveCaptureOptions,
388) -> Result<ConjureSolutions, anyhow::Error> {
389    enum ConjureOutputDir {
390        Temp(tempfile::TempDir),
391        Fixed(PathBuf),
392    }
393
394    impl ConjureOutputDir {
395        fn path(&self) -> &std::path::Path {
396            match self {
397                Self::Temp(dir) => dir.path(),
398                Self::Fixed(path) => path,
399            }
400        }
401    }
402
403    let output_dir = match &capture_options.artifact_dir {
404        Some(path) => {
405            // A failed run leaves its diagnostics behind. Conjure may reuse files in an existing
406            // output directory, and the glob below would also read stale solution files, so each
407            // invocation must start with an empty artifact directory.
408            if path.exists() {
409                fs::remove_dir_all(path)?;
410            }
411            fs::create_dir_all(path)?;
412            ConjureOutputDir::Fixed(path.clone())
413        }
414        None => ConjureOutputDir::Temp(tempdir()?),
415    };
416
417    let mut cmd = std::process::Command::new("conjure");
418    let number_of_solutions_arg = if number_of_solutions == 0 {
419        "all".to_string()
420    } else {
421        number_of_solutions.to_string()
422    };
423
424    cmd.arg("solve")
425        .arg(format!("--number-of-solutions={number_of_solutions_arg}"))
426        .arg("--copy-solutions=no")
427        .arg("--solutions-in-one-file")
428        .arg("--output-format=json")
429        .arg("-o")
430        .arg(output_dir.path());
431
432    if let Some(options) = &capture_options.savilerow_options {
433        cmd.arg(format!("--savilerow-options={options}"));
434    }
435
436    cmd.arg(essence_file);
437
438    if let Some(file) = param_file {
439        cmd.arg(file);
440    }
441
442    let conjure_solve_start = Instant::now();
443    let output = cmd.output()?;
444    let conjure_solve_wall_time_s = conjure_solve_start.elapsed().as_secs_f64();
445
446    if !output.status.success() {
447        let stderr =
448            String::from_utf8(output.stderr).unwrap_or_else(|e| e.utf8_error().to_string());
449        return Err(anyhow::Error::msg(format!(
450            "Error: `conjure solve` exited with code {}; stderr: {}",
451            output.status, stderr
452        )));
453    }
454
455    let domains = domains_for_conjure_solutions(essence_file, param_file, Arc::clone(&context))?;
456
457    let solutions_files: Vec<_> =
458        glob(&format!("{}/*.solutions.json", output_dir.path().display()))?.collect();
459    if solutions_files.is_empty() {
460        // Unsatisfiable / no solutions: Conjure may omit the solutions file.
461        let timings = read_conjure_timings(output_dir.path(), conjure_solve_wall_time_s)?;
462        return Ok(ConjureSolutions {
463            solutions: Vec::new(),
464            timings,
465        });
466    }
467
468    let mut solutions_set = Vec::new();
469    for solutions_file in solutions_files {
470        let solutions_file = solutions_file?;
471        let text = fs::read_to_string(&solutions_file)?;
472        solutions_set.extend(solutions_from_simplified_json_str(&text, &domains)?);
473    }
474
475    let timings = read_conjure_timings(output_dir.path(), conjure_solve_wall_time_s)?;
476
477    Ok(ConjureSolutions {
478        solutions: solutions_set
479            .into_iter()
480            .filter(|x| !x.is_empty())
481            .collect(),
482        timings,
483    })
484}
485
486fn domains_for_conjure_solutions(
487    essence_file: &str,
488    param_file: Option<&str>,
489    context: Arc<RwLock<Context<'static>>>,
490) -> Result<BTreeMap<Name, conjure_cp::ast::DomainPtr>, anyhow::Error> {
491    let problem = parse_essence_file_native(essence_file, Arc::clone(&context))?;
492    let unified = match param_file {
493        Some(param_path) if param_path.ends_with(".json") => {
494            let given_domains = domains_from_model(&problem);
495            let params =
496                params_from_simplified_json_str(&fs::read_to_string(param_path)?, &given_domains)?;
497            let param_model =
498                param_model_from_assignments(params, &given_domains, Arc::clone(&context));
499            instantiate_model(problem, param_model)?
500        }
501        Some(param_path) => {
502            let param_model = parse_essence_file_native(param_path, Arc::clone(&context))?;
503            instantiate_model(problem, param_model)?
504        }
505        None => problem,
506    };
507    Ok(domains_from_model(&unified))
508}
509
510fn read_conjure_timings(
511    path: &std::path::Path,
512    conjure_solve_wall_time_s: f64,
513) -> Result<Option<ConjureRunTimings>, anyhow::Error> {
514    let stats_files: Vec<_> = glob(&format!("{}/*.stats.json", path.display()))?.collect();
515    if stats_files.is_empty() {
516        return Ok(None);
517    }
518
519    let mut timings = ConjureRunTimings {
520        wall_clock_time_s: conjure_solve_wall_time_s,
521        ..Default::default()
522    };
523    for stats_file in stats_files {
524        let stats_file = stats_file?;
525        let stats: JsonValue = serde_json::from_str(&fs::read_to_string(&stats_file)?)?;
526        let savilerow_total_time = stats
527            .pointer("/savilerowInfo/SavileRowTotalTime")
528            .and_then(JsonValue::as_str)
529            .and_then(|value| value.parse::<f64>().ok())
530            .unwrap_or_default();
531        let solve_time = stats
532            .pointer("/savilerowInfo/SolverTotalTime")
533            .and_then(JsonValue::as_str)
534            .and_then(|value| value.parse::<f64>().ok())
535            .or_else(|| {
536                stats
537                    .pointer("/savilerowInfo/SolverSolveTime")
538                    .and_then(JsonValue::as_str)
539                    .and_then(|value| value.parse::<f64>().ok())
540            })
541            .unwrap_or_default();
542
543        timings.savilerow_translation_time_s += savilerow_total_time;
544        timings.solve_time_s += solve_time;
545    }
546
547    timings.conjure_translation_time_s =
548        (conjure_solve_wall_time_s - timings.savilerow_translation_time_s - timings.solve_time_s)
549            .max(0.0);
550    timings.translation_time_s =
551        timings.conjure_translation_time_s + timings.savilerow_translation_time_s;
552
553    Ok(Some(timings))
554}
555
556pub fn solutions_to_json(solutions: &[BTreeMap<Name, Literal>]) -> JsonValue {
557    let json_solutions = solutions.iter().map(solution_to_json).collect();
558    let ans = JsonValue::Array(json_solutions);
559    sort_json_object(&ans, true)
560}
561
562/// Render solutions in the format produced by Conjure's `--solutions-in-one-file` option.
563pub fn solutions_to_essence(solutions: &[BTreeMap<Name, Literal>]) -> String {
564    let mut solutions = solutions.iter().collect::<Vec<_>>();
565    solutions.sort_by(|lhs, rhs| solution_essence_cmp(lhs, rhs));
566
567    let mut output = String::new();
568    for (index, solution) in solutions.iter().enumerate() {
569        writeln!(output, "$ Solution: {:06}", index + 1).unwrap();
570        writeln!(output, "language Essence 1.3\n").unwrap();
571        for (name, value) in *solution {
572            writeln!(output, "letting {name} be {value}").unwrap();
573        }
574        output.push_str("\n\n");
575    }
576    output
577}
578
579fn solution_essence_cmp(
580    lhs: &BTreeMap<Name, Literal>,
581    rhs: &BTreeMap<Name, Literal>,
582) -> std::cmp::Ordering {
583    lhs.iter()
584        .zip(rhs)
585        .find_map(|((lhs_name, lhs_value), (rhs_name, rhs_value))| {
586            let ordering = lhs_name.cmp(rhs_name);
587            (ordering != std::cmp::Ordering::Equal)
588                .then_some(ordering)
589                .or_else(|| {
590                    let ordering = lhs_value.essence_cmp(rhs_value);
591                    (ordering != std::cmp::Ordering::Equal).then_some(ordering)
592                })
593        })
594        .unwrap_or_else(|| lhs.len().cmp(&rhs.len()))
595}
596
597fn solution_to_json(solution: &BTreeMap<Name, Literal>) -> JsonValue {
598    let mut json_solution = Map::new();
599    for (var_name, constant) in solution {
600        let serialized_constant = serde_json::to_value(constant).unwrap();
601        json_solution.insert(var_name.to_string(), serialized_constant);
602    }
603    sort_json_object(&JsonValue::Object(json_solution), false)
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use conjure_cp::ast::{DeclarationPtr, Domain, Moo, Reference};
610
611    #[test]
612    fn renders_conjure_multi_solution_essence_format() {
613        let mut first = BTreeMap::new();
614        first.insert(Name::User("b".into()), Literal::Bool(false));
615        first.insert(Name::User("x".into()), Literal::Int(1));
616        let mut second = BTreeMap::new();
617        second.insert(Name::User("b".into()), Literal::Bool(true));
618        second.insert(Name::User("x".into()), Literal::Int(2));
619
620        assert_eq!(
621            solutions_to_essence(&[first, second]),
622            concat!(
623                "$ Solution: 000001\n",
624                "language Essence 1.3\n\n",
625                "letting b be false\n",
626                "letting x be 1\n\n\n",
627                "$ Solution: 000002\n",
628                "language Essence 1.3\n\n",
629                "letting b be true\n",
630                "letting x be 2\n\n\n",
631            )
632        );
633    }
634
635    #[test]
636    fn retroactive_pruning_removes_dominated_prior_solution() {
637        let x = Name::User("x".into());
638        let x_ref = Expression::Atomic(
639            Metadata::new(),
640            Atom::Reference(Reference::new(DeclarationPtr::new_find(
641                x.clone(),
642                Domain::bool(),
643            ))),
644        );
645        let dominance_expression = Expression::Imply(
646            Metadata::new(),
647            Moo::new(x_ref),
648            Moo::new(Expression::FromSolution(
649                Metadata::new(),
650                Moo::new(Atom::Reference(Reference::new(DeclarationPtr::new_find(
651                    x.clone(),
652                    Domain::bool(),
653                )))),
654            )),
655        );
656
657        let mut sol_true = BTreeMap::new();
658        sol_true.insert(x.clone(), Literal::Int(1));
659        let mut sol_false = BTreeMap::new();
660        sol_false.insert(x, Literal::Int(0));
661
662        let pruned =
663            retroactively_prune_dominated(vec![sol_true, sol_false.clone()], &dominance_expression);
664
665        assert_eq!(pruned, vec![sol_false]);
666    }
667}