use crate::compress::get_dependency_cone;
use crate::proof::{Proof, ProofNodeId, ProofStep};
use rustc_hash::FxHashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone)]
pub struct MinimizeConfig {
pub max_passes: usize,
pub enable_dedup: bool,
}
impl Default for MinimizeConfig {
fn default() -> Self {
Self {
max_passes: 4,
enable_dedup: true,
}
}
}
impl MinimizeConfig {
pub fn new() -> Self {
Self::default()
}
pub fn without_dedup(mut self) -> Self {
self.enable_dedup = false;
self
}
pub fn with_max_passes(mut self, passes: usize) -> Self {
self.max_passes = passes;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct MinimizeResult {
pub passes: usize,
pub nodes_removed: usize,
pub duplicates_collapsed: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct ConclusionHash(u64);
impl ConclusionHash {
fn for_axiom(conclusion: &str) -> Self {
let mut h = DefaultHasher::new();
0u8.hash(&mut h); conclusion.hash(&mut h);
Self(h.finish())
}
fn for_inference(rule: &str, conclusion: &str, args: &[String], premise_count: usize) -> Self {
let mut h = DefaultHasher::new();
1u8.hash(&mut h); rule.hash(&mut h);
conclusion.hash(&mut h);
args.hash(&mut h);
premise_count.hash(&mut h);
Self(h.finish())
}
}
fn nodes_are_equal(proof: &Proof, lhs: ProofNodeId, rhs: ProofNodeId) -> bool {
match (proof.get_node(lhs), proof.get_node(rhs)) {
(Some(l), Some(r)) => match (&l.step, &r.step) {
(ProofStep::Axiom { conclusion: cl }, ProofStep::Axiom { conclusion: cr }) => cl == cr,
(
ProofStep::Inference {
rule: rl,
premises: pl,
conclusion: cl,
args: al,
},
ProofStep::Inference {
rule: rr,
premises: pr,
conclusion: cr,
args: ar,
},
) => rl == rr && cl == cr && al.as_slice() == ar.as_slice() && pl.len() == pr.len(),
_ => false,
},
_ => false,
}
}
fn rebuild_cone(
proof: &Proof,
root_id: ProofNodeId,
id_remap: &FxHashMap<ProofNodeId, ProofNodeId>,
) -> (Proof, usize) {
let nodes_before = proof.len();
let effective_root = resolve(root_id, id_remap);
let cone_set: std::collections::HashSet<ProofNodeId> =
get_dependency_cone(proof, effective_root)
.into_iter()
.collect();
let mut new_proof = Proof::new();
let mut local_remap: FxHashMap<ProofNodeId, ProofNodeId> = FxHashMap::default();
for node in proof.nodes() {
if !cone_set.contains(&node.id) {
continue;
}
let new_id = match &node.step {
ProofStep::Axiom { conclusion } => new_proof.add_axiom(conclusion.clone()),
ProofStep::Inference {
rule,
premises,
conclusion,
args,
} => {
let new_premises: Vec<ProofNodeId> = premises
.iter()
.map(|p| {
let resolved = resolve(*p, id_remap);
*local_remap.get(&resolved).unwrap_or(&resolved)
})
.collect();
new_proof.add_inference_with_args(
rule.clone(),
new_premises,
args.to_vec(),
conclusion.clone(),
)
}
};
local_remap.insert(node.id, new_id);
}
let nodes_after = new_proof.len();
let removed = nodes_before.saturating_sub(nodes_after);
(new_proof, removed)
}
fn resolve(id: ProofNodeId, remap: &FxHashMap<ProofNodeId, ProofNodeId>) -> ProofNodeId {
let mut current = id;
for _ in 0..1024 {
match remap.get(¤t) {
Some(&next) if next != current => current = next,
_ => break,
}
}
current
}
fn dedup_pass(proof: &Proof) -> (FxHashMap<ProofNodeId, ProofNodeId>, usize) {
let mut canon: FxHashMap<ConclusionHash, ProofNodeId> = FxHashMap::default();
let mut remap: FxHashMap<ProofNodeId, ProofNodeId> = FxHashMap::default();
let mut collapsed = 0usize;
for node in proof.nodes() {
let hash = match &node.step {
ProofStep::Axiom { conclusion } => ConclusionHash::for_axiom(conclusion),
ProofStep::Inference {
rule,
premises,
conclusion,
args,
} => ConclusionHash::for_inference(rule, conclusion, args.as_slice(), premises.len()),
};
match canon.get(&hash).copied() {
Some(existing) if nodes_are_equal(proof, existing, node.id) => {
remap.insert(node.id, existing);
collapsed += 1;
}
Some(_) => {
}
None => {
canon.insert(hash, node.id);
}
}
}
(remap, collapsed)
}
pub struct ProofMinimizer {
config: MinimizeConfig,
}
impl ProofMinimizer {
pub fn new(config: MinimizeConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self::new(MinimizeConfig::default())
}
pub fn minimize(&self, proof: &mut Proof) -> MinimizeResult {
let mut result = MinimizeResult::default();
if proof.is_empty() {
return result;
}
for pass in 0..self.config.max_passes {
let size_before = proof.len();
let mut pass_collapsed = 0usize;
if self.config.enable_dedup {
let (remap, collapsed) = dedup_pass(proof);
pass_collapsed = collapsed;
if !remap.is_empty() {
if let Some(root_id) = proof.root() {
let (new_proof, _) = rebuild_cone(proof, root_id, &remap);
*proof = new_proof;
}
}
}
if let Some(root_id) = proof.root() {
let empty_remap = FxHashMap::default();
let (new_proof, _) = rebuild_cone(proof, root_id, &empty_remap);
*proof = new_proof;
}
let size_after = proof.len();
let removed_this_pass = size_before.saturating_sub(size_after);
result.passes = pass + 1;
result.nodes_removed += removed_this_pass;
result.duplicates_collapsed += pass_collapsed;
if removed_this_pass == 0 && pass_collapsed == 0 {
break;
}
}
result
}
}
impl Default for ProofMinimizer {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proof::Proof;
fn proof_with_duplicate_axioms() -> (Proof, ProofNodeId, ProofNodeId) {
let mut proof = Proof::new();
let a1 = proof.add_axiom("p"); let a2 = proof.add_axiom("q"); proof.update_conclusion(a2, "p");
let root = proof.add_inference("merge", vec![a1, a2], "merged_p");
(proof, a1, root)
}
#[test]
fn test_duplicate_axiom_proof_shrinks() {
let (mut proof, _a1, _root) = proof_with_duplicate_axioms();
let size_before = proof.len();
let minimizer = ProofMinimizer::with_defaults();
let result = minimizer.minimize(&mut proof);
assert!(
result.nodes_removed > 0 || proof.len() < size_before,
"minimizer must remove at least one duplicate node"
);
}
#[test]
fn test_idempotent_on_minimal() {
let mut proof = Proof::new();
let a1 = proof.add_axiom("x");
let a2 = proof.add_axiom("y");
proof.add_inference("and", vec![a1, a2], "x_and_y");
let minimizer = ProofMinimizer::with_defaults();
let result1 = minimizer.minimize(&mut proof);
let size_after_first = proof.len();
let result2 = minimizer.minimize(&mut proof);
let size_after_second = proof.len();
assert_eq!(
result1.nodes_removed, 0,
"a minimal proof should have nothing removed"
);
assert_eq!(
size_after_first, size_after_second,
"second minimize should produce the same size"
);
assert_eq!(
result2.nodes_removed, 0,
"second minimize should report no removals"
);
}
#[test]
fn test_preserves_conclusion() {
let (mut proof, _a1, _root) = proof_with_duplicate_axioms();
let original_conclusion = proof
.root_node()
.map(|n| n.conclusion().to_string())
.expect("proof must have a root node");
let minimizer = ProofMinimizer::with_defaults();
let _result = minimizer.minimize(&mut proof);
let after_conclusion = proof
.root_node()
.map(|n| n.conclusion().to_string())
.expect("proof must still have a root node after minimize");
assert_eq!(
original_conclusion, after_conclusion,
"root conclusion must be preserved across minimization"
);
}
#[test]
fn test_iterates_to_fixed_point() {
let mut proof = Proof::new();
let a0 = proof.add_axiom("p_orig");
let a1 = proof.add_axiom("p_dup_raw");
proof.update_conclusion(a1, "p_orig"); let layer1 = proof.add_inference("l1", vec![a0, a1], "layer1_out");
let a2 = proof.add_axiom("r_orig");
let a3 = proof.add_axiom("r_dup_raw");
proof.update_conclusion(a3, "r_orig"); let layer2 = proof.add_inference("l2", vec![a2, a3], "layer2_out");
proof.add_inference("root_rule", vec![layer1, layer2], "final_root");
let minimizer = ProofMinimizer::new(MinimizeConfig {
max_passes: 4,
enable_dedup: true,
});
let result = minimizer.minimize(&mut proof);
assert!(
result.nodes_removed > 0,
"minimizer must remove duplicates; nodes_removed={}",
result.nodes_removed
);
let size_stable = proof.len();
let result2 = minimizer.minimize(&mut proof);
assert_eq!(
proof.len(),
size_stable,
"proof size must not change after fixed-point"
);
assert_eq!(
result2.nodes_removed, 0,
"no further removals expected at fixed-point"
);
assert!(result.passes >= 1, "at least one pass must be recorded");
}
#[test]
fn test_disable_dedup_preserves_size() {
let mut proof = Proof::new();
let a1 = proof.add_axiom("distinct_x");
let a2 = proof.add_axiom("distinct_y");
let a3 = proof.add_axiom("distinct_z");
let i1 = proof.add_inference("and", vec![a1, a2], "and_xy");
proof.add_inference("combine", vec![i1, a3], "root_out");
let size_before = proof.len();
let minimizer = ProofMinimizer::new(MinimizeConfig {
max_passes: 4,
enable_dedup: false,
});
let result = minimizer.minimize(&mut proof);
assert_eq!(
result.nodes_removed, 0,
"no nodes should be removed from a minimal, fully-connected proof"
);
assert_eq!(
proof.len(),
size_before,
"disabling dedup on a minimal proof must not shrink it"
);
}
#[test]
fn test_empty_proof_safe() {
let mut proof = Proof::new();
let minimizer = ProofMinimizer::with_defaults();
let result = minimizer.minimize(&mut proof);
assert_eq!(result.nodes_removed, 0);
assert_eq!(result.passes, 0);
assert!(proof.is_empty());
}
}