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