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}