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}