Skip to main content

tensorlogic_oxirs_bridge/
ontology_diff.rs

1//! Ontology diff: compare two [`SymbolTable`]s and report differences.
2//!
3//! The primary entry point is [`compare_symbol_tables`], which returns an
4//! [`OntologyDiff`] describing every domain and predicate that was added,
5//! removed, or modified between two symbol tables.
6
7use serde::{Deserialize, Serialize};
8use tensorlogic_adapters::SymbolTable;
9
10// ── DiffEntry ─────────────────────────────────────────────────────────────────
11
12/// A single change entry recorded in an [`OntologyDiff`].
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub enum DiffEntry {
15    /// The symbol exists in `b` but not in `a`.
16    Added(String),
17    /// The symbol exists in `a` but not in `b`.
18    Removed(String),
19    /// The symbol exists in both but its definition changed.
20    Modified { before: String, after: String },
21}
22
23impl DiffEntry {
24    /// Return the name of the symbol this entry refers to.
25    pub fn name(&self) -> &str {
26        match self {
27            DiffEntry::Added(n) => n.as_str(),
28            DiffEntry::Removed(n) => n.as_str(),
29            DiffEntry::Modified { before, .. } => before.as_str(),
30        }
31    }
32
33    /// Returns `true` if this is an [`DiffEntry::Added`] variant.
34    pub fn is_addition(&self) -> bool {
35        matches!(self, DiffEntry::Added(_))
36    }
37
38    /// Returns `true` if this is a [`DiffEntry::Removed`] variant.
39    pub fn is_removal(&self) -> bool {
40        matches!(self, DiffEntry::Removed(_))
41    }
42
43    /// Returns `true` if this is a [`DiffEntry::Modified`] variant.
44    pub fn is_modification(&self) -> bool {
45        matches!(self, DiffEntry::Modified { .. })
46    }
47}
48
49// ── OntologyDiff ──────────────────────────────────────────────────────────────
50
51/// Result of comparing two [`SymbolTable`]s.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct OntologyDiff {
54    /// Changes in domain (class) definitions.
55    pub domain_diffs: Vec<DiffEntry>,
56    /// Changes in predicate (property) definitions.
57    pub predicate_diffs: Vec<DiffEntry>,
58}
59
60impl OntologyDiff {
61    /// Create a new, empty [`OntologyDiff`].
62    pub fn new() -> Self {
63        OntologyDiff::default()
64    }
65
66    /// Returns `true` when no domain or predicate differences were found.
67    pub fn is_empty(&self) -> bool {
68        self.domain_diffs.is_empty() && self.predicate_diffs.is_empty()
69    }
70
71    /// Total number of change entries across domains and predicates.
72    pub fn total_changes(&self) -> usize {
73        self.domain_diffs.len() + self.predicate_diffs.len()
74    }
75
76    /// Human-readable multi-line report.
77    pub fn report(&self) -> String {
78        let mut out = String::new();
79        out.push_str("OntologyDiff Report\n");
80        out.push_str("===================\n");
81
82        if self.domain_diffs.is_empty() {
83            out.push_str("Domains: no changes\n");
84        } else {
85            out.push_str("Domains:\n");
86            for entry in &self.domain_diffs {
87                match entry {
88                    DiffEntry::Added(n) => {
89                        out.push_str(&format!("  + Added: {}\n", n));
90                    }
91                    DiffEntry::Removed(n) => {
92                        out.push_str(&format!("  - Removed: {}\n", n));
93                    }
94                    DiffEntry::Modified { before, after } => {
95                        out.push_str(&format!("  ~ Modified: {} -> {}\n", before, after));
96                    }
97                }
98            }
99        }
100
101        if self.predicate_diffs.is_empty() {
102            out.push_str("Predicates: no changes\n");
103        } else {
104            out.push_str("Predicates:\n");
105            for entry in &self.predicate_diffs {
106                match entry {
107                    DiffEntry::Added(n) => {
108                        out.push_str(&format!("  + Added: {}\n", n));
109                    }
110                    DiffEntry::Removed(n) => {
111                        out.push_str(&format!("  - Removed: {}\n", n));
112                    }
113                    DiffEntry::Modified { before, after } => {
114                        out.push_str(&format!("  ~ Modified: {} -> {}\n", before, after));
115                    }
116                }
117            }
118        }
119
120        out
121    }
122
123    /// Short one-line summary.
124    pub fn summary(&self) -> String {
125        let added = self.domain_diffs.iter().filter(|e| e.is_addition()).count()
126            + self
127                .predicate_diffs
128                .iter()
129                .filter(|e| e.is_addition())
130                .count();
131        let removed = self.domain_diffs.iter().filter(|e| e.is_removal()).count()
132            + self
133                .predicate_diffs
134                .iter()
135                .filter(|e| e.is_removal())
136                .count();
137        let modified = self
138            .domain_diffs
139            .iter()
140            .filter(|e| e.is_modification())
141            .count()
142            + self
143                .predicate_diffs
144                .iter()
145                .filter(|e| e.is_modification())
146                .count();
147        format!(
148            "OntologyDiff: {} added, {} removed, {} modified ({} total)",
149            added,
150            removed,
151            modified,
152            self.total_changes()
153        )
154    }
155}
156
157// ── compare_symbol_tables ─────────────────────────────────────────────────────
158
159/// Compare two [`SymbolTable`]s and return an [`OntologyDiff`] describing the
160/// differences.
161///
162/// # Domain comparison
163///
164/// - A domain present in `b` but absent from `a` → [`DiffEntry::Added`].
165/// - A domain present in `a` but absent from `b` → [`DiffEntry::Removed`].
166/// - A domain present in both: if its `cardinality` changed → [`DiffEntry::Modified`].
167///
168/// # Predicate comparison
169///
170/// - A predicate present in `b` but absent from `a` → [`DiffEntry::Added`].
171/// - A predicate present in `a` but absent from `b` → [`DiffEntry::Removed`].
172/// - A predicate present in both: if its `arity` or `arg_domains` changed
173///   → [`DiffEntry::Modified`].
174pub fn compare_symbol_tables(a: &SymbolTable, b: &SymbolTable) -> OntologyDiff {
175    let mut diff = OntologyDiff::new();
176
177    // ── Domains ───────────────────────────────────────────────────────────────
178    for (name, b_info) in &b.domains {
179        match a.domains.get(name) {
180            None => {
181                diff.domain_diffs.push(DiffEntry::Added(name.clone()));
182            }
183            Some(a_info) => {
184                if a_info.cardinality != b_info.cardinality {
185                    diff.domain_diffs.push(DiffEntry::Modified {
186                        before: format!("{}(cardinality={})", name, a_info.cardinality),
187                        after: format!("{}(cardinality={})", name, b_info.cardinality),
188                    });
189                }
190            }
191        }
192    }
193    for name in a.domains.keys() {
194        if !b.domains.contains_key(name) {
195            diff.domain_diffs.push(DiffEntry::Removed(name.clone()));
196        }
197    }
198
199    // ── Predicates ────────────────────────────────────────────────────────────
200    for (name, b_pred) in &b.predicates {
201        match a.predicates.get(name) {
202            None => {
203                diff.predicate_diffs.push(DiffEntry::Added(name.clone()));
204            }
205            Some(a_pred) => {
206                let arity_changed = a_pred.arity != b_pred.arity;
207                let domains_changed = a_pred.arg_domains != b_pred.arg_domains;
208                if arity_changed || domains_changed {
209                    diff.predicate_diffs.push(DiffEntry::Modified {
210                        before: format!(
211                            "{}(arity={}, domains=[{}])",
212                            name,
213                            a_pred.arity,
214                            a_pred.arg_domains.join(", ")
215                        ),
216                        after: format!(
217                            "{}(arity={}, domains=[{}])",
218                            name,
219                            b_pred.arity,
220                            b_pred.arg_domains.join(", ")
221                        ),
222                    });
223                }
224            }
225        }
226    }
227    for name in a.predicates.keys() {
228        if !b.predicates.contains_key(name) {
229            diff.predicate_diffs.push(DiffEntry::Removed(name.clone()));
230        }
231    }
232
233    diff
234}
235
236// ── tests ─────────────────────────────────────────────────────────────────────
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use tensorlogic_adapters::{DomainInfo, PredicateInfo};
242
243    fn make_table_with_domain(name: &str) -> SymbolTable {
244        let mut t = SymbolTable::new();
245        t.add_domain(DomainInfo::new(name, 10))
246            .expect("add_domain should succeed");
247        t
248    }
249
250    fn make_table_with_predicate(domain: &str, pred: &str) -> SymbolTable {
251        let mut t = SymbolTable::new();
252        t.add_domain(DomainInfo::new(domain, 10))
253            .expect("add_domain should succeed");
254        t.add_predicate(PredicateInfo::new(pred, vec![domain.to_string()]))
255            .expect("add_predicate should succeed");
256        t
257    }
258
259    #[test]
260    fn test_diff_identical_tables() {
261        let a = SymbolTable::new();
262        let b = SymbolTable::new();
263        let diff = compare_symbol_tables(&a, &b);
264        assert!(diff.is_empty());
265    }
266
267    #[test]
268    fn test_diff_added_domain() {
269        let a = SymbolTable::new();
270        let b = make_table_with_domain("Person");
271        let diff = compare_symbol_tables(&a, &b);
272        assert_eq!(diff.domain_diffs.len(), 1);
273        assert!(diff.domain_diffs[0].is_addition());
274        assert_eq!(diff.domain_diffs[0].name(), "Person");
275    }
276
277    #[test]
278    fn test_diff_removed_domain() {
279        let a = make_table_with_domain("Animal");
280        let b = SymbolTable::new();
281        let diff = compare_symbol_tables(&a, &b);
282        assert_eq!(diff.domain_diffs.len(), 1);
283        assert!(diff.domain_diffs[0].is_removal());
284        assert_eq!(diff.domain_diffs[0].name(), "Animal");
285    }
286
287    #[test]
288    fn test_diff_added_predicate() {
289        let a = make_table_with_domain("Person");
290        let b = make_table_with_predicate("Person", "knows");
291        let diff = compare_symbol_tables(&a, &b);
292        assert_eq!(diff.predicate_diffs.len(), 1);
293        assert!(diff.predicate_diffs[0].is_addition());
294        assert_eq!(diff.predicate_diffs[0].name(), "knows");
295    }
296
297    #[test]
298    fn test_diff_removed_predicate() {
299        let a = make_table_with_predicate("Person", "knows");
300        let b = make_table_with_domain("Person");
301        let diff = compare_symbol_tables(&a, &b);
302        assert_eq!(diff.predicate_diffs.len(), 1);
303        assert!(diff.predicate_diffs[0].is_removal());
304        assert_eq!(diff.predicate_diffs[0].name(), "knows");
305    }
306
307    #[test]
308    fn test_diff_is_empty_on_empty() {
309        assert!(OntologyDiff::new().is_empty());
310    }
311
312    #[test]
313    fn test_diff_total_changes() {
314        let mut diff = OntologyDiff::new();
315        diff.domain_diffs.push(DiffEntry::Added("A".to_string()));
316        diff.domain_diffs.push(DiffEntry::Added("B".to_string()));
317        diff.predicate_diffs
318            .push(DiffEntry::Removed("p".to_string()));
319        assert_eq!(diff.total_changes(), 3);
320    }
321
322    #[test]
323    fn test_diff_report_nonempty() {
324        let mut a = SymbolTable::new();
325        a.add_domain(DomainInfo::new("OldDomain", 5))
326            .expect("add_domain should succeed");
327        let mut b = SymbolTable::new();
328        b.add_domain(DomainInfo::new("NewDomain", 5))
329            .expect("add_domain should succeed");
330        let diff = compare_symbol_tables(&a, &b);
331        let report = diff.report();
332        assert!(report.contains("Added") || report.contains("Removed"));
333    }
334
335    #[test]
336    fn test_diff_summary_format() {
337        let diff = OntologyDiff::new();
338        let summary = diff.summary();
339        assert!(summary.starts_with("OntologyDiff:"));
340    }
341
342    #[test]
343    fn test_diff_entry_helpers() {
344        let added = DiffEntry::Added("x".to_string());
345        assert!(added.is_addition());
346        assert!(!added.is_removal());
347        assert!(!added.is_modification());
348
349        let removed = DiffEntry::Removed("y".to_string());
350        assert!(!removed.is_addition());
351        assert!(removed.is_removal());
352        assert!(!removed.is_modification());
353
354        let modified = DiffEntry::Modified {
355            before: "z(arity=1)".to_string(),
356            after: "z(arity=2)".to_string(),
357        };
358        assert!(!modified.is_addition());
359        assert!(!modified.is_removal());
360        assert!(modified.is_modification());
361    }
362}