Skip to main content

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        // Schema path E-13: a record-level diagnostic is `RecordName`; the
113        // dropped label is named in the message.
114        return Err(SchemaError::new(
115            record_name.clone(),
116            "algebra.extract-invalidates-root",
117            format!(
118                "no valid subschema: removing label {label:?} deletes a mandatory field of record {record_name:?}"
119            ),
120        ));
121    }
122
123    // Step 5: drop invalidated records and any fields (mandatory or not)
124    // that still point at one.
125    let mut new_env: IndexMap<String, Record> = IndexMap::new();
126    for (name, rec) in &trimmed {
127        if invalidated.contains(name) {
128            continue;
129        }
130        let fields: Vec<_> = rec
131            .fields()
132            .iter()
133            .filter(|f| !matches!(&f.ty, FieldType::Ref(r) if invalidated.contains(&r.name)))
134            .cloned()
135            .collect();
136        new_env.insert(
137            name.clone(),
138            Record::new(fields).expect(
139                "dropping fields typed to an already-invalidated record cannot introduce a duplicate label",
140            ),
141        );
142    }
143
144    let result = Schema::new(Ref::new(s.root().name.clone()), new_env).expect(
145        "dropping only invalidated records/fields leaves every surviving Ref resolvable within \
146         the surviving env",
147    );
148    Ok(normalize(&prune(&result)))
149}