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}