Skip to main content

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}