Skip to main content

tensorlogic_adapters/
diff.rs

1//! Schema diff utilities for comparing and tracking changes.
2//!
3//! This module provides tools for comparing two SymbolTables and generating
4//! detailed diff reports showing additions, deletions, and modifications.
5
6use std::collections::HashSet;
7
8use crate::{DomainInfo, PredicateInfo, SymbolTable};
9
10/// Comparison result for two symbol tables.
11///
12/// Contains detailed information about all differences between two schemas.
13#[derive(Clone, Debug, Default)]
14pub struct SchemaDiff {
15    /// Domains added in the new schema.
16    pub domains_added: Vec<DomainInfo>,
17    /// Domains removed from the old schema.
18    pub domains_removed: Vec<DomainInfo>,
19    /// Domains modified between schemas.
20    pub domains_modified: Vec<DomainModification>,
21    /// Predicates added in the new schema.
22    pub predicates_added: Vec<PredicateInfo>,
23    /// Predicates removed from the old schema.
24    pub predicates_removed: Vec<PredicateInfo>,
25    /// Predicates modified between schemas.
26    pub predicates_modified: Vec<PredicateModification>,
27    /// Variables added in the new schema.
28    pub variables_added: Vec<(String, String)>,
29    /// Variables removed from the old schema.
30    pub variables_removed: Vec<(String, String)>,
31    /// Variables with changed types.
32    pub variables_modified: Vec<VariableModification>,
33}
34
35impl SchemaDiff {
36    /// Check if there are any differences.
37    pub fn has_changes(&self) -> bool {
38        !self.domains_added.is_empty()
39            || !self.domains_removed.is_empty()
40            || !self.domains_modified.is_empty()
41            || !self.predicates_added.is_empty()
42            || !self.predicates_removed.is_empty()
43            || !self.predicates_modified.is_empty()
44            || !self.variables_added.is_empty()
45            || !self.variables_removed.is_empty()
46            || !self.variables_modified.is_empty()
47    }
48
49    /// Check if the diff represents backward-compatible changes.
50    ///
51    /// A change is backward-compatible if it only adds new entities
52    /// or expands existing ones without removing or breaking existing definitions.
53    pub fn is_backward_compatible(&self) -> bool {
54        // Removals break backward compatibility
55        if !self.domains_removed.is_empty()
56            || !self.predicates_removed.is_empty()
57            || !self.variables_removed.is_empty()
58        {
59            return false;
60        }
61
62        // Check domain modifications for breaking changes
63        for modification in &self.domains_modified {
64            // Cardinality reduction breaks compatibility
65            if modification.new_cardinality < modification.old_cardinality {
66                return false;
67            }
68        }
69
70        // Check predicate modifications for breaking changes
71        for modification in &self.predicates_modified {
72            // Signature changes break compatibility
73            if modification.signature_changed {
74                return false;
75            }
76        }
77
78        // Variable type changes break compatibility
79        if !self.variables_modified.is_empty() {
80            return false;
81        }
82
83        true
84    }
85
86    /// Get a summary of the changes.
87    pub fn summary(&self) -> DiffSummary {
88        DiffSummary {
89            domains_added: self.domains_added.len(),
90            domains_removed: self.domains_removed.len(),
91            domains_modified: self.domains_modified.len(),
92            predicates_added: self.predicates_added.len(),
93            predicates_removed: self.predicates_removed.len(),
94            predicates_modified: self.predicates_modified.len(),
95            variables_added: self.variables_added.len(),
96            variables_removed: self.variables_removed.len(),
97            variables_modified: self.variables_modified.len(),
98            is_backward_compatible: self.is_backward_compatible(),
99        }
100    }
101
102    /// Generate a human-readable report.
103    pub fn report(&self) -> String {
104        let mut output = String::new();
105
106        if !self.has_changes() {
107            output.push_str("No changes detected.\n");
108            return output;
109        }
110
111        let summary = self.summary();
112        output.push_str("Schema Diff Summary:\n");
113        output.push_str(&format!(
114            "  Backward Compatible: {}\n\n",
115            summary.is_backward_compatible
116        ));
117
118        if !self.domains_added.is_empty() {
119            output.push_str(&format!("Domains Added ({}):\n", self.domains_added.len()));
120            for domain in &self.domains_added {
121                output.push_str(&format!(
122                    "  + {} (cardinality: {})\n",
123                    domain.name, domain.cardinality
124                ));
125            }
126            output.push('\n');
127        }
128
129        if !self.domains_removed.is_empty() {
130            output.push_str(&format!(
131                "Domains Removed ({}):\n",
132                self.domains_removed.len()
133            ));
134            for domain in &self.domains_removed {
135                output.push_str(&format!(
136                    "  - {} (cardinality: {})\n",
137                    domain.name, domain.cardinality
138                ));
139            }
140            output.push('\n');
141        }
142
143        if !self.domains_modified.is_empty() {
144            output.push_str(&format!(
145                "Domains Modified ({}):\n",
146                self.domains_modified.len()
147            ));
148            for modification in &self.domains_modified {
149                output.push_str(&format!("  ~ {}\n", modification.domain_name));
150                if modification.old_cardinality != modification.new_cardinality {
151                    output.push_str(&format!(
152                        "    cardinality: {} -> {}\n",
153                        modification.old_cardinality, modification.new_cardinality
154                    ));
155                }
156                if modification.description_changed {
157                    output.push_str("    description: changed\n");
158                }
159            }
160            output.push('\n');
161        }
162
163        if !self.predicates_added.is_empty() {
164            output.push_str(&format!(
165                "Predicates Added ({}):\n",
166                self.predicates_added.len()
167            ));
168            for pred in &self.predicates_added {
169                output.push_str(&format!(
170                    "  + {} (arity: {})\n",
171                    pred.name,
172                    pred.arg_domains.len()
173                ));
174            }
175            output.push('\n');
176        }
177
178        if !self.predicates_removed.is_empty() {
179            output.push_str(&format!(
180                "Predicates Removed ({}):\n",
181                self.predicates_removed.len()
182            ));
183            for pred in &self.predicates_removed {
184                output.push_str(&format!(
185                    "  - {} (arity: {})\n",
186                    pred.name,
187                    pred.arg_domains.len()
188                ));
189            }
190            output.push('\n');
191        }
192
193        if !self.predicates_modified.is_empty() {
194            output.push_str(&format!(
195                "Predicates Modified ({}):\n",
196                self.predicates_modified.len()
197            ));
198            for modification in &self.predicates_modified {
199                output.push_str(&format!("  ~ {}\n", modification.predicate_name));
200                if modification.signature_changed {
201                    output.push_str(&format!(
202                        "    signature: {:?} -> {:?}\n",
203                        modification.old_signature, modification.new_signature
204                    ));
205                }
206            }
207            output.push('\n');
208        }
209
210        output
211    }
212}
213
214/// Modification details for a domain.
215#[derive(Clone, Debug)]
216pub struct DomainModification {
217    /// Name of the modified domain.
218    pub domain_name: String,
219    /// Old cardinality.
220    pub old_cardinality: usize,
221    /// New cardinality.
222    pub new_cardinality: usize,
223    /// Whether description changed.
224    pub description_changed: bool,
225    /// Whether metadata changed.
226    pub metadata_changed: bool,
227}
228
229/// Modification details for a predicate.
230#[derive(Clone, Debug)]
231pub struct PredicateModification {
232    /// Name of the modified predicate.
233    pub predicate_name: String,
234    /// Whether the signature changed.
235    pub signature_changed: bool,
236    /// Old argument domains.
237    pub old_signature: Vec<String>,
238    /// New argument domains.
239    pub new_signature: Vec<String>,
240    /// Whether description changed.
241    pub description_changed: bool,
242}
243
244/// Modification details for a variable binding.
245#[derive(Clone, Debug)]
246pub struct VariableModification {
247    /// Variable name.
248    pub variable_name: String,
249    /// Old domain.
250    pub old_domain: String,
251    /// New domain.
252    pub new_domain: String,
253}
254
255/// Summary statistics for a schema diff.
256#[derive(Clone, Debug)]
257pub struct DiffSummary {
258    /// Number of domains added.
259    pub domains_added: usize,
260    /// Number of domains removed.
261    pub domains_removed: usize,
262    /// Number of domains modified.
263    pub domains_modified: usize,
264    /// Number of predicates added.
265    pub predicates_added: usize,
266    /// Number of predicates removed.
267    pub predicates_removed: usize,
268    /// Number of predicates modified.
269    pub predicates_modified: usize,
270    /// Number of variables added.
271    pub variables_added: usize,
272    /// Number of variables removed.
273    pub variables_removed: usize,
274    /// Number of variables modified.
275    pub variables_modified: usize,
276    /// Whether changes are backward compatible.
277    pub is_backward_compatible: bool,
278}
279
280impl DiffSummary {
281    /// Total number of changes.
282    pub fn total_changes(&self) -> usize {
283        self.domains_added
284            + self.domains_removed
285            + self.domains_modified
286            + self.predicates_added
287            + self.predicates_removed
288            + self.predicates_modified
289            + self.variables_added
290            + self.variables_removed
291            + self.variables_modified
292    }
293}
294
295/// Compute the difference between two symbol tables.
296///
297/// # Example
298///
299/// ```rust
300/// use tensorlogic_adapters::{SymbolTable, DomainInfo, compute_diff};
301///
302/// let mut old_table = SymbolTable::new();
303/// old_table.add_domain(DomainInfo::new("Person", 100)).unwrap();
304///
305/// let mut new_table = SymbolTable::new();
306/// new_table.add_domain(DomainInfo::new("Person", 100)).unwrap();
307/// new_table.add_domain(DomainInfo::new("Location", 50)).unwrap();
308///
309/// let diff = compute_diff(&old_table, &new_table);
310/// assert_eq!(diff.domains_added.len(), 1);
311/// assert!(diff.is_backward_compatible());
312/// ```
313pub fn compute_diff(old: &SymbolTable, new: &SymbolTable) -> SchemaDiff {
314    let mut diff = SchemaDiff::default();
315
316    // Compute domain differences
317    let old_domain_names: HashSet<_> = old.domains.keys().collect();
318    let new_domain_names: HashSet<_> = new.domains.keys().collect();
319
320    for name in new_domain_names.difference(&old_domain_names) {
321        diff.domains_added.push(new.domains[*name].clone());
322    }
323
324    for name in old_domain_names.difference(&new_domain_names) {
325        diff.domains_removed.push(old.domains[*name].clone());
326    }
327
328    for name in old_domain_names.intersection(&new_domain_names) {
329        let old_domain = &old.domains[*name];
330        let new_domain = &new.domains[*name];
331
332        if old_domain.cardinality != new_domain.cardinality
333            || old_domain.description != new_domain.description
334            || old_domain.metadata != new_domain.metadata
335        {
336            diff.domains_modified.push(DomainModification {
337                domain_name: (*name).clone(),
338                old_cardinality: old_domain.cardinality,
339                new_cardinality: new_domain.cardinality,
340                description_changed: old_domain.description != new_domain.description,
341                metadata_changed: old_domain.metadata != new_domain.metadata,
342            });
343        }
344    }
345
346    // Compute predicate differences
347    let old_pred_names: HashSet<_> = old.predicates.keys().collect();
348    let new_pred_names: HashSet<_> = new.predicates.keys().collect();
349
350    for name in new_pred_names.difference(&old_pred_names) {
351        diff.predicates_added.push(new.predicates[*name].clone());
352    }
353
354    for name in old_pred_names.difference(&new_pred_names) {
355        diff.predicates_removed.push(old.predicates[*name].clone());
356    }
357
358    for name in old_pred_names.intersection(&new_pred_names) {
359        let old_pred = &old.predicates[*name];
360        let new_pred = &new.predicates[*name];
361
362        let signature_changed = old_pred.arg_domains != new_pred.arg_domains;
363        let description_changed = old_pred.description != new_pred.description;
364
365        if signature_changed || description_changed {
366            diff.predicates_modified.push(PredicateModification {
367                predicate_name: (*name).clone(),
368                signature_changed,
369                old_signature: old_pred.arg_domains.clone(),
370                new_signature: new_pred.arg_domains.clone(),
371                description_changed,
372            });
373        }
374    }
375
376    // Compute variable binding differences
377    let old_var_names: HashSet<_> = old.variables.keys().collect();
378    let new_var_names: HashSet<_> = new.variables.keys().collect();
379
380    for name in new_var_names.difference(&old_var_names) {
381        diff.variables_added
382            .push(((*name).clone(), new.variables[*name].clone()));
383    }
384
385    for name in old_var_names.difference(&new_var_names) {
386        diff.variables_removed
387            .push(((*name).clone(), old.variables[*name].clone()));
388    }
389
390    for name in old_var_names.intersection(&new_var_names) {
391        let old_domain = &old.variables[*name];
392        let new_domain = &new.variables[*name];
393
394        if old_domain != new_domain {
395            diff.variables_modified.push(VariableModification {
396                variable_name: (*name).clone(),
397                old_domain: old_domain.clone(),
398                new_domain: new_domain.clone(),
399            });
400        }
401    }
402
403    diff
404}
405
406/// Merge two symbol tables, preferring values from the newer table.
407///
408/// This is useful for applying schema migrations or updates.
409pub fn merge_tables(base: &SymbolTable, update: &SymbolTable) -> SymbolTable {
410    let mut merged = base.clone();
411
412    // Merge domains (update overwrites base)
413    for (name, domain) in &update.domains {
414        merged.domains.insert(name.clone(), domain.clone());
415    }
416
417    // Merge predicates (update overwrites base)
418    for (name, predicate) in &update.predicates {
419        merged.predicates.insert(name.clone(), predicate.clone());
420    }
421
422    // Merge variables (update overwrites base)
423    for (name, domain) in &update.variables {
424        merged.variables.insert(name.clone(), domain.clone());
425    }
426
427    merged
428}
429
430/// Compute schema compatibility between two versions.
431#[derive(Clone, Debug, PartialEq, Eq)]
432pub enum CompatibilityLevel {
433    /// Schemas are identical.
434    Identical,
435    /// Changes are backward compatible.
436    BackwardCompatible,
437    /// Changes are forward compatible (old can read new).
438    ForwardCompatible,
439    /// Changes break compatibility.
440    Breaking,
441}
442
443/// Determine compatibility level between two schemas.
444pub fn check_compatibility(old: &SymbolTable, new: &SymbolTable) -> CompatibilityLevel {
445    let diff = compute_diff(old, new);
446
447    if !diff.has_changes() {
448        return CompatibilityLevel::Identical;
449    }
450
451    if diff.is_backward_compatible() {
452        return CompatibilityLevel::BackwardCompatible;
453    }
454
455    // Check for forward compatibility (only removals, no additions or modifications)
456    if diff.domains_added.is_empty()
457        && diff.predicates_added.is_empty()
458        && diff.variables_added.is_empty()
459        && diff.domains_modified.is_empty()
460        && diff.predicates_modified.is_empty()
461        && diff.variables_modified.is_empty()
462    {
463        return CompatibilityLevel::ForwardCompatible;
464    }
465
466    CompatibilityLevel::Breaking
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_identical_schemas() {
475        let mut table = SymbolTable::new();
476        table.add_domain(DomainInfo::new("Person", 100)).unwrap();
477
478        let diff = compute_diff(&table, &table);
479        assert!(!diff.has_changes());
480        assert!(diff.is_backward_compatible());
481        assert_eq!(
482            check_compatibility(&table, &table),
483            CompatibilityLevel::Identical
484        );
485    }
486
487    #[test]
488    fn test_domain_addition() {
489        let mut old_table = SymbolTable::new();
490        old_table
491            .add_domain(DomainInfo::new("Person", 100))
492            .unwrap();
493
494        let mut new_table = old_table.clone();
495        new_table
496            .add_domain(DomainInfo::new("Location", 50))
497            .unwrap();
498
499        let diff = compute_diff(&old_table, &new_table);
500        assert_eq!(diff.domains_added.len(), 1);
501        assert_eq!(diff.domains_added[0].name, "Location");
502        assert!(diff.is_backward_compatible());
503        assert_eq!(
504            check_compatibility(&old_table, &new_table),
505            CompatibilityLevel::BackwardCompatible
506        );
507    }
508
509    #[test]
510    fn test_domain_removal() {
511        let mut old_table = SymbolTable::new();
512        old_table
513            .add_domain(DomainInfo::new("Person", 100))
514            .unwrap();
515        old_table
516            .add_domain(DomainInfo::new("Location", 50))
517            .unwrap();
518
519        let mut new_table = SymbolTable::new();
520        new_table
521            .add_domain(DomainInfo::new("Person", 100))
522            .unwrap();
523
524        let diff = compute_diff(&old_table, &new_table);
525        assert_eq!(diff.domains_removed.len(), 1);
526        assert_eq!(diff.domains_removed[0].name, "Location");
527        assert!(!diff.is_backward_compatible());
528        assert_eq!(
529            check_compatibility(&old_table, &new_table),
530            CompatibilityLevel::ForwardCompatible
531        );
532    }
533
534    #[test]
535    fn test_domain_modification() {
536        let mut old_table = SymbolTable::new();
537        old_table
538            .add_domain(DomainInfo::new("Person", 100))
539            .unwrap();
540
541        let mut new_table = SymbolTable::new();
542        new_table
543            .add_domain(DomainInfo::new("Person", 200))
544            .unwrap();
545
546        let diff = compute_diff(&old_table, &new_table);
547        assert_eq!(diff.domains_modified.len(), 1);
548        assert_eq!(diff.domains_modified[0].old_cardinality, 100);
549        assert_eq!(diff.domains_modified[0].new_cardinality, 200);
550        assert!(diff.is_backward_compatible()); // Cardinality increase is compatible
551    }
552
553    #[test]
554    fn test_cardinality_reduction_breaks_compatibility() {
555        let mut old_table = SymbolTable::new();
556        old_table
557            .add_domain(DomainInfo::new("Person", 200))
558            .unwrap();
559
560        let mut new_table = SymbolTable::new();
561        new_table
562            .add_domain(DomainInfo::new("Person", 100))
563            .unwrap();
564
565        let diff = compute_diff(&old_table, &new_table);
566        assert!(!diff.is_backward_compatible());
567        assert_eq!(
568            check_compatibility(&old_table, &new_table),
569            CompatibilityLevel::Breaking
570        );
571    }
572
573    #[test]
574    fn test_predicate_addition() {
575        let mut old_table = SymbolTable::new();
576        old_table
577            .add_domain(DomainInfo::new("Person", 100))
578            .unwrap();
579
580        let mut new_table = old_table.clone();
581        new_table
582            .add_predicate(PredicateInfo::new("knows", vec!["Person".to_string()]))
583            .unwrap();
584
585        let diff = compute_diff(&old_table, &new_table);
586        assert_eq!(diff.predicates_added.len(), 1);
587        assert!(diff.is_backward_compatible());
588    }
589
590    #[test]
591    fn test_predicate_signature_change() {
592        let mut old_table = SymbolTable::new();
593        old_table
594            .add_domain(DomainInfo::new("Person", 100))
595            .unwrap();
596        old_table
597            .add_predicate(PredicateInfo::new("knows", vec!["Person".to_string()]))
598            .unwrap();
599
600        let mut new_table = SymbolTable::new();
601        new_table
602            .add_domain(DomainInfo::new("Person", 100))
603            .unwrap();
604        new_table
605            .add_predicate(PredicateInfo::new(
606                "knows",
607                vec!["Person".to_string(), "Person".to_string()],
608            ))
609            .unwrap();
610
611        let diff = compute_diff(&old_table, &new_table);
612        assert_eq!(diff.predicates_modified.len(), 1);
613        assert!(diff.predicates_modified[0].signature_changed);
614        assert!(!diff.is_backward_compatible());
615    }
616
617    #[test]
618    fn test_merge_tables() {
619        let mut base = SymbolTable::new();
620        base.add_domain(DomainInfo::new("Person", 100)).unwrap();
621
622        let mut update = SymbolTable::new();
623        update.add_domain(DomainInfo::new("Person", 200)).unwrap();
624        update.add_domain(DomainInfo::new("Location", 50)).unwrap();
625
626        let merged = merge_tables(&base, &update);
627        assert_eq!(merged.domains.len(), 2);
628        assert_eq!(merged.get_domain("Person").unwrap().cardinality, 200);
629        assert!(merged.get_domain("Location").is_some());
630    }
631
632    #[test]
633    fn test_diff_report() {
634        let mut old_table = SymbolTable::new();
635        old_table
636            .add_domain(DomainInfo::new("Person", 100))
637            .unwrap();
638
639        let mut new_table = old_table.clone();
640        new_table
641            .add_domain(DomainInfo::new("Location", 50))
642            .unwrap();
643
644        let diff = compute_diff(&old_table, &new_table);
645        let report = diff.report();
646        assert!(report.contains("Domains Added"));
647        assert!(report.contains("Location"));
648    }
649
650    #[test]
651    fn test_summary_total_changes() {
652        let mut old_table = SymbolTable::new();
653        old_table
654            .add_domain(DomainInfo::new("Person", 100))
655            .unwrap();
656
657        let mut new_table = old_table.clone();
658        new_table
659            .add_domain(DomainInfo::new("Location", 50))
660            .unwrap();
661        new_table.add_domain(DomainInfo::new("Event", 30)).unwrap();
662
663        let diff = compute_diff(&old_table, &new_table);
664        let summary = diff.summary();
665        assert_eq!(summary.total_changes(), 2);
666    }
667}