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