1use 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}