Skip to main content

logicpearl_verify/
lib.rs

1// SPDX-License-Identifier: MIT
2//! Solver-backed verification helpers.
3//!
4//! This crate contains reusable checks for rule candidates and feature
5//! constraints. It is used by discovery and conformance workflows when
6//! LogicPearl needs to reason about boolean conjunctions or expression
7//! satisfiability. It does not load artifact bundles or run end-user inputs.
8
9use logicpearl_core::{LogicPearlError, Result};
10use logicpearl_ir::{
11    validate_expression_against_schema, ComparisonOperator, ComparisonValue,
12    DerivedFeatureOperator, Expression, FeatureDefinition, FeatureType, LogicPearlGateIr,
13};
14use logicpearl_solver::{
15    check_sat, check_sat_with_values, solve_keep_bools_lexicographic, LexObjective, SatStatus,
16    SolverBackend, SolverSettings,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::{BTreeMap, BTreeSet};
21use std::fs;
22
23#[derive(Debug, Clone)]
24pub struct BooleanConjunctionSearchOptions {
25    pub min_conditions: usize,
26    pub max_conditions: usize,
27    pub min_positive_support: usize,
28    pub max_negative_hits: usize,
29    pub max_rules: usize,
30}
31
32#[derive(Debug, Clone)]
33pub struct BooleanSearchExample {
34    pub features: BTreeMap<String, bool>,
35    pub positive: bool,
36}
37
38#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
39pub struct BooleanConjunctionCandidate {
40    pub required_true_features: Vec<String>,
41    pub positive_hits: usize,
42    pub negative_hits: usize,
43}
44
45pub fn status() -> Result<&'static str> {
46    Ok("solver-backed verification helpers available")
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct FormalSpec {
51    #[serde(default = "default_spec_version")]
52    pub spec_version: String,
53    pub rules: Vec<FormalSpecRule>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct FormalSpecRule {
58    pub id: String,
59    pub deny_when: Expression,
60    pub label: Option<String>,
61    pub message: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct FormalSpecVerificationReport {
66    pub spec_rule_count: usize,
67    pub gate_rule_count: usize,
68    pub complete: bool,
69    pub no_spurious_rules: bool,
70    pub fully_verified: bool,
71    pub solver_backend: String,
72    pub spec_rule_checks: Vec<FormalSpecRuleCheck>,
73    pub gate_rule_checks: Vec<GateRuleCheck>,
74    pub overall_spec_gap_witness: Option<String>,
75    pub overall_spurious_witness: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct FormalSpecRuleCheck {
80    pub id: String,
81    pub satisfied_by_gate: bool,
82    pub witness: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct GateRuleCheck {
87    pub id: String,
88    pub implied_by_spec: bool,
89    pub witness: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93struct SolverCheckResult {
94    unsat: bool,
95    witness: Option<String>,
96    backend_used: SolverBackend,
97}
98
99#[derive(Debug, Clone)]
100struct FormalSpecSmtContext {
101    declarations: String,
102    assertions: Vec<String>,
103    value_symbols: Vec<String>,
104}
105
106pub fn load_formal_spec(path: impl AsRef<std::path::Path>) -> Result<FormalSpec> {
107    let payload = fs::read_to_string(path)?;
108    let spec: FormalSpec = serde_json::from_str(&payload)?;
109    validate_formal_spec(&spec)?;
110    Ok(spec)
111}
112
113pub fn validate_formal_spec(spec: &FormalSpec) -> Result<()> {
114    if spec.spec_version != "1.0" {
115        return Err(LogicPearlError::message(format!(
116            "unsupported spec_version: {}",
117            spec.spec_version
118        )));
119    }
120    if spec.rules.is_empty() {
121        return Err(LogicPearlError::message(
122            "formal spec must define at least one rule",
123        ));
124    }
125    let mut ids = BTreeSet::new();
126    for rule in &spec.rules {
127        if rule.id.is_empty() {
128            return Err(LogicPearlError::message(
129                "formal spec rule id must be non-empty",
130            ));
131        }
132        if !ids.insert(rule.id.clone()) {
133            return Err(LogicPearlError::message(format!(
134                "duplicate formal spec rule ids: {}",
135                rule.id
136            )));
137        }
138    }
139    Ok(())
140}
141
142pub fn verify_gate_against_formal_spec(
143    gate: &LogicPearlGateIr,
144    spec: &FormalSpec,
145) -> Result<FormalSpecVerificationReport> {
146    validate_formal_spec(spec)?;
147    for rule in &spec.rules {
148        validate_expression_against_schema(&rule.deny_when, &gate.input_schema)?;
149    }
150    let context = build_formal_spec_smt_context(gate)?;
151    let gate_expr = union_expression(gate, gate.rules.iter().map(|rule| &rule.deny_when))?;
152    let spec_expr = union_expression(gate, spec.rules.iter().map(|rule| &rule.deny_when))?;
153
154    let overall_spec_gap_witness =
155        check_formula_with_witness(&context, &format!("(and {spec_expr} (not {gate_expr}))"))?;
156    let overall_spurious_witness =
157        check_formula_with_witness(&context, &format!("(and {gate_expr} (not {spec_expr}))"))?;
158
159    let spec_rule_checks = spec
160        .rules
161        .iter()
162        .map(|rule| {
163            let result = check_formula_with_witness(
164                &context,
165                &format!(
166                    "(and {} (not {}))",
167                    emit_expression_smt(&rule.deny_when, gate)?,
168                    gate_expr
169                ),
170            )?;
171            Ok(FormalSpecRuleCheck {
172                id: rule.id.clone(),
173                satisfied_by_gate: result.unsat,
174                witness: result.witness,
175            })
176        })
177        .collect::<Result<Vec<_>>>()?;
178
179    let gate_rule_checks = gate
180        .rules
181        .iter()
182        .map(|rule| {
183            let result = check_formula_with_witness(
184                &context,
185                &format!(
186                    "(and {} (not {}))",
187                    emit_expression_smt(&rule.deny_when, gate)?,
188                    spec_expr
189                ),
190            )?;
191            Ok(GateRuleCheck {
192                id: rule.id.clone(),
193                implied_by_spec: result.unsat,
194                witness: result.witness,
195            })
196        })
197        .collect::<Result<Vec<_>>>()?;
198
199    let complete = overall_spec_gap_witness.unsat;
200    let no_spurious_rules = overall_spurious_witness.unsat;
201    Ok(FormalSpecVerificationReport {
202        spec_rule_count: spec.rules.len(),
203        gate_rule_count: gate.rules.len(),
204        complete,
205        no_spurious_rules,
206        fully_verified: complete && no_spurious_rules,
207        solver_backend: overall_spec_gap_witness.backend_used.as_str().to_string(),
208        spec_rule_checks,
209        gate_rule_checks,
210        overall_spec_gap_witness: overall_spec_gap_witness.witness,
211        overall_spurious_witness: overall_spurious_witness.witness,
212    })
213}
214
215fn default_spec_version() -> String {
216    "1.0".to_string()
217}
218
219pub fn synthesize_boolean_conjunctions(
220    examples: &[BooleanSearchExample],
221    options: &BooleanConjunctionSearchOptions,
222) -> Result<Vec<BooleanConjunctionCandidate>> {
223    if examples.is_empty() {
224        return Ok(Vec::new());
225    }
226    if options.max_conditions == 0 {
227        return Err(LogicPearlError::message(
228            "max_conditions must be at least 1 for boolean conjunction synthesis",
229        ));
230    }
231    if options.min_conditions == 0 {
232        return Err(LogicPearlError::message(
233            "min_conditions must be at least 1 for boolean conjunction synthesis",
234        ));
235    }
236    if options.min_conditions > options.max_conditions {
237        return Err(LogicPearlError::message(
238            "min_conditions cannot exceed max_conditions for boolean conjunction synthesis",
239        ));
240    }
241    if options.max_rules == 0 {
242        return Ok(Vec::new());
243    }
244
245    let feature_names = candidate_feature_names(examples, options.min_positive_support);
246    if feature_names.is_empty() {
247        return Ok(Vec::new());
248    }
249
250    let positives: Vec<&BooleanSearchExample> =
251        examples.iter().filter(|example| example.positive).collect();
252    let negatives: Vec<&BooleanSearchExample> = examples
253        .iter()
254        .filter(|example| !example.positive)
255        .collect();
256    if positives.len() < options.min_positive_support {
257        return Ok(Vec::new());
258    }
259
260    let mut uncovered_positive_indexes: Vec<usize> = (0..positives.len()).collect();
261    let mut discovered = Vec::new();
262
263    for _ in 0..options.max_rules {
264        if uncovered_positive_indexes.len() < options.min_positive_support {
265            break;
266        }
267
268        let candidate = solve_best_conjunction(
269            &feature_names,
270            &positives,
271            &negatives,
272            &uncovered_positive_indexes,
273            options,
274        )?;
275        let Some(candidate) = candidate else {
276            break;
277        };
278        if candidate.positive_hits < options.min_positive_support {
279            break;
280        }
281
282        let covered_positive_indexes: Vec<usize> = uncovered_positive_indexes
283            .iter()
284            .copied()
285            .filter(|index| {
286                conjunction_matches(
287                    &positives[*index].features,
288                    &candidate.required_true_features,
289                )
290            })
291            .collect();
292        if covered_positive_indexes.is_empty() {
293            break;
294        }
295
296        uncovered_positive_indexes.retain(|index| !covered_positive_indexes.contains(index));
297        discovered.push(candidate);
298    }
299
300    Ok(discovered)
301}
302
303fn build_formal_spec_smt_context(gate: &LogicPearlGateIr) -> Result<FormalSpecSmtContext> {
304    let mut declarations = String::from("(set-option :produce-models true)\n");
305    let mut assertions = Vec::new();
306    let mut value_symbols = Vec::new();
307
308    for feature in gate
309        .input_schema
310        .features
311        .iter()
312        .filter(|feature| feature.derived.is_none())
313    {
314        let symbol = smt_symbol(&feature.id);
315        declarations.push_str(&format!(
316            "(declare-fun {symbol} () {})\n",
317            smt_sort(&feature.feature_type)
318        ));
319        value_symbols.push(symbol.clone());
320
321        if matches!(feature.feature_type, FeatureType::Int) {
322            assertions.push(format!("(is_int {symbol})"));
323        }
324        if let Some(min) = feature.min {
325            assertions.push(format!("(>= {symbol} {})", smt_real_literal(min)));
326        }
327        if let Some(max) = feature.max {
328            assertions.push(format!("(<= {symbol} {})", smt_real_literal(max)));
329        }
330        if matches!(feature.feature_type, FeatureType::Enum) {
331            if let Some(values) = &feature.values {
332                assertions.push(format!(
333                    "(or {})",
334                    values
335                        .iter()
336                        .map(|value| format!("(= {symbol} {})", smt_literal(feature, value)))
337                        .collect::<Vec<_>>()
338                        .join(" ")
339                ));
340            }
341        }
342    }
343
344    if let Some(verification) = &gate.verification {
345        if let Some(domain_constraints) = &verification.domain_constraints {
346            for constraint in domain_constraints {
347                assertions.push(emit_comparison_smt(constraint, gate)?);
348            }
349        }
350    }
351
352    Ok(FormalSpecSmtContext {
353        declarations,
354        assertions,
355        value_symbols,
356    })
357}
358
359fn check_formula_with_witness(
360    context: &FormalSpecSmtContext,
361    formula: &str,
362) -> Result<SolverCheckResult> {
363    let solver_settings = SolverSettings::from_env()?;
364    let status = check_sat(
365        &build_check_sat_script(context, formula, false),
366        &solver_settings,
367    )
368    .map_err(|err| {
369        LogicPearlError::message(format!("formal spec verification solver failed: {err}"))
370    })?;
371    match status.status {
372        SatStatus::Unsat => Ok(SolverCheckResult {
373            unsat: true,
374            witness: None,
375            backend_used: status.report.backend_used,
376        }),
377        SatStatus::Sat => {
378            if context.value_symbols.is_empty() {
379                return Ok(SolverCheckResult {
380                    unsat: false,
381                    witness: None,
382                    backend_used: status.report.backend_used,
383                });
384            }
385
386            let witness_output = check_sat_with_values(
387                &build_check_sat_script(context, formula, true),
388                &solver_settings,
389            )
390            .map_err(|err| {
391                LogicPearlError::message(format!(
392                    "formal spec verification witness solver failed: {err}"
393                ))
394            })?;
395            if witness_output.status != SatStatus::Sat {
396                return Err(LogicPearlError::message(format!(
397                    "{} returned {:?} while producing a formal spec verification witness",
398                    witness_output.report.backend_used.as_str(),
399                    witness_output.status
400                )));
401            }
402
403            Ok(SolverCheckResult {
404                unsat: false,
405                witness: render_value_witness(&context.value_symbols, &witness_output.values),
406                backend_used: witness_output.report.backend_used,
407            })
408        }
409        SatStatus::Unknown => Err(LogicPearlError::message(format!(
410            "{} returned unknown during formal spec verification",
411            status.report.backend_used.as_str()
412        ))),
413    }
414}
415
416fn render_value_witness(
417    value_symbols: &[String],
418    values: &BTreeMap<String, String>,
419) -> Option<String> {
420    let bindings = value_symbols
421        .iter()
422        .filter_map(|symbol| {
423            values
424                .get(symbol)
425                .map(|value| format!("({symbol} {value})"))
426        })
427        .collect::<Vec<_>>();
428    if bindings.is_empty() {
429        None
430    } else {
431        Some(format!("({})", bindings.join(" ")))
432    }
433}
434
435fn build_check_sat_script(
436    context: &FormalSpecSmtContext,
437    formula: &str,
438    include_values: bool,
439) -> String {
440    let mut smt = String::new();
441    smt.push_str(&context.declarations);
442    for assertion in &context.assertions {
443        smt.push_str(&format!("(assert {assertion})\n"));
444    }
445    smt.push_str(&format!("(assert {formula})\n"));
446    smt.push_str("(check-sat)\n");
447    if include_values && !context.value_symbols.is_empty() {
448        smt.push_str(&format!(
449            "(get-value ({}))\n",
450            context.value_symbols.join(" ")
451        ));
452    }
453    smt
454}
455
456fn union_expression<'a>(
457    gate: &LogicPearlGateIr,
458    expressions: impl Iterator<Item = &'a Expression>,
459) -> Result<String> {
460    let rendered = expressions
461        .map(|expression| emit_expression_smt(expression, gate))
462        .collect::<Result<Vec<_>>>()?;
463    Ok(if rendered.is_empty() {
464        "false".to_string()
465    } else if rendered.len() == 1 {
466        rendered[0].clone()
467    } else {
468        format!("(or {})", rendered.join(" "))
469    })
470}
471
472fn emit_expression_smt(expression: &Expression, gate: &LogicPearlGateIr) -> Result<String> {
473    match expression {
474        Expression::Comparison(comparison) => emit_comparison_smt(comparison, gate),
475        Expression::All { all } => Ok(if all.is_empty() {
476            "true".to_string()
477        } else {
478            format!(
479                "(and {})",
480                all.iter()
481                    .map(|child| emit_expression_smt(child, gate))
482                    .collect::<Result<Vec<_>>>()?
483                    .join(" ")
484            )
485        }),
486        Expression::Any { any } => Ok(if any.is_empty() {
487            "false".to_string()
488        } else {
489            format!(
490                "(or {})",
491                any.iter()
492                    .map(|child| emit_expression_smt(child, gate))
493                    .collect::<Result<Vec<_>>>()?
494                    .join(" ")
495            )
496        }),
497        Expression::Not { expr } => Ok(format!("(not {})", emit_expression_smt(expr, gate)?)),
498    }
499}
500
501fn emit_comparison_smt(
502    comparison: &logicpearl_ir::ComparisonExpression,
503    gate: &LogicPearlGateIr,
504) -> Result<String> {
505    let feature = gate
506        .input_schema
507        .features
508        .iter()
509        .find(|feature| feature.id == comparison.feature)
510        .ok_or_else(|| {
511            LogicPearlError::message(format!(
512                "unknown feature referenced in formal spec verification: {}",
513                comparison.feature
514            ))
515        })?;
516    let left = emit_feature_term(&comparison.feature, gate)?;
517    let right = match &comparison.value {
518        ComparisonValue::FeatureRef { feature_ref } => emit_feature_term(feature_ref, gate)?,
519        ComparisonValue::Literal(value) => smt_literal(feature, value),
520    };
521
522    Ok(match comparison.op {
523        ComparisonOperator::Eq => format!("(= {left} {right})"),
524        ComparisonOperator::Ne => format!("(not (= {left} {right}))"),
525        ComparisonOperator::Gt => format!("(> {left} {right})"),
526        ComparisonOperator::Gte => format!("(>= {left} {right})"),
527        ComparisonOperator::Lt => format!("(< {left} {right})"),
528        ComparisonOperator::Lte => format!("(<= {left} {right})"),
529        ComparisonOperator::In | ComparisonOperator::NotIn => {
530            let values = comparison
531                .value
532                .literal()
533                .and_then(Value::as_array)
534                .ok_or_else(|| {
535                    LogicPearlError::message(
536                        "formal spec verification requires array literal for in/not_in",
537                    )
538                })?;
539            let membership = if values.is_empty() {
540                "false".to_string()
541            } else {
542                format!(
543                    "(or {})",
544                    values
545                        .iter()
546                        .map(|value| format!("(= {left} {})", smt_literal(feature, value)))
547                        .collect::<Vec<_>>()
548                        .join(" ")
549                )
550            };
551            if matches!(comparison.op, ComparisonOperator::NotIn) {
552                format!("(not {membership})")
553            } else {
554                membership
555            }
556        }
557    })
558}
559
560fn emit_feature_term(feature_id: &str, gate: &LogicPearlGateIr) -> Result<String> {
561    let feature = gate
562        .input_schema
563        .features
564        .iter()
565        .find(|feature| feature.id == feature_id)
566        .ok_or_else(|| {
567            LogicPearlError::message(format!(
568                "unknown feature referenced in formal spec verification: {feature_id}"
569            ))
570        })?;
571    match &feature.derived {
572        None => Ok(smt_symbol(&feature.id)),
573        Some(derived) => {
574            let left = emit_feature_term(&derived.left_feature, gate)?;
575            let right = emit_feature_term(&derived.right_feature, gate)?;
576            Ok(match derived.op {
577                DerivedFeatureOperator::Difference => format!("(- {left} {right})"),
578                DerivedFeatureOperator::Ratio => {
579                    format!("(ite (= {right} 0.0) 0.0 (/ {left} {right}))")
580                }
581            })
582        }
583    }
584}
585
586fn smt_sort(feature_type: &FeatureType) -> &'static str {
587    match feature_type {
588        FeatureType::Bool => "Bool",
589        FeatureType::Int | FeatureType::Float => "Real",
590        FeatureType::String | FeatureType::Enum => "String",
591    }
592}
593
594fn smt_symbol(feature_id: &str) -> String {
595    let mut sanitized = String::from("f_");
596    sanitized.push_str(
597        &feature_id
598            .chars()
599            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
600            .collect::<String>(),
601    );
602    sanitized
603}
604
605fn smt_literal(feature: &FeatureDefinition, value: &Value) -> String {
606    match feature.feature_type {
607        FeatureType::Bool => {
608            if value.as_bool().unwrap_or(false) {
609                "true".to_string()
610            } else {
611                "false".to_string()
612            }
613        }
614        FeatureType::Int | FeatureType::Float => smt_real_literal(value.as_f64().unwrap_or(0.0)),
615        FeatureType::String | FeatureType::Enum => {
616            smt_string_literal(value.as_str().unwrap_or(&value.to_string()))
617        }
618    }
619}
620
621fn smt_real_literal(value: f64) -> String {
622    if value.fract() == 0.0 {
623        format!("{value:.1}")
624    } else {
625        format!("{value:?}")
626    }
627}
628
629fn smt_string_literal(value: &str) -> String {
630    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\"\""))
631}
632
633fn candidate_feature_names(
634    examples: &[BooleanSearchExample],
635    min_positive_support: usize,
636) -> Vec<String> {
637    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
638    let mut seen = BTreeSet::new();
639    for example in examples.iter().filter(|example| example.positive) {
640        seen.clear();
641        for (feature, value) in &example.features {
642            if *value && seen.insert(feature.clone()) {
643                *counts.entry(feature.clone()).or_default() += 1;
644            }
645        }
646    }
647    counts
648        .into_iter()
649        .filter_map(|(feature, count)| (count >= min_positive_support).then_some(feature))
650        .collect()
651}
652
653fn solve_best_conjunction(
654    feature_names: &[String],
655    positives: &[&BooleanSearchExample],
656    negatives: &[&BooleanSearchExample],
657    uncovered_positive_indexes: &[usize],
658    options: &BooleanConjunctionSearchOptions,
659) -> Result<Option<BooleanConjunctionCandidate>> {
660    let mut smt = String::new();
661    for index in 0..feature_names.len() {
662        smt.push_str(&format!("(declare-fun keep_{index} () Bool)\n"));
663    }
664    smt.push_str(&format!(
665        "(assert (<= {} {}))\n",
666        keep_sum(feature_names.len()),
667        options.max_conditions
668    ));
669    smt.push_str(&format!(
670        "(assert (>= {} 1))\n",
671        keep_sum(feature_names.len())
672    ));
673    smt.push_str(&format!(
674        "(assert (>= {} {}))\n",
675        keep_sum(feature_names.len()),
676        options.min_conditions
677    ));
678
679    for (position, index) in uncovered_positive_indexes.iter().enumerate() {
680        let expression = example_match_expression(&positives[*index].features, feature_names);
681        smt.push_str(&format!("(declare-fun pos_{position} () Bool)\n"));
682        smt.push_str(&format!("(assert (= pos_{position} {expression}))\n"));
683    }
684    for (index, example) in negatives.iter().enumerate() {
685        let expression = example_match_expression(&example.features, feature_names);
686        smt.push_str(&format!("(declare-fun neg_{index} () Bool)\n"));
687        smt.push_str(&format!("(assert (= neg_{index} {expression}))\n"));
688    }
689
690    smt.push_str(&format!(
691        "(assert (<= {} {}))\n",
692        hit_sum("neg", negatives.len(), true),
693        options.max_negative_hits
694    ));
695    smt.push_str(&format!(
696        "(assert (>= {} {}))\n",
697        hit_sum("pos", uncovered_positive_indexes.len(), true),
698        options.min_positive_support
699    ));
700    let objectives = vec![
701        LexObjective::maximize(hit_sum("pos", uncovered_positive_indexes.len(), true)),
702        LexObjective::minimize(hit_sum("neg", negatives.len(), true)),
703        LexObjective::minimize(keep_sum(feature_names.len())),
704        LexObjective::minimize(keep_index_sum(feature_names.len())),
705    ];
706
707    let selected_indexes = solve_selected_feature_indexes(feature_names.len(), &smt, &objectives)?;
708    if selected_indexes.is_empty() {
709        return Ok(None);
710    }
711    let selected_features: Vec<String> = selected_indexes
712        .iter()
713        .map(|index| feature_names[*index].clone())
714        .collect();
715
716    let positive_hits = uncovered_positive_indexes
717        .iter()
718        .filter(|index| conjunction_matches(&positives[**index].features, &selected_features))
719        .count();
720    let negative_hits = negatives
721        .iter()
722        .filter(|example| conjunction_matches(&example.features, &selected_features))
723        .count();
724
725    Ok(Some(BooleanConjunctionCandidate {
726        required_true_features: selected_features,
727        positive_hits,
728        negative_hits,
729    }))
730}
731
732fn example_match_expression(features: &BTreeMap<String, bool>, feature_names: &[String]) -> String {
733    let clauses: Vec<String> = feature_names
734        .iter()
735        .enumerate()
736        .map(|(index, feature)| {
737            if *features.get(feature).unwrap_or(&false) {
738                format!("(=> keep_{index} true)")
739            } else {
740                format!("(=> keep_{index} false)")
741            }
742        })
743        .collect();
744    if clauses.is_empty() {
745        "true".to_string()
746    } else if clauses.len() == 1 {
747        clauses[0].clone()
748    } else {
749        format!("(and {})", clauses.join(" "))
750    }
751}
752
753fn hit_sum(prefix: &str, count: usize, when_true: bool) -> String {
754    solver_sum(
755        (0..count)
756            .map(|index| {
757                if when_true {
758                    format!("(ite {prefix}_{index} 1 0)")
759                } else {
760                    format!("(ite {prefix}_{index} 0 1)")
761                }
762            })
763            .collect(),
764    )
765}
766
767fn keep_sum(count: usize) -> String {
768    solver_sum(
769        (0..count)
770            .map(|index| format!("(ite keep_{index} 1 0)"))
771            .collect(),
772    )
773}
774
775fn keep_index_sum(count: usize) -> String {
776    solver_sum(
777        (0..count)
778            .map(|index| format!("(ite keep_{index} {} 0)", index + 1))
779            .collect(),
780    )
781}
782
783fn solver_sum(terms: Vec<String>) -> String {
784    match terms.len() {
785        0 => "0".to_string(),
786        1 => terms.into_iter().next().expect("single term should exist"),
787        _ => format!("(+ {})", terms.join(" ")),
788    }
789}
790
791fn conjunction_matches(
792    features: &BTreeMap<String, bool>,
793    required_true_features: &[String],
794) -> bool {
795    required_true_features
796        .iter()
797        .all(|feature| features.get(feature).copied().unwrap_or(false))
798}
799
800fn solve_selected_feature_indexes(
801    feature_count: usize,
802    preamble: &str,
803    objectives: &[LexObjective],
804) -> Result<Vec<usize>> {
805    let solver_settings = SolverSettings::from_env()?;
806    let result = solve_keep_bools_lexicographic(
807        preamble,
808        objectives,
809        "keep",
810        feature_count,
811        &solver_settings,
812    )
813    .map_err(|err| {
814        LogicPearlError::message(format!(
815            "boolean conjunction synthesis solver failed: {err}"
816        ))
817    })?;
818    match result.status {
819        SatStatus::Sat => Ok(result.selected),
820        SatStatus::Unsat => Ok(Vec::new()),
821        SatStatus::Unknown => Err(LogicPearlError::message(format!(
822            "{} returned unknown while solving boolean conjunction synthesis",
823            result.report.backend_used.as_str()
824        ))),
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::{
831        synthesize_boolean_conjunctions, verify_gate_against_formal_spec,
832        BooleanConjunctionSearchOptions, BooleanSearchExample, FormalSpec, FormalSpecRule,
833    };
834    use logicpearl_ir::LogicPearlGateIr;
835    use logicpearl_solver::{check_sat, resolve_backend, SolverSettings};
836    use serde_json::json;
837    use std::collections::BTreeMap;
838
839    #[test]
840    fn synthesizes_exact_two_feature_conjunction() {
841        if !solver_available() {
842            return;
843        }
844
845        let examples = vec![
846            example(&[("a", true), ("b", true), ("c", false)], true),
847            example(&[("a", true), ("b", true), ("c", true)], true),
848            example(&[("a", true), ("b", false), ("c", true)], false),
849            example(&[("a", false), ("b", true), ("c", true)], false),
850        ];
851        let candidates = synthesize_boolean_conjunctions(
852            &examples,
853            &BooleanConjunctionSearchOptions {
854                min_conditions: 1,
855                max_conditions: 2,
856                min_positive_support: 2,
857                max_negative_hits: 0,
858                max_rules: 1,
859            },
860        )
861        .unwrap();
862
863        assert_eq!(candidates.len(), 1);
864        assert_eq!(
865            candidates[0].required_true_features,
866            vec!["a".to_string(), "b".to_string()]
867        );
868        assert_eq!(candidates[0].positive_hits, 2);
869        assert_eq!(candidates[0].negative_hits, 0);
870    }
871
872    #[test]
873    fn formal_spec_verification_reports_complete_and_non_spurious_gate() {
874        if !solver_available() {
875            return;
876        }
877
878        let gate = sample_gate();
879        let spec = FormalSpec {
880            spec_version: "1.0".to_string(),
881            rules: vec![
882                FormalSpecRule {
883                    id: "minor".to_string(),
884                    deny_when: serde_json::from_value(json!({
885                        "feature": "age",
886                        "op": "<",
887                        "value": 18
888                    }))
889                    .unwrap(),
890                    label: None,
891                    message: None,
892                },
893                FormalSpecRule {
894                    id: "viewer_write".to_string(),
895                    deny_when: serde_json::from_value(json!({
896                        "all": [
897                            {"feature": "role", "op": "==", "value": "viewer"},
898                            {"feature": "action", "op": "==", "value": "write"}
899                        ]
900                    }))
901                    .unwrap(),
902                    label: None,
903                    message: None,
904                },
905            ],
906        };
907
908        let report = verify_gate_against_formal_spec(&gate, &spec).unwrap();
909        assert!(report.complete);
910        assert!(report.no_spurious_rules);
911        assert!(report.fully_verified);
912        assert_eq!(
913            report.solver_backend,
914            resolve_backend(&SolverSettings::from_env().expect("solver settings should parse"))
915                .expect("an active solver backend should resolve")
916                .as_str()
917        );
918    }
919
920    #[test]
921    fn formal_spec_verification_reports_missing_and_spurious_rules() {
922        if !solver_available() {
923            return;
924        }
925
926        let gate = sample_gate();
927        let spec = FormalSpec {
928            spec_version: "1.0".to_string(),
929            rules: vec![FormalSpecRule {
930                id: "minor_only".to_string(),
931                deny_when: serde_json::from_value(json!({
932                    "feature": "age",
933                    "op": "<",
934                    "value": 18
935                }))
936                .unwrap(),
937                label: None,
938                message: None,
939            }],
940        };
941
942        let report = verify_gate_against_formal_spec(&gate, &spec).unwrap();
943        assert!(report.complete);
944        assert!(!report.no_spurious_rules);
945        assert!(report
946            .gate_rule_checks
947            .iter()
948            .any(|check| check.id == "viewer_write" && !check.implied_by_spec));
949        assert!(report.overall_spurious_witness.is_some());
950    }
951
952    fn solver_available() -> bool {
953        check_sat("(check-sat)\n", &SolverSettings::default()).is_ok()
954    }
955
956    fn example(features: &[(&str, bool)], positive: bool) -> BooleanSearchExample {
957        BooleanSearchExample {
958            features: features
959                .iter()
960                .map(|(name, value)| ((*name).to_string(), *value))
961                .collect::<BTreeMap<_, _>>(),
962            positive,
963        }
964    }
965
966    fn sample_gate() -> LogicPearlGateIr {
967        LogicPearlGateIr::from_json_str(
968            &json!({
969                "ir_version": "1.0",
970                "gate_id": "sample_gate",
971                "gate_type": "bitmask_gate",
972                "input_schema": {
973                    "features": [
974                        {"id": "age", "type": "int", "description": null, "values": null, "min": 0, "max": 120, "editable": null},
975                        {"id": "role", "type": "enum", "description": null, "values": ["viewer", "editor"], "min": null, "max": null, "editable": null},
976                        {"id": "action", "type": "enum", "description": null, "values": ["read", "write"], "min": null, "max": null, "editable": null}
977                    ]
978                },
979                "rules": [
980                    {
981                        "id": "minor",
982                        "kind": "predicate",
983                        "bit": 0,
984                        "deny_when": {"feature": "age", "op": "<", "value": 18},
985                        "verification_status": "pipeline_unverified"
986                    },
987                    {
988                        "id": "viewer_write",
989                        "kind": "predicate",
990                        "bit": 1,
991                        "deny_when": {
992                            "all": [
993                                {"feature": "role", "op": "==", "value": "viewer"},
994                                {"feature": "action", "op": "==", "value": "write"}
995                            ]
996                        },
997                        "verification_status": "pipeline_unverified"
998                    }
999                ],
1000                "evaluation": {"combine": "bitwise_or", "allow_when_bitmask": 0},
1001                "verification": null,
1002                "provenance": null
1003            })
1004            .to_string(),
1005        )
1006        .unwrap()
1007    }
1008}