omnist/ops/minimize.rs
1//! Schema minimization: partition-refinement to the canonical minimal form.
2//! Ported from `~/dev/omnist/omnist/ops/minimize.py`.
3//!
4//! `normalize(s)` returns an equivalent schema with the *fewest possible*
5//! env records, unique up to record naming (paper Theorems 3-4).
6//!
7//! Algorithm:
8//!
9//! 1. `s = prune(s)` -- mandatory first step. Two semantically-equal records
10//! must not be kept apart by never-emittable fields or unreachable
11//! records; pruning first is what makes the partition canonical.
12//! 2. **Initial partition**: env records grouped by `local_signature` -- a
13//! target-blind structural key, so records that might turn out
14//! equivalent via differently-named ref targets still start in the same
15//! block.
16//! 3. **Refine**: split any block whose members disagree, for some label,
17//! on which *block* their same-labeled ref-typed field points to. Repeat
18//! until no block splits (a fixpoint -- always reached on a finite env).
19//! 4. **Merge**: collapse each stable block to a single representative --
20//! its lexicographically smallest member name (deterministic) -- and
21//! remap every ref and the root to representatives.
22//!
23//! Special case: an unsatisfiable (empty-language) root. `prune` deliberately
24//! leaves such a root's fields untouched (see its own doc comment), so
25//! partition refinement over the unsatisfiable core isn't meaningful --
26//! `normalize` just returns the pruned schema unchanged in that case.
27
28use std::hash::Hash;
29
30use indexmap::IndexMap;
31
32use crate::schema::{Field, FieldType, Record, Ref, Schema};
33
34use super::prune::{is_empty, prune};
35use super::signature::{LocalSignature, local_signature};
36
37/// Partitions `s.env`'s record names into structural-equivalence classes via
38/// MinimizeSA-style partition refinement (module doc comment, steps 2-3):
39/// an initial `local_signature` grouping refined to a fixpoint by which
40/// *block* each same-labeled ref field points to.
41///
42/// Operates on `s.env` exactly as given -- it does **not** prune first, so
43/// unreachable or unsatisfiable records are still classified. [`normalize`]
44/// calls this after its own prune/is_empty steps; `lint` calls it on the raw
45/// schema so duplicates are reported as authored. Each returned block is a
46/// list of names; a block of length > 1 is a set of records with identical
47/// structure.
48pub fn equivalence_classes(s: &Schema) -> Vec<Vec<String>> {
49 let mut names: Vec<String> = s.env().keys().cloned().collect();
50 names.sort();
51
52 let mut blocks: Vec<Vec<String>> = group_by(&names, |n| {
53 local_signature(s.env().get(n).expect("n comes from s.env's own keys"))
54 });
55 let mut block_of: IndexMap<String, usize> = IndexMap::new();
56 for (i, block) in blocks.iter().enumerate() {
57 for n in block {
58 block_of.insert(n.clone(), i);
59 }
60 }
61
62 loop {
63 let mut new_blocks: Vec<Vec<String>> = Vec::new();
64 let mut new_block_of: IndexMap<String, usize> = IndexMap::new();
65 for block in &blocks {
66 let subs = group_by(block, |n| {
67 refine_key(
68 s.env().get(n).expect("n comes from s.env's own keys"),
69 &block_of,
70 )
71 });
72 for sub in subs {
73 let idx = new_blocks.len();
74 for n in &sub {
75 new_block_of.insert(n.clone(), idx);
76 }
77 new_blocks.push(sub);
78 }
79 }
80 let changed = new_blocks.len() != blocks.len();
81 blocks = new_blocks;
82 block_of = new_block_of;
83 if !changed {
84 return blocks;
85 }
86 }
87}
88
89/// The canonical minimal schema equivalent to `s`: fewest env records,
90/// unique up to record naming. See the module doc comment for the algorithm
91/// (paper's Algorithm 2, MinimizeSA).
92pub fn normalize(s: &Schema) -> Schema {
93 let pruned = prune(s);
94 if is_empty(&pruned) {
95 return pruned;
96 }
97
98 let mut names: Vec<String> = pruned.env().keys().cloned().collect();
99 names.sort();
100 let blocks = equivalence_classes(&pruned);
101
102 let mut rep: IndexMap<String, String> = IndexMap::new();
103 for block in &blocks {
104 let keep = block
105 .iter()
106 .min()
107 .expect("equivalence_classes never returns an empty block")
108 .clone();
109 for n in block {
110 rep.insert(n.clone(), keep.clone());
111 }
112 }
113
114 let mut new_env: IndexMap<String, Record> = IndexMap::new();
115 for name in &names {
116 if rep.get(name) == Some(name) {
117 new_env.insert(
118 name.clone(),
119 remap(
120 pruned
121 .env()
122 .get(name)
123 .expect("name comes from pruned.env's own keys"),
124 &rep,
125 ),
126 );
127 }
128 }
129 // `rep` is built from `equivalence_classes(&pruned)`, which partitions
130 // *every* name in `pruned.env()` into exactly one block -- and
131 // `pruned.root().name` is always a key of `pruned.env()` (Schema's own
132 // invariant: the root always resolves). So `rep` always has an entry
133 // for it; a fallback here would be dead code (see `lint::reachable`'s
134 // identical note on this pattern).
135 let new_root_name = rep
136 .get(&pruned.root().name)
137 .cloned()
138 .expect("every env record name, including the root's, is classified into rep");
139 Schema::new(Ref::new(new_root_name), new_env)
140 .expect("normalize only remaps refs to representative names that stay present in new_env")
141}
142
143fn group_by<K, F>(names: &[String], key_fn: F) -> Vec<Vec<String>>
144where
145 K: Eq + Hash,
146 F: Fn(&String) -> K,
147{
148 let mut groups: IndexMap<K, Vec<String>> = IndexMap::new();
149 for n in names {
150 groups.entry(key_fn(n)).or_default().push(n.clone());
151 }
152 groups.into_values().collect()
153}
154
155/// A record's refinement key: its target-blind local signature, plus -- for
156/// each field in label order -- the current block id of its ref target (or
157/// `None` for a scalar field). Two records land in the same refined block
158/// only if they agree on both.
159type RefineKey = (
160 LocalSignature,
161 Vec<(String, usize, Option<usize>, Option<usize>)>,
162);
163
164fn refine_key(rec: &Record, block_of: &IndexMap<String, usize>) -> RefineKey {
165 let mut fields: Vec<(String, usize, Option<usize>, Option<usize>)> = rec
166 .fields()
167 .iter()
168 .map(|f| {
169 let blk = match &f.ty {
170 FieldType::Ref(r) => Some(
171 *block_of
172 .get(&r.name)
173 .expect("every ref target is classified before refine_key runs on it"),
174 ),
175 FieldType::Scalar(_) | FieldType::Any => None,
176 };
177 (f.label.clone(), f.min, f.max, blk)
178 })
179 .collect();
180 fields.sort_by(|a, b| a.0.cmp(&b.0));
181 (local_signature(rec), fields)
182}
183
184fn remap(rec: &Record, rep: &IndexMap<String, String>) -> Record {
185 let fields: Vec<Field> = rec
186 .fields()
187 .iter()
188 .map(|f| {
189 let ty = match &f.ty {
190 // `remap` is only ever called on a record still present in
191 // `pruned.env()`, and every Ref-typed field on it targets
192 // another name in that same env (Schema's own invariant) --
193 // which `rep` classifies for every name. A fallback here
194 // would be dead code, same reasoning as `new_root_name`
195 // above.
196 FieldType::Ref(r) => FieldType::Ref(Ref::new(
197 rep.get(&r.name)
198 .cloned()
199 .expect("every ref target is classified into rep"),
200 )),
201 FieldType::Scalar(s) => FieldType::Scalar(*s),
202 FieldType::Any => FieldType::Any,
203 };
204 Field::new(f.label.clone(), ty, f.min, f.max)
205 .expect("remapping a ref target name changes neither label nor cardinality")
206 })
207 .collect();
208 Record::new(fields)
209 .expect("remap doesn't add/remove/rename fields, so it cannot introduce a duplicate label")
210}