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        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}