Skip to main content

step_io/reader/
mod.rs

1//! [`read`] — STEP source to [`StepModel`] plus a provenance [`Report`].
2//!
3//! Reading runs in three steps. [`read`] first calls into
4//! [`parser`](crate::parser) to parse the source, and finally into the
5//! generated readers to turn the entities into the typed model. The step
6//! in between lives here:
7//! non-standard input is healed in place where a safe rewrite exists, and
8//! dropped with a [`DropReason`] where none does. The [`Report`] accounts
9//! for everything that came in — kept, normalized, or dropped and why.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use crate::generated::model::StepModel;
14use crate::generated::read::{RefSlot, complex_ref_slots, in_subset, read as gen_read, ref_slots};
15use crate::parser::{Attribute, Error, RawEntity, parse_bytes};
16
17mod entity_normalize;
18
19/// Why an entity was dropped before it could enter the model.
20/// `NonstandardValue` = one of the entity's own attribute *values* is nonstandard
21/// and could not be normalized (a required reference is missing, an integer field
22/// holds a fractional value). `NonstandardReference` = one of its *reference*
23/// slots points at a target the schema does not admit. `Unimplemented` = the
24/// entity type is outside the generated closure (not yet modeled, or an
25/// unknown/non-schema name). `Cascade` = a referrer whose target was
26/// dropped/dangling. `Unclassified` = the generated read could not consume the
27/// entity's own attributes (off-kind scalar, bad enum token, short arity, …) and
28/// no policy layer (normalize / `drop_pass`) classified it — a frontier signal:
29/// investigate and absorb via normalize or `nonstd_ref`.
30#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
31pub enum DropKind {
32    NonstandardValue,
33    Unimplemented,
34    NonstandardReference,
35    Cascade,
36    Unclassified,
37}
38
39/// A drop reason: kind + a human label (`<TYPE>` / `<ENT>: '<slot>' references
40/// <TYPE>` / a slot-rule name / `via-<root>`). Aggregated as instance counts per
41/// reason.
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
43pub struct DropReason {
44    pub kind: DropKind,
45    pub key: String,
46}
47
48/// Input-to-model provenance. Every input entity plus every synthetic entity
49/// added by normalization is either kept (`validated`) or dropped with a reason
50/// (`drops`): `validated + sum(drops) == n_in + n_synth`. `norm` = rewrite (fix)
51/// notes from the pre-read normalize pass. The source header (including the
52/// identified schema) lives on the model: [`StepModel::header`].
53#[derive(Clone, Debug, Default)]
54pub struct Report {
55    /// Input entity count (data section, before normalization).
56    pub n_in: usize,
57    /// Synthetic entities added by per-entity normalization (add-only).
58    pub n_synth: usize,
59    /// Kept entity count (entities that entered the model).
60    pub validated: usize,
61    /// Dropped entities, per-entity: input id + reason (nonstandard-value +
62    /// unimplemented + cascade + nonstandard-reference). `dropped.len()` is the
63    /// total drop count.
64    pub dropped: Vec<(u64, DropReason)>,
65    /// Non-standard rewrite notes (kept entities, fixed in place).
66    pub norm: Vec<&'static str>,
67}
68
69/// Collect every entity id referenced (transitively) by an attribute.
70fn collect_refs(a: &Attribute, out: &mut Vec<u64>) {
71    match a {
72        Attribute::EntityRef(n) => out.push(*n),
73        Attribute::List(l) => l.iter().for_each(|e| collect_refs(e, out)),
74        Attribute::Typed { value, .. } => collect_refs(value, out),
75        _ => {}
76    }
77}
78
79fn entity_refs(ent: &RawEntity) -> Vec<u64> {
80    let mut out = Vec::new();
81    match ent {
82        RawEntity::Simple { attributes, .. } => {
83            for a in attributes {
84                collect_refs(a, &mut out);
85            }
86        }
87        RawEntity::Complex { parts, .. } => {
88            for p in parts {
89                for a in &p.attributes {
90                    collect_refs(a, &mut out);
91                }
92            }
93        }
94    }
95    out
96}
97
98fn ent_name(e: &RawEntity) -> String {
99    match e {
100        RawEntity::Simple { name, .. } => name.clone(),
101        RawEntity::Complex { parts, .. } => {
102            let mut n: Vec<&str> = parts.iter().map(|p| p.name.as_str()).collect();
103            n.sort_unstable();
104            format!("({})", n.join("+"))
105        }
106    }
107}
108
109/// Slot-aware nonstandard-ref check: does any ref slot of `ent` point at a kept,
110/// in-closure target whose type the slot's SELECT does not admit? Returns the
111/// first `<ENT>: '<slot>' references <TYPE>` label. Out-of-closure / dangling
112/// targets are NOT flagged here — those are cascade/unimplemented.
113fn nonstd_ref(ent: &RawEntity, graph: &BTreeMap<u64, RawEntity>) -> Option<String> {
114    match ent {
115        RawEntity::Simple {
116            name, attributes, ..
117        } => check_ref_slots(name, attributes, ref_slots(name), graph),
118        RawEntity::Complex { parts, .. } => parts.iter().find_map(|p| {
119            check_ref_slots(&p.name, &p.attributes, complex_ref_slots(&p.name), graph)
120        }),
121    }
122}
123
124fn check_ref_slots(
125    ename: &str,
126    attrs: &[Attribute],
127    slots: &[RefSlot],
128    graph: &BTreeMap<u64, RawEntity>,
129) -> Option<String> {
130    for rs in slots {
131        let Some(a) = attrs.get(rs.idx) else { continue };
132        let mut targets: Vec<u64> = Vec::new();
133        collect_refs(a, &mut targets);
134        for r in targets {
135            let Some(target) = graph.get(&r) else {
136                continue;
137            };
138            if !in_subset(target) {
139                continue; // out-of-closure: cascade / unimplemented handles it
140            }
141            let admitted = match target {
142                RawEntity::Complex { .. } => rs.complex_ok,
143                RawEntity::Simple { name, .. } => rs.allowed.contains(&name.as_str()),
144            };
145            if !admitted {
146                return Some(format!(
147                    "{ename}: '{}' references {}",
148                    rs.name,
149                    ent_name(target)
150                ));
151            }
152        }
153    }
154    None
155}
156
157/// Filter a normalized graph to the closure subset, recording WHY each entity is
158/// dropped. An entity survives iff it is a closure type, all its transitive refs
159/// stay kept, and no ref slot holds a type the slot does not admit (fixpoint).
160///
161/// `known_bad` = entities the generated read already failed on (own-attr); they
162/// are pre-dropped roots here so their referrers cascade. Their drop reason is
163/// owned by the caller's read-failure accumulator, so they are NOT re-recorded
164/// in `dropped` (avoids double-counting in the provenance accounting).
165fn drop_pass(
166    graph: &BTreeMap<u64, RawEntity>,
167    known_bad: &BTreeSet<u64>,
168) -> (BTreeSet<u64>, Vec<(u64, DropReason)>) {
169    let mut dropped: Vec<(u64, DropReason)> = Vec::new();
170    let mut keep: BTreeSet<u64> = BTreeSet::new();
171    let mut root_of: BTreeMap<u64, String> = BTreeMap::new();
172    for (&id, e) in graph {
173        if known_bad.contains(&id) {
174            // Dropped by read (reason owned by read_fail); seed root_of so its
175            // referrers get a cascade label, but do not re-record the drop.
176            root_of.insert(id, "unclassified".to_string());
177        } else if in_subset(e) {
178            keep.insert(id);
179        } else {
180            let n = ent_name(e);
181            root_of.insert(id, n.clone());
182            dropped.push((
183                id,
184                DropReason {
185                    kind: DropKind::Unimplemented,
186                    key: n,
187                },
188            ));
189        }
190    }
191    loop {
192        let mut removed = false;
193        let snapshot: Vec<u64> = keep.iter().copied().collect();
194        for id in snapshot {
195            let ent = &graph[&id];
196            let mut cascaded = false;
197            for r in entity_refs(ent) {
198                if !keep.contains(&r) {
199                    keep.remove(&id);
200                    let root = root_of
201                        .get(&r)
202                        .cloned()
203                        .unwrap_or_else(|| "dangling".to_string());
204                    dropped.push((
205                        id,
206                        DropReason {
207                            kind: DropKind::Cascade,
208                            key: format!("via-{root}"),
209                        },
210                    ));
211                    root_of.insert(id, root);
212                    removed = true;
213                    cascaded = true;
214                    break;
215                }
216            }
217            if cascaded {
218                continue;
219            }
220            if let Some(reason) = nonstd_ref(ent, graph) {
221                keep.remove(&id);
222                root_of.insert(id, reason.clone());
223                dropped.push((
224                    id,
225                    DropReason {
226                        kind: DropKind::NonstandardReference,
227                        key: reason,
228                    },
229                ));
230                removed = true;
231            }
232        }
233        if !removed {
234            break;
235        }
236    }
237    // Return the kept-id set (not a cloned entity map): the reader borrows the
238    // entities from the full graph, so no RawEntity is cloned here.
239    (keep, dropped)
240}
241
242/// Full pre-read normalization: `entity_normalize` (per-entity, add-only synthetic
243/// fixups) then `generic_normalize` (generic slot-kind rules). Returns the map,
244/// the rewrite notes, the nonstandard-value drop reasons, and the synthetic-add count
245/// (`entity_normalize` never removes, so the count delta is exactly the adds).
246#[allow(clippy::type_complexity)]
247fn normalize_all(
248    raw: BTreeMap<u64, RawEntity>,
249) -> (
250    BTreeMap<u64, RawEntity>,
251    Vec<&'static str>,
252    Vec<(u64, &'static str)>,
253    usize,
254) {
255    let mut raw = raw;
256    let mut norm: Vec<&'static str> = Vec::new();
257    let before = raw.len();
258    entity_normalize::apply(&mut raw, &mut norm);
259    let n_synth = raw.len() - before;
260    let (normalized, gnorm, slot_drops) = crate::generated::generic_normalize::normalize(raw);
261    norm.extend(gnorm);
262    (normalized, norm, slot_drops, n_synth)
263}
264
265/// Read STEP source into the schema-faithful model, returning a provenance
266/// `Report`.
267///
268/// The generated read is per-entity fallible: an entity whose own attributes the
269/// strict reader cannot consume is dropped (reason recorded), never panicking.
270/// A read failure pre-drops that entity in the next `drop_pass`, so its referrers
271/// cascade through the same engine. The loop is bounded and converges in ≤2 real
272/// iterations (own-attr readability is graph-independent, so all failures surface
273/// in the first build; the second `drop_pass` cascades them to a clean set).
274///
275/// # Errors
276/// Returns a [`Error`] only if the source does not parse. A parsed source
277/// never fails the read: a malformed entity is dropped with a reason in
278/// [`Report::dropped`] (the generated read is structurally panic-free).
279pub fn read(src: &[u8]) -> Result<(StepModel, Report), Error> {
280    /// ≤2 real iterations; one extra for slack (a violation degrades to dropping,
281    /// not hanging — `debug_assert` flags non-convergence in dev).
282    const MAX_ITERS: usize = 3;
283
284    let g = parse_bytes(src)?;
285    let n_in = g.entities.len();
286    let header = crate::header::from_raw(&g.header, g.schema);
287    let raw: BTreeMap<u64, RawEntity> = g.entities;
288    let (normalized, norm, slot_drops, n_synth) = normalize_all(raw);
289
290    // Outer cascade fixpoint: drop_pass (graph/ref validity) -> gen_read (own-attr
291    // validity). read failures feed `known_bad` so the next drop_pass cascades
292    // their referrers. `drop_pass` always seeds from the full `normalized` graph
293    // (never `kept`) so the kept set is monotonically shrinking — this guarantees
294    // termination; seeding from `kept` would break it.
295    let mut known_bad: BTreeSet<u64> = BTreeSet::new();
296    let mut read_fail: BTreeMap<u64, String> = BTreeMap::new();
297    let mut model = StepModel::default();
298    let mut dropped: Vec<(u64, DropReason)> = Vec::new();
299    let mut validated = 0usize;
300    let mut converged = false;
301    for _ in 0..MAX_ITERS {
302        let (kept, kept_dropped) = drop_pass(&normalized, &known_bad);
303        // No catch_unwind belt: the generated read is structurally panic-free
304        // (codegen emits no panic!/expect/unwrap/raw indexing — gated by grep), so
305        // it returns per-entity drops instead of unwinding.
306        let (m, _idmap, read_drops) = gen_read(&normalized, &kept);
307        model = m;
308        dropped = kept_dropped;
309        validated = kept.len();
310        if read_drops.is_empty() {
311            converged = true;
312            break;
313        }
314        for (id, r) in read_drops {
315            read_fail.entry(id).or_insert(r);
316            known_bad.insert(id);
317        }
318    }
319    debug_assert!(
320        converged,
321        "fallible read did not converge in {MAX_ITERS} iters"
322    );
323
324    // Finalize provenance: cascade drops (this iter) + nonstandard-value (normalize) +
325    // accumulated read failures (Unclassified). Each id appears exactly once —
326    // `drop_pass` excludes `known_bad` from its `dropped`, and `read_fail` keys ==
327    // `known_bad`, so the three sets are disjoint (accounting invariant holds).
328    for (id, r) in slot_drops {
329        dropped.push((
330            id,
331            DropReason {
332                kind: DropKind::NonstandardValue,
333                key: r.to_string(),
334            },
335        ));
336    }
337    for (id, r) in read_fail {
338        dropped.push((
339            id,
340            DropReason {
341                kind: DropKind::Unclassified,
342                key: r,
343            },
344        ));
345    }
346    model.header = header;
347    Ok((
348        model,
349        Report {
350            n_in,
351            n_synth,
352            validated,
353            dropped,
354            norm,
355        },
356    ))
357}