Skip to main content

oxiz_proof/
compress.rs

1//! Proof compression and optimization.
2//!
3//! This module provides algorithms for compressing and optimizing proofs,
4//! including:
5//! - Removing redundant steps
6//! - Merging duplicate subproofs
7//! - Trimming unused branches
8//! - Reordering steps for better structure
9
10use crate::proof::{Proof, ProofNodeId, ProofStep};
11use rustc_hash::FxHashSet;
12use std::collections::VecDeque;
13
14/// Configuration for proof compression
15#[derive(Debug, Clone)]
16pub struct CompressionConfig {
17    /// Remove redundant steps (same conclusion from same premises)
18    pub remove_redundant: bool,
19    /// Trim unused branches
20    pub trim_unused: bool,
21    /// Inline trivial steps
22    pub inline_trivial: bool,
23    /// Maximum number of compression passes
24    pub max_passes: usize,
25}
26
27impl Default for CompressionConfig {
28    fn default() -> Self {
29        Self {
30            remove_redundant: true,
31            trim_unused: true,
32            inline_trivial: true,
33            max_passes: 3,
34        }
35    }
36}
37
38/// Result of proof compression
39#[derive(Debug, Clone)]
40pub struct CompressionResult {
41    /// Number of nodes before compression
42    pub nodes_before: usize,
43    /// Number of nodes after compression
44    pub nodes_after: usize,
45    /// Number of nodes removed
46    pub nodes_removed: usize,
47    /// Compression ratio (nodes_after / nodes_before)
48    pub ratio: f64,
49}
50
51impl CompressionResult {
52    /// Create a new compression result
53    #[must_use]
54    pub fn new(nodes_before: usize, nodes_after: usize) -> Self {
55        let nodes_removed = nodes_before.saturating_sub(nodes_after);
56        let ratio = if nodes_before > 0 {
57            nodes_after as f64 / nodes_before as f64
58        } else {
59            1.0
60        };
61
62        Self {
63            nodes_before,
64            nodes_after,
65            nodes_removed,
66            ratio,
67        }
68    }
69}
70
71/// Proof compression engine
72pub struct ProofCompressor {
73    /// Configuration
74    config: CompressionConfig,
75}
76
77impl ProofCompressor {
78    /// Create a new proof compressor with default configuration
79    #[must_use]
80    pub fn new() -> Self {
81        Self {
82            config: CompressionConfig::default(),
83        }
84    }
85
86    /// Create a proof compressor with custom configuration
87    #[must_use]
88    pub fn with_config(config: CompressionConfig) -> Self {
89        Self { config }
90    }
91
92    /// Compress a proof
93    pub fn compress(&self, proof: &Proof) -> (Proof, CompressionResult) {
94        let nodes_before = proof.len();
95        let mut compressed = proof.clone();
96
97        for _ in 0..self.config.max_passes {
98            let prev_len = compressed.len();
99
100            if self.config.trim_unused {
101                compressed = self.trim_unused_nodes(&compressed);
102            }
103
104            if self.config.remove_redundant {
105                compressed = self.remove_redundant(&compressed);
106            }
107
108            if self.config.inline_trivial {
109                compressed = self.inline_trivial_steps(&compressed);
110            }
111
112            // Stop if no progress
113            if compressed.len() == prev_len {
114                break;
115            }
116        }
117
118        let nodes_after = compressed.len();
119        let result = CompressionResult::new(nodes_before, nodes_after);
120
121        (compressed, result)
122    }
123
124    /// Trim unused nodes (nodes not reachable from roots)
125    fn trim_unused_nodes(&self, proof: &Proof) -> Proof {
126        // Mark all reachable nodes
127        let mut reachable = FxHashSet::default();
128        let mut queue = VecDeque::new();
129
130        // Start from roots
131        for &root_id in proof.roots() {
132            queue.push_back(root_id);
133            reachable.insert(root_id);
134        }
135
136        // BFS to find all reachable nodes
137        while let Some(node_id) = queue.pop_front() {
138            if let Some(node) = proof.get_node(node_id)
139                && let ProofStep::Inference { premises, .. } = &node.step
140            {
141                for &premise_id in premises {
142                    if reachable.insert(premise_id) {
143                        queue.push_back(premise_id);
144                    }
145                }
146            }
147        }
148
149        // Build new proof with only reachable nodes
150        let mut new_proof = Proof::new();
151        let mut id_map = rustc_hash::FxHashMap::default();
152
153        // Copy reachable nodes in topological order
154        for node in proof.nodes() {
155            if reachable.contains(&node.id) {
156                let new_id = match &node.step {
157                    ProofStep::Axiom { conclusion } => new_proof.add_axiom(conclusion.clone()),
158                    ProofStep::Inference {
159                        rule,
160                        premises,
161                        conclusion,
162                        args,
163                    } => {
164                        let new_premises: Vec<ProofNodeId> = premises
165                            .iter()
166                            .filter_map(|p| id_map.get(p).copied())
167                            .collect();
168
169                        new_proof.add_inference_with_args(
170                            rule.clone(),
171                            new_premises,
172                            args.to_vec(),
173                            conclusion.clone(),
174                        )
175                    }
176                };
177                id_map.insert(node.id, new_id);
178            }
179        }
180
181        new_proof
182    }
183
184    /// Remove redundant steps (duplicate conclusions)
185    fn remove_redundant(&self, proof: &Proof) -> Proof {
186        // Already handled by proof deduplication in the Proof structure
187        proof.clone()
188    }
189
190    /// Inline trivial steps (identity operations, reflexivity, etc.)
191    fn inline_trivial_steps(&self, proof: &Proof) -> Proof {
192        let mut new_proof = Proof::new();
193        let mut id_map = rustc_hash::FxHashMap::default();
194
195        for node in proof.nodes() {
196            let is_trivial = match &node.step {
197                ProofStep::Axiom { .. } => false,
198                ProofStep::Inference { rule, premises, .. } => {
199                    // Trivial if it's a reflexivity or identity rule with single premise
200                    (rule == "refl" || rule == "identity") && premises.len() <= 1
201                }
202            };
203
204            if is_trivial {
205                // Skip this node, map to its premise
206                if let ProofStep::Inference { premises, .. } = &node.step
207                    && let Some(&premise_id) = premises.first()
208                    && let Some(&mapped_premise) = id_map.get(&premise_id)
209                {
210                    id_map.insert(node.id, mapped_premise);
211                    continue;
212                }
213            }
214
215            // Copy non-trivial nodes
216            let new_id = match &node.step {
217                ProofStep::Axiom { conclusion } => new_proof.add_axiom(conclusion.clone()),
218                ProofStep::Inference {
219                    rule,
220                    premises,
221                    conclusion,
222                    args,
223                } => {
224                    let new_premises: Vec<ProofNodeId> = premises
225                        .iter()
226                        .filter_map(|p| id_map.get(p).copied())
227                        .collect();
228
229                    new_proof.add_inference_with_args(
230                        rule.clone(),
231                        new_premises,
232                        args.to_vec(),
233                        conclusion.clone(),
234                    )
235                }
236            };
237            id_map.insert(node.id, new_id);
238        }
239
240        new_proof
241    }
242}
243
244impl Default for ProofCompressor {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250/// Trim a proof to only include nodes needed for a specific conclusion
251pub fn trim_to_conclusion(proof: &Proof, conclusion_id: ProofNodeId) -> Proof {
252    let mut needed = FxHashSet::default();
253    let mut queue = VecDeque::new();
254
255    queue.push_back(conclusion_id);
256    needed.insert(conclusion_id);
257
258    // BFS backward through premises
259    while let Some(node_id) = queue.pop_front() {
260        if let Some(node) = proof.get_node(node_id)
261            && let ProofStep::Inference { premises, .. } = &node.step
262        {
263            for &premise_id in premises {
264                if needed.insert(premise_id) {
265                    queue.push_back(premise_id);
266                }
267            }
268        }
269    }
270
271    // Build new proof with only needed nodes
272    let mut new_proof = Proof::new();
273    let mut id_map = rustc_hash::FxHashMap::default();
274
275    for node in proof.nodes() {
276        if needed.contains(&node.id) {
277            let new_id = match &node.step {
278                ProofStep::Axiom { conclusion } => new_proof.add_axiom(conclusion.clone()),
279                ProofStep::Inference {
280                    rule,
281                    premises,
282                    conclusion,
283                    args,
284                } => {
285                    let new_premises: Vec<ProofNodeId> = premises
286                        .iter()
287                        .filter_map(|p| id_map.get(p).copied())
288                        .collect();
289
290                    new_proof.add_inference_with_args(
291                        rule.clone(),
292                        new_premises,
293                        args.to_vec(),
294                        conclusion.clone(),
295                    )
296                }
297            };
298            id_map.insert(node.id, new_id);
299        }
300    }
301
302    new_proof
303}
304
305/// Get the dependency cone of a node (all nodes it depends on)
306#[must_use]
307pub fn get_dependency_cone(proof: &Proof, node_id: ProofNodeId) -> Vec<ProofNodeId> {
308    let mut deps = FxHashSet::default();
309    let mut queue = VecDeque::new();
310
311    queue.push_back(node_id);
312    deps.insert(node_id);
313
314    while let Some(id) = queue.pop_front() {
315        if let Some(node) = proof.get_node(id)
316            && let ProofStep::Inference { premises, .. } = &node.step
317        {
318            for &premise_id in premises {
319                if deps.insert(premise_id) {
320                    queue.push_back(premise_id);
321                }
322            }
323        }
324    }
325
326    deps.into_iter().collect()
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_compression_result() {
335        let result = CompressionResult::new(100, 80);
336        assert_eq!(result.nodes_before, 100);
337        assert_eq!(result.nodes_after, 80);
338        assert_eq!(result.nodes_removed, 20);
339        assert!((result.ratio - 0.8).abs() < 0.01);
340    }
341
342    #[test]
343    fn test_trim_unused() {
344        let mut proof = Proof::new();
345        let a1 = proof.add_axiom("p");
346        let a2 = proof.add_axiom("q");
347        let _unused = proof.add_axiom("r"); // This is unused
348        proof.add_inference("and", vec![a1, a2], "p /\\ q");
349
350        let compressor = ProofCompressor::new();
351        let (compressed, _) = compressor.compress(&proof);
352
353        // Should remove the unused axiom
354        assert!(compressed.len() <= proof.len());
355    }
356
357    #[test]
358    fn test_trim_to_conclusion() {
359        let mut proof = Proof::new();
360        let a1 = proof.add_axiom("p");
361        let a2 = proof.add_axiom("q");
362        let a3 = proof.add_axiom("r");
363        let i1 = proof.add_inference("and", vec![a1, a2], "p /\\ q");
364        let _i2 = proof.add_inference("or", vec![a3], "r");
365
366        // Trim to only include nodes needed for i1
367        let trimmed = trim_to_conclusion(&proof, i1);
368
369        // Should have 3 nodes: a1, a2, i1 (not a3 or i2)
370        assert_eq!(trimmed.len(), 3);
371    }
372
373    #[test]
374    fn test_dependency_cone() {
375        let mut proof = Proof::new();
376        let a1 = proof.add_axiom("p");
377        let a2 = proof.add_axiom("q");
378        let i1 = proof.add_inference("and", vec![a1, a2], "p /\\ q");
379        let i2 = proof.add_inference("not", vec![i1], "~(p /\\ q)");
380
381        let cone = get_dependency_cone(&proof, i2);
382
383        // Should include i2, i1, a1, a2
384        assert_eq!(cone.len(), 4);
385        assert!(cone.contains(&a1));
386        assert!(cone.contains(&a2));
387        assert!(cone.contains(&i1));
388        assert!(cone.contains(&i2));
389    }
390
391    #[test]
392    fn test_compressor_default() {
393        let compressor = ProofCompressor::default();
394        assert!(compressor.config.remove_redundant);
395        assert!(compressor.config.trim_unused);
396    }
397
398    #[test]
399    fn test_compress_simple_proof() {
400        let mut proof = Proof::new();
401        let a1 = proof.add_axiom("p");
402        let a2 = proof.add_axiom("q");
403        proof.add_inference("and", vec![a1, a2], "p /\\ q");
404
405        let compressor = ProofCompressor::new();
406        let (compressed, result) = compressor.compress(&proof);
407
408        assert_eq!(result.nodes_before, proof.len());
409        assert_eq!(result.nodes_after, compressed.len());
410    }
411
412    // Property-based tests
413    mod proptests {
414        use super::*;
415        use proptest::prelude::*;
416
417        fn var_name() -> impl Strategy<Value = String> {
418            "[a-z][0-9]*".prop_map(|s| s.to_string())
419        }
420
421        proptest! {
422            /// Compression should never increase proof size
423            #[test]
424            fn prop_compression_reduces_or_maintains_size(
425                conclusions in prop::collection::vec(var_name(), 1..15)
426            ) {
427                let mut proof = Proof::new();
428                for conclusion in &conclusions {
429                    proof.add_axiom(conclusion);
430                }
431
432                let compressor = ProofCompressor::new();
433                let (compressed, result) = compressor.compress(&proof);
434
435                prop_assert!(compressed.len() <= proof.len());
436                prop_assert_eq!(result.nodes_before, proof.len());
437                prop_assert_eq!(result.nodes_after, compressed.len());
438            }
439
440            /// Compression ratio should be between 0 and 1
441            #[test]
442            fn prop_compression_ratio_bounds(
443                conclusions in prop::collection::vec(var_name(), 1..10)
444            ) {
445                let mut proof = Proof::new();
446                for conclusion in &conclusions {
447                    proof.add_axiom(conclusion);
448                }
449
450                let compressor = ProofCompressor::new();
451                let (_, result) = compressor.compress(&proof);
452
453                prop_assert!(result.ratio >= 0.0);
454                prop_assert!(result.ratio <= 1.0);
455            }
456
457            /// Nodes removed should equal nodes_before - nodes_after
458            #[test]
459            fn prop_nodes_removed_consistency(
460                conclusions in prop::collection::vec(var_name(), 1..12)
461            ) {
462                let mut proof = Proof::new();
463                for conclusion in &conclusions {
464                    proof.add_axiom(conclusion);
465                }
466
467                let compressor = ProofCompressor::new();
468                let (_, result) = compressor.compress(&proof);
469
470                prop_assert_eq!(
471                    result.nodes_removed,
472                    result.nodes_before.saturating_sub(result.nodes_after)
473                );
474            }
475
476            /// Dependency cone should include the target node
477            #[test]
478            fn prop_dependency_cone_includes_target(
479                conclusions in prop::collection::vec(var_name(), 2..8)
480            ) {
481                let mut proof = Proof::new();
482                let mut ids = Vec::new();
483
484                for conclusion in &conclusions {
485                    ids.push(proof.add_axiom(conclusion));
486                }
487
488                if ids.len() >= 2 {
489                    let inf_id = proof.add_inference("and", vec![ids[0], ids[1]], "result");
490                    let cone = get_dependency_cone(&proof, inf_id);
491                    prop_assert!(cone.contains(&inf_id));
492                }
493            }
494
495            /// Dependency cone should include all premises
496            #[test]
497            fn prop_dependency_cone_includes_premises(
498                conclusions in prop::collection::vec(var_name(), 2..8)
499            ) {
500                let mut proof = Proof::new();
501                let mut premise_ids = Vec::new();
502
503                for conclusion in &conclusions {
504                    premise_ids.push(proof.add_axiom(conclusion));
505                }
506
507                if premise_ids.len() >= 2 {
508                    let inf_id = proof.add_inference(
509                        "and",
510                        premise_ids[..2].to_vec(),
511                        "result"
512                    );
513                    let cone = get_dependency_cone(&proof, inf_id);
514
515                    for &premise_id in &premise_ids[..2] {
516                        prop_assert!(cone.contains(&premise_id));
517                    }
518                }
519            }
520
521            /// Trimmed proof should not be larger than original
522            #[test]
523            fn prop_trim_reduces_size(
524                conclusions in prop::collection::vec(var_name(), 2..10)
525            ) {
526                let mut proof = Proof::new();
527                let mut ids = Vec::new();
528
529                for conclusion in &conclusions {
530                    ids.push(proof.add_axiom(conclusion));
531                }
532
533                if ids.len() >= 2 {
534                    let target = proof.add_inference("and", vec![ids[0], ids[1]], "target");
535                    let trimmed = trim_to_conclusion(&proof, target);
536                    prop_assert!(trimmed.len() <= proof.len());
537                }
538            }
539
540            /// Trimmed proof should contain the target conclusion
541            #[test]
542            fn prop_trim_contains_target(
543                conclusions in prop::collection::vec(var_name(), 2..8)
544            ) {
545                let mut proof = Proof::new();
546                let mut ids = Vec::new();
547
548                for conclusion in &conclusions {
549                    ids.push(proof.add_axiom(conclusion));
550                }
551
552                if ids.len() >= 2 {
553                    let target = proof.add_inference("and", vec![ids[0], ids[1]], "target");
554                    let trimmed = trim_to_conclusion(&proof, target);
555
556                    // Target should be reachable in trimmed proof
557                    prop_assert!(!trimmed.is_empty());
558                }
559            }
560
561            /// Multiple compression passes should be idempotent
562            #[test]
563            fn prop_compression_idempotent(
564                conclusions in prop::collection::vec(var_name(), 1..8)
565            ) {
566                let mut proof = Proof::new();
567                for conclusion in &conclusions {
568                    proof.add_axiom(conclusion);
569                }
570
571                let compressor = ProofCompressor::new();
572                let (compressed1, _) = compressor.compress(&proof);
573                let (compressed2, _) = compressor.compress(&compressed1);
574
575                // Second compression shouldn't change size
576                prop_assert_eq!(compressed1.len(), compressed2.len());
577            }
578
579            /// Compression with no optimization should preserve size
580            #[test]
581            fn prop_no_optimization_preserves_size(
582                conclusions in prop::collection::vec(var_name(), 1..8)
583            ) {
584                let mut proof = Proof::new();
585                for conclusion in &conclusions {
586                    proof.add_axiom(conclusion);
587                }
588
589                let config = CompressionConfig {
590                    remove_redundant: false,
591                    trim_unused: false,
592                    inline_trivial: false,
593                    max_passes: 0,
594                };
595                let compressor = ProofCompressor::with_config(config);
596                let (compressed, _) = compressor.compress(&proof);
597
598                prop_assert_eq!(compressed.len(), proof.len());
599            }
600        }
601    }
602}