Skip to main content

cadmpeg_ir/
report.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Decode loss and validation findings.
3
4use std::collections::BTreeMap;
5use std::fmt;
6
7use crate::provenance::Provenance;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Severity of a loss note or validation finding.
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum Severity {
17    /// Informational; no action needed.
18    Info,
19    /// Non-fatal approximation or normalization.
20    Warning,
21    /// A correctness problem in the produced IR or export.
22    Error,
23    /// A hard stop: the requested operation cannot be completed faithfully.
24    Blocking,
25}
26
27impl fmt::Display for Severity {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(match self {
30            Self::Info => "info",
31            Self::Warning => "warning",
32            Self::Error => "error",
33            Self::Blocking => "blocking",
34        })
35    }
36}
37
38/// What subsystem a loss pertains to.
39#[derive(
40    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
41)]
42#[serde(rename_all = "snake_case")]
43pub enum LossCategory {
44    /// Geometry (surfaces/curves/points) not transferred or approximated.
45    Geometry,
46    /// Topology (graph structure) not transferred.
47    Topology,
48    /// Materials/appearances not transferred.
49    Material,
50    /// Document metadata not transferred.
51    Metadata,
52    /// Units/tolerances issues.
53    Units,
54    /// Attributes (names, colors, custom attribs) not transferred.
55    Attribute,
56    /// Features, sketches, parameters, configurations, or design history not transferred.
57    DesignIntent,
58    /// Anything else.
59    Other,
60}
61
62impl fmt::Display for LossCategory {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(match self {
65            Self::Geometry => "geometry",
66            Self::Topology => "topology",
67            Self::Material => "material",
68            Self::Metadata => "metadata",
69            Self::Units => "units",
70            Self::Attribute => "attribute",
71            Self::DesignIntent => "design_intent",
72            Self::Other => "other",
73        })
74    }
75}
76
77/// Strict-mode handling for a loss code.
78#[derive(
79    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
80)]
81#[serde(rename_all = "snake_case")]
82pub enum StrictConsequence {
83    /// Strict mode must refuse the operation.
84    Reject,
85    /// Strict mode may proceed.
86    Tolerate,
87}
88
89/// Stable machine-readable loss kinds.
90#[derive(
91    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
92)]
93#[serde(rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum LossCode {
96    /// Container-only decode was requested; entity decode was not attempted.
97    ContainerOnly,
98    /// No geometry stream was located in the container, so no B-rep could be
99    /// transferred.
100    MissingGeometryStream,
101    /// The B-rep topology graph was not transferred, though carriers or a
102    /// container were decoded.
103    TopologyNotTransferred,
104    /// B-rep geometry was not transferred, though carriers or a container were
105    /// decoded.
106    GeometryNotTransferred,
107    /// A reference graph decoded but did not close into a consistent
108    /// surface/pcurve/edge/vertex binding.
109    ReferenceGraphNotClosed,
110    /// Face sense, body kind, or a body/region/shell hierarchy was supplied by
111    /// a deterministic gauge because the source fields were unresolved.
112    TopologyGaugeSubstituted,
113    /// A carrier axis, plane, or orientation was inferred from adjacent
114    /// carriers rather than read from a source field.
115    CarrierAxisInferred,
116    /// Informational carrier or record census; no content was lost.
117    CarrierSummary,
118    /// Materials or appearances were not transferred.
119    MaterialNotTransferred,
120    /// Document, feature, or part metadata was not transferred.
121    MetadataNotTransferred,
122    /// Attributes (names, colors, custom attributes) were not transferred.
123    AttributesNotTransferred,
124    /// Named feature operations and their dependency tables were retained as
125    /// native passthrough rather than replayed.
126    FeatureHistoryRetained,
127    /// The part is an assembly; component geometry lives in external referenced
128    /// files, not inline.
129    AssemblyComponentsExternal,
130    /// A record was decoded but yielded no typed IR entity.
131    RecordNotTyped,
132    /// A decode-time diagnostic surfaced as a loss note; detail is in the
133    /// message.
134    DecodeDiagnostic,
135    /// Standalone mesh vertices were stored at reduced (f32) precision by the
136    /// source archive.
137    MeshVertexPrecision,
138    /// Some source object records were not transferred to typed IR.
139    ObjectRecordsUntransferred,
140    /// An object family or class is not supported and was not transferred.
141    UnsupportedObjectFamily,
142    /// A named source asset (geometry, material, or other) was not transferred.
143    AssetNotTransferred,
144    /// The IR contained no exportable solids, so the target representation is
145    /// empty.
146    NoExportableSolids,
147    /// Hidden bodies were omitted from the exported output.
148    HiddenBodyOmitted,
149    /// A body's non-identity transform was not applied; coordinates are written
150    /// in body-local space.
151    BodyTransformNotApplied,
152    /// Signed or self-intersecting analytic surfaces were normalized to the
153    /// target's positive-radius convention.
154    AnalyticSurfaceNormalized,
155    /// Elliptical cones were reduced to circular conical carriers.
156    EllipticalConeReduced,
157    /// Edges without a typed 3D curve were omitted from their edge loops.
158    CurvelessEdgeOmitted,
159    /// Faces resting on an unknown surface were omitted from the exported shell.
160    UnknownSurfaceFaceOmitted,
161    /// Parameter-space pcurves were not written; consumers recompute trims.
162    PcurveOmitted,
163    /// Subdivision surfaces were omitted because the writer does not encode
164    /// control cages.
165    SubdOmitted,
166    /// Tessellations were omitted because the writer emits exact geometry only.
167    TessellationOmitted,
168    /// Product-manufacturing-information annotations were not represented in the target.
169    PmiOmitted,
170    /// Source-object associations were not represented in the target.
171    SourceAssociationOmitted,
172    /// Uninterpreted passthrough records were not represented in the target.
173    PassthroughRecordOmitted,
174    /// Procedural surface or curve definitions were reduced to their solved
175    /// carriers.
176    ProceduralReduced,
177    /// Parametric design or history records were not represented in the target.
178    ParametricRecordOmitted,
179    /// Appearance assets were reduced to base colors; schemas, textures, and
180    /// shader properties were dropped.
181    AppearanceReduced,
182}
183
184impl LossCode {
185    /// The stable `snake_case` identifier, matching the serialized form.
186    pub fn as_str(self) -> &'static str {
187        match self {
188            Self::ContainerOnly => "container_only",
189            Self::MissingGeometryStream => "missing_geometry_stream",
190            Self::TopologyNotTransferred => "topology_not_transferred",
191            Self::GeometryNotTransferred => "geometry_not_transferred",
192            Self::ReferenceGraphNotClosed => "reference_graph_not_closed",
193            Self::TopologyGaugeSubstituted => "topology_gauge_substituted",
194            Self::CarrierAxisInferred => "carrier_axis_inferred",
195            Self::CarrierSummary => "carrier_summary",
196            Self::MaterialNotTransferred => "material_not_transferred",
197            Self::MetadataNotTransferred => "metadata_not_transferred",
198            Self::AttributesNotTransferred => "attributes_not_transferred",
199            Self::FeatureHistoryRetained => "feature_history_retained",
200            Self::AssemblyComponentsExternal => "assembly_components_external",
201            Self::RecordNotTyped => "record_not_typed",
202            Self::DecodeDiagnostic => "decode_diagnostic",
203            Self::MeshVertexPrecision => "mesh_vertex_precision",
204            Self::ObjectRecordsUntransferred => "object_records_untransferred",
205            Self::UnsupportedObjectFamily => "unsupported_object_family",
206            Self::AssetNotTransferred => "asset_not_transferred",
207            Self::NoExportableSolids => "no_exportable_solids",
208            Self::HiddenBodyOmitted => "hidden_body_omitted",
209            Self::BodyTransformNotApplied => "body_transform_not_applied",
210            Self::AnalyticSurfaceNormalized => "analytic_surface_normalized",
211            Self::EllipticalConeReduced => "elliptical_cone_reduced",
212            Self::CurvelessEdgeOmitted => "curveless_edge_omitted",
213            Self::UnknownSurfaceFaceOmitted => "unknown_surface_face_omitted",
214            Self::PcurveOmitted => "pcurve_omitted",
215            Self::SubdOmitted => "subd_omitted",
216            Self::TessellationOmitted => "tessellation_omitted",
217            Self::PmiOmitted => "pmi_omitted",
218            Self::SourceAssociationOmitted => "source_association_omitted",
219            Self::PassthroughRecordOmitted => "passthrough_record_omitted",
220            Self::ProceduralReduced => "procedural_reduced",
221            Self::ParametricRecordOmitted => "parametric_record_omitted",
222            Self::AppearanceReduced => "appearance_reduced",
223        }
224    }
225
226    /// Returns the strict-mode consequence of this code.
227    pub fn strict_consequence(self) -> StrictConsequence {
228        match self {
229            Self::MissingGeometryStream
230            | Self::TopologyNotTransferred
231            | Self::GeometryNotTransferred
232            | Self::ReferenceGraphNotClosed
233            | Self::CurvelessEdgeOmitted
234            | Self::UnknownSurfaceFaceOmitted
235            | Self::SubdOmitted
236            | Self::NoExportableSolids => StrictConsequence::Reject,
237            _ => StrictConsequence::Tolerate,
238        }
239    }
240}
241
242impl fmt::Display for LossCode {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(self.as_str())
245    }
246}
247
248/// One attributable instance of incomplete or approximate transfer.
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
250pub struct LossNote {
251    /// Stable machine-readable loss kind.
252    pub code: LossCode,
253    /// Affected subsystem.
254    pub category: LossCategory,
255    /// How serious the loss is.
256    pub severity: Severity,
257    /// Human-readable explanation.
258    pub message: String,
259    /// Where in the source the loss occurred, when attributable.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub provenance: Option<Provenance>,
262}
263
264/// Transfer status and loss details from a successful decode.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
266pub struct DecodeReport {
267    /// Source format id.
268    pub format: String,
269    /// Whether the decode stopped at the container layer (no entity decode).
270    pub container_only: bool,
271    /// Whether the decoder transferred B-rep geometry into the IR.
272    pub geometry_transferred: bool,
273    /// Decode coverage counts keyed by measure name.
274    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275    pub coverage: BTreeMap<String, usize>,
276    /// Explicit loss notes.
277    pub losses: Vec<LossNote>,
278    /// Free-form informational notes (e.g. container findings).
279    pub notes: Vec<String>,
280}
281
282impl DecodeReport {
283    /// Count loss notes at or above [`Severity::Error`].
284    pub fn error_count(&self) -> usize {
285        self.losses
286            .iter()
287            .filter(|l| l.severity >= Severity::Error)
288            .count()
289    }
290}
291
292/// Entity census and fidelity details from a successful export.
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
294pub struct ExportReport {
295    /// Target format id.
296    pub format: String,
297    /// Exported entity counts keyed by target entity kind.
298    pub entity_counts: BTreeMap<String, usize>,
299    /// Total exported entities.
300    pub total_entities: usize,
301    /// Omitted, normalized, or reduced content.
302    pub losses: Vec<LossNote>,
303    /// Informational details about the export path.
304    pub notes: Vec<String>,
305}
306
307impl ExportReport {
308    /// Count loss notes at or above [`Severity::Error`].
309    pub fn error_count(&self) -> usize {
310        self.losses
311            .iter()
312            .filter(|loss| loss.severity >= Severity::Error)
313            .count()
314    }
315}
316
317/// Which invariant a validation finding concerns.
318#[derive(
319    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
320)]
321#[serde(rename_all = "snake_case")]
322pub enum Check {
323    /// The document schema version is not the version accepted by this build.
324    Version,
325    /// Entity identifiers are empty, duplicated, or not globally unique.
326    Identity,
327    /// Product occurrence ownership, references, or acyclicity.
328    ProductStructure,
329    /// PMI targets and annotation-to-annotation references.
330    Pmi,
331    /// Presentation-layer membership and references.
332    Presentation,
333    /// An arena is not sorted lexicographically by entity id.
334    ArenaOrder,
335    /// A referenced id does not resolve in its arena.
336    ReferentialIntegrity,
337    /// A face loop's coedge ring does not close.
338    LoopClosure,
339    /// An edge's two coedges do not pair consistently.
340    CoedgePairing,
341    /// Wire edges, free vertices, or wire bodies violate topology ownership rules.
342    WireTopology,
343    /// A face-bearing shell is disconnected through physical-edge incidence.
344    ShellTopology,
345    /// A geometry carrier cannot be reached from topology or retained construction data.
346    CarrierReachability,
347    /// An annotation key, stream index, or field path is invalid.
348    Annotations,
349    /// A source-native namespace record has an unresolved link.
350    NativeLinks,
351    /// An edge parameter range violates the carrier's canonical domain.
352    ParameterDomain,
353    /// A document-wide or per-entity tolerance is not sane.
354    Tolerances,
355    /// A preserved byte payload does not match its declared digest or length.
356    PayloadIntegrity,
357    /// A tessellation payload is malformed.
358    Tessellation,
359    /// The document's units are missing or non-canonical, or a tolerance is
360    /// invalid.
361    Units,
362    /// A geometric quantity is out of sane range (e.g. negative radius).
363    Bounds,
364    /// Evaluated carrier geometry disagrees with the topology it supports:
365    /// an edge's curve endpoints or a pcurve's surface image miss the edge's
366    /// vertex positions.
367    GeometricConsistency,
368    /// Arena counts / cross-references are internally inconsistent.
369    Counts,
370}
371
372impl fmt::Display for Check {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        f.write_str(match self {
375            Self::Version => "version",
376            Self::Identity => "identity",
377            Self::ProductStructure => "product_structure",
378            Self::Pmi => "pmi",
379            Self::Presentation => "presentation",
380            Self::ArenaOrder => "arena_order",
381            Self::ReferentialIntegrity => "referential_integrity",
382            Self::LoopClosure => "loop_closure",
383            Self::CoedgePairing => "coedge_pairing",
384            Self::WireTopology => "wire_topology",
385            Self::ShellTopology => "shell_topology",
386            Self::CarrierReachability => "carrier_reachability",
387            Self::Annotations => "annotations",
388            Self::NativeLinks => "native_links",
389            Self::ParameterDomain => "parameter_domain",
390            Self::Tolerances => "tolerances",
391            Self::PayloadIntegrity => "payload_integrity",
392            Self::Tessellation => "tessellation",
393            Self::Units => "units",
394            Self::Bounds => "bounds",
395            Self::GeometricConsistency => "geometric_consistency",
396            Self::Counts => "counts",
397        })
398    }
399}
400
401/// A single validation finding.
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
403pub struct Finding {
404    /// Which check produced this finding.
405    pub check: Check,
406    /// Severity.
407    pub severity: Severity,
408    /// Human-readable explanation.
409    pub message: String,
410    /// The entity id the finding is about, when applicable.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub entity: Option<String>,
413}
414
415/// Entity counts, findings, and propagated decode losses for one document.
416#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
417pub struct ValidationReport {
418    /// Count of entities per arena, keyed by entity kind (sorted).
419    pub entity_counts: BTreeMap<String, usize>,
420    /// Findings, in discovery order.
421    pub findings: Vec<Finding>,
422    /// Loss notes supplied to validation.
423    #[serde(default)]
424    pub losses: Vec<LossNote>,
425}
426
427impl ValidationReport {
428    /// Number of findings at or above [`Severity::Error`].
429    pub fn error_count(&self) -> usize {
430        self.findings
431            .iter()
432            .filter(|f| f.severity >= Severity::Error)
433            .count()
434    }
435
436    /// Number of findings at exactly [`Severity::Warning`].
437    pub fn warning_count(&self) -> usize {
438        self.findings
439            .iter()
440            .filter(|f| f.severity == Severity::Warning)
441            .count()
442    }
443
444    /// True when there are no [`Severity::Error`]/[`Severity::Blocking`] findings.
445    pub fn is_ok(&self) -> bool {
446        self.error_count() == 0
447    }
448}
449
450#[cfg(test)]
451#[allow(clippy::unwrap_used)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn loss_code_serializes_under_its_stable_identifier() {
457        let note = LossNote {
458            code: LossCode::TopologyNotTransferred,
459            category: LossCategory::Topology,
460            severity: Severity::Blocking,
461            message: "topology graph not transferred".to_owned(),
462            provenance: None,
463        };
464        let value: serde_json::Value = serde_json::to_value(&note).expect("required invariant");
465        assert_eq!(value["code"], "topology_not_transferred");
466        assert_eq!(value["code"], LossCode::TopologyNotTransferred.as_str());
467    }
468
469    #[test]
470    fn loss_code_carries_reversibility_and_strict_consequence() {
471        assert_eq!(
472            LossCode::TopologyNotTransferred.strict_consequence(),
473            StrictConsequence::Reject
474        );
475        assert_eq!(
476            LossCode::PassthroughRecordOmitted.strict_consequence(),
477            StrictConsequence::Tolerate
478        );
479    }
480}