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