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, assess_inverse_bind,
22    measure_linear_transform, 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 = 7;
33/// Immutable identity of the current outer result envelope.
34pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:7";
35/// Current nested measurement-contract version.
36pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 13;
37/// Immutable identity of the current nested measurement contract.
38pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:13";
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 v7.
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 declaration 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 declaration 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 declaration 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 declaration 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 inverse-bind declaration retains its count but cannot serialize 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 expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
948            let joint_bind_path =
949                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
950            validate_derived_matrix(
951                &joint.joint_bind_to_mesh,
952                &joint_bind_path,
953                &finite_matrix,
954                invalid,
955            )?;
956            validate_derived_reason_compatibility(
957                &joint.joint_bind_to_mesh,
958                skin.inverse_bind_accessor.status,
959                skin.inverse_bind_accessor.matrices.len(),
960                joint_offset,
961                &joint_bind_path,
962                DerivedMatrixDomain::JointBindToMesh,
963                invalid,
964            )?;
965            validate_derived_source(
966                &joint.joint_bind_to_mesh,
967                expected_source,
968                None,
969                &joint_bind_path,
970                DerivedMatrixDomain::JointBindToMesh,
971                invalid,
972            )?;
973
974            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
975            validate_derived_matrix(
976                &joint.mesh_bind_world,
977                &mesh_bind_path,
978                &finite_matrix,
979                invalid,
980            )?;
981            validate_derived_reason_compatibility(
982                &joint.mesh_bind_world,
983                skin.inverse_bind_accessor.status,
984                skin.inverse_bind_accessor.matrices.len(),
985                joint_offset,
986                &mesh_bind_path,
987                DerivedMatrixDomain::MeshBindWorld,
988                invalid,
989            )?;
990            let joint_rest_world_available = assets
991                .skeleton_nodes
992                .get(joint.node_index)
993                .ok_or_else(|| {
994                    invalid(
995                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
996                        "joint node_index must reference a skeleton node",
997                    )
998                })?
999                .rest_world_matrix
1000                .is_some();
1001            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1002                .rest_world_matrix
1003                .as_ref();
1004            validate_mesh_bind_world_reason_compatibility(
1005                &joint.mesh_bind_world,
1006                joint_rest_world_available,
1007                &mesh_bind_path,
1008                invalid,
1009            )?;
1010            validate_derived_source(
1011                &joint.mesh_bind_world,
1012                expected_source,
1013                joint_rest_world,
1014                &mesh_bind_path,
1015                DerivedMatrixDomain::MeshBindWorld,
1016                invalid,
1017            )?;
1018        }
1019        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1020            && !scale.is_finite()
1021        {
1022            return Err(MeasurementContractError::NonFiniteValue {
1023                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1024            });
1025        }
1026        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1027        if skin.joint_bind_linear_summary != expected_summary {
1028            return Err(invalid(
1029                format!("skins[{offset}].joint_bind_linear_summary"),
1030                "joint-bind linear summary must match the skin joint observations",
1031            ));
1032        }
1033        let mut previous_attachment_node = None;
1034        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1035            if attachment.node_index >= assets.skeleton_nodes.len() {
1036                return Err(invalid(
1037                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1038                    "attachment node_index must reference a skeleton node",
1039                ));
1040            }
1041            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1042                return Err(invalid(
1043                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1044                    "attachment node_index values must be strictly increasing and unique",
1045                ));
1046            }
1047            previous_attachment_node = Some(attachment.node_index);
1048        }
1049    }
1050    Ok(())
1051}
1052
1053fn validate_derived_reason_compatibility(
1054    matrix: &SkinDerivedMatrixMeasurements,
1055    status: SourceInverseBindAccessorStatus,
1056    readable_matrix_count: usize,
1057    joint_index: usize,
1058    path: &str,
1059    domain: DerivedMatrixDomain,
1060    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1061) -> Result<(), MeasurementContractError> {
1062    let requires_accessor_reason = match status {
1063        SourceInverseBindAccessorStatus::Absent => {
1064            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1065        }
1066        SourceInverseBindAccessorStatus::EmptyAccessor => {
1067            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1068        }
1069        SourceInverseBindAccessorStatus::Unreadable => {
1070            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1071        }
1072        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1073            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1074        }
1075        SourceInverseBindAccessorStatus::Available
1076        | SourceInverseBindAccessorStatus::CountMismatch => None,
1077    };
1078    if let Some(expected) = requires_accessor_reason {
1079        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1080            return Err(invalid(
1081                path.into(),
1082                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1083            ));
1084        }
1085    } else {
1086        match (domain, matrix.unavailable_reason) {
1087            (
1088                _,
1089                Some(
1090                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1091                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1092                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1093                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1094                ),
1095            ) => {
1096                return Err(invalid(
1097                    format!("{path}.unavailable_reason"),
1098                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1099                ));
1100            }
1101            (
1102                DerivedMatrixDomain::JointBindToMesh,
1103                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1104            ) => {
1105                return Err(invalid(
1106                    format!("{path}.unavailable_reason"),
1107                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1108                ));
1109            }
1110            (
1111                DerivedMatrixDomain::MeshBindWorld,
1112                Some(
1113                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1114                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1115                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1116                ),
1117            ) => {
1118                return Err(invalid(
1119                    format!("{path}.unavailable_reason"),
1120                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1121                ));
1122            }
1123            _ => {}
1124        }
1125    }
1126    Ok(())
1127}
1128
1129fn validate_mesh_bind_world_reason_compatibility(
1130    matrix: &SkinDerivedMatrixMeasurements,
1131    joint_rest_world_available: bool,
1132    path: &str,
1133    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1134) -> Result<(), MeasurementContractError> {
1135    match matrix.unavailable_reason {
1136        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1137            if joint_rest_world_available =>
1138        {
1139            Err(invalid(
1140                format!("{path}.unavailable_reason"),
1141                "an available joint rest-world matrix cannot be reported as unavailable",
1142            ))
1143        }
1144        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1145            if !joint_rest_world_available =>
1146        {
1147            Err(invalid(
1148                format!("{path}.unavailable_reason"),
1149                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1150            ))
1151        }
1152        _ => Ok(()),
1153    }
1154}
1155
1156#[derive(Clone, Copy, PartialEq, Eq)]
1157enum ParentVisit {
1158    Unvisited,
1159    Visiting,
1160    Done,
1161}
1162
1163#[derive(Clone, Copy)]
1164enum DerivedMatrixDomain {
1165    JointBindToMesh,
1166    MeshBindWorld,
1167}
1168
1169fn validate_derived_source(
1170    measurements: &SkinDerivedMatrixMeasurements,
1171    expected_source: Option<&[f32; 16]>,
1172    joint_rest_world: Option<&[f32; 16]>,
1173    path: &str,
1174    domain: DerivedMatrixDomain,
1175    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1176) -> Result<(), MeasurementContractError> {
1177    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1178        return Err(invalid(
1179            format!("{path}.source_inverse_bind_matrix"),
1180            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1181        ));
1182    }
1183    let Some(source) = expected_source else {
1184        if measurements.inversion_quality.is_some() {
1185            return Err(invalid(
1186                format!("{path}.inversion_quality"),
1187                "inversion quality requires a readable source inverse-bind matrix",
1188            ));
1189        }
1190        return Ok(());
1191    };
1192    let raw = Mat4::from_cols_array(source);
1193    match domain {
1194        DerivedMatrixDomain::JointBindToMesh => {
1195            let assessment = assess_inverse_bind(raw);
1196            if measurements.inversion_quality != assessment.quality {
1197                return Err(invalid(
1198                    format!("{path}.inversion_quality"),
1199                    "inversion quality must be derived from the source linear 3x3",
1200                ));
1201            }
1202            match assessment.inverse {
1203                Ok(inverse) => {
1204                    if measurements.matrix != Some(inverse.to_cols_array())
1205                        || measurements.unavailable_reason.is_some()
1206                    {
1207                        return Err(invalid(
1208                            path.into(),
1209                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1210                        ));
1211                    }
1212                }
1213                Err(reason) => {
1214                    if measurements.matrix.is_some()
1215                        || measurements.unavailable_reason != Some(reason)
1216                    {
1217                        return Err(invalid(
1218                            path.into(),
1219                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1220                        ));
1221                    }
1222                }
1223            }
1224        }
1225        DerivedMatrixDomain::MeshBindWorld => {
1226            if measurements.inversion_quality.is_some() {
1227                return Err(invalid(
1228                    format!("{path}.inversion_quality"),
1229                    "mesh_bind_world does not invert its source matrix",
1230                ));
1231            }
1232            if let Some(world) = joint_rest_world {
1233                let expected = Mat4::from_cols_array(world) * raw;
1234                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1235                    if measurements.matrix != Some(expected.to_cols_array())
1236                        || measurements.unavailable_reason.is_some()
1237                    {
1238                        return Err(invalid(
1239                            path.into(),
1240                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1241                        ));
1242                    }
1243                } else if measurements.unavailable_reason
1244                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1245                {
1246                    return Err(invalid(
1247                        format!("{path}.unavailable_reason"),
1248                        "a non-finite mesh-bind product requires its typed unavailable reason",
1249                    ));
1250                }
1251            }
1252        }
1253    }
1254    Ok(())
1255}
1256
1257fn validate_derived_matrix(
1258    matrix: &SkinDerivedMatrixMeasurements,
1259    path: &str,
1260    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1261    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1262) -> Result<(), MeasurementContractError> {
1263    if let Some(source) = &matrix.source_inverse_bind_matrix {
1264        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1265    }
1266    if let Some(quality) = matrix.inversion_quality {
1267        let value = quality.reciprocal_condition_number_inf;
1268        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1269            return Err(invalid(
1270                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1271                "reciprocal condition number must be finite and between zero and one",
1272            ));
1273        }
1274    }
1275    match (
1276        &matrix.matrix,
1277        matrix.linear.as_ref(),
1278        matrix.unavailable_reason,
1279    ) {
1280        (Some(matrix), Some(linear), None) => {
1281            finite_matrix(matrix, &format!("{path}.matrix"))?;
1282            validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1283            if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1284                return Err(invalid(
1285                    format!("{path}.linear"),
1286                    "linear facts must be derived from the available matrix",
1287                ));
1288            }
1289        }
1290        (None, None, Some(_)) => {}
1291        (Some(_), Some(_), Some(_)) => {
1292            return Err(invalid(
1293                path.into(),
1294                "an available derived matrix cannot have an unavailable reason",
1295            ));
1296        }
1297        _ => {
1298            return Err(invalid(
1299                path.into(),
1300                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1301            ));
1302        }
1303    }
1304    Ok(())
1305}
1306
1307fn validate_material_resources(
1308    assets: &AssetMeasurements,
1309    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1310) -> Result<(), MeasurementContractError> {
1311    let absent = assets.material_definitions.is_empty()
1312        && assets.textures.is_empty()
1313        && assets.images.is_empty();
1314    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1315        return Err(invalid(
1316            "material_resource_coverage".into(),
1317            "unavailable resource coverage requires empty material, texture, and image arrays",
1318        ));
1319    }
1320
1321    for (offset, material) in assets.material_definitions.iter().enumerate() {
1322        if material.material_index != offset {
1323            return Err(invalid(
1324                format!("material_definitions[{offset}].material_index"),
1325                "material_index must be contiguous and match source order",
1326            ));
1327        }
1328        let mut previous_slot = None;
1329        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1330            if binding.texture_index >= assets.textures.len() {
1331                return Err(invalid(
1332                    format!(
1333                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1334                    ),
1335                    "texture_index must reference a source texture",
1336                ));
1337            }
1338            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1339                return Err(invalid(
1340                    format!(
1341                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1342                    ),
1343                    "texture bindings must be strictly ordered by slot and unique",
1344                ));
1345            }
1346            previous_slot = Some(binding.slot);
1347        }
1348    }
1349    for (offset, texture) in assets.textures.iter().enumerate() {
1350        if texture.texture_index != offset {
1351            return Err(invalid(
1352                format!("textures[{offset}].texture_index"),
1353                "texture_index must be contiguous and match source order",
1354            ));
1355        }
1356        if texture.image_index >= assets.images.len() {
1357            return Err(invalid(
1358                format!("textures[{offset}].image_index"),
1359                "image_index must reference a source image",
1360            ));
1361        }
1362    }
1363    for (offset, image) in assets.images.iter().enumerate() {
1364        validate_image_measurement(image, offset, invalid)?;
1365    }
1366    Ok(())
1367}
1368
1369fn validate_image_measurement(
1370    image: &ImageMeasurements,
1371    offset: usize,
1372    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1373) -> Result<(), MeasurementContractError> {
1374    if image.image_index != offset {
1375        return Err(invalid(
1376            format!("images[{offset}].image_index"),
1377            "image_index must be contiguous and match source order",
1378        ));
1379    }
1380    let available = [
1381        image.width.is_some(),
1382        image.height.is_some(),
1383        image.channel_count.is_some(),
1384        image.decoded_color_type.is_some(),
1385    ];
1386    match (
1387        available.into_iter().all(|value| value),
1388        image.unavailable_reason,
1389    ) {
1390        (true, None) => {
1391            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1392                image.width,
1393                image.height,
1394                image.channel_count,
1395                image.decoded_color_type,
1396            ) else {
1397                return Err(invalid(
1398                    format!("images[{offset}]"),
1399                    "available image metadata must include width, height, channel_count, and decoded_color_type",
1400                ));
1401            };
1402            if width == 0 || height == 0 {
1403                return Err(invalid(
1404                    format!("images[{offset}]"),
1405                    "available image dimensions must be greater than zero",
1406                ));
1407            }
1408            if channel_count != color_type_channel_count(decoded_color_type) {
1409                return Err(invalid(
1410                    format!("images[{offset}].channel_count"),
1411                    "channel_count must match decoded_color_type",
1412                ));
1413            }
1414            if image.detected_container.is_none() {
1415                return Err(invalid(
1416                    format!("images[{offset}].detected_container"),
1417                    "available image metadata requires a detected_container",
1418                ));
1419            }
1420        }
1421        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1422        (true, Some(_)) => {
1423            return Err(invalid(
1424                format!("images[{offset}]"),
1425                "available image metadata cannot have an unavailable_reason",
1426            ));
1427        }
1428        (false, None) if available.into_iter().all(|value| !value) => {
1429            return Err(invalid(
1430                format!("images[{offset}]"),
1431                "missing image metadata requires an unavailable_reason",
1432            ));
1433        }
1434        (false, _) => {
1435            return Err(invalid(
1436                format!("images[{offset}]"),
1437                "available image metadata must include width, height, channel_count, and decoded_color_type",
1438            ));
1439        }
1440    }
1441    match image.unavailable_reason {
1442        Some(crate::model::ImageUnavailableReason::DecodeFailed)
1443            if image.detected_container.is_none() =>
1444        {
1445            return Err(invalid(
1446                format!("images[{offset}].detected_container"),
1447                "decode_failed requires a detected_container",
1448            ));
1449        }
1450        Some(
1451            crate::model::ImageUnavailableReason::SourceUnavailable
1452            | crate::model::ImageUnavailableReason::InvalidDataUri
1453            | crate::model::ImageUnavailableReason::UnsupportedContainer,
1454        ) if image.detected_container.is_some() => {
1455            return Err(invalid(
1456                format!("images[{offset}].detected_container"),
1457                "this unavailable_reason cannot have a detected_container",
1458            ));
1459        }
1460        _ => {}
1461    }
1462    Ok(())
1463}
1464
1465fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1466    match color_type {
1467        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1468        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1469        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1470        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1471    }
1472}
1473
1474/// Typed read-side subset accepted when a consumer needs measurements from a
1475/// current `measure` or `lint` report.
1476///
1477/// This intentionally models only the fields needed to recover the nested
1478/// measurement contract. Unknown fields remain forward-compatible, while all
1479/// protocol identities and command constraints are validated by
1480/// [`MeasurementReportInput::into_files`].
1481#[derive(Debug, Deserialize)]
1482pub struct MeasurementReportInput {
1483    schema_version: Option<u32>,
1484    schema: Option<String>,
1485    command: Option<String>,
1486    files: Option<Vec<MeasurementFileInput>>,
1487}
1488
1489#[derive(Debug, Deserialize)]
1490struct MeasurementFileInput {
1491    path: Option<String>,
1492    input: Option<InputIdentityInput>,
1493    measurements: Option<MeasurementPayloadInput>,
1494}
1495
1496#[derive(Debug, Deserialize)]
1497struct InputIdentityInput {
1498    sha256: Option<String>,
1499    bytes: Option<u64>,
1500}
1501
1502#[derive(Debug, Deserialize)]
1503#[serde(untagged)]
1504enum SkeletonNodeMeasurementInput {
1505    Current(Box<crate::measure::SkeletonNodeMeasurements>),
1506    Earlier {
1507        #[serde(rename = "node_index")]
1508        _node_index: usize,
1509    },
1510}
1511
1512#[derive(Debug, Deserialize)]
1513#[serde(untagged)]
1514enum SkinMeasurementInput {
1515    Current(Box<crate::measure::SkinMeasurements>),
1516    Earlier {
1517        #[serde(rename = "skin_index")]
1518        _skin_index: usize,
1519    },
1520}
1521
1522#[derive(Debug, Deserialize)]
1523struct MeasurementPayloadInput {
1524    schema_version: Option<u32>,
1525    schema: Option<String>,
1526    clips: Option<BTreeMap<String, ClipMeasurements>>,
1527    material_resource_coverage: Option<MaterialResourceCoverage>,
1528    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
1529    textures: Option<Vec<TextureMeasurements>>,
1530    images: Option<Vec<ImageMeasurements>>,
1531    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
1532    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
1533    skins: Option<Vec<SkinMeasurementInput>>,
1534    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
1535    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
1536    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
1537    default_scene_index: Option<usize>,
1538}
1539
1540/// One validated file record recovered from a measurement report.
1541///
1542/// The record retains its source path and full nested measurement contract so
1543/// consumers can choose the clip, mesh, and cardinality policies appropriate
1544/// to their workflow.
1545#[derive(Debug, Clone)]
1546pub struct MeasurementReportFile {
1547    path: String,
1548    input: InputIdentity,
1549    measurements: MeasurementContract,
1550}
1551
1552impl MeasurementReportFile {
1553    /// Source path recorded by the producing report.
1554    pub fn path(&self) -> &str {
1555        &self.path
1556    }
1557
1558    /// Immutable identity of the source bytes used to produce this record.
1559    pub fn input(&self) -> &InputIdentity {
1560        &self.input
1561    }
1562
1563    /// Validated nested measurement contract.
1564    pub fn measurements(&self) -> &MeasurementContract {
1565        &self.measurements
1566    }
1567
1568    /// Consume this record and return its validated measurement contract.
1569    pub fn into_measurements(self) -> MeasurementContract {
1570        self.measurements
1571    }
1572}
1573
1574/// A typed measurement-report subset failed current-contract validation.
1575#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1576#[non_exhaustive]
1577pub enum MeasurementReportError {
1578    /// The outer envelope omitted its version.
1579    #[error("report envelope has no `schema_version`")]
1580    MissingOutputVersion,
1581    /// The outer envelope uses an unsupported version.
1582    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
1583    UnsupportedOutputVersion {
1584        /// Version found in the input.
1585        found: u32,
1586    },
1587    /// The outer envelope does not carry the immutable current identity.
1588    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
1589    WrongOutputIdentity,
1590    /// The outer envelope omitted its command.
1591    #[error("report envelope has no `command`")]
1592    MissingCommand,
1593    /// The outer envelope belongs to a command without file measurements.
1594    #[error("report command {command:?} does not carry measurement file records")]
1595    UnsupportedCommand {
1596        /// Command found in the input.
1597        command: String,
1598    },
1599    /// The outer envelope omitted its file array.
1600    #[error("report envelope has no `files` array")]
1601    MissingFiles,
1602    /// One file record failed validation.
1603    #[error("files[{file_index}] {source}")]
1604    File {
1605        /// Zero-based index of the invalid file record.
1606        file_index: usize,
1607        /// Typed record-validation failure.
1608        #[source]
1609        source: MeasurementFileError,
1610    },
1611}
1612
1613/// One measurement-report file record failed validation.
1614#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1615#[non_exhaustive]
1616pub enum MeasurementFileError {
1617    /// The file record omitted its source path.
1618    #[error("has no `path`")]
1619    MissingPath,
1620    /// The file record omitted its source-byte identity.
1621    #[error("has no `input`")]
1622    MissingInput,
1623    /// The source-byte identity omitted its SHA-256 digest.
1624    #[error("input has no `sha256`")]
1625    MissingSha256,
1626    /// The source-byte identity uses a malformed SHA-256 digest.
1627    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
1628    InvalidSha256,
1629    /// The source-byte identity omitted its byte count.
1630    #[error("input has no `bytes`")]
1631    MissingBytes,
1632    /// The file record omitted its nested measurement contract.
1633    #[error("has no measurements")]
1634    MissingMeasurements,
1635    /// The nested measurement contract omitted its version.
1636    #[error("has no versioned measurement contract")]
1637    MissingMeasurementVersion,
1638    /// The nested measurement contract uses an unsupported version.
1639    #[error(
1640        "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
1641    )]
1642    UnsupportedMeasurementVersion {
1643        /// Version found in the nested contract.
1644        found: u32,
1645    },
1646    /// The nested contract does not carry the immutable measurement identity.
1647    #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
1648    WrongMeasurementIdentity,
1649    /// The nested contract omitted its clip-measurement map.
1650    #[error("measurement contract has no `clips` map")]
1651    MissingClips,
1652    /// The nested contract omitted material resource coverage.
1653    #[error("measurement contract has no `material_resource_coverage`")]
1654    MissingMaterialResourceCoverage,
1655    /// The nested contract omitted its material definition array.
1656    #[error("measurement contract has no `material_definitions` array")]
1657    MissingMaterialDefinitions,
1658    /// The nested contract omitted its texture array.
1659    #[error("measurement contract has no `textures` array")]
1660    MissingTextures,
1661    /// The nested contract omitted its image array.
1662    #[error("measurement contract has no `images` array")]
1663    MissingImages,
1664    /// The nested contract omitted skeleton source coverage.
1665    #[error("measurement contract has no `skeleton_source_coverage`")]
1666    MissingSkeletonSourceCoverage,
1667    /// The nested contract omitted its source skeleton-node array.
1668    #[error("measurement contract has no `skeleton_nodes` array")]
1669    MissingSkeletonNodes,
1670    /// The nested contract omitted its source skin array.
1671    #[error("measurement contract has no `skins` array")]
1672    MissingSkins,
1673    /// The nested contract omitted its mesh-definition array.
1674    #[error("measurement contract has no `mesh_definitions` array")]
1675    MissingMeshDefinitions,
1676    /// The nested contract omitted its node-instance array.
1677    #[error("measurement contract has no `node_instances` array")]
1678    MissingNodeInstances,
1679    /// The nested contract omitted its scene array.
1680    #[error("measurement contract has no `scenes` array")]
1681    MissingScenes,
1682    /// The nested measurement values do not satisfy the current contract.
1683    #[error("has invalid measurements: {source}")]
1684    InvalidMeasurements {
1685        /// Measurement validation failure.
1686        #[source]
1687        source: MeasurementContractError,
1688    },
1689}
1690
1691impl MeasurementReportError {
1692    /// Zero-based file index for an error in one report record.
1693    ///
1694    /// Envelope-level errors return `None`.
1695    pub fn file_index(&self) -> Option<usize> {
1696        match self {
1697            Self::File { file_index, .. } => Some(*file_index),
1698            _ => None,
1699        }
1700    }
1701
1702    fn file(file_index: usize, source: MeasurementFileError) -> Self {
1703        Self::File { file_index, source }
1704    }
1705}
1706
1707impl MeasurementReportInput {
1708    /// Number of file records present before nested record validation.
1709    ///
1710    /// Returns `None` when the report omitted its file array. Consumers can
1711    /// retain this count while [`MeasurementReportInput::into_files`] performs
1712    /// full validation, then apply their own cardinality and error policy.
1713    pub fn file_count(&self) -> Option<usize> {
1714        self.files.as_ref().map(Vec::len)
1715    }
1716
1717    /// Validate current output/measurement identities and recover every file's
1718    /// complete measurement record from a `measure` or `lint` report.
1719    ///
1720    /// File order is preserved. Empty and multi-file reports are accepted so
1721    /// callers can apply their own cardinality policy.
1722    ///
1723    /// # Errors
1724    ///
1725    /// Returns a typed error for a missing or unsupported identity, command,
1726    /// file shape, nested measurement contract, or measurement payload.
1727    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
1728        match self.schema_version {
1729            Some(OUTPUT_SCHEMA_VERSION) => {}
1730            Some(found) => {
1731                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
1732            }
1733            None => return Err(MeasurementReportError::MissingOutputVersion),
1734        }
1735        if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
1736            return Err(MeasurementReportError::WrongOutputIdentity);
1737        }
1738        match self.command.as_deref() {
1739            Some("measure" | "lint") => {}
1740            Some(command) => {
1741                return Err(MeasurementReportError::UnsupportedCommand {
1742                    command: command.to_owned(),
1743                });
1744            }
1745            None => return Err(MeasurementReportError::MissingCommand),
1746        }
1747        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
1748        files
1749            .into_iter()
1750            .enumerate()
1751            .map(|(file_index, file)| {
1752                let path = file.path.ok_or_else(|| {
1753                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
1754                })?;
1755                let input = file.input.ok_or_else(|| {
1756                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
1757                })?;
1758                let sha256 = input.sha256.ok_or_else(|| {
1759                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
1760                })?;
1761                if sha256.len() != 64
1762                    || !sha256
1763                        .bytes()
1764                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1765                {
1766                    return Err(MeasurementReportError::file(
1767                        file_index,
1768                        MeasurementFileError::InvalidSha256,
1769                    ));
1770                }
1771                let bytes = input.bytes.ok_or_else(|| {
1772                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
1773                })?;
1774                let measurements = file.measurements.ok_or_else(|| {
1775                    MeasurementReportError::file(
1776                        file_index,
1777                        MeasurementFileError::MissingMeasurements,
1778                    )
1779                })?;
1780                match measurements.schema_version {
1781                    Some(MEASUREMENTS_SCHEMA_VERSION) => {}
1782                    Some(found) => {
1783                        return Err(MeasurementReportError::file(
1784                            file_index,
1785                            MeasurementFileError::UnsupportedMeasurementVersion { found },
1786                        ));
1787                    }
1788                    None => {
1789                        return Err(MeasurementReportError::file(
1790                            file_index,
1791                            MeasurementFileError::MissingMeasurementVersion,
1792                        ));
1793                    }
1794                }
1795                if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
1796                    return Err(MeasurementReportError::file(
1797                        file_index,
1798                        MeasurementFileError::WrongMeasurementIdentity,
1799                    ));
1800                }
1801                let clips = measurements.clips.ok_or_else(|| {
1802                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
1803                })?;
1804                let material_resource_coverage =
1805                    measurements.material_resource_coverage.ok_or_else(|| {
1806                        MeasurementReportError::file(
1807                            file_index,
1808                            MeasurementFileError::MissingMaterialResourceCoverage,
1809                        )
1810                    })?;
1811                let material_definitions = measurements.material_definitions.ok_or_else(|| {
1812                    MeasurementReportError::file(
1813                        file_index,
1814                        MeasurementFileError::MissingMaterialDefinitions,
1815                    )
1816                })?;
1817                let textures = measurements.textures.ok_or_else(|| {
1818                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
1819                })?;
1820                let images = measurements.images.ok_or_else(|| {
1821                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
1822                })?;
1823                let skeleton_source_coverage =
1824                    measurements.skeleton_source_coverage.ok_or_else(|| {
1825                        MeasurementReportError::file(
1826                            file_index,
1827                            MeasurementFileError::MissingSkeletonSourceCoverage,
1828                        )
1829                    })?;
1830                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
1831                    MeasurementReportError::file(
1832                        file_index,
1833                        MeasurementFileError::MissingSkeletonNodes,
1834                    )
1835                })?;
1836                let skeleton_nodes = skeleton_nodes
1837                    .into_iter()
1838                    .enumerate()
1839                    .map(|(offset, node)| match node {
1840                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
1841                        SkeletonNodeMeasurementInput::Earlier { .. } => {
1842                            Err(MeasurementReportError::file(
1843                                file_index,
1844                                MeasurementFileError::InvalidMeasurements {
1845                                    source: MeasurementContractError::InvalidStructure {
1846                                        path: format!("skeleton_nodes[{offset}]"),
1847                                        reason: "uses a shape from an earlier measurement contract"
1848                                            .into(),
1849                                    },
1850                                },
1851                            ))
1852                        }
1853                    })
1854                    .collect::<Result<Vec<_>, _>>()?;
1855                let skins = measurements.skins.ok_or_else(|| {
1856                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
1857                })?;
1858                let skins = skins
1859                    .into_iter()
1860                    .enumerate()
1861                    .map(|(offset, skin)| match skin {
1862                        SkinMeasurementInput::Current(skin) => Ok(*skin),
1863                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
1864                            file_index,
1865                            MeasurementFileError::InvalidMeasurements {
1866                                source: MeasurementContractError::InvalidStructure {
1867                                    path: format!("skins[{offset}]"),
1868                                    reason: "uses a shape from an earlier measurement contract"
1869                                        .into(),
1870                                },
1871                            },
1872                        )),
1873                    })
1874                    .collect::<Result<Vec<_>, _>>()?;
1875                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
1876                    MeasurementReportError::file(
1877                        file_index,
1878                        MeasurementFileError::MissingMeshDefinitions,
1879                    )
1880                })?;
1881                let node_instances = measurements.node_instances.ok_or_else(|| {
1882                    MeasurementReportError::file(
1883                        file_index,
1884                        MeasurementFileError::MissingNodeInstances,
1885                    )
1886                })?;
1887                let scenes = measurements.scenes.ok_or_else(|| {
1888                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
1889                })?;
1890                let assets = AssetMeasurements {
1891                    material_resource_coverage,
1892                    material_definitions,
1893                    textures,
1894                    images,
1895                    skeleton_source_coverage,
1896                    skeleton_nodes,
1897                    skins,
1898                    mesh_definitions,
1899                    node_instances,
1900                    scenes,
1901                    default_scene_index: measurements.default_scene_index,
1902                };
1903                let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
1904                    MeasurementReportError::file(
1905                        file_index,
1906                        MeasurementFileError::InvalidMeasurements { source },
1907                    )
1908                })?;
1909                Ok(MeasurementReportFile {
1910                    path,
1911                    input: InputIdentity { sha256, bytes },
1912                    measurements,
1913                })
1914            })
1915            .collect()
1916    }
1917}
1918
1919#[cfg(test)]
1920mod measurement_report_input_tests {
1921    use super::*;
1922
1923    #[test]
1924    fn v11_nested_version_is_rejected_before_current_shape_decode() {
1925        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
1926            "schema_version": OUTPUT_SCHEMA_VERSION,
1927            "schema": OUTPUT_SCHEMA_ID,
1928            "command": "measure",
1929            "files": [{
1930                "path": "measurements-v11.json",
1931                "input": { "sha256": "0".repeat(64), "bytes": 0 },
1932                "measurements": {
1933                    "schema_version": 11,
1934                    "schema": "urn:animsmith:schema:measurements:11",
1935                    "skeleton_nodes": [{
1936                        "node_index": 0,
1937                        "scene_root_indices": [],
1938                        "local_rest": {
1939                            "kind": "trs",
1940                            "translation_m": [0.0, 0.0, 0.0],
1941                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
1942                            "scale": [1.0, 1.0, 1.0]
1943                        },
1944                        "rest_world_matrix": [
1945                            1.0, 0.0, 0.0, 0.0,
1946                            0.0, 1.0, 0.0, 0.0,
1947                            0.0, 0.0, 1.0, 0.0,
1948                            0.0, 0.0, 0.0, 1.0
1949                        ]
1950                    }],
1951                    "skins": [{ "skin_index": 0 }]
1952                }
1953            }]
1954        }))
1955        .expect("unsupported payload shapes remain decodable for version rejection");
1956
1957        assert!(matches!(
1958            report.into_files(),
1959            Err(MeasurementReportError::File {
1960                file_index: 0,
1961                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
1962            })
1963        ));
1964    }
1965
1966    #[test]
1967    fn recovered_payloads_run_measurement_contract_validation() {
1968        // Exercise the last-resort contract guard with private NaN inputs that
1969        // JSON cannot encode. Public-boundary tests separately cover finite
1970        // deserializer values that overflow while narrowing into f32 mesh
1971        // bounds. Together they prove no input route can bypass
1972        // MeasurementContract::new.
1973        let file =
1974            |path: &str,
1975             clips: BTreeMap<String, ClipMeasurements>,
1976             mesh_definitions: Vec<crate::measure::MeshDefinitionMeasurements>| {
1977                MeasurementFileInput {
1978                    path: Some(path.into()),
1979                    input: Some(InputIdentityInput {
1980                        sha256: Some("0".repeat(64)),
1981                        bytes: Some(0),
1982                    }),
1983                    measurements: Some(MeasurementPayloadInput {
1984                        schema_version: Some(MEASUREMENTS_SCHEMA_VERSION),
1985                        schema: Some(MEASUREMENTS_SCHEMA_ID.into()),
1986                        clips: Some(clips),
1987                        material_resource_coverage: Some(MaterialResourceCoverage::Unavailable),
1988                        material_definitions: Some(Vec::new()),
1989                        textures: Some(Vec::new()),
1990                        images: Some(Vec::new()),
1991                        skeleton_source_coverage: Some(SourceSkeletonCoverage::Unavailable),
1992                        skeleton_nodes: Some(Vec::new()),
1993                        skins: Some(Vec::new()),
1994                        mesh_definitions: Some(mesh_definitions),
1995                        node_instances: Some(Vec::new()),
1996                        scenes: Some(Vec::new()),
1997                        default_scene_index: None,
1998                    }),
1999                }
2000            };
2001        let report = |files| MeasurementReportInput {
2002            schema_version: Some(OUTPUT_SCHEMA_VERSION),
2003            schema: Some(OUTPUT_SCHEMA_ID.into()),
2004            command: Some("measure".into()),
2005            files: Some(files),
2006        };
2007        let invalid_clip = || ClipMeasurements {
2008            duration_s: f64::NAN,
2009            frame_count: 1,
2010            animated_bones: Vec::new(),
2011            bone_rotation_range_deg: BTreeMap::new(),
2012            loop_continuity: None,
2013            loop_endpoint_mode: None,
2014            frame_grid: None,
2015            loop_seam_ratio: None,
2016            gait: None,
2017            speed_mps: None,
2018        };
2019        let invalid_mesh = || crate::measure::MeshDefinitionMeasurements {
2020            mesh_index: 0,
2021            name: "mesh".into(),
2022            vertex_count: 1,
2023            geometry_aabb: None,
2024            geometry_centroid: None,
2025            max_joints_per_vertex: 1,
2026            weight_sum_min: Some(f64::NAN),
2027            weight_sum_max: Some(1.0),
2028            additional_influence_sets: Vec::new(),
2029        };
2030        let valid = || file("valid.glb", BTreeMap::new(), Vec::new());
2031        let cases = [
2032            (
2033                report(vec![file(
2034                    "invalid-clip.glb",
2035                    BTreeMap::from([("walk".into(), invalid_clip())]),
2036                    Vec::new(),
2037                )]),
2038                MeasurementReportError::File {
2039                    file_index: 0,
2040                    source: MeasurementFileError::InvalidMeasurements {
2041                        source: MeasurementContractError::NonFiniteValue {
2042                            path: "clips[\"walk\"].duration_s".into(),
2043                        },
2044                    },
2045                },
2046                "files[0] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2047                0,
2048            ),
2049            (
2050                report(vec![file(
2051                    "invalid-mesh.glb",
2052                    BTreeMap::new(),
2053                    vec![invalid_mesh()],
2054                )]),
2055                MeasurementReportError::File {
2056                    file_index: 0,
2057                    source: MeasurementFileError::InvalidMeasurements {
2058                        source: MeasurementContractError::NonFiniteValue {
2059                            path: "mesh_definitions[0].weight_sum_min".into(),
2060                        },
2061                    },
2062                },
2063                "files[0] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2064                0,
2065            ),
2066            (
2067                report(vec![
2068                    valid(),
2069                    file(
2070                        "invalid-clip.glb",
2071                        BTreeMap::from([("walk".into(), invalid_clip())]),
2072                        Vec::new(),
2073                    ),
2074                ]),
2075                MeasurementReportError::File {
2076                    file_index: 1,
2077                    source: MeasurementFileError::InvalidMeasurements {
2078                        source: MeasurementContractError::NonFiniteValue {
2079                            path: "clips[\"walk\"].duration_s".into(),
2080                        },
2081                    },
2082                },
2083                "files[1] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2084                1,
2085            ),
2086            (
2087                report(vec![
2088                    valid(),
2089                    file("invalid-mesh.glb", BTreeMap::new(), vec![invalid_mesh()]),
2090                ]),
2091                MeasurementReportError::File {
2092                    file_index: 1,
2093                    source: MeasurementFileError::InvalidMeasurements {
2094                        source: MeasurementContractError::NonFiniteValue {
2095                            path: "mesh_definitions[0].weight_sum_min".into(),
2096                        },
2097                    },
2098                },
2099                "files[1] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2100                1,
2101            ),
2102        ];
2103
2104        for (input, expected, expected_display, expected_file_index) in cases {
2105            let error = input
2106                .into_files()
2107                .expect_err("recovered evidence must be validated");
2108            assert_eq!(error, expected);
2109            assert_eq!(error.file_index(), Some(expected_file_index));
2110            assert_eq!(error.to_string(), expected_display);
2111        }
2112    }
2113}
2114
2115#[derive(Debug, Clone, Serialize)]
2116struct FileEvidence {
2117    path: String,
2118    input: InputIdentity,
2119    rig: RigInfo,
2120    measurements: MeasurementContract,
2121}
2122
2123impl FileEvidence {
2124    fn new(
2125        path: impl Into<String>,
2126        input: InputIdentity,
2127        rig: RigInfo,
2128        measurements: MeasurementContract,
2129    ) -> Self {
2130        Self {
2131            path: path.into(),
2132            input,
2133            rig,
2134            measurements,
2135        }
2136    }
2137}
2138
2139/// One source file and its measurement-command evidence.
2140#[derive(Debug, Clone, Serialize)]
2141pub struct MeasureFileReport {
2142    #[serde(flatten)]
2143    evidence: FileEvidence,
2144}
2145
2146impl MeasureFileReport {
2147    /// Construct a measurement-command file report.
2148    pub fn new(
2149        path: impl Into<String>,
2150        input: InputIdentity,
2151        rig: RigInfo,
2152        measurements: MeasurementContract,
2153    ) -> Self {
2154        Self {
2155            evidence: FileEvidence::new(path, input, rig, measurements),
2156        }
2157    }
2158
2159    /// Display path supplied by the producer.
2160    pub fn path(&self) -> &str {
2161        &self.evidence.path
2162    }
2163
2164    /// Immutable identity of the source bytes used to produce this record.
2165    pub fn input(&self) -> &InputIdentity {
2166        &self.evidence.input
2167    }
2168
2169    /// Nested measurement evidence.
2170    pub fn measurements(&self) -> &MeasurementContract {
2171        &self.evidence.measurements
2172    }
2173}
2174
2175/// One source file and its lint-command evidence.
2176#[derive(Debug, Clone, Serialize)]
2177pub struct LintFileReport {
2178    #[serde(flatten)]
2179    evidence: FileEvidence,
2180    checks: Vec<CheckEvaluation>,
2181}
2182
2183impl LintFileReport {
2184    /// Construct a lint file report with one record per catalog check.
2185    pub fn new(
2186        path: impl Into<String>,
2187        input: InputIdentity,
2188        rig: RigInfo,
2189        checks: Vec<CheckEvaluation>,
2190        measurements: MeasurementContract,
2191    ) -> Self {
2192        Self {
2193            evidence: FileEvidence::new(path, input, rig, measurements),
2194            checks,
2195        }
2196    }
2197
2198    /// Display path supplied by the producer.
2199    pub fn path(&self) -> &str {
2200        &self.evidence.path
2201    }
2202
2203    /// Immutable identity of the source bytes used to produce this record.
2204    pub fn input(&self) -> &InputIdentity {
2205        &self.evidence.input
2206    }
2207
2208    /// Check records in catalog order.
2209    pub fn checks(&self) -> &[CheckEvaluation] {
2210        &self.checks
2211    }
2212
2213    /// Nested measurement evidence.
2214    pub fn measurements(&self) -> &MeasurementContract {
2215        &self.evidence.measurements
2216    }
2217}
2218
2219#[derive(Debug, Clone, Serialize)]
2220struct EnvelopeHeader {
2221    schema_version: u32,
2222    schema: &'static str,
2223    tool: ToolInfo,
2224    command: &'static str,
2225}
2226
2227impl EnvelopeHeader {
2228    fn new(tool: ToolInfo, command: &'static str) -> Self {
2229        Self {
2230            schema_version: OUTPUT_SCHEMA_VERSION,
2231            schema: OUTPUT_SCHEMA_ID,
2232            tool,
2233            command,
2234        }
2235    }
2236}
2237
2238#[derive(Debug, Clone, Serialize)]
2239struct MeasureSummary {
2240    files: usize,
2241}
2242
2243#[derive(Debug, Clone, Default, Serialize)]
2244struct FindingSummary {
2245    error: usize,
2246    warning: usize,
2247    note: usize,
2248}
2249
2250impl FindingSummary {
2251    fn add(&mut self, severity: Severity) {
2252        match severity {
2253            Severity::Error => self.error += 1,
2254            Severity::Warning => self.warning += 1,
2255            Severity::Note => self.note += 1,
2256        }
2257    }
2258}
2259
2260#[derive(Debug, Clone, Default, Serialize)]
2261struct SelectionSummary {
2262    selected: usize,
2263    unselected: usize,
2264}
2265
2266#[derive(Debug, Clone, Default, Serialize)]
2267struct ConfigurationSummary {
2268    enabled: usize,
2269    disabled: usize,
2270}
2271
2272#[derive(Debug, Clone, Default, Serialize)]
2273struct ApplicabilitySummary {
2274    applicable: usize,
2275    not_applicable: usize,
2276}
2277
2278#[derive(Debug, Clone, Default, Serialize)]
2279struct EvaluationStateSummary {
2280    complete: usize,
2281    partial: usize,
2282    not_evaluated: usize,
2283}
2284
2285#[derive(Debug, Clone, Default, Serialize)]
2286struct CheckSummary {
2287    total: usize,
2288    selection: SelectionSummary,
2289    configuration: ConfigurationSummary,
2290    applicability: ApplicabilitySummary,
2291    evaluation: EvaluationStateSummary,
2292    gaps: usize,
2293}
2294
2295#[derive(Debug, Clone, Serialize)]
2296struct LintSummary {
2297    files: usize,
2298    findings: FindingSummary,
2299    checks: CheckSummary,
2300}
2301
2302/// Current measure-command result envelope.
2303#[derive(Debug, Clone, Serialize)]
2304pub struct MeasureEnvelope {
2305    #[serde(flatten)]
2306    header: EnvelopeHeader,
2307    summary: MeasureSummary,
2308    files: Vec<MeasureFileReport>,
2309}
2310
2311impl MeasureEnvelope {
2312    /// Construct a schema-valid measurement envelope.
2313    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Self {
2314        Self {
2315            header: EnvelopeHeader::new(tool, "measure"),
2316            summary: MeasureSummary { files: files.len() },
2317            files,
2318        }
2319    }
2320}
2321
2322/// Current lint-command result envelope.
2323#[derive(Debug, Clone, Serialize)]
2324pub struct LintEnvelope {
2325    #[serde(flatten)]
2326    header: EnvelopeHeader,
2327    summary: LintSummary,
2328    files: Vec<LintFileReport>,
2329}
2330
2331impl LintEnvelope {
2332    /// Construct a schema-valid lint envelope and derive its summary from the
2333    /// supplied check records.
2334    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Self {
2335        let mut findings = FindingSummary::default();
2336        let mut checks = CheckSummary::default();
2337        for file in &files {
2338            for check in file.checks() {
2339                checks.total += 1;
2340                for finding in check.findings() {
2341                    findings.add(finding.severity);
2342                }
2343                match check.selection() {
2344                    SelectionState::Selected => checks.selection.selected += 1,
2345                    SelectionState::Unselected => checks.selection.unselected += 1,
2346                }
2347                match check.configuration() {
2348                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
2349                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
2350                }
2351                match check.applicability() {
2352                    Applicability::Applicable => checks.applicability.applicable += 1,
2353                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
2354                }
2355                match check.evaluation() {
2356                    EvaluationState::Complete => checks.evaluation.complete += 1,
2357                    EvaluationState::Partial => checks.evaluation.partial += 1,
2358                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
2359                }
2360                checks.gaps += check.gaps().len();
2361            }
2362        }
2363        Self {
2364            header: EnvelopeHeader::new(tool, "lint"),
2365            summary: LintSummary {
2366                files: files.len(),
2367                findings,
2368                checks,
2369            },
2370            files,
2371        }
2372    }
2373}
2374
2375#[derive(Debug, Clone, Serialize)]
2376struct DiffInputs {
2377    before: String,
2378    after: String,
2379}
2380
2381#[derive(Debug, Clone, Serialize)]
2382struct DiffSummary {
2383    deltas: usize,
2384}
2385
2386/// Current diff-command result envelope.
2387#[derive(Debug, Serialize)]
2388pub struct DiffEnvelope {
2389    #[serde(flatten)]
2390    header: EnvelopeHeader,
2391    inputs: DiffInputs,
2392    summary: DiffSummary,
2393    deltas: Vec<MetricDelta>,
2394}
2395
2396impl DiffEnvelope {
2397    /// Construct a schema-valid diff envelope.
2398    pub fn new(
2399        tool: ToolInfo,
2400        before: impl Into<String>,
2401        after: impl Into<String>,
2402        deltas: Vec<MetricDelta>,
2403    ) -> Self {
2404        Self {
2405            header: EnvelopeHeader::new(tool, "diff"),
2406            inputs: DiffInputs {
2407                before: before.into(),
2408                after: after.into(),
2409            },
2410            summary: DiffSummary {
2411                deltas: deltas.len(),
2412            },
2413            deltas,
2414        }
2415    }
2416}