omnist/ops/extract.rs
1//! Subschema extraction (paper Algorithm 5, ExtractSubschema). Ported from
2//! `~/dev/omnist/omnist/ops/extract.py`.
3//!
4//! Given a schema and a set of *permissible labels* `keep` (the paper's
5//! `X'`), produces the minimal subschema that recognizes only documents
6//! built from those labels.
7//!
8//! Algorithm:
9//!
10//! 1. For every record in the env, delete any field whose label is not in
11//! `keep`.
12//! 2. If a deleted field had `min >= 1` (mandatory), that record is
13//! *invalidated* -- the paper's "state removed": there is no way to
14//! build a document at that record's shape without a label that's no
15//! longer available.
16//! 3. **Propagate.** A record with a *mandatory* field whose type is an
17//! invalidated record is itself invalidated, and so on transitively -- a
18//! least-fixpoint closure, same shape as `super::prune`'s satisfiability
19//! fixpoint.
20//! 4. If the root ends up invalidated, there is no valid subschema for this
21//! `keep` set at all: [`extract`] returns a [`SchemaError`] naming the
22//! first offending label and record.
23//! 5. Otherwise, invalidated records (and fields typed to them, along with
24//! any fields already dropped in step 1) are gone; the result is run
25//! through [`super::prune::prune`] and [`super::minimize::normalize`]
26//! (Algorithm 5's own final MakeUseful + Minimize step).
27//!
28//! **Design decision: mandatory deletion is an error, not silently-optional**
29//! -- matching the Python reference. Silently loosening a deleted mandatory
30//! field to optional would mean the result no longer reflects Algorithm 5's
31//! semantics, and would more often hide a mistake in the caller's `keep` set
32//! than express an intentional relaxation.
33
34use indexmap::{IndexMap, IndexSet};
35
36use crate::error::SchemaError;
37use crate::schema::{FieldType, Record, Ref, Schema};
38
39use super::minimize::normalize;
40use super::prune::prune;
41
42/// The minimal subschema of `s` that only recognizes documents built from
43/// labels in `keep`. Returns a [`SchemaError`] if deleting the other labels
44/// would invalidate the root record (see the module doc comment).
45///
46/// Takes a concrete `&[&str]` rather than a generic `IntoIterator` on
47/// purpose: a generic parameter here would monomorphize a separate copy of
48/// `extract` per distinct caller argument type (`Vec<String>`, an array
49/// literal, an empty slice, ...), and `cargo llvm-cov` counts per-
50/// instantiation coverage separately -- so a fully-tested generic version
51/// could still report less than 100% simply because not every
52/// instantiation was independently exercised, without any real gap in
53/// behavior coverage. A single concrete signature sidesteps that entirely.
54pub fn extract(s: &Schema, keep: &[&str]) -> Result<Schema, SchemaError> {
55 let keep_set: IndexSet<String> = keep.iter().map(|s| (*s).to_string()).collect();
56
57 // Step 1+2: per-record field deletion, tracking which records are
58 // directly invalidated by the loss of a mandatory field, and the first
59 // offending (label, record) pair for the error message.
60 let mut trimmed: IndexMap<String, Record> = IndexMap::new();
61 let mut invalidated: IndexSet<String> = IndexSet::new();
62 let mut first_offender: Option<(String, String)> = None;
63
64 for (name, rec) in s.env() {
65 let mut kept_fields = Vec::new();
66 for f in rec.fields() {
67 if keep_set.contains(&f.label) {
68 kept_fields.push(f.clone());
69 } else if f.min >= 1 {
70 if first_offender.is_none() {
71 first_offender = Some((f.label.clone(), name.clone()));
72 }
73 invalidated.insert(name.clone());
74 }
75 }
76 trimmed.insert(
77 name.clone(),
78 Record::new(kept_fields).expect(
79 "dropping fields from an already-valid Record cannot introduce a duplicate label",
80 ),
81 );
82 }
83
84 // Step 3: propagate invalidation -- a record with a mandatory field
85 // typed to an invalidated record is itself invalidated. Least fixpoint,
86 // same shape as prune's satisfiable_set.
87 let mut changed = true;
88 while changed {
89 changed = false;
90 for (name, rec) in &trimmed {
91 if invalidated.contains(name) {
92 continue;
93 }
94 for f in rec.fields() {
95 if f.min >= 1
96 && let FieldType::Ref(r) = &f.ty
97 && invalidated.contains(&r.name)
98 {
99 invalidated.insert(name.clone());
100 changed = true;
101 break;
102 }
103 }
104 }
105 }
106
107 // Step 4: root invalidated -> no valid subschema.
108 if invalidated.contains(&s.root().name) {
109 let (label, record_name) = first_offender.expect(
110 "root invalidated implies step 1 recorded an offender before propagation began",
111 );
112 return Err(SchemaError::new(
113 format!("{record_name}.{label}"),
114 "algebra.extract-invalidates-root",
115 format!(
116 "no valid subschema: removing label {label:?} deletes a mandatory field of record {record_name:?}"
117 ),
118 ));
119 }
120
121 // Step 5: drop invalidated records and any fields (mandatory or not)
122 // that still point at one.
123 let mut new_env: IndexMap<String, Record> = IndexMap::new();
124 for (name, rec) in &trimmed {
125 if invalidated.contains(name) {
126 continue;
127 }
128 let fields: Vec<_> = rec
129 .fields()
130 .iter()
131 .filter(|f| !matches!(&f.ty, FieldType::Ref(r) if invalidated.contains(&r.name)))
132 .cloned()
133 .collect();
134 new_env.insert(
135 name.clone(),
136 Record::new(fields).expect(
137 "dropping fields typed to an already-invalidated record cannot introduce a duplicate label",
138 ),
139 );
140 }
141
142 let result = Schema::new(Ref::new(s.root().name.clone()), new_env).expect(
143 "dropping only invalidated records/fields leaves every surviving Ref resolvable within \
144 the surviving env",
145 );
146 Ok(normalize(&prune(&result)))
147}