omnist/ops/isomorphic.rs
1//! Schema isomorphism -- ported from `~/dev/omnist/omnist/ops/isomorphic.py`.
2//!
3//! Two schemas are equivalent iff their minimized (normalized) forms are
4//! isomorphic. That gives a second, algorithm-independent decision procedure
5//! for `equivalent`, structurally unrelated to bidirectional
6//! `subschema::compatible_with`, so the two can be cross-checked against
7//! each other in tests (the "dual-algorithm oracle" -- see `tests.rs`, the
8//! `minimize`/`isomorphic` triple-check strategy from the issue).
9//!
10//! [`is_isomorphic`] is deliberately not part of the crate's public surface
11//! commitment the way `subschema::equivalent` is -- it exists purely as an
12//! independent oracle for tests, matching the Python reference's choice to
13//! keep `_isomorphic` private.
14//!
15//! Algorithm: parallel traversal from both roots, building a bijection
16//! `name_a -> name_b` (and its inverse) between env record names as the
17//! traversal discovers pairs. At each visited record pair, `local_signature`
18//! must match; since it sorts fields by label and ref/scalar shape is part
19//! of the key, fields on the two sides line up one-to-one by label once the
20//! signatures agree. For each ref-typed field, the two targets are
21//! recursively required to be isomorphic, with the bijection enforced
22//! consistently in both directions.
23//!
24//! Both inputs are assumed already normalized (pruned + minimized) by the
25//! caller -- this module does not call `normalize` itself.
26
27use indexmap::IndexMap;
28
29use crate::schema::{FieldType, Schema};
30
31use super::prune::is_empty;
32use super::signature::local_signature;
33
34/// True iff normalized schemas `a` and `b` are isomorphic: there is a
35/// bijection between their env record names under which the two root
36/// records (and everything reachable from them) match exactly.
37///
38/// **Empty-schema convention.** If both `a` and `b` are unsatisfiable, they
39/// are treated as isomorphic (both accept the empty language). If exactly
40/// one is empty, they are *not* isomorphic.
41pub fn is_isomorphic(a: &Schema, b: &Schema) -> bool {
42 let (empty_a, empty_b) = (is_empty(a), is_empty(b));
43 if empty_a || empty_b {
44 return empty_a && empty_b;
45 }
46
47 let mut map_ab: IndexMap<String, String> = IndexMap::new();
48 let mut map_ba: IndexMap<String, String> = IndexMap::new();
49 walk(
50 a,
51 a.root().name.clone(),
52 b,
53 b.root().name.clone(),
54 &mut map_ab,
55 &mut map_ba,
56 )
57}
58
59fn walk(
60 a: &Schema,
61 na: String,
62 b: &Schema,
63 nb: String,
64 map_ab: &mut IndexMap<String, String>,
65 map_ba: &mut IndexMap<String, String>,
66) -> bool {
67 if map_ab.contains_key(&na) || map_ba.contains_key(&nb) {
68 // Already visited on at least one side: the bijection must agree
69 // both ways, or the schemas aren't isomorphic.
70 return map_ab.get(&na) == Some(&nb) && map_ba.get(&nb) == Some(&na);
71 }
72
73 map_ab.insert(na.clone(), nb.clone());
74 map_ba.insert(nb.clone(), na.clone());
75
76 let ra = a
77 .env()
78 .get(&na)
79 .expect("caller only walks names taken from a Schema's own root/Ref graph");
80 let rb = b
81 .env()
82 .get(&nb)
83 .expect("caller only walks names taken from a Schema's own root/Ref graph");
84 if local_signature(ra) != local_signature(rb) {
85 return false;
86 }
87
88 // local_signature sorts fields by label and includes the label in its
89 // key, so two records with equal signatures declare exactly the same
90 // set of labels -- fields on the two sides line up one-to-one by label.
91 for fa in ra.fields() {
92 let fb = rb
93 .field(&fa.label)
94 .expect("equal local_signature guarantees the same label set on both sides");
95 if let (FieldType::Ref(ra_ref), FieldType::Ref(rb_ref)) = (&fa.ty, &fb.ty)
96 && !walk(
97 a,
98 ra_ref.name.clone(),
99 b,
100 rb_ref.name.clone(),
101 map_ab,
102 map_ba,
103 )
104 {
105 return false;
106 }
107 }
108 true
109}