Skip to main content

animsmith_core/
contract.rs

1//! Versioned JSON result-contract types shared by CLI and embedded producers.
2//!
3//! The CLI is one producer of these envelopes. Embedded pipelines can use the
4//! same constructors and immutable protocol identities without duplicating the
5//! wire shape or hard-coding URNs.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use glam::Mat4;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::diff::MetricDelta;
14use crate::evaluation::{
15    Applicability, CheckEvaluation, ConfigurationState, EvaluationState, SelectionState,
16};
17use crate::measure::{
18    Aabb, AssetMeasurements, ClipMeasurements, ImageMeasurements, LinearTransformClassification,
19    LinearTransformMeasurements, MaterialDefinitionMeasurements, SkeletonNodeLocalRestMeasurements,
20    SkeletonRestWorldMatrixUnavailableReason, SkinDerivedMatrixMeasurements,
21    SkinDerivedMatrixUnavailableReason, TextureMeasurements, measure_linear_transform,
22    summarize_skin_bind_linear,
23};
24use crate::model::{
25    DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
26    SourceSkeletonCoverage,
27};
28use crate::profile::ResolvedRoles;
29use crate::{Document, Severity};
30
31/// Current outer result-envelope version.
32pub const OUTPUT_SCHEMA_VERSION: u32 = 6;
33/// Immutable identity of the current outer result envelope.
34pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:6";
35/// Current nested measurement-contract version.
36pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 12;
37/// Immutable identity of the current nested measurement contract.
38pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:12";
39
40/// Source checkout identity for the producing animsmith build.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct ToolSource {
43    revision: Option<String>,
44    dirty: Option<bool>,
45}
46
47impl ToolSource {
48    /// Construct source identity from a full Git revision and dirty bit.
49    ///
50    /// Packaged or otherwise provenance-free builds use `None` for fields they
51    /// cannot establish rather than claiming a clean checkout. Revisions that
52    /// are not full 40-character hexadecimal Git object ids are dropped so an
53    /// envelope constructed through this API remains within output v6.
54    pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
55        let revision = revision.filter(|revision| {
56            revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
57        });
58        Self { revision, dirty }
59    }
60}
61
62/// Identity of the animsmith producer that emitted an envelope.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct ToolInfo {
65    name: &'static str,
66    version: &'static str,
67    source: ToolSource,
68}
69
70impl ToolInfo {
71    /// Construct animsmith producer identity from this package's version and
72    /// optional source-checkout metadata.
73    pub fn animsmith(source: ToolSource) -> Self {
74        Self {
75            name: "animsmith",
76            version: env!("CARGO_PKG_VERSION"),
77            source,
78        }
79    }
80}
81
82/// Immutable identity of the bytes used to produce one file report.
83///
84/// The digest is lowercase hexadecimal SHA-256 so consumers can compare
85/// identities without retaining the source bytes themselves.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct InputIdentity {
88    sha256: String,
89    bytes: u64,
90}
91
92impl InputIdentity {
93    /// Calculate the identity for source bytes.
94    pub fn from_bytes(bytes: &[u8]) -> Self {
95        Self {
96            sha256: format!("{:x}", Sha256::digest(bytes)),
97            bytes: bytes.len() as u64,
98        }
99    }
100
101    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
102    pub fn sha256(&self) -> &str {
103        &self.sha256
104    }
105
106    /// Number of source bytes represented by this identity.
107    pub fn bytes(&self) -> u64 {
108        self.bytes
109    }
110}
111
112/// Rig profile and resolved semantic-role bindings for one input file.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct RigInfo {
115    profile: String,
116    resolved_roles: BTreeMap<&'static str, String>,
117}
118
119/// Resolved-role evidence did not belong to the supplied document.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[non_exhaustive]
122pub enum RigInfoError {
123    /// A resolved role referenced a bone outside the document's skeleton.
124    #[error(
125        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
126    )]
127    InvalidBoneId {
128        /// Stable semantic role name.
129        role: &'static str,
130        /// Invalid bone index carried by the resolution.
131        bone: usize,
132        /// Number of bones available in the supplied document.
133        bone_count: usize,
134    },
135    /// A valid bone index now names a different bone than the resolution did.
136    #[error(
137        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
138    )]
139    BoneNameMismatch {
140        /// Stable semantic role name.
141        role: &'static str,
142        /// Bone index carried by the resolution.
143        bone: usize,
144        /// Bone name captured when the role was resolved.
145        expected: String,
146        /// Bone name at that index in the supplied document.
147        found: String,
148    },
149}
150
151impl RigInfo {
152    /// Project resolved roles into their stable role names and source bone
153    /// names for the result contract.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`RigInfoError`] when `roles` references a bone outside the
158    /// supplied document, such as a resolution produced from another
159    /// skeleton.
160    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
161        let resolved_roles = roles
162            .iter_with_names()
163            .map(|(role, bone, expected_name)| {
164                let name = doc
165                    .skeleton
166                    .bones
167                    .get(bone)
168                    .ok_or(RigInfoError::InvalidBoneId {
169                        role: role.as_str(),
170                        bone,
171                        bone_count: doc.skeleton.bones.len(),
172                    })?;
173                if name.name != expected_name {
174                    return Err(RigInfoError::BoneNameMismatch {
175                        role: role.as_str(),
176                        bone,
177                        expected: expected_name.to_owned(),
178                        found: name.name.clone(),
179                    });
180                }
181                Ok((role.as_str(), name.name.clone()))
182            })
183            .collect::<Result<_, _>>()?;
184        Ok(Self {
185            profile: roles.profile.clone(),
186            resolved_roles,
187        })
188    }
189}
190
191/// Independently versioned measurement payload nested in measure and lint
192/// file records.
193#[derive(Debug, Clone, Serialize)]
194pub struct MeasurementContract {
195    schema_version: u32,
196    schema: &'static str,
197    clips: BTreeMap<String, ClipMeasurements>,
198    #[serde(flatten)]
199    assets: AssetMeasurements,
200}
201
202/// Measurement evidence could not satisfy the current measurement contract.
203#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
204#[non_exhaustive]
205pub enum MeasurementContractError {
206    /// A required or present numeric value was non-finite.
207    #[error("measurement value {path} must be finite")]
208    NonFiniteValue {
209        /// Human-readable location within the measurement contract.
210        path: String,
211    },
212    /// Related measurement fields were structurally inconsistent.
213    #[error("measurement structure {path} is invalid: {reason}")]
214    InvalidStructure {
215        /// Human-readable location within the measurement contract.
216        path: String,
217        /// Stable explanation of the violated relationship.
218        reason: String,
219    },
220}
221
222impl MeasurementContract {
223    /// Construct the current measurement contract.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`MeasurementContractError`] when required or present numeric
228    /// evidence is non-finite or structurally inconsistent.
229    pub fn new(
230        clips: BTreeMap<String, ClipMeasurements>,
231        assets: AssetMeasurements,
232    ) -> Result<Self, MeasurementContractError> {
233        validate_measurements(&clips, &assets)?;
234        Ok(Self {
235            schema_version: MEASUREMENTS_SCHEMA_VERSION,
236            schema: MEASUREMENTS_SCHEMA_ID,
237            clips,
238            assets,
239        })
240    }
241
242    /// Per-clip measurements keyed by clip name.
243    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
244        &self.clips
245    }
246
247    /// Static source-geometry, node-instance, and declared-scene evidence.
248    pub fn assets(&self) -> &AssetMeasurements {
249        &self.assets
250    }
251
252    /// Consume the contract and return its clip and static asset measurements.
253    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
254        (self.clips, self.assets)
255    }
256}
257
258fn validate_measurements(
259    clips: &BTreeMap<String, ClipMeasurements>,
260    assets: &AssetMeasurements,
261) -> Result<(), MeasurementContractError> {
262    let finite = |value: f64, path: String| {
263        value
264            .is_finite()
265            .then_some(())
266            .ok_or(MeasurementContractError::NonFiniteValue { path })
267    };
268    for (clip_name, clip) in clips {
269        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
270        for (bone, value) in &clip.bone_rotation_range_deg {
271            finite(
272                *value,
273                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
274            )?;
275        }
276        if let Some(loop_continuity) = &clip.loop_continuity {
277            if loop_continuity.bones.is_empty() {
278                return Err(MeasurementContractError::InvalidStructure {
279                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
280                    reason: "present loop-continuity evidence must contain at least one bone"
281                        .into(),
282                });
283            }
284            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
285                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
286                if usize::try_from(bone.bone_index) != Ok(expected_index) {
287                    return Err(MeasurementContractError::InvalidStructure {
288                        path: format!("{path}.bone_index"),
289                        reason: format!(
290                            "expected skeleton-order index {expected_index}, found {}",
291                            bone.bone_index
292                        ),
293                    });
294                }
295                for (field, value) in [
296                    ("position_delta_m", bone.position_delta_m),
297                    ("rotation_delta_deg", bone.rotation_delta_deg),
298                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
299                    (
300                        "seam_angular_velocity_delta_degps",
301                        bone.seam_angular_velocity_delta_degps,
302                    ),
303                ] {
304                    finite(value, format!("{path}.{field}"))?;
305                    if value < 0.0 {
306                        return Err(MeasurementContractError::InvalidStructure {
307                            path: format!("{path}.{field}"),
308                            reason: "loop-continuity deltas must be non-negative".into(),
309                        });
310                    }
311                }
312            }
313        }
314        if let Some(frame_grid) = clip.frame_grid {
315            let path = format!("clips[{clip_name:?}].frame_grid");
316            finite(frame_grid.fps, format!("{path}.fps"))?;
317            if frame_grid.fps <= 0.0 {
318                return Err(MeasurementContractError::InvalidStructure {
319                    path: format!("{path}.fps"),
320                    reason: "declared frame-grid FPS must be positive".into(),
321                });
322            }
323            if frame_grid.frame_intervals == 0 {
324                return Err(MeasurementContractError::InvalidStructure {
325                    path: format!("{path}.frame_intervals"),
326                    reason: "declared frame-grid evidence must contain at least one interval"
327                        .into(),
328                });
329            }
330        }
331        if let Some(value) = clip.loop_seam_ratio {
332            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
333        }
334        if let Some(gait) = &clip.gait {
335            if let Some(value) = gait.phase {
336                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
337            }
338            finite(
339                gait.lr_amplitude_m,
340                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
341            )?;
342        }
343        if let Some(value) = clip.speed_mps {
344            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
345        }
346    }
347    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
348        path,
349        reason: reason.to_owned(),
350    };
351    let finite_aabb = |aabb: &Aabb, path: &str| {
352        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
353            for (axis, value) in values.into_iter().enumerate() {
354                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
355            }
356        }
357        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
358            if min > max {
359                return Err(invalid(
360                    format!("{path}.min[{axis}]"),
361                    "AABB minimum cannot exceed maximum",
362                ));
363            }
364        }
365        Ok(())
366    };
367
368    let mut mesh_indices = BTreeSet::new();
369    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
370        if !mesh_indices.insert(mesh.mesh_index) {
371            return Err(invalid(
372                format!("mesh_definitions[{index}].mesh_index"),
373                "mesh_index must be unique",
374            ));
375        }
376        if let Some(aabb) = &mesh.geometry_aabb {
377            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
378        }
379        if let Some(centroid) = mesh.geometry_centroid {
380            for (axis, value) in centroid.into_iter().enumerate() {
381                finite(
382                    f64::from(value),
383                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
384                )?;
385            }
386        }
387        if let Some(value) = mesh.weight_sum_min {
388            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
389        }
390        if let Some(value) = mesh.weight_sum_max {
391            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
392        }
393        let mut previous_set_index = None;
394        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
395            let path = format!(
396                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
397            );
398            if set.set_index == 0 {
399                return Err(invalid(path, "set_index must be at least 1"));
400            }
401            if !set.joints_present && !set.weights_present {
402                return Err(invalid(
403                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
404                    "an additional influence set must declare joints, weights, or both",
405                ));
406            }
407            if set.joints_without_weights_present && !set.joints_present {
408                return Err(invalid(
409                    format!(
410                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
411                    ),
412                    "joints_without_weights_present requires joints_present",
413                ));
414            }
415            if set.weights_without_joints_present && !set.weights_present {
416                return Err(invalid(
417                    format!(
418                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
419                    ),
420                    "weights_without_joints_present requires weights_present",
421                ));
422            }
423            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
424                return Err(invalid(
425                    format!(
426                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
427                    ),
428                    "joints_without_weights_present is required when weights_present is false",
429                ));
430            }
431            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
432                return Err(invalid(
433                    format!(
434                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
435                    ),
436                    "weights_without_joints_present is required when joints_present is false",
437                ));
438            }
439            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
440                return Err(invalid(
441                    path,
442                    "set_index values must be strictly increasing and unique",
443                ));
444            }
445            previous_set_index = Some(set.set_index);
446        }
447    }
448
449    let mut node_indices = BTreeSet::new();
450    for (index, instance) in assets.node_instances.iter().enumerate() {
451        if !node_indices.insert(instance.node_index) {
452            return Err(invalid(
453                format!("node_instances[{index}].node_index"),
454                "node_index must be unique",
455            ));
456        }
457        if !mesh_indices.contains(&instance.mesh_index) {
458            return Err(invalid(
459                format!("node_instances[{index}].mesh_index"),
460                "mesh_index must reference a mesh definition",
461            ));
462        }
463        match (
464            instance.static_node_world_aabb.as_ref(),
465            instance.static_node_world_aabb_unavailable_reason,
466        ) {
467            (Some(aabb), None) => finite_aabb(
468                aabb,
469                &format!("node_instances[{index}].static_node_world_aabb"),
470            )?,
471            (None, Some(_)) => {}
472            (Some(_), Some(_)) => {
473                return Err(invalid(
474                    format!("node_instances[{index}]"),
475                    "an available static node AABB cannot have an unavailable reason",
476                ));
477            }
478            (None, None) => {
479                return Err(invalid(
480                    format!("node_instances[{index}]"),
481                    "a missing static node AABB requires an unavailable reason",
482                ));
483            }
484        }
485    }
486
487    let mut scene_indices = BTreeSet::new();
488    for (index, scene) in assets.scenes.iter().enumerate() {
489        if !scene_indices.insert(scene.scene_index) {
490            return Err(invalid(
491                format!("scenes[{index}].scene_index"),
492                "scene_index must be unique",
493            ));
494        }
495        if scene.excluded_instance_count > scene.instance_count {
496            return Err(invalid(
497                format!("scenes[{index}].excluded_instance_count"),
498                "excluded_instance_count cannot exceed instance_count",
499            ));
500        }
501        let available = scene.instance_count - scene.excluded_instance_count;
502        match (&scene.static_scene_world_aabb, available) {
503            (Some(aabb), 1..) => {
504                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
505            }
506            (None, 0) => {}
507            (Some(_), 0) => {
508                return Err(invalid(
509                    format!("scenes[{index}].static_scene_world_aabb"),
510                    "a scene with no available instances cannot have an AABB",
511                ));
512            }
513            (None, _) => {
514                return Err(invalid(
515                    format!("scenes[{index}].static_scene_world_aabb"),
516                    "a scene with available instances requires an AABB",
517                ));
518            }
519        }
520    }
521    if let Some(default_scene_index) = assets.default_scene_index
522        && !scene_indices.contains(&default_scene_index)
523    {
524        return Err(invalid(
525            "default_scene_index".into(),
526            "default_scene_index must reference a declared scene",
527        ));
528    }
529    validate_skeleton_measurements(assets, &invalid)?;
530    validate_material_resources(assets, &invalid)?;
531    Ok(())
532}
533
534fn validate_linear_transform_fields(
535    linear: &LinearTransformMeasurements,
536    path: &str,
537    invalid: &impl Fn(String, &str) -> MeasurementContractError,
538) -> Result<(), MeasurementContractError> {
539    let numeric_fields_present = linear.axis_lengths.is_some()
540        && linear.determinant.is_some()
541        && linear.orientation.is_some();
542    if linear.classification == LinearTransformClassification::NonFinite {
543        if linear.axis_lengths.is_some()
544            || linear.determinant.is_some()
545            || linear.orientation.is_some()
546            || linear.uniform_scale.is_some()
547        {
548            return Err(invalid(
549                path.into(),
550                "a non_finite classification cannot carry numeric linear-transform facts",
551            ));
552        }
553        return Ok(());
554    }
555    if !numeric_fields_present {
556        return Err(invalid(
557            path.into(),
558            "a finite classification requires axis_lengths, determinant, and orientation",
559        ));
560    }
561    for (axis, value) in linear
562        .axis_lengths
563        .expect("presence checked")
564        .into_iter()
565        .enumerate()
566    {
567        if !value.is_finite() {
568            return Err(MeasurementContractError::NonFiniteValue {
569                path: format!("{path}.axis_lengths[{axis}]"),
570            });
571        }
572        if value < 0.0 {
573            return Err(invalid(
574                format!("{path}.axis_lengths[{axis}]"),
575                "axis lengths must be non-negative",
576            ));
577        }
578    }
579    if !linear.determinant.expect("presence checked").is_finite() {
580        return Err(MeasurementContractError::NonFiniteValue {
581            path: format!("{path}.determinant"),
582        });
583    }
584    if let Some(scale) = linear.uniform_scale {
585        if !scale.is_finite() {
586            return Err(MeasurementContractError::NonFiniteValue {
587                path: format!("{path}.uniform_scale"),
588            });
589        }
590        if scale < 0.0 {
591            return Err(invalid(
592                format!("{path}.uniform_scale"),
593                "uniform scale must be non-negative",
594            ));
595        }
596    }
597    Ok(())
598}
599
600fn validate_skeleton_measurements(
601    assets: &AssetMeasurements,
602    invalid: &impl Fn(String, &str) -> MeasurementContractError,
603) -> Result<(), MeasurementContractError> {
604    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
605        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
606            return Err(invalid(
607                "skeleton_source_coverage".into(),
608                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
609            ));
610        }
611        return Ok(());
612    }
613
614    let finite_matrix = |matrix: &[f32; 16], path: &str| {
615        for (component, value) in matrix.iter().enumerate() {
616            if !value.is_finite() {
617                return Err(MeasurementContractError::NonFiniteValue {
618                    path: format!("{path}[{component}]"),
619                });
620            }
621        }
622        Ok(())
623    };
624    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
625        if node.node_index != offset {
626            return Err(invalid(
627                format!("skeleton_nodes[{offset}].node_index"),
628                "node_index must be contiguous and match source order",
629            ));
630        }
631        match &node.local_rest {
632            SkeletonNodeLocalRestMeasurements::Trs {
633                translation_parent_space_m,
634                rotation_xyzw,
635                scale,
636            } => {
637                for (field, values) in [
638                    (
639                        "translation_parent_space_m",
640                        translation_parent_space_m.as_slice(),
641                    ),
642                    ("rotation_xyzw", rotation_xyzw.as_slice()),
643                    ("scale", scale.as_slice()),
644                ] {
645                    for (component, value) in values.iter().enumerate() {
646                        if !value.is_finite() {
647                            return Err(MeasurementContractError::NonFiniteValue {
648                                path: format!(
649                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
650                                ),
651                            });
652                        }
653                    }
654                }
655            }
656            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
657                matrix,
658                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
659            )?,
660            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
661        }
662        let node_path = format!("skeleton_nodes[{offset}]");
663        validate_linear_transform_fields(
664            &node.rest_world_linear,
665            &format!("{node_path}.rest_world_linear"),
666            invalid,
667        )?;
668        match (
669            node.rest_world_matrix.as_ref(),
670            node.rest_world_translation_m.as_ref(),
671            node.rest_world_matrix_unavailable_reason,
672        ) {
673            (Some(matrix), Some(translation), None) => {
674                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
675                for (component, value) in translation.iter().enumerate() {
676                    if !value.is_finite() {
677                        return Err(MeasurementContractError::NonFiniteValue {
678                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
679                        });
680                    }
681                }
682                let expected_translation = [matrix[12], matrix[13], matrix[14]];
683                if *translation != expected_translation {
684                    return Err(invalid(
685                        format!("{node_path}.rest_world_translation_m"),
686                        "rest_world_translation_m must equal the rest-world matrix translation column",
687                    ));
688                }
689                let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
690                if node.rest_world_linear != expected_linear {
691                    return Err(invalid(
692                        format!("{node_path}.rest_world_linear"),
693                        "rest_world_linear must be derived from rest_world_matrix",
694                    ));
695                }
696            }
697            (None, None, Some(_)) => {
698                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
699                {
700                    return Err(invalid(
701                        format!("{node_path}.rest_world_linear"),
702                        "an unavailable rest-world matrix requires a non_finite linear classification",
703                    ));
704                }
705            }
706            (Some(_), Some(_), Some(_)) => {
707                return Err(invalid(
708                    node_path,
709                    "an available rest_world_matrix cannot have an unavailable reason",
710                ));
711            }
712            _ => {
713                return Err(invalid(
714                    node_path,
715                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
716                ));
717            }
718        }
719    }
720    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
721        if let Some(parent) = node.parent_node_index
722            && parent >= assets.skeleton_nodes.len()
723        {
724            return Err(invalid(
725                format!("skeleton_nodes[{offset}].parent_node_index"),
726                "parent_node_index must reference a skeleton node",
727            ));
728        }
729        let mut previous_scene = None;
730        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
731            if !assets
732                .scenes
733                .iter()
734                .any(|scene| scene.scene_index == *scene_index)
735            {
736                return Err(invalid(
737                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
738                    "scene_root_indices values must reference declared scenes",
739                ));
740            }
741            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
742                return Err(invalid(
743                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
744                    "scene_root_indices values must be strictly increasing and unique",
745                ));
746            }
747            previous_scene = Some(*scene_index);
748        }
749    }
750    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
751    for start in 0..assets.skeleton_nodes.len() {
752        if visits.get(start) != Some(&ParentVisit::Unvisited) {
753            continue;
754        }
755        let mut path = Vec::new();
756        let mut current = start;
757        loop {
758            match visits.get(current).copied().ok_or_else(|| {
759                invalid(
760                    format!("skeleton_nodes[{current}].parent_node_index"),
761                    "parent_node_index must reference a skeleton node",
762                )
763            })? {
764                ParentVisit::Done => break,
765                ParentVisit::Visiting => {
766                    return Err(invalid(
767                        format!("skeleton_nodes[{current}].parent_node_index"),
768                        "source node parent graph must be acyclic",
769                    ));
770                }
771                ParentVisit::Unvisited => {
772                    *visits.get_mut(current).ok_or_else(|| {
773                        invalid(
774                            format!("skeleton_nodes[{current}].parent_node_index"),
775                            "parent_node_index must reference a skeleton node",
776                        )
777                    })? = ParentVisit::Visiting;
778                    path.push(current);
779                    match assets
780                        .skeleton_nodes
781                        .get(current)
782                        .ok_or_else(|| {
783                            invalid(
784                                format!("skeleton_nodes[{current}].parent_node_index"),
785                                "parent_node_index must reference a skeleton node",
786                            )
787                        })?
788                        .parent_node_index
789                    {
790                        Some(parent) => current = parent,
791                        None => break,
792                    }
793                }
794            }
795        }
796        for node_index in path {
797            *visits.get_mut(node_index).ok_or_else(|| {
798                invalid(
799                    format!("skeleton_nodes[{node_index}].parent_node_index"),
800                    "parent_node_index must reference a skeleton node",
801                )
802            })? = ParentVisit::Done;
803        }
804    }
805
806    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
807        let local_rest_available = !matches!(
808            node.local_rest,
809            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
810        );
811        let path = format!("skeleton_nodes[{offset}]");
812        if !local_rest_available {
813            if node.rest_world_matrix.is_some()
814                || node.rest_world_matrix_unavailable_reason
815                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
816            {
817                return Err(invalid(
818                    path,
819                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
820                ));
821            }
822            continue;
823        }
824
825        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
826            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
827                invalid(
828                    format!("skeleton_nodes[{offset}].parent_node_index"),
829                    "parent_node_index must reference a skeleton node",
830                )
831            })?;
832            if parent.rest_world_matrix.is_none() {
833                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
834            } else {
835                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
836            }
837        } else {
838            None
839        };
840        match (
841            node.rest_world_matrix.is_some(),
842            expected_unavailable_reason,
843        ) {
844            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
845            }
846            (false, Some(expected))
847                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
848            _ => {
849                return Err(invalid(
850                    path,
851                    "rest-world availability must agree with local rest and parent rest-world evidence",
852                ));
853            }
854        }
855    }
856
857    for (offset, skin) in assets.skins.iter().enumerate() {
858        if skin.skin_index != offset {
859            return Err(invalid(
860                format!("skins[{offset}].skin_index"),
861                "skin_index must be contiguous and match source order",
862            ));
863        }
864        if let Some(root) = skin.skeleton_root_node_index
865            && root >= assets.skeleton_nodes.len()
866        {
867            return Err(invalid(
868                format!("skins[{offset}].skeleton_root_node_index"),
869                "skeleton_root_node_index must reference a skeleton node",
870            ));
871        }
872        for (joint_offset, joint) in skin.joints.iter().enumerate() {
873            if joint.joint_index != joint_offset {
874                return Err(invalid(
875                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
876                    "joint_index must be contiguous and match declared skin order",
877                ));
878            }
879            if joint.node_index >= assets.skeleton_nodes.len() {
880                return Err(invalid(
881                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
882                    "joint node_index must reference a skeleton node",
883                ));
884            }
885        }
886        match skin.inverse_bind_accessor.status {
887            SourceInverseBindAccessorStatus::Absent => {
888                if skin.inverse_bind_accessor.declared_count.is_some()
889                    || !skin.inverse_bind_accessor.matrices.is_empty()
890                {
891                    return Err(invalid(
892                        format!("skins[{offset}].inverse_bind_accessor"),
893                        "an absent inverse-bind accessor has no declared count or matrices",
894                    ));
895                }
896            }
897            SourceInverseBindAccessorStatus::EmptyAccessor => {
898                if skin.inverse_bind_accessor.declared_count != Some(0)
899                    || !skin.inverse_bind_accessor.matrices.is_empty()
900                {
901                    return Err(invalid(
902                        format!("skins[{offset}].inverse_bind_accessor"),
903                        "an empty inverse-bind accessor has declared_count 0 and no matrices",
904                    ));
905                }
906            }
907            SourceInverseBindAccessorStatus::Available => {
908                if skin.inverse_bind_accessor.declared_count
909                    != Some(skin.inverse_bind_accessor.matrices.len())
910                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
911                {
912                    return Err(invalid(
913                        format!("skins[{offset}].inverse_bind_accessor"),
914                        "an available inverse-bind accessor must retain its declared finite matrices and cover every joint",
915                    ));
916                }
917            }
918            SourceInverseBindAccessorStatus::CountMismatch => {
919                if skin.inverse_bind_accessor.declared_count
920                    != Some(skin.inverse_bind_accessor.matrices.len())
921                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
922                {
923                    return Err(invalid(
924                        format!("skins[{offset}].inverse_bind_accessor"),
925                        "a count-mismatched inverse-bind accessor retains fewer matrices than joints",
926                    ));
927                }
928            }
929            SourceInverseBindAccessorStatus::Unreadable => {
930                if skin.inverse_bind_accessor.declared_count.is_none()
931                    || !skin.inverse_bind_accessor.matrices.is_empty()
932                {
933                    return Err(invalid(
934                        format!("skins[{offset}].inverse_bind_accessor"),
935                        "an unreadable declared inverse-bind accessor retains its count but cannot serialize raw matrices",
936                    ));
937                }
938            }
939        }
940        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
941            finite_matrix(
942                matrix,
943                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
944            )?;
945        }
946        for (joint_offset, joint) in skin.joints.iter().enumerate() {
947            let joint_bind_path =
948                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
949            validate_derived_matrix(
950                &joint.joint_bind_to_mesh,
951                &joint_bind_path,
952                &finite_matrix,
953                invalid,
954            )?;
955            validate_derived_reason_compatibility(
956                &joint.joint_bind_to_mesh,
957                skin.inverse_bind_accessor.status,
958                skin.inverse_bind_accessor.matrices.len(),
959                joint_offset,
960                &joint_bind_path,
961                DerivedMatrixDomain::JointBindToMesh,
962                invalid,
963            )?;
964
965            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
966            validate_derived_matrix(
967                &joint.mesh_bind_world,
968                &mesh_bind_path,
969                &finite_matrix,
970                invalid,
971            )?;
972            validate_derived_reason_compatibility(
973                &joint.mesh_bind_world,
974                skin.inverse_bind_accessor.status,
975                skin.inverse_bind_accessor.matrices.len(),
976                joint_offset,
977                &mesh_bind_path,
978                DerivedMatrixDomain::MeshBindWorld,
979                invalid,
980            )?;
981            let joint_rest_world_available = assets
982                .skeleton_nodes
983                .get(joint.node_index)
984                .ok_or_else(|| {
985                    invalid(
986                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
987                        "joint node_index must reference a skeleton node",
988                    )
989                })?
990                .rest_world_matrix
991                .is_some();
992            validate_mesh_bind_world_reason_compatibility(
993                &joint.mesh_bind_world,
994                joint_rest_world_available,
995                &mesh_bind_path,
996                invalid,
997            )?;
998        }
999        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1000            && !scale.is_finite()
1001        {
1002            return Err(MeasurementContractError::NonFiniteValue {
1003                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1004            });
1005        }
1006        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1007        if skin.joint_bind_linear_summary != expected_summary {
1008            return Err(invalid(
1009                format!("skins[{offset}].joint_bind_linear_summary"),
1010                "joint-bind linear summary must match the skin joint observations",
1011            ));
1012        }
1013        let mut previous_attachment_node = None;
1014        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1015            if attachment.node_index >= assets.skeleton_nodes.len() {
1016                return Err(invalid(
1017                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1018                    "attachment node_index must reference a skeleton node",
1019                ));
1020            }
1021            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1022                return Err(invalid(
1023                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1024                    "attachment node_index values must be strictly increasing and unique",
1025                ));
1026            }
1027            previous_attachment_node = Some(attachment.node_index);
1028        }
1029    }
1030    Ok(())
1031}
1032
1033fn validate_derived_reason_compatibility(
1034    matrix: &SkinDerivedMatrixMeasurements,
1035    status: SourceInverseBindAccessorStatus,
1036    readable_matrix_count: usize,
1037    joint_index: usize,
1038    path: &str,
1039    domain: DerivedMatrixDomain,
1040    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1041) -> Result<(), MeasurementContractError> {
1042    let requires_accessor_reason = match status {
1043        SourceInverseBindAccessorStatus::Absent => {
1044            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1045        }
1046        SourceInverseBindAccessorStatus::EmptyAccessor => {
1047            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1048        }
1049        SourceInverseBindAccessorStatus::Unreadable => {
1050            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1051        }
1052        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1053            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1054        }
1055        SourceInverseBindAccessorStatus::Available
1056        | SourceInverseBindAccessorStatus::CountMismatch => None,
1057    };
1058    if let Some(expected) = requires_accessor_reason {
1059        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1060            return Err(invalid(
1061                path.into(),
1062                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1063            ));
1064        }
1065    } else {
1066        match (domain, matrix.unavailable_reason) {
1067            (
1068                _,
1069                Some(
1070                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1071                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1072                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1073                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1074                ),
1075            ) => {
1076                return Err(invalid(
1077                    format!("{path}.unavailable_reason"),
1078                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1079                ));
1080            }
1081            (
1082                DerivedMatrixDomain::JointBindToMesh,
1083                Some(
1084                    SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable
1085                    | SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix,
1086                ),
1087            ) => {
1088                return Err(invalid(
1089                    format!("{path}.unavailable_reason"),
1090                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1091                ));
1092            }
1093            (
1094                DerivedMatrixDomain::MeshBindWorld,
1095                Some(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible),
1096            ) => {
1097                return Err(invalid(
1098                    format!("{path}.unavailable_reason"),
1099                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1100                ));
1101            }
1102            _ => {}
1103        }
1104    }
1105    Ok(())
1106}
1107
1108fn validate_mesh_bind_world_reason_compatibility(
1109    matrix: &SkinDerivedMatrixMeasurements,
1110    joint_rest_world_available: bool,
1111    path: &str,
1112    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1113) -> Result<(), MeasurementContractError> {
1114    match matrix.unavailable_reason {
1115        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1116            if joint_rest_world_available =>
1117        {
1118            Err(invalid(
1119                format!("{path}.unavailable_reason"),
1120                "an available joint rest-world matrix cannot be reported as unavailable",
1121            ))
1122        }
1123        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1124            if !joint_rest_world_available =>
1125        {
1126            Err(invalid(
1127                format!("{path}.unavailable_reason"),
1128                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1129            ))
1130        }
1131        _ => Ok(()),
1132    }
1133}
1134
1135#[derive(Clone, Copy, PartialEq, Eq)]
1136enum ParentVisit {
1137    Unvisited,
1138    Visiting,
1139    Done,
1140}
1141
1142#[derive(Clone, Copy)]
1143enum DerivedMatrixDomain {
1144    JointBindToMesh,
1145    MeshBindWorld,
1146}
1147
1148fn validate_derived_matrix(
1149    matrix: &SkinDerivedMatrixMeasurements,
1150    path: &str,
1151    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1152    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1153) -> Result<(), MeasurementContractError> {
1154    match (
1155        &matrix.matrix,
1156        matrix.linear.as_ref(),
1157        matrix.unavailable_reason,
1158    ) {
1159        (Some(matrix), Some(linear), None) => {
1160            finite_matrix(matrix, &format!("{path}.matrix"))?;
1161            validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1162            if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1163                return Err(invalid(
1164                    format!("{path}.linear"),
1165                    "linear facts must be derived from the available matrix",
1166                ));
1167            }
1168        }
1169        (None, None, Some(_)) => {}
1170        (Some(_), Some(_), Some(_)) => {
1171            return Err(invalid(
1172                path.into(),
1173                "an available derived matrix cannot have an unavailable reason",
1174            ));
1175        }
1176        _ => {
1177            return Err(invalid(
1178                path.into(),
1179                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1180            ));
1181        }
1182    }
1183    Ok(())
1184}
1185
1186fn validate_material_resources(
1187    assets: &AssetMeasurements,
1188    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1189) -> Result<(), MeasurementContractError> {
1190    let absent = assets.material_definitions.is_empty()
1191        && assets.textures.is_empty()
1192        && assets.images.is_empty();
1193    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1194        return Err(invalid(
1195            "material_resource_coverage".into(),
1196            "unavailable resource coverage requires empty material, texture, and image arrays",
1197        ));
1198    }
1199
1200    for (offset, material) in assets.material_definitions.iter().enumerate() {
1201        if material.material_index != offset {
1202            return Err(invalid(
1203                format!("material_definitions[{offset}].material_index"),
1204                "material_index must be contiguous and match source order",
1205            ));
1206        }
1207        let mut previous_slot = None;
1208        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1209            if binding.texture_index >= assets.textures.len() {
1210                return Err(invalid(
1211                    format!(
1212                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1213                    ),
1214                    "texture_index must reference a source texture",
1215                ));
1216            }
1217            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1218                return Err(invalid(
1219                    format!(
1220                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1221                    ),
1222                    "texture bindings must be strictly ordered by slot and unique",
1223                ));
1224            }
1225            previous_slot = Some(binding.slot);
1226        }
1227    }
1228    for (offset, texture) in assets.textures.iter().enumerate() {
1229        if texture.texture_index != offset {
1230            return Err(invalid(
1231                format!("textures[{offset}].texture_index"),
1232                "texture_index must be contiguous and match source order",
1233            ));
1234        }
1235        if texture.image_index >= assets.images.len() {
1236            return Err(invalid(
1237                format!("textures[{offset}].image_index"),
1238                "image_index must reference a source image",
1239            ));
1240        }
1241    }
1242    for (offset, image) in assets.images.iter().enumerate() {
1243        validate_image_measurement(image, offset, invalid)?;
1244    }
1245    Ok(())
1246}
1247
1248fn validate_image_measurement(
1249    image: &ImageMeasurements,
1250    offset: usize,
1251    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1252) -> Result<(), MeasurementContractError> {
1253    if image.image_index != offset {
1254        return Err(invalid(
1255            format!("images[{offset}].image_index"),
1256            "image_index must be contiguous and match source order",
1257        ));
1258    }
1259    let available = [
1260        image.width.is_some(),
1261        image.height.is_some(),
1262        image.channel_count.is_some(),
1263        image.decoded_color_type.is_some(),
1264    ];
1265    match (
1266        available.into_iter().all(|value| value),
1267        image.unavailable_reason,
1268    ) {
1269        (true, None) => {
1270            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1271                image.width,
1272                image.height,
1273                image.channel_count,
1274                image.decoded_color_type,
1275            ) else {
1276                return Err(invalid(
1277                    format!("images[{offset}]"),
1278                    "available image metadata must include width, height, channel_count, and decoded_color_type",
1279                ));
1280            };
1281            if width == 0 || height == 0 {
1282                return Err(invalid(
1283                    format!("images[{offset}]"),
1284                    "available image dimensions must be greater than zero",
1285                ));
1286            }
1287            if channel_count != color_type_channel_count(decoded_color_type) {
1288                return Err(invalid(
1289                    format!("images[{offset}].channel_count"),
1290                    "channel_count must match decoded_color_type",
1291                ));
1292            }
1293            if image.detected_container.is_none() {
1294                return Err(invalid(
1295                    format!("images[{offset}].detected_container"),
1296                    "available image metadata requires a detected_container",
1297                ));
1298            }
1299        }
1300        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1301        (true, Some(_)) => {
1302            return Err(invalid(
1303                format!("images[{offset}]"),
1304                "available image metadata cannot have an unavailable_reason",
1305            ));
1306        }
1307        (false, None) if available.into_iter().all(|value| !value) => {
1308            return Err(invalid(
1309                format!("images[{offset}]"),
1310                "missing image metadata requires an unavailable_reason",
1311            ));
1312        }
1313        (false, _) => {
1314            return Err(invalid(
1315                format!("images[{offset}]"),
1316                "available image metadata must include width, height, channel_count, and decoded_color_type",
1317            ));
1318        }
1319    }
1320    match image.unavailable_reason {
1321        Some(crate::model::ImageUnavailableReason::DecodeFailed)
1322            if image.detected_container.is_none() =>
1323        {
1324            return Err(invalid(
1325                format!("images[{offset}].detected_container"),
1326                "decode_failed requires a detected_container",
1327            ));
1328        }
1329        Some(
1330            crate::model::ImageUnavailableReason::SourceUnavailable
1331            | crate::model::ImageUnavailableReason::InvalidDataUri
1332            | crate::model::ImageUnavailableReason::UnsupportedContainer,
1333        ) if image.detected_container.is_some() => {
1334            return Err(invalid(
1335                format!("images[{offset}].detected_container"),
1336                "this unavailable_reason cannot have a detected_container",
1337            ));
1338        }
1339        _ => {}
1340    }
1341    Ok(())
1342}
1343
1344fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1345    match color_type {
1346        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1347        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1348        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1349        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1350    }
1351}
1352
1353/// Typed read-side subset accepted when a consumer needs measurements from a
1354/// current `measure` or `lint` report.
1355///
1356/// This intentionally models only the fields needed to recover the nested
1357/// measurement contract. Unknown fields remain forward-compatible, while all
1358/// protocol identities and command constraints are validated by
1359/// [`MeasurementReportInput::into_files`].
1360#[derive(Debug, Deserialize)]
1361pub struct MeasurementReportInput {
1362    schema_version: Option<u32>,
1363    schema: Option<String>,
1364    command: Option<String>,
1365    files: Option<Vec<MeasurementFileInput>>,
1366}
1367
1368#[derive(Debug, Deserialize)]
1369struct MeasurementFileInput {
1370    path: Option<String>,
1371    input: Option<InputIdentityInput>,
1372    measurements: Option<MeasurementPayloadInput>,
1373}
1374
1375#[derive(Debug, Deserialize)]
1376struct InputIdentityInput {
1377    sha256: Option<String>,
1378    bytes: Option<u64>,
1379}
1380
1381#[derive(Debug, Deserialize)]
1382#[serde(untagged)]
1383enum SkeletonNodeMeasurementInput {
1384    Current(Box<crate::measure::SkeletonNodeMeasurements>),
1385    Earlier {
1386        #[serde(rename = "node_index")]
1387        _node_index: usize,
1388    },
1389}
1390
1391#[derive(Debug, Deserialize)]
1392#[serde(untagged)]
1393enum SkinMeasurementInput {
1394    Current(Box<crate::measure::SkinMeasurements>),
1395    Earlier {
1396        #[serde(rename = "skin_index")]
1397        _skin_index: usize,
1398    },
1399}
1400
1401#[derive(Debug, Deserialize)]
1402struct MeasurementPayloadInput {
1403    schema_version: Option<u32>,
1404    schema: Option<String>,
1405    clips: Option<BTreeMap<String, ClipMeasurements>>,
1406    material_resource_coverage: Option<MaterialResourceCoverage>,
1407    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
1408    textures: Option<Vec<TextureMeasurements>>,
1409    images: Option<Vec<ImageMeasurements>>,
1410    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
1411    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
1412    skins: Option<Vec<SkinMeasurementInput>>,
1413    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
1414    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
1415    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
1416    default_scene_index: Option<usize>,
1417}
1418
1419/// One validated file record recovered from a measurement report.
1420///
1421/// The record retains its source path and full nested measurement contract so
1422/// consumers can choose the clip, mesh, and cardinality policies appropriate
1423/// to their workflow.
1424#[derive(Debug, Clone)]
1425pub struct MeasurementReportFile {
1426    path: String,
1427    input: InputIdentity,
1428    measurements: MeasurementContract,
1429}
1430
1431impl MeasurementReportFile {
1432    /// Source path recorded by the producing report.
1433    pub fn path(&self) -> &str {
1434        &self.path
1435    }
1436
1437    /// Immutable identity of the source bytes used to produce this record.
1438    pub fn input(&self) -> &InputIdentity {
1439        &self.input
1440    }
1441
1442    /// Validated nested measurement contract.
1443    pub fn measurements(&self) -> &MeasurementContract {
1444        &self.measurements
1445    }
1446
1447    /// Consume this record and return its validated measurement contract.
1448    pub fn into_measurements(self) -> MeasurementContract {
1449        self.measurements
1450    }
1451}
1452
1453/// A typed measurement-report subset failed current-contract validation.
1454#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1455#[non_exhaustive]
1456pub enum MeasurementReportError {
1457    /// The outer envelope omitted its version.
1458    #[error("report envelope has no `schema_version`")]
1459    MissingOutputVersion,
1460    /// The outer envelope uses an unsupported version.
1461    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
1462    UnsupportedOutputVersion {
1463        /// Version found in the input.
1464        found: u32,
1465    },
1466    /// The outer envelope does not carry the immutable current identity.
1467    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
1468    WrongOutputIdentity,
1469    /// The outer envelope omitted its command.
1470    #[error("report envelope has no `command`")]
1471    MissingCommand,
1472    /// The outer envelope belongs to a command without file measurements.
1473    #[error("report command {command:?} does not carry measurement file records")]
1474    UnsupportedCommand {
1475        /// Command found in the input.
1476        command: String,
1477    },
1478    /// The outer envelope omitted its file array.
1479    #[error("report envelope has no `files` array")]
1480    MissingFiles,
1481    /// One file record failed validation.
1482    #[error("files[{file_index}] {source}")]
1483    File {
1484        /// Zero-based index of the invalid file record.
1485        file_index: usize,
1486        /// Typed record-validation failure.
1487        #[source]
1488        source: MeasurementFileError,
1489    },
1490}
1491
1492/// One measurement-report file record failed validation.
1493#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1494#[non_exhaustive]
1495pub enum MeasurementFileError {
1496    /// The file record omitted its source path.
1497    #[error("has no `path`")]
1498    MissingPath,
1499    /// The file record omitted its source-byte identity.
1500    #[error("has no `input`")]
1501    MissingInput,
1502    /// The source-byte identity omitted its SHA-256 digest.
1503    #[error("input has no `sha256`")]
1504    MissingSha256,
1505    /// The source-byte identity uses a malformed SHA-256 digest.
1506    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
1507    InvalidSha256,
1508    /// The source-byte identity omitted its byte count.
1509    #[error("input has no `bytes`")]
1510    MissingBytes,
1511    /// The file record omitted its nested measurement contract.
1512    #[error("has no measurements")]
1513    MissingMeasurements,
1514    /// The nested measurement contract omitted its version.
1515    #[error("has no versioned measurement contract")]
1516    MissingMeasurementVersion,
1517    /// The nested measurement contract uses an unsupported version.
1518    #[error(
1519        "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
1520    )]
1521    UnsupportedMeasurementVersion {
1522        /// Version found in the nested contract.
1523        found: u32,
1524    },
1525    /// The nested contract does not carry the immutable measurement identity.
1526    #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
1527    WrongMeasurementIdentity,
1528    /// The nested contract omitted its clip-measurement map.
1529    #[error("measurement contract has no `clips` map")]
1530    MissingClips,
1531    /// The nested contract omitted material resource coverage.
1532    #[error("measurement contract has no `material_resource_coverage`")]
1533    MissingMaterialResourceCoverage,
1534    /// The nested contract omitted its material definition array.
1535    #[error("measurement contract has no `material_definitions` array")]
1536    MissingMaterialDefinitions,
1537    /// The nested contract omitted its texture array.
1538    #[error("measurement contract has no `textures` array")]
1539    MissingTextures,
1540    /// The nested contract omitted its image array.
1541    #[error("measurement contract has no `images` array")]
1542    MissingImages,
1543    /// The nested contract omitted skeleton source coverage.
1544    #[error("measurement contract has no `skeleton_source_coverage`")]
1545    MissingSkeletonSourceCoverage,
1546    /// The nested contract omitted its source skeleton-node array.
1547    #[error("measurement contract has no `skeleton_nodes` array")]
1548    MissingSkeletonNodes,
1549    /// The nested contract omitted its source skin array.
1550    #[error("measurement contract has no `skins` array")]
1551    MissingSkins,
1552    /// The nested contract omitted its mesh-definition array.
1553    #[error("measurement contract has no `mesh_definitions` array")]
1554    MissingMeshDefinitions,
1555    /// The nested contract omitted its node-instance array.
1556    #[error("measurement contract has no `node_instances` array")]
1557    MissingNodeInstances,
1558    /// The nested contract omitted its scene array.
1559    #[error("measurement contract has no `scenes` array")]
1560    MissingScenes,
1561    /// The nested measurement values do not satisfy the current contract.
1562    #[error("has invalid measurements: {source}")]
1563    InvalidMeasurements {
1564        /// Measurement validation failure.
1565        #[source]
1566        source: MeasurementContractError,
1567    },
1568}
1569
1570impl MeasurementReportError {
1571    /// Zero-based file index for an error in one report record.
1572    ///
1573    /// Envelope-level errors return `None`.
1574    pub fn file_index(&self) -> Option<usize> {
1575        match self {
1576            Self::File { file_index, .. } => Some(*file_index),
1577            _ => None,
1578        }
1579    }
1580
1581    fn file(file_index: usize, source: MeasurementFileError) -> Self {
1582        Self::File { file_index, source }
1583    }
1584}
1585
1586impl MeasurementReportInput {
1587    /// Number of file records present before nested record validation.
1588    ///
1589    /// Returns `None` when the report omitted its file array. Consumers can
1590    /// retain this count while [`MeasurementReportInput::into_files`] performs
1591    /// full validation, then apply their own cardinality and error policy.
1592    pub fn file_count(&self) -> Option<usize> {
1593        self.files.as_ref().map(Vec::len)
1594    }
1595
1596    /// Validate current output/measurement identities and recover every file's
1597    /// complete measurement record from a `measure` or `lint` report.
1598    ///
1599    /// File order is preserved. Empty and multi-file reports are accepted so
1600    /// callers can apply their own cardinality policy.
1601    ///
1602    /// # Errors
1603    ///
1604    /// Returns a typed error for a missing or unsupported identity, command,
1605    /// file shape, nested measurement contract, or measurement payload.
1606    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
1607        match self.schema_version {
1608            Some(OUTPUT_SCHEMA_VERSION) => {}
1609            Some(found) => {
1610                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
1611            }
1612            None => return Err(MeasurementReportError::MissingOutputVersion),
1613        }
1614        if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
1615            return Err(MeasurementReportError::WrongOutputIdentity);
1616        }
1617        match self.command.as_deref() {
1618            Some("measure" | "lint") => {}
1619            Some(command) => {
1620                return Err(MeasurementReportError::UnsupportedCommand {
1621                    command: command.to_owned(),
1622                });
1623            }
1624            None => return Err(MeasurementReportError::MissingCommand),
1625        }
1626        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
1627        files
1628            .into_iter()
1629            .enumerate()
1630            .map(|(file_index, file)| {
1631                let path = file.path.ok_or_else(|| {
1632                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
1633                })?;
1634                let input = file.input.ok_or_else(|| {
1635                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
1636                })?;
1637                let sha256 = input.sha256.ok_or_else(|| {
1638                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
1639                })?;
1640                if sha256.len() != 64
1641                    || !sha256
1642                        .bytes()
1643                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1644                {
1645                    return Err(MeasurementReportError::file(
1646                        file_index,
1647                        MeasurementFileError::InvalidSha256,
1648                    ));
1649                }
1650                let bytes = input.bytes.ok_or_else(|| {
1651                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
1652                })?;
1653                let measurements = file.measurements.ok_or_else(|| {
1654                    MeasurementReportError::file(
1655                        file_index,
1656                        MeasurementFileError::MissingMeasurements,
1657                    )
1658                })?;
1659                match measurements.schema_version {
1660                    Some(MEASUREMENTS_SCHEMA_VERSION) => {}
1661                    Some(found) => {
1662                        return Err(MeasurementReportError::file(
1663                            file_index,
1664                            MeasurementFileError::UnsupportedMeasurementVersion { found },
1665                        ));
1666                    }
1667                    None => {
1668                        return Err(MeasurementReportError::file(
1669                            file_index,
1670                            MeasurementFileError::MissingMeasurementVersion,
1671                        ));
1672                    }
1673                }
1674                if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
1675                    return Err(MeasurementReportError::file(
1676                        file_index,
1677                        MeasurementFileError::WrongMeasurementIdentity,
1678                    ));
1679                }
1680                let clips = measurements.clips.ok_or_else(|| {
1681                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
1682                })?;
1683                let material_resource_coverage =
1684                    measurements.material_resource_coverage.ok_or_else(|| {
1685                        MeasurementReportError::file(
1686                            file_index,
1687                            MeasurementFileError::MissingMaterialResourceCoverage,
1688                        )
1689                    })?;
1690                let material_definitions = measurements.material_definitions.ok_or_else(|| {
1691                    MeasurementReportError::file(
1692                        file_index,
1693                        MeasurementFileError::MissingMaterialDefinitions,
1694                    )
1695                })?;
1696                let textures = measurements.textures.ok_or_else(|| {
1697                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
1698                })?;
1699                let images = measurements.images.ok_or_else(|| {
1700                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
1701                })?;
1702                let skeleton_source_coverage =
1703                    measurements.skeleton_source_coverage.ok_or_else(|| {
1704                        MeasurementReportError::file(
1705                            file_index,
1706                            MeasurementFileError::MissingSkeletonSourceCoverage,
1707                        )
1708                    })?;
1709                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
1710                    MeasurementReportError::file(
1711                        file_index,
1712                        MeasurementFileError::MissingSkeletonNodes,
1713                    )
1714                })?;
1715                let skeleton_nodes = skeleton_nodes
1716                    .into_iter()
1717                    .enumerate()
1718                    .map(|(offset, node)| match node {
1719                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
1720                        SkeletonNodeMeasurementInput::Earlier { .. } => {
1721                            Err(MeasurementReportError::file(
1722                                file_index,
1723                                MeasurementFileError::InvalidMeasurements {
1724                                    source: MeasurementContractError::InvalidStructure {
1725                                        path: format!("skeleton_nodes[{offset}]"),
1726                                        reason: "uses a shape from an earlier measurement contract"
1727                                            .into(),
1728                                    },
1729                                },
1730                            ))
1731                        }
1732                    })
1733                    .collect::<Result<Vec<_>, _>>()?;
1734                let skins = measurements.skins.ok_or_else(|| {
1735                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
1736                })?;
1737                let skins = skins
1738                    .into_iter()
1739                    .enumerate()
1740                    .map(|(offset, skin)| match skin {
1741                        SkinMeasurementInput::Current(skin) => Ok(*skin),
1742                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
1743                            file_index,
1744                            MeasurementFileError::InvalidMeasurements {
1745                                source: MeasurementContractError::InvalidStructure {
1746                                    path: format!("skins[{offset}]"),
1747                                    reason: "uses a shape from an earlier measurement contract"
1748                                        .into(),
1749                                },
1750                            },
1751                        )),
1752                    })
1753                    .collect::<Result<Vec<_>, _>>()?;
1754                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
1755                    MeasurementReportError::file(
1756                        file_index,
1757                        MeasurementFileError::MissingMeshDefinitions,
1758                    )
1759                })?;
1760                let node_instances = measurements.node_instances.ok_or_else(|| {
1761                    MeasurementReportError::file(
1762                        file_index,
1763                        MeasurementFileError::MissingNodeInstances,
1764                    )
1765                })?;
1766                let scenes = measurements.scenes.ok_or_else(|| {
1767                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
1768                })?;
1769                let assets = AssetMeasurements {
1770                    material_resource_coverage,
1771                    material_definitions,
1772                    textures,
1773                    images,
1774                    skeleton_source_coverage,
1775                    skeleton_nodes,
1776                    skins,
1777                    mesh_definitions,
1778                    node_instances,
1779                    scenes,
1780                    default_scene_index: measurements.default_scene_index,
1781                };
1782                let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
1783                    MeasurementReportError::file(
1784                        file_index,
1785                        MeasurementFileError::InvalidMeasurements { source },
1786                    )
1787                })?;
1788                Ok(MeasurementReportFile {
1789                    path,
1790                    input: InputIdentity { sha256, bytes },
1791                    measurements,
1792                })
1793            })
1794            .collect()
1795    }
1796}
1797
1798#[cfg(test)]
1799mod measurement_report_input_tests {
1800    use super::*;
1801
1802    #[test]
1803    fn v11_nested_version_is_rejected_before_current_shape_decode() {
1804        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
1805            "schema_version": OUTPUT_SCHEMA_VERSION,
1806            "schema": OUTPUT_SCHEMA_ID,
1807            "command": "measure",
1808            "files": [{
1809                "path": "measurements-v11.json",
1810                "input": { "sha256": "0".repeat(64), "bytes": 0 },
1811                "measurements": {
1812                    "schema_version": 11,
1813                    "schema": "urn:animsmith:schema:measurements:11",
1814                    "skeleton_nodes": [{
1815                        "node_index": 0,
1816                        "scene_root_indices": [],
1817                        "local_rest": {
1818                            "kind": "trs",
1819                            "translation_m": [0.0, 0.0, 0.0],
1820                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
1821                            "scale": [1.0, 1.0, 1.0]
1822                        },
1823                        "rest_world_matrix": [
1824                            1.0, 0.0, 0.0, 0.0,
1825                            0.0, 1.0, 0.0, 0.0,
1826                            0.0, 0.0, 1.0, 0.0,
1827                            0.0, 0.0, 0.0, 1.0
1828                        ]
1829                    }],
1830                    "skins": [{ "skin_index": 0 }]
1831                }
1832            }]
1833        }))
1834        .expect("unsupported payload shapes remain decodable for version rejection");
1835
1836        assert!(matches!(
1837            report.into_files(),
1838            Err(MeasurementReportError::File {
1839                file_index: 0,
1840                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
1841            })
1842        ));
1843    }
1844
1845    #[test]
1846    fn recovered_payloads_run_measurement_contract_validation() {
1847        // Exercise the last-resort contract guard with private NaN inputs that
1848        // JSON cannot encode. Public-boundary tests separately cover finite
1849        // deserializer values that overflow while narrowing into f32 mesh
1850        // bounds. Together they prove no input route can bypass
1851        // MeasurementContract::new.
1852        let file =
1853            |path: &str,
1854             clips: BTreeMap<String, ClipMeasurements>,
1855             mesh_definitions: Vec<crate::measure::MeshDefinitionMeasurements>| {
1856                MeasurementFileInput {
1857                    path: Some(path.into()),
1858                    input: Some(InputIdentityInput {
1859                        sha256: Some("0".repeat(64)),
1860                        bytes: Some(0),
1861                    }),
1862                    measurements: Some(MeasurementPayloadInput {
1863                        schema_version: Some(MEASUREMENTS_SCHEMA_VERSION),
1864                        schema: Some(MEASUREMENTS_SCHEMA_ID.into()),
1865                        clips: Some(clips),
1866                        material_resource_coverage: Some(MaterialResourceCoverage::Unavailable),
1867                        material_definitions: Some(Vec::new()),
1868                        textures: Some(Vec::new()),
1869                        images: Some(Vec::new()),
1870                        skeleton_source_coverage: Some(SourceSkeletonCoverage::Unavailable),
1871                        skeleton_nodes: Some(Vec::new()),
1872                        skins: Some(Vec::new()),
1873                        mesh_definitions: Some(mesh_definitions),
1874                        node_instances: Some(Vec::new()),
1875                        scenes: Some(Vec::new()),
1876                        default_scene_index: None,
1877                    }),
1878                }
1879            };
1880        let report = |files| MeasurementReportInput {
1881            schema_version: Some(OUTPUT_SCHEMA_VERSION),
1882            schema: Some(OUTPUT_SCHEMA_ID.into()),
1883            command: Some("measure".into()),
1884            files: Some(files),
1885        };
1886        let invalid_clip = || ClipMeasurements {
1887            duration_s: f64::NAN,
1888            frame_count: 1,
1889            animated_bones: Vec::new(),
1890            bone_rotation_range_deg: BTreeMap::new(),
1891            loop_continuity: None,
1892            loop_endpoint_mode: None,
1893            frame_grid: None,
1894            loop_seam_ratio: None,
1895            gait: None,
1896            speed_mps: None,
1897        };
1898        let invalid_mesh = || crate::measure::MeshDefinitionMeasurements {
1899            mesh_index: 0,
1900            name: "mesh".into(),
1901            vertex_count: 1,
1902            geometry_aabb: None,
1903            geometry_centroid: None,
1904            max_joints_per_vertex: 1,
1905            weight_sum_min: Some(f64::NAN),
1906            weight_sum_max: Some(1.0),
1907            additional_influence_sets: Vec::new(),
1908        };
1909        let valid = || file("valid.glb", BTreeMap::new(), Vec::new());
1910        let cases = [
1911            (
1912                report(vec![file(
1913                    "invalid-clip.glb",
1914                    BTreeMap::from([("walk".into(), invalid_clip())]),
1915                    Vec::new(),
1916                )]),
1917                MeasurementReportError::File {
1918                    file_index: 0,
1919                    source: MeasurementFileError::InvalidMeasurements {
1920                        source: MeasurementContractError::NonFiniteValue {
1921                            path: "clips[\"walk\"].duration_s".into(),
1922                        },
1923                    },
1924                },
1925                "files[0] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
1926                0,
1927            ),
1928            (
1929                report(vec![file(
1930                    "invalid-mesh.glb",
1931                    BTreeMap::new(),
1932                    vec![invalid_mesh()],
1933                )]),
1934                MeasurementReportError::File {
1935                    file_index: 0,
1936                    source: MeasurementFileError::InvalidMeasurements {
1937                        source: MeasurementContractError::NonFiniteValue {
1938                            path: "mesh_definitions[0].weight_sum_min".into(),
1939                        },
1940                    },
1941                },
1942                "files[0] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
1943                0,
1944            ),
1945            (
1946                report(vec![
1947                    valid(),
1948                    file(
1949                        "invalid-clip.glb",
1950                        BTreeMap::from([("walk".into(), invalid_clip())]),
1951                        Vec::new(),
1952                    ),
1953                ]),
1954                MeasurementReportError::File {
1955                    file_index: 1,
1956                    source: MeasurementFileError::InvalidMeasurements {
1957                        source: MeasurementContractError::NonFiniteValue {
1958                            path: "clips[\"walk\"].duration_s".into(),
1959                        },
1960                    },
1961                },
1962                "files[1] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
1963                1,
1964            ),
1965            (
1966                report(vec![
1967                    valid(),
1968                    file("invalid-mesh.glb", BTreeMap::new(), vec![invalid_mesh()]),
1969                ]),
1970                MeasurementReportError::File {
1971                    file_index: 1,
1972                    source: MeasurementFileError::InvalidMeasurements {
1973                        source: MeasurementContractError::NonFiniteValue {
1974                            path: "mesh_definitions[0].weight_sum_min".into(),
1975                        },
1976                    },
1977                },
1978                "files[1] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
1979                1,
1980            ),
1981        ];
1982
1983        for (input, expected, expected_display, expected_file_index) in cases {
1984            let error = input
1985                .into_files()
1986                .expect_err("recovered evidence must be validated");
1987            assert_eq!(error, expected);
1988            assert_eq!(error.file_index(), Some(expected_file_index));
1989            assert_eq!(error.to_string(), expected_display);
1990        }
1991    }
1992}
1993
1994#[derive(Debug, Clone, Serialize)]
1995struct FileEvidence {
1996    path: String,
1997    input: InputIdentity,
1998    rig: RigInfo,
1999    measurements: MeasurementContract,
2000}
2001
2002impl FileEvidence {
2003    fn new(
2004        path: impl Into<String>,
2005        input: InputIdentity,
2006        rig: RigInfo,
2007        measurements: MeasurementContract,
2008    ) -> Self {
2009        Self {
2010            path: path.into(),
2011            input,
2012            rig,
2013            measurements,
2014        }
2015    }
2016}
2017
2018/// One source file and its measurement-command evidence.
2019#[derive(Debug, Clone, Serialize)]
2020pub struct MeasureFileReport {
2021    #[serde(flatten)]
2022    evidence: FileEvidence,
2023}
2024
2025impl MeasureFileReport {
2026    /// Construct a measurement-command file report.
2027    pub fn new(
2028        path: impl Into<String>,
2029        input: InputIdentity,
2030        rig: RigInfo,
2031        measurements: MeasurementContract,
2032    ) -> Self {
2033        Self {
2034            evidence: FileEvidence::new(path, input, rig, measurements),
2035        }
2036    }
2037
2038    /// Display path supplied by the producer.
2039    pub fn path(&self) -> &str {
2040        &self.evidence.path
2041    }
2042
2043    /// Immutable identity of the source bytes used to produce this record.
2044    pub fn input(&self) -> &InputIdentity {
2045        &self.evidence.input
2046    }
2047
2048    /// Nested measurement evidence.
2049    pub fn measurements(&self) -> &MeasurementContract {
2050        &self.evidence.measurements
2051    }
2052}
2053
2054/// One source file and its lint-command evidence.
2055#[derive(Debug, Clone, Serialize)]
2056pub struct LintFileReport {
2057    #[serde(flatten)]
2058    evidence: FileEvidence,
2059    checks: Vec<CheckEvaluation>,
2060}
2061
2062impl LintFileReport {
2063    /// Construct a lint file report with one record per catalog check.
2064    pub fn new(
2065        path: impl Into<String>,
2066        input: InputIdentity,
2067        rig: RigInfo,
2068        checks: Vec<CheckEvaluation>,
2069        measurements: MeasurementContract,
2070    ) -> Self {
2071        Self {
2072            evidence: FileEvidence::new(path, input, rig, measurements),
2073            checks,
2074        }
2075    }
2076
2077    /// Display path supplied by the producer.
2078    pub fn path(&self) -> &str {
2079        &self.evidence.path
2080    }
2081
2082    /// Immutable identity of the source bytes used to produce this record.
2083    pub fn input(&self) -> &InputIdentity {
2084        &self.evidence.input
2085    }
2086
2087    /// Check records in catalog order.
2088    pub fn checks(&self) -> &[CheckEvaluation] {
2089        &self.checks
2090    }
2091
2092    /// Nested measurement evidence.
2093    pub fn measurements(&self) -> &MeasurementContract {
2094        &self.evidence.measurements
2095    }
2096}
2097
2098#[derive(Debug, Clone, Serialize)]
2099struct EnvelopeHeader {
2100    schema_version: u32,
2101    schema: &'static str,
2102    tool: ToolInfo,
2103    command: &'static str,
2104}
2105
2106impl EnvelopeHeader {
2107    fn new(tool: ToolInfo, command: &'static str) -> Self {
2108        Self {
2109            schema_version: OUTPUT_SCHEMA_VERSION,
2110            schema: OUTPUT_SCHEMA_ID,
2111            tool,
2112            command,
2113        }
2114    }
2115}
2116
2117#[derive(Debug, Clone, Serialize)]
2118struct MeasureSummary {
2119    files: usize,
2120}
2121
2122#[derive(Debug, Clone, Default, Serialize)]
2123struct FindingSummary {
2124    error: usize,
2125    warning: usize,
2126    note: usize,
2127}
2128
2129impl FindingSummary {
2130    fn add(&mut self, severity: Severity) {
2131        match severity {
2132            Severity::Error => self.error += 1,
2133            Severity::Warning => self.warning += 1,
2134            Severity::Note => self.note += 1,
2135        }
2136    }
2137}
2138
2139#[derive(Debug, Clone, Default, Serialize)]
2140struct SelectionSummary {
2141    selected: usize,
2142    unselected: usize,
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize)]
2146struct ConfigurationSummary {
2147    enabled: usize,
2148    disabled: usize,
2149}
2150
2151#[derive(Debug, Clone, Default, Serialize)]
2152struct ApplicabilitySummary {
2153    applicable: usize,
2154    not_applicable: usize,
2155}
2156
2157#[derive(Debug, Clone, Default, Serialize)]
2158struct EvaluationStateSummary {
2159    complete: usize,
2160    partial: usize,
2161    not_evaluated: usize,
2162}
2163
2164#[derive(Debug, Clone, Default, Serialize)]
2165struct CheckSummary {
2166    total: usize,
2167    selection: SelectionSummary,
2168    configuration: ConfigurationSummary,
2169    applicability: ApplicabilitySummary,
2170    evaluation: EvaluationStateSummary,
2171    gaps: usize,
2172}
2173
2174#[derive(Debug, Clone, Serialize)]
2175struct LintSummary {
2176    files: usize,
2177    findings: FindingSummary,
2178    checks: CheckSummary,
2179}
2180
2181/// Current measure-command result envelope.
2182#[derive(Debug, Clone, Serialize)]
2183pub struct MeasureEnvelope {
2184    #[serde(flatten)]
2185    header: EnvelopeHeader,
2186    summary: MeasureSummary,
2187    files: Vec<MeasureFileReport>,
2188}
2189
2190impl MeasureEnvelope {
2191    /// Construct a schema-valid measurement envelope.
2192    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Self {
2193        Self {
2194            header: EnvelopeHeader::new(tool, "measure"),
2195            summary: MeasureSummary { files: files.len() },
2196            files,
2197        }
2198    }
2199}
2200
2201/// Current lint-command result envelope.
2202#[derive(Debug, Clone, Serialize)]
2203pub struct LintEnvelope {
2204    #[serde(flatten)]
2205    header: EnvelopeHeader,
2206    summary: LintSummary,
2207    files: Vec<LintFileReport>,
2208}
2209
2210impl LintEnvelope {
2211    /// Construct a schema-valid lint envelope and derive its summary from the
2212    /// supplied check records.
2213    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Self {
2214        let mut findings = FindingSummary::default();
2215        let mut checks = CheckSummary::default();
2216        for file in &files {
2217            for check in file.checks() {
2218                checks.total += 1;
2219                for finding in check.findings() {
2220                    findings.add(finding.severity);
2221                }
2222                match check.selection() {
2223                    SelectionState::Selected => checks.selection.selected += 1,
2224                    SelectionState::Unselected => checks.selection.unselected += 1,
2225                }
2226                match check.configuration() {
2227                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
2228                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
2229                }
2230                match check.applicability() {
2231                    Applicability::Applicable => checks.applicability.applicable += 1,
2232                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
2233                }
2234                match check.evaluation() {
2235                    EvaluationState::Complete => checks.evaluation.complete += 1,
2236                    EvaluationState::Partial => checks.evaluation.partial += 1,
2237                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
2238                }
2239                checks.gaps += check.gaps().len();
2240            }
2241        }
2242        Self {
2243            header: EnvelopeHeader::new(tool, "lint"),
2244            summary: LintSummary {
2245                files: files.len(),
2246                findings,
2247                checks,
2248            },
2249            files,
2250        }
2251    }
2252}
2253
2254#[derive(Debug, Clone, Serialize)]
2255struct DiffInputs {
2256    before: String,
2257    after: String,
2258}
2259
2260#[derive(Debug, Clone, Serialize)]
2261struct DiffSummary {
2262    deltas: usize,
2263}
2264
2265/// Current diff-command result envelope.
2266#[derive(Debug, Serialize)]
2267pub struct DiffEnvelope {
2268    #[serde(flatten)]
2269    header: EnvelopeHeader,
2270    inputs: DiffInputs,
2271    summary: DiffSummary,
2272    deltas: Vec<MetricDelta>,
2273}
2274
2275impl DiffEnvelope {
2276    /// Construct a schema-valid diff envelope.
2277    pub fn new(
2278        tool: ToolInfo,
2279        before: impl Into<String>,
2280        after: impl Into<String>,
2281        deltas: Vec<MetricDelta>,
2282    ) -> Self {
2283        Self {
2284            header: EnvelopeHeader::new(tool, "diff"),
2285            inputs: DiffInputs {
2286                before: before.into(),
2287                after: after.into(),
2288            },
2289            summary: DiffSummary {
2290                deltas: deltas.len(),
2291            },
2292            deltas,
2293        }
2294    }
2295}