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 _;
9use std::io::Read;
10
11use glam::Mat4;
12use serde::de::{DeserializeSeed, EnumAccess, MapAccess, SeqAccess, VariantAccess, Visitor};
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::value::RawValue;
15use sha2::{Digest, Sha256};
16
17use crate::diff::MetricDelta;
18use crate::evaluation::{
19    Applicability, CheckEvaluation, CheckEvaluationGapRef, CheckEvaluationValidationInput,
20    ConfigurationState, EvaluationState, SelectionState, validate_and_derive_check_evaluation,
21};
22use crate::measure::{
23    Aabb, AssetMeasurements, ClipMeasurements, ImageMeasurements, LinearTransformClassification,
24    LinearTransformMeasurements, MaterialDefinitionMeasurements, MeasurementAvailability,
25    SkeletonNodeLocalRestMeasurements, SkeletonRestWorldMatrixUnavailableReason,
26    SkinDerivedMatrixMeasurements, SkinDerivedMatrixUnavailableReason, TextureMeasurements,
27    assess_inverse_bind, measure_linear_transform, summarize_skin_bind_linear,
28};
29use crate::metrics::canonical_net_yaw_deg;
30use crate::model::{
31    DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
32    SourceSkeletonCoverage,
33};
34use crate::prediction::{
35    EnginePredictionFacetStateV1, PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
36    PREDICTION_V1_MAX_FACETS_PER_FILE, PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
37    PredictionContractError, PredictionDecodeError, PredictionProvenanceV1,
38    decode_engine_prediction_v1, decode_prediction_provenance_v1,
39    validate_measurement_references_batch,
40};
41use crate::profile::ResolvedRoles;
42use crate::{Document, Severity};
43
44/// Current outer result-envelope version.
45pub const OUTPUT_SCHEMA_VERSION: u32 = 10;
46/// Immutable identity of the current outer result envelope.
47pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:10";
48/// Maximum serialized bytes accepted by the output-v10 report reader.
49pub const OUTPUT_V10_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
50/// Maximum file records carried by one output-v10 envelope.
51pub const OUTPUT_V10_MAX_FILES: usize = 4_096;
52/// Maximum check records carried by one output-v10 lint file.
53pub const OUTPUT_V10_MAX_CHECKS_PER_FILE: usize = 4_096;
54/// Current nested measurement-contract version.
55pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 15;
56/// Immutable identity of the current nested measurement contract.
57pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:15";
58
59/// Source checkout identity for the producing animsmith build.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ToolSource {
62    revision: Option<String>,
63    dirty: Option<bool>,
64}
65
66impl ToolSource {
67    /// Construct source identity from a full Git revision and dirty bit.
68    ///
69    /// Packaged or otherwise provenance-free builds use `None` for fields they
70    /// cannot establish rather than claiming a clean checkout. Revisions that
71    /// are not full 40-character hexadecimal Git object ids are dropped so an
72    /// envelope constructed through this API remains within output v10.
73    pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
74        let revision = revision.filter(|revision| {
75            revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
76        });
77        Self { revision, dirty }
78    }
79}
80
81/// Identity of the animsmith producer that emitted an envelope.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct ToolInfo {
84    name: &'static str,
85    version: &'static str,
86    source: ToolSource,
87}
88
89impl ToolInfo {
90    /// Construct animsmith producer identity from this package's version and
91    /// optional source-checkout metadata.
92    pub fn animsmith(source: ToolSource) -> Self {
93        Self {
94            name: "animsmith",
95            version: env!("CARGO_PKG_VERSION"),
96            source,
97        }
98    }
99}
100
101/// Immutable identity of the bytes used to produce one file report.
102///
103/// The digest is lowercase hexadecimal SHA-256 so consumers can compare
104/// identities without retaining the source bytes themselves.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct InputIdentity {
107    sha256: String,
108    bytes: u64,
109}
110
111/// Lowercase hexadecimal SHA-256 digest of exactly these bytes.
112///
113/// The digest type carries no `LowerHex` impl, so every producer of a
114/// contract `sha256` field goes through this one formatter rather than
115/// re-deriving the encoding.
116#[must_use]
117pub fn sha256_hex(bytes: &[u8]) -> String {
118    sha256_digest_hex(Sha256::digest(bytes).into())
119}
120
121fn sha256_digest_hex(digest: [u8; 32]) -> String {
122    let mut hex = String::with_capacity(64);
123    for byte in digest {
124        let _ = write!(hex, "{byte:02x}");
125    }
126    hex
127}
128
129impl InputIdentity {
130    /// Calculate the identity for source bytes.
131    pub fn from_bytes(bytes: &[u8]) -> Self {
132        Self {
133            sha256: sha256_hex(bytes),
134            bytes: bytes.len() as u64,
135        }
136    }
137
138    /// Construct an identity from an already-computed SHA-256 digest and exact byte count.
139    ///
140    /// This keeps streaming and bounded readers on the same lowercase digest
141    /// authority without exposing an unchecked string constructor.
142    pub fn from_sha256_digest(digest: [u8; 32], bytes: u64) -> Self {
143        Self {
144            sha256: sha256_digest_hex(digest),
145            bytes,
146        }
147    }
148
149    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
150    pub fn sha256(&self) -> &str {
151        &self.sha256
152    }
153
154    /// Number of source bytes represented by this identity.
155    pub fn bytes(&self) -> u64 {
156        self.bytes
157    }
158}
159
160/// Rig profile and resolved semantic-role bindings for one input file.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct RigInfo {
163    profile: String,
164    resolved_roles: BTreeMap<&'static str, String>,
165}
166
167/// Resolved-role evidence did not belong to the supplied document.
168#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[non_exhaustive]
170pub enum RigInfoError {
171    /// A resolved role referenced a bone outside the document's skeleton.
172    #[error(
173        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
174    )]
175    InvalidBoneId {
176        /// Stable semantic role name.
177        role: &'static str,
178        /// Invalid bone index carried by the resolution.
179        bone: usize,
180        /// Number of bones available in the supplied document.
181        bone_count: usize,
182    },
183    /// A valid bone index now names a different bone than the resolution did.
184    #[error(
185        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
186    )]
187    BoneNameMismatch {
188        /// Stable semantic role name.
189        role: &'static str,
190        /// Bone index carried by the resolution.
191        bone: usize,
192        /// Bone name captured when the role was resolved.
193        expected: String,
194        /// Bone name at that index in the supplied document.
195        found: String,
196    },
197}
198
199impl RigInfo {
200    /// Project resolved roles into their stable role names and source bone
201    /// names for the result contract.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`RigInfoError`] when `roles` references a bone outside the
206    /// supplied document, such as a resolution produced from another
207    /// skeleton.
208    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
209        let resolved_roles = roles
210            .iter_with_names()
211            .map(|(role, bone, expected_name)| {
212                let name = doc
213                    .skeleton
214                    .bones
215                    .get(bone)
216                    .ok_or(RigInfoError::InvalidBoneId {
217                        role: role.as_str(),
218                        bone,
219                        bone_count: doc.skeleton.bones.len(),
220                    })?;
221                if name.name != expected_name {
222                    return Err(RigInfoError::BoneNameMismatch {
223                        role: role.as_str(),
224                        bone,
225                        expected: expected_name.to_owned(),
226                        found: name.name.clone(),
227                    });
228                }
229                Ok((role.as_str(), name.name.clone()))
230            })
231            .collect::<Result<_, _>>()?;
232        Ok(Self {
233            profile: roles.profile.clone(),
234            resolved_roles,
235        })
236    }
237}
238
239/// Independently versioned measurement payload nested in measure and lint
240/// file records.
241#[derive(Debug, Clone, Serialize)]
242pub struct MeasurementContract {
243    schema_version: u32,
244    schema: &'static str,
245    clips: BTreeMap<String, ClipMeasurements>,
246    #[serde(flatten)]
247    assets: AssetMeasurements,
248}
249
250/// Measurement evidence could not satisfy the current measurement contract.
251#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
252#[non_exhaustive]
253pub enum MeasurementContractError {
254    /// A required or present numeric value was non-finite.
255    #[error("measurement value {path} must be finite")]
256    NonFiniteValue {
257        /// Human-readable location within the measurement contract.
258        path: String,
259    },
260    /// Related measurement fields were structurally inconsistent.
261    #[error("measurement structure {path} is invalid: {reason}")]
262    InvalidStructure {
263        /// Human-readable location within the measurement contract.
264        path: String,
265        /// Stable explanation of the violated relationship.
266        reason: String,
267    },
268}
269
270impl MeasurementContract {
271    /// Construct the current measurement contract.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`MeasurementContractError`] when required or present numeric
276    /// evidence is non-finite or structurally inconsistent.
277    pub fn new(
278        clips: BTreeMap<String, ClipMeasurements>,
279        assets: AssetMeasurements,
280    ) -> Result<Self, MeasurementContractError> {
281        validate_measurements(&clips, &assets)?;
282        Ok(Self {
283            schema_version: MEASUREMENTS_SCHEMA_VERSION,
284            schema: MEASUREMENTS_SCHEMA_ID,
285            clips,
286            assets,
287        })
288    }
289
290    /// Per-clip measurements keyed by clip name.
291    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
292        &self.clips
293    }
294
295    /// Static source-geometry, node-instance, and declared-scene evidence.
296    pub fn assets(&self) -> &AssetMeasurements {
297        &self.assets
298    }
299
300    /// Consume the contract and return its clip and static asset measurements.
301    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
302        (self.clips, self.assets)
303    }
304}
305
306fn validate_measurements(
307    clips: &BTreeMap<String, ClipMeasurements>,
308    assets: &AssetMeasurements,
309) -> Result<(), MeasurementContractError> {
310    let finite = |value: f64, path: String| {
311        value
312            .is_finite()
313            .then_some(())
314            .ok_or(MeasurementContractError::NonFiniteValue { path })
315    };
316    let permits_roundoff = |observed: f64, lower_bound: f64| {
317        let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
318        observed + tolerance >= lower_bound
319    };
320    let check_availability = |value_present: bool,
321                              availability: MeasurementAvailability,
322                              path: String| {
323        match (value_present, availability) {
324            (true, MeasurementAvailability::Measured) => Ok(()),
325            (
326                false,
327                MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
328            ) => Ok(()),
329            _ => Err(MeasurementContractError::InvalidStructure {
330                path,
331                reason: "value presence must match availability status".into(),
332            }),
333        }
334    };
335    for (clip_name, clip) in clips {
336        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
337        let mut previous_bone_index = None;
338        let mut covered_bone_names = BTreeSet::new();
339        for (offset, bone) in clip.bone_channels.iter().enumerate() {
340            let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
341            if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
342                return Err(MeasurementContractError::InvalidStructure {
343                    path: format!("{path}.bone_index"),
344                    reason: "bone channel entries must use strictly increasing unique bone indices"
345                        .into(),
346                });
347            }
348            previous_bone_index = Some(bone.bone_index);
349            if bone.properties.is_empty() {
350                return Err(MeasurementContractError::InvalidStructure {
351                    path: format!("{path}.properties"),
352                    reason: "bone channel coverage must contain at least one property".into(),
353                });
354            }
355            if bone
356                .properties
357                .windows(2)
358                .any(|properties| properties[0] >= properties[1])
359            {
360                return Err(MeasurementContractError::InvalidStructure {
361                    path: format!("{path}.properties"),
362                    reason:
363                        "channel properties must be unique and ordered translation, rotation, scale"
364                            .into(),
365                });
366            }
367            covered_bone_names.insert(bone.bone_name.clone());
368        }
369        let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
370        if clip.animated_bones != expected_animated_bones {
371            return Err(MeasurementContractError::InvalidStructure {
372                path: format!("clips[{clip_name:?}].animated_bones"),
373                reason: "animated_bones must equal the sorted unique bone names in bone_channels"
374                    .into(),
375            });
376        }
377        for (bone, value) in &clip.bone_rotation_range_deg {
378            if clip.animated_bones.binary_search(bone).is_err() {
379                return Err(MeasurementContractError::InvalidStructure {
380                    path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
381                    reason: "rotation-range bones must be present in animated_bones".into(),
382                });
383            }
384            finite(
385                *value,
386                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
387            )?;
388        }
389        check_availability(
390            clip.loop_continuity.is_some(),
391            clip.loop_continuity_availability,
392            format!("clips[{clip_name:?}].loop_continuity"),
393        )?;
394        check_availability(
395            clip.loop_endpoint_mode.is_some(),
396            clip.loop_endpoint_mode_availability,
397            format!("clips[{clip_name:?}].loop_endpoint_mode"),
398        )?;
399        check_availability(
400            clip.frame_grid.is_some(),
401            clip.frame_grid_availability,
402            format!("clips[{clip_name:?}].frame_grid"),
403        )?;
404        check_availability(
405            clip.loop_seam_ratio.is_some(),
406            clip.loop_seam_ratio_availability,
407            format!("clips[{clip_name:?}].loop_seam_ratio"),
408        )?;
409        check_availability(
410            clip.gait.is_some(),
411            clip.gait_availability,
412            format!("clips[{clip_name:?}].gait"),
413        )?;
414        check_availability(
415            clip.root_trajectory.is_some(),
416            clip.root_trajectory_availability,
417            format!("clips[{clip_name:?}].root_trajectory"),
418        )?;
419        check_availability(
420            clip.speed_mps.is_some(),
421            clip.speed_mps_availability,
422            format!("clips[{clip_name:?}].speed_mps"),
423        )?;
424        if let Some(gait) = &clip.gait {
425            check_availability(
426                gait.phase.is_some(),
427                gait.phase_availability,
428                format!("clips[{clip_name:?}].gait.phase"),
429            )?;
430        }
431        if let Some(trajectory) = &clip.root_trajectory {
432            let path = format!("clips[{clip_name:?}].root_trajectory");
433            check_availability(
434                trajectory.translation.is_some(),
435                trajectory.translation_availability,
436                format!("{path}.translation"),
437            )?;
438            check_availability(
439                trajectory.yaw.is_some(),
440                trajectory.yaw_availability,
441                format!("{path}.yaw"),
442            )?;
443            if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
444                return Err(MeasurementContractError::InvalidStructure {
445                    path: format!("{path}.translation_availability"),
446                    reason:
447                        "translation remains applicable when a root-trajectory bone is selected"
448                            .into(),
449                });
450            }
451            if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
452                return Err(MeasurementContractError::InvalidStructure {
453                    path: format!("{path}.yaw_availability"),
454                    reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
455                });
456            }
457            if let Some(translation) = trajectory.translation {
458                for (field, value) in [
459                    (
460                        "horizontal_displacement_x_m",
461                        translation.horizontal_displacement_x_m,
462                    ),
463                    (
464                        "horizontal_displacement_z_m",
465                        translation.horizontal_displacement_z_m,
466                    ),
467                    ("horizontal_travel_m", translation.horizontal_travel_m),
468                    (
469                        "vertical_displacement_m",
470                        translation.vertical_displacement_m,
471                    ),
472                    (
473                        "vertical_min_displacement_m",
474                        translation.vertical_min_displacement_m,
475                    ),
476                    (
477                        "vertical_max_displacement_m",
478                        translation.vertical_max_displacement_m,
479                    ),
480                ] {
481                    finite(value, format!("{path}.translation.{field}"))?;
482                }
483                if translation.horizontal_travel_m < 0.0 {
484                    return Err(MeasurementContractError::InvalidStructure {
485                        path: format!("{path}.translation.horizontal_travel_m"),
486                        reason: "sampled horizontal travel must be non-negative".into(),
487                    });
488                }
489                let horizontal_displacement_m = translation
490                    .horizontal_displacement_x_m
491                    .hypot(translation.horizontal_displacement_z_m);
492                if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
493                    return Err(MeasurementContractError::InvalidStructure {
494                        path: format!("{path}.translation.horizontal_travel_m"),
495                        reason: "sampled horizontal travel must contain endpoint displacement"
496                            .into(),
497                    });
498                }
499                if translation.vertical_min_displacement_m > 0.0
500                    || translation.vertical_max_displacement_m < 0.0
501                    || translation.vertical_displacement_m < translation.vertical_min_displacement_m
502                    || translation.vertical_displacement_m > translation.vertical_max_displacement_m
503                {
504                    return Err(MeasurementContractError::InvalidStructure {
505                        path: format!("{path}.translation"),
506                        reason: "vertical extrema must include zero and the endpoint displacement"
507                            .into(),
508                    });
509                }
510            }
511            if let Some(yaw) = trajectory.yaw {
512                finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
513                finite(
514                    yaw.unwrapped_yaw_deg,
515                    format!("{path}.yaw.unwrapped_yaw_deg"),
516                )?;
517                finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
518                if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
519                    return Err(MeasurementContractError::InvalidStructure {
520                        path: format!("{path}.yaw.net_yaw_deg"),
521                        reason: "net yaw must be in the inclusive range [-180, 180]".into(),
522                    });
523                }
524                if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
525                    return Err(MeasurementContractError::InvalidStructure {
526                        path: format!("{path}.yaw.net_yaw_deg"),
527                        reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
528                            .into(),
529                    });
530                }
531                if yaw.yaw_travel_deg < 0.0 {
532                    return Err(MeasurementContractError::InvalidStructure {
533                        path: format!("{path}.yaw.yaw_travel_deg"),
534                        reason: "sampled yaw travel must be non-negative".into(),
535                    });
536                }
537                if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
538                    return Err(MeasurementContractError::InvalidStructure {
539                        path: format!("{path}.yaw.yaw_travel_deg"),
540                        reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
541                    });
542                }
543            }
544        }
545        if let Some(loop_continuity) = &clip.loop_continuity {
546            if loop_continuity.bones.is_empty() {
547                return Err(MeasurementContractError::InvalidStructure {
548                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
549                    reason: "present loop-continuity evidence must contain at least one bone"
550                        .into(),
551                });
552            }
553            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
554                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
555                if usize::try_from(bone.bone_index) != Ok(expected_index) {
556                    return Err(MeasurementContractError::InvalidStructure {
557                        path: format!("{path}.bone_index"),
558                        reason: format!(
559                            "expected skeleton-order index {expected_index}, found {}",
560                            bone.bone_index
561                        ),
562                    });
563                }
564                for (field, value) in [
565                    ("position_delta_m", bone.position_delta_m),
566                    ("rotation_delta_deg", bone.rotation_delta_deg),
567                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
568                    (
569                        "seam_angular_velocity_delta_degps",
570                        bone.seam_angular_velocity_delta_degps,
571                    ),
572                ] {
573                    finite(value, format!("{path}.{field}"))?;
574                    if value < 0.0 {
575                        return Err(MeasurementContractError::InvalidStructure {
576                            path: format!("{path}.{field}"),
577                            reason: "loop-continuity deltas must be non-negative".into(),
578                        });
579                    }
580                }
581            }
582        }
583        if let Some(frame_grid) = &clip.frame_grid {
584            let path = format!("clips[{clip_name:?}].frame_grid");
585            finite(frame_grid.fps, format!("{path}.fps"))?;
586            if frame_grid.fps <= 0.0 {
587                return Err(MeasurementContractError::InvalidStructure {
588                    path: format!("{path}.fps"),
589                    reason: "declared frame-grid FPS must be positive".into(),
590                });
591            }
592            if frame_grid.frame_intervals == 0 {
593                return Err(MeasurementContractError::InvalidStructure {
594                    path: format!("{path}.frame_intervals"),
595                    reason: "declared frame-grid evidence must contain at least one interval"
596                        .into(),
597                });
598            }
599        }
600        if let Some(value) = clip.loop_seam_ratio {
601            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
602        }
603        if let Some(gait) = &clip.gait {
604            if let Some(value) = gait.phase {
605                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
606            }
607            finite(
608                gait.lr_amplitude_m,
609                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
610            )?;
611        }
612        if let Some(value) = clip.speed_mps {
613            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
614        }
615    }
616    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
617        path,
618        reason: reason.to_owned(),
619    };
620    let finite_aabb = |aabb: &Aabb, path: &str| {
621        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
622            for (axis, value) in values.into_iter().enumerate() {
623                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
624            }
625        }
626        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
627            if min > max {
628                return Err(invalid(
629                    format!("{path}.min[{axis}]"),
630                    "AABB minimum cannot exceed maximum",
631                ));
632            }
633        }
634        Ok(())
635    };
636
637    let mut mesh_indices = BTreeSet::new();
638    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
639        if !mesh_indices.insert(mesh.mesh_index) {
640            return Err(invalid(
641                format!("mesh_definitions[{index}].mesh_index"),
642                "mesh_index must be unique",
643            ));
644        }
645        if let Some(aabb) = &mesh.geometry_aabb {
646            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
647        }
648        if let Some(centroid) = mesh.geometry_centroid {
649            for (axis, value) in centroid.into_iter().enumerate() {
650                finite(
651                    f64::from(value),
652                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
653                )?;
654            }
655        }
656        if let Some(value) = mesh.weight_sum_min {
657            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
658        }
659        if let Some(value) = mesh.weight_sum_max {
660            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
661        }
662        let mut previous_set_index = None;
663        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
664            let path = format!(
665                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
666            );
667            if set.set_index == 0 {
668                return Err(invalid(path, "set_index must be at least 1"));
669            }
670            if !set.joints_present && !set.weights_present {
671                return Err(invalid(
672                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
673                    "an additional influence set must declare joints, weights, or both",
674                ));
675            }
676            if set.joints_without_weights_present && !set.joints_present {
677                return Err(invalid(
678                    format!(
679                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
680                    ),
681                    "joints_without_weights_present requires joints_present",
682                ));
683            }
684            if set.weights_without_joints_present && !set.weights_present {
685                return Err(invalid(
686                    format!(
687                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
688                    ),
689                    "weights_without_joints_present requires weights_present",
690                ));
691            }
692            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
693                return Err(invalid(
694                    format!(
695                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
696                    ),
697                    "joints_without_weights_present is required when weights_present is false",
698                ));
699            }
700            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
701                return Err(invalid(
702                    format!(
703                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
704                    ),
705                    "weights_without_joints_present is required when joints_present is false",
706                ));
707            }
708            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
709                return Err(invalid(
710                    path,
711                    "set_index values must be strictly increasing and unique",
712                ));
713            }
714            previous_set_index = Some(set.set_index);
715        }
716    }
717
718    let mut node_indices = BTreeSet::new();
719    for (index, instance) in assets.node_instances.iter().enumerate() {
720        if !node_indices.insert(instance.node_index) {
721            return Err(invalid(
722                format!("node_instances[{index}].node_index"),
723                "node_index must be unique",
724            ));
725        }
726        if !mesh_indices.contains(&instance.mesh_index) {
727            return Err(invalid(
728                format!("node_instances[{index}].mesh_index"),
729                "mesh_index must reference a mesh definition",
730            ));
731        }
732        match (
733            instance.static_node_world_aabb.as_ref(),
734            instance.static_node_world_aabb_unavailable_reason,
735        ) {
736            (Some(aabb), None) => finite_aabb(
737                aabb,
738                &format!("node_instances[{index}].static_node_world_aabb"),
739            )?,
740            (None, Some(_)) => {}
741            (Some(_), Some(_)) => {
742                return Err(invalid(
743                    format!("node_instances[{index}]"),
744                    "an available static node AABB cannot have an unavailable reason",
745                ));
746            }
747            (None, None) => {
748                return Err(invalid(
749                    format!("node_instances[{index}]"),
750                    "a missing static node AABB requires an unavailable reason",
751                ));
752            }
753        }
754    }
755
756    let mut scene_indices = BTreeSet::new();
757    for (index, scene) in assets.scenes.iter().enumerate() {
758        if !scene_indices.insert(scene.scene_index) {
759            return Err(invalid(
760                format!("scenes[{index}].scene_index"),
761                "scene_index must be unique",
762            ));
763        }
764        if scene.excluded_instance_count > scene.instance_count {
765            return Err(invalid(
766                format!("scenes[{index}].excluded_instance_count"),
767                "excluded_instance_count cannot exceed instance_count",
768            ));
769        }
770        let available = scene.instance_count - scene.excluded_instance_count;
771        match (&scene.static_scene_world_aabb, available) {
772            (Some(aabb), 1..) => {
773                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
774            }
775            (None, 0) => {}
776            (Some(_), 0) => {
777                return Err(invalid(
778                    format!("scenes[{index}].static_scene_world_aabb"),
779                    "a scene with no available instances cannot have an AABB",
780                ));
781            }
782            (None, _) => {
783                return Err(invalid(
784                    format!("scenes[{index}].static_scene_world_aabb"),
785                    "a scene with available instances requires an AABB",
786                ));
787            }
788        }
789    }
790    if let Some(default_scene_index) = assets.default_scene_index
791        && !scene_indices.contains(&default_scene_index)
792    {
793        return Err(invalid(
794            "default_scene_index".into(),
795            "default_scene_index must reference a declared scene",
796        ));
797    }
798    validate_skeleton_measurements(assets, &invalid)?;
799    validate_material_resources(assets, &invalid)?;
800    Ok(())
801}
802
803fn validate_linear_transform_fields(
804    linear: &LinearTransformMeasurements,
805    path: &str,
806    invalid: &impl Fn(String, &str) -> MeasurementContractError,
807) -> Result<(), MeasurementContractError> {
808    let numeric_fields_present = linear.axis_lengths.is_some()
809        && linear.determinant.is_some()
810        && linear.orientation.is_some();
811    if linear.classification == LinearTransformClassification::NonFinite {
812        if linear.axis_lengths.is_some()
813            || linear.determinant.is_some()
814            || linear.orientation.is_some()
815            || linear.uniform_scale.is_some()
816        {
817            return Err(invalid(
818                path.into(),
819                "a non_finite classification cannot carry numeric linear-transform facts",
820            ));
821        }
822        return Ok(());
823    }
824    if !numeric_fields_present {
825        return Err(invalid(
826            path.into(),
827            "a finite classification requires axis_lengths, determinant, and orientation",
828        ));
829    }
830    for (axis, value) in linear
831        .axis_lengths
832        .expect("presence checked")
833        .into_iter()
834        .enumerate()
835    {
836        if !value.is_finite() {
837            return Err(MeasurementContractError::NonFiniteValue {
838                path: format!("{path}.axis_lengths[{axis}]"),
839            });
840        }
841        if value < 0.0 {
842            return Err(invalid(
843                format!("{path}.axis_lengths[{axis}]"),
844                "axis lengths must be non-negative",
845            ));
846        }
847    }
848    if !linear.determinant.expect("presence checked").is_finite() {
849        return Err(MeasurementContractError::NonFiniteValue {
850            path: format!("{path}.determinant"),
851        });
852    }
853    if let Some(scale) = linear.uniform_scale {
854        if !scale.is_finite() {
855            return Err(MeasurementContractError::NonFiniteValue {
856                path: format!("{path}.uniform_scale"),
857            });
858        }
859        if scale < 0.0 {
860            return Err(invalid(
861                format!("{path}.uniform_scale"),
862                "uniform scale must be non-negative",
863            ));
864        }
865    }
866    Ok(())
867}
868
869fn validate_skeleton_measurements(
870    assets: &AssetMeasurements,
871    invalid: &impl Fn(String, &str) -> MeasurementContractError,
872) -> Result<(), MeasurementContractError> {
873    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
874        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
875            return Err(invalid(
876                "skeleton_source_coverage".into(),
877                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
878            ));
879        }
880        return Ok(());
881    }
882
883    let finite_matrix = |matrix: &[f32; 16], path: &str| {
884        for (component, value) in matrix.iter().enumerate() {
885            if !value.is_finite() {
886                return Err(MeasurementContractError::NonFiniteValue {
887                    path: format!("{path}[{component}]"),
888                });
889            }
890        }
891        Ok(())
892    };
893    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
894        if node.node_index != offset {
895            return Err(invalid(
896                format!("skeleton_nodes[{offset}].node_index"),
897                "node_index must be contiguous and match source order",
898            ));
899        }
900        match &node.local_rest {
901            SkeletonNodeLocalRestMeasurements::Trs {
902                translation_parent_space_m,
903                rotation_xyzw,
904                scale,
905            } => {
906                for (field, values) in [
907                    (
908                        "translation_parent_space_m",
909                        translation_parent_space_m.as_slice(),
910                    ),
911                    ("rotation_xyzw", rotation_xyzw.as_slice()),
912                    ("scale", scale.as_slice()),
913                ] {
914                    for (component, value) in values.iter().enumerate() {
915                        if !value.is_finite() {
916                            return Err(MeasurementContractError::NonFiniteValue {
917                                path: format!(
918                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
919                                ),
920                            });
921                        }
922                    }
923                }
924            }
925            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
926                matrix,
927                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
928            )?,
929            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
930        }
931        let node_path = format!("skeleton_nodes[{offset}]");
932        validate_linear_transform_fields(
933            &node.rest_world_linear,
934            &format!("{node_path}.rest_world_linear"),
935            invalid,
936        )?;
937        match (
938            node.rest_world_matrix.as_ref(),
939            node.rest_world_translation_m.as_ref(),
940            node.rest_world_matrix_unavailable_reason,
941        ) {
942            (Some(matrix), Some(translation), None) => {
943                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
944                for (component, value) in translation.iter().enumerate() {
945                    if !value.is_finite() {
946                        return Err(MeasurementContractError::NonFiniteValue {
947                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
948                        });
949                    }
950                }
951                let expected_translation = [matrix[12], matrix[13], matrix[14]];
952                if *translation != expected_translation {
953                    return Err(invalid(
954                        format!("{node_path}.rest_world_translation_m"),
955                        "rest_world_translation_m must equal the rest-world matrix translation column",
956                    ));
957                }
958                let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
959                if node.rest_world_linear != expected_linear {
960                    return Err(invalid(
961                        format!("{node_path}.rest_world_linear"),
962                        "rest_world_linear must be derived from rest_world_matrix",
963                    ));
964                }
965            }
966            (None, None, Some(_)) => {
967                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
968                {
969                    return Err(invalid(
970                        format!("{node_path}.rest_world_linear"),
971                        "an unavailable rest-world matrix requires a non_finite linear classification",
972                    ));
973                }
974            }
975            (Some(_), Some(_), Some(_)) => {
976                return Err(invalid(
977                    node_path,
978                    "an available rest_world_matrix cannot have an unavailable reason",
979                ));
980            }
981            _ => {
982                return Err(invalid(
983                    node_path,
984                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
985                ));
986            }
987        }
988    }
989    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
990        if let Some(parent) = node.parent_node_index
991            && parent >= assets.skeleton_nodes.len()
992        {
993            return Err(invalid(
994                format!("skeleton_nodes[{offset}].parent_node_index"),
995                "parent_node_index must reference a skeleton node",
996            ));
997        }
998        let mut previous_scene = None;
999        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1000            if !assets
1001                .scenes
1002                .iter()
1003                .any(|scene| scene.scene_index == *scene_index)
1004            {
1005                return Err(invalid(
1006                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1007                    "scene_root_indices values must reference declared scenes",
1008                ));
1009            }
1010            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1011                return Err(invalid(
1012                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1013                    "scene_root_indices values must be strictly increasing and unique",
1014                ));
1015            }
1016            previous_scene = Some(*scene_index);
1017        }
1018    }
1019    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1020    for start in 0..assets.skeleton_nodes.len() {
1021        if visits.get(start) != Some(&ParentVisit::Unvisited) {
1022            continue;
1023        }
1024        let mut path = Vec::new();
1025        let mut current = start;
1026        loop {
1027            match visits.get(current).copied().ok_or_else(|| {
1028                invalid(
1029                    format!("skeleton_nodes[{current}].parent_node_index"),
1030                    "parent_node_index must reference a skeleton node",
1031                )
1032            })? {
1033                ParentVisit::Done => break,
1034                ParentVisit::Visiting => {
1035                    return Err(invalid(
1036                        format!("skeleton_nodes[{current}].parent_node_index"),
1037                        "source node parent graph must be acyclic",
1038                    ));
1039                }
1040                ParentVisit::Unvisited => {
1041                    *visits.get_mut(current).ok_or_else(|| {
1042                        invalid(
1043                            format!("skeleton_nodes[{current}].parent_node_index"),
1044                            "parent_node_index must reference a skeleton node",
1045                        )
1046                    })? = ParentVisit::Visiting;
1047                    path.push(current);
1048                    match assets
1049                        .skeleton_nodes
1050                        .get(current)
1051                        .ok_or_else(|| {
1052                            invalid(
1053                                format!("skeleton_nodes[{current}].parent_node_index"),
1054                                "parent_node_index must reference a skeleton node",
1055                            )
1056                        })?
1057                        .parent_node_index
1058                    {
1059                        Some(parent) => current = parent,
1060                        None => break,
1061                    }
1062                }
1063            }
1064        }
1065        for node_index in path {
1066            *visits.get_mut(node_index).ok_or_else(|| {
1067                invalid(
1068                    format!("skeleton_nodes[{node_index}].parent_node_index"),
1069                    "parent_node_index must reference a skeleton node",
1070                )
1071            })? = ParentVisit::Done;
1072        }
1073    }
1074
1075    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1076        let local_rest_available = !matches!(
1077            node.local_rest,
1078            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1079        );
1080        let path = format!("skeleton_nodes[{offset}]");
1081        if !local_rest_available {
1082            if node.rest_world_matrix.is_some()
1083                || node.rest_world_matrix_unavailable_reason
1084                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1085            {
1086                return Err(invalid(
1087                    path,
1088                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1089                ));
1090            }
1091            continue;
1092        }
1093
1094        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1095            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1096                invalid(
1097                    format!("skeleton_nodes[{offset}].parent_node_index"),
1098                    "parent_node_index must reference a skeleton node",
1099                )
1100            })?;
1101            if parent.rest_world_matrix.is_none() {
1102                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1103            } else {
1104                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1105            }
1106        } else {
1107            None
1108        };
1109        match (
1110            node.rest_world_matrix.is_some(),
1111            expected_unavailable_reason,
1112        ) {
1113            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1114            }
1115            (false, Some(expected))
1116                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1117            _ => {
1118                return Err(invalid(
1119                    path,
1120                    "rest-world availability must agree with local rest and parent rest-world evidence",
1121                ));
1122            }
1123        }
1124    }
1125
1126    for (offset, skin) in assets.skins.iter().enumerate() {
1127        if skin.skin_index != offset {
1128            return Err(invalid(
1129                format!("skins[{offset}].skin_index"),
1130                "skin_index must be contiguous and match source order",
1131            ));
1132        }
1133        if let Some(root) = skin.skeleton_root_node_index
1134            && root >= assets.skeleton_nodes.len()
1135        {
1136            return Err(invalid(
1137                format!("skins[{offset}].skeleton_root_node_index"),
1138                "skeleton_root_node_index must reference a skeleton node",
1139            ));
1140        }
1141        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1142            if joint.joint_index != joint_offset {
1143                return Err(invalid(
1144                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1145                    "joint_index must be contiguous and match declared skin order",
1146                ));
1147            }
1148            if joint.node_index >= assets.skeleton_nodes.len() {
1149                return Err(invalid(
1150                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
1151                    "joint node_index must reference a skeleton node",
1152                ));
1153            }
1154        }
1155        match skin.inverse_bind_accessor.status {
1156            SourceInverseBindAccessorStatus::Absent => {
1157                if skin.inverse_bind_accessor.declared_count.is_some()
1158                    || !skin.inverse_bind_accessor.matrices.is_empty()
1159                {
1160                    return Err(invalid(
1161                        format!("skins[{offset}].inverse_bind_accessor"),
1162                        "an absent inverse-bind declaration has no declared count or matrices",
1163                    ));
1164                }
1165            }
1166            SourceInverseBindAccessorStatus::EmptyAccessor => {
1167                if skin.inverse_bind_accessor.declared_count != Some(0)
1168                    || !skin.inverse_bind_accessor.matrices.is_empty()
1169                {
1170                    return Err(invalid(
1171                        format!("skins[{offset}].inverse_bind_accessor"),
1172                        "an empty inverse-bind declaration has declared_count 0 and no matrices",
1173                    ));
1174                }
1175            }
1176            SourceInverseBindAccessorStatus::Available => {
1177                if skin.inverse_bind_accessor.declared_count
1178                    != Some(skin.inverse_bind_accessor.matrices.len())
1179                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1180                {
1181                    return Err(invalid(
1182                        format!("skins[{offset}].inverse_bind_accessor"),
1183                        "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1184                    ));
1185                }
1186            }
1187            SourceInverseBindAccessorStatus::CountMismatch => {
1188                if skin.inverse_bind_accessor.declared_count
1189                    != Some(skin.inverse_bind_accessor.matrices.len())
1190                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1191                {
1192                    return Err(invalid(
1193                        format!("skins[{offset}].inverse_bind_accessor"),
1194                        "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1195                    ));
1196                }
1197            }
1198            SourceInverseBindAccessorStatus::Unreadable => {
1199                if skin.inverse_bind_accessor.declared_count.is_none()
1200                    || !skin.inverse_bind_accessor.matrices.is_empty()
1201                {
1202                    return Err(invalid(
1203                        format!("skins[{offset}].inverse_bind_accessor"),
1204                        "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1205                    ));
1206                }
1207            }
1208        }
1209        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1210            finite_matrix(
1211                matrix,
1212                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1213            )?;
1214        }
1215        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1216            let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1217            let joint_bind_path =
1218                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1219            validate_derived_matrix(
1220                &joint.joint_bind_to_mesh,
1221                &joint_bind_path,
1222                &finite_matrix,
1223                invalid,
1224            )?;
1225            validate_derived_reason_compatibility(
1226                &joint.joint_bind_to_mesh,
1227                skin.inverse_bind_accessor.status,
1228                skin.inverse_bind_accessor.matrices.len(),
1229                joint_offset,
1230                &joint_bind_path,
1231                DerivedMatrixDomain::JointBindToMesh,
1232                invalid,
1233            )?;
1234            validate_derived_source(
1235                &joint.joint_bind_to_mesh,
1236                expected_source,
1237                None,
1238                &joint_bind_path,
1239                DerivedMatrixDomain::JointBindToMesh,
1240                invalid,
1241            )?;
1242
1243            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1244            validate_derived_matrix(
1245                &joint.mesh_bind_world,
1246                &mesh_bind_path,
1247                &finite_matrix,
1248                invalid,
1249            )?;
1250            validate_derived_reason_compatibility(
1251                &joint.mesh_bind_world,
1252                skin.inverse_bind_accessor.status,
1253                skin.inverse_bind_accessor.matrices.len(),
1254                joint_offset,
1255                &mesh_bind_path,
1256                DerivedMatrixDomain::MeshBindWorld,
1257                invalid,
1258            )?;
1259            let joint_rest_world_available = assets
1260                .skeleton_nodes
1261                .get(joint.node_index)
1262                .ok_or_else(|| {
1263                    invalid(
1264                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
1265                        "joint node_index must reference a skeleton node",
1266                    )
1267                })?
1268                .rest_world_matrix
1269                .is_some();
1270            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1271                .rest_world_matrix
1272                .as_ref();
1273            validate_mesh_bind_world_reason_compatibility(
1274                &joint.mesh_bind_world,
1275                joint_rest_world_available,
1276                &mesh_bind_path,
1277                invalid,
1278            )?;
1279            validate_derived_source(
1280                &joint.mesh_bind_world,
1281                expected_source,
1282                joint_rest_world,
1283                &mesh_bind_path,
1284                DerivedMatrixDomain::MeshBindWorld,
1285                invalid,
1286            )?;
1287        }
1288        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1289            && !scale.is_finite()
1290        {
1291            return Err(MeasurementContractError::NonFiniteValue {
1292                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1293            });
1294        }
1295        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1296        if skin.joint_bind_linear_summary != expected_summary {
1297            return Err(invalid(
1298                format!("skins[{offset}].joint_bind_linear_summary"),
1299                "joint-bind linear summary must match the skin joint observations",
1300            ));
1301        }
1302        let mut previous_attachment_node = None;
1303        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1304            if attachment.node_index >= assets.skeleton_nodes.len() {
1305                return Err(invalid(
1306                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1307                    "attachment node_index must reference a skeleton node",
1308                ));
1309            }
1310            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1311                return Err(invalid(
1312                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1313                    "attachment node_index values must be strictly increasing and unique",
1314                ));
1315            }
1316            previous_attachment_node = Some(attachment.node_index);
1317        }
1318    }
1319    Ok(())
1320}
1321
1322fn validate_derived_reason_compatibility(
1323    matrix: &SkinDerivedMatrixMeasurements,
1324    status: SourceInverseBindAccessorStatus,
1325    readable_matrix_count: usize,
1326    joint_index: usize,
1327    path: &str,
1328    domain: DerivedMatrixDomain,
1329    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1330) -> Result<(), MeasurementContractError> {
1331    let requires_accessor_reason = match status {
1332        SourceInverseBindAccessorStatus::Absent => {
1333            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1334        }
1335        SourceInverseBindAccessorStatus::EmptyAccessor => {
1336            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1337        }
1338        SourceInverseBindAccessorStatus::Unreadable => {
1339            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1340        }
1341        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1342            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1343        }
1344        SourceInverseBindAccessorStatus::Available
1345        | SourceInverseBindAccessorStatus::CountMismatch => None,
1346    };
1347    if let Some(expected) = requires_accessor_reason {
1348        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1349            return Err(invalid(
1350                path.into(),
1351                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1352            ));
1353        }
1354    } else {
1355        match (domain, matrix.unavailable_reason) {
1356            (
1357                _,
1358                Some(
1359                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1360                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1361                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1362                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1363                ),
1364            ) => {
1365                return Err(invalid(
1366                    format!("{path}.unavailable_reason"),
1367                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1368                ));
1369            }
1370            (
1371                DerivedMatrixDomain::JointBindToMesh,
1372                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1373            ) => {
1374                return Err(invalid(
1375                    format!("{path}.unavailable_reason"),
1376                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1377                ));
1378            }
1379            (
1380                DerivedMatrixDomain::MeshBindWorld,
1381                Some(
1382                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1383                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1384                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1385                ),
1386            ) => {
1387                return Err(invalid(
1388                    format!("{path}.unavailable_reason"),
1389                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1390                ));
1391            }
1392            _ => {}
1393        }
1394    }
1395    Ok(())
1396}
1397
1398fn validate_mesh_bind_world_reason_compatibility(
1399    matrix: &SkinDerivedMatrixMeasurements,
1400    joint_rest_world_available: bool,
1401    path: &str,
1402    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1403) -> Result<(), MeasurementContractError> {
1404    match matrix.unavailable_reason {
1405        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1406            if joint_rest_world_available =>
1407        {
1408            Err(invalid(
1409                format!("{path}.unavailable_reason"),
1410                "an available joint rest-world matrix cannot be reported as unavailable",
1411            ))
1412        }
1413        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1414            if !joint_rest_world_available =>
1415        {
1416            Err(invalid(
1417                format!("{path}.unavailable_reason"),
1418                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1419            ))
1420        }
1421        _ => Ok(()),
1422    }
1423}
1424
1425#[derive(Clone, Copy, PartialEq, Eq)]
1426enum ParentVisit {
1427    Unvisited,
1428    Visiting,
1429    Done,
1430}
1431
1432#[derive(Clone, Copy)]
1433enum DerivedMatrixDomain {
1434    JointBindToMesh,
1435    MeshBindWorld,
1436}
1437
1438fn validate_derived_source(
1439    measurements: &SkinDerivedMatrixMeasurements,
1440    expected_source: Option<&[f32; 16]>,
1441    joint_rest_world: Option<&[f32; 16]>,
1442    path: &str,
1443    domain: DerivedMatrixDomain,
1444    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1445) -> Result<(), MeasurementContractError> {
1446    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1447        return Err(invalid(
1448            format!("{path}.source_inverse_bind_matrix"),
1449            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1450        ));
1451    }
1452    let Some(source) = expected_source else {
1453        if measurements.inversion_quality.is_some() {
1454            return Err(invalid(
1455                format!("{path}.inversion_quality"),
1456                "inversion quality requires a readable source inverse-bind matrix",
1457            ));
1458        }
1459        return Ok(());
1460    };
1461    let raw = Mat4::from_cols_array(source);
1462    match domain {
1463        DerivedMatrixDomain::JointBindToMesh => {
1464            let assessment = assess_inverse_bind(raw);
1465            if measurements.inversion_quality != assessment.quality {
1466                return Err(invalid(
1467                    format!("{path}.inversion_quality"),
1468                    "inversion quality must be derived from the source linear 3x3",
1469                ));
1470            }
1471            match assessment.inverse {
1472                Ok(inverse) => {
1473                    if measurements.matrix != Some(inverse.to_cols_array())
1474                        || measurements.unavailable_reason.is_some()
1475                    {
1476                        return Err(invalid(
1477                            path.into(),
1478                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1479                        ));
1480                    }
1481                }
1482                Err(reason) => {
1483                    if measurements.matrix.is_some()
1484                        || measurements.unavailable_reason != Some(reason)
1485                    {
1486                        return Err(invalid(
1487                            path.into(),
1488                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1489                        ));
1490                    }
1491                }
1492            }
1493        }
1494        DerivedMatrixDomain::MeshBindWorld => {
1495            if measurements.inversion_quality.is_some() {
1496                return Err(invalid(
1497                    format!("{path}.inversion_quality"),
1498                    "mesh_bind_world does not invert its source matrix",
1499                ));
1500            }
1501            if let Some(world) = joint_rest_world {
1502                let expected = Mat4::from_cols_array(world) * raw;
1503                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1504                    if measurements.matrix != Some(expected.to_cols_array())
1505                        || measurements.unavailable_reason.is_some()
1506                    {
1507                        return Err(invalid(
1508                            path.into(),
1509                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1510                        ));
1511                    }
1512                } else if measurements.unavailable_reason
1513                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1514                {
1515                    return Err(invalid(
1516                        format!("{path}.unavailable_reason"),
1517                        "a non-finite mesh-bind product requires its typed unavailable reason",
1518                    ));
1519                }
1520            }
1521        }
1522    }
1523    Ok(())
1524}
1525
1526fn validate_derived_matrix(
1527    matrix: &SkinDerivedMatrixMeasurements,
1528    path: &str,
1529    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1530    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1531) -> Result<(), MeasurementContractError> {
1532    if let Some(source) = &matrix.source_inverse_bind_matrix {
1533        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1534    }
1535    if let Some(quality) = matrix.inversion_quality {
1536        let value = quality.reciprocal_condition_number_inf;
1537        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1538            return Err(invalid(
1539                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1540                "reciprocal condition number must be finite and between zero and one",
1541            ));
1542        }
1543    }
1544    match (
1545        &matrix.matrix,
1546        matrix.linear.as_ref(),
1547        matrix.unavailable_reason,
1548    ) {
1549        (Some(matrix), Some(linear), None) => {
1550            finite_matrix(matrix, &format!("{path}.matrix"))?;
1551            validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1552            if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1553                return Err(invalid(
1554                    format!("{path}.linear"),
1555                    "linear facts must be derived from the available matrix",
1556                ));
1557            }
1558        }
1559        (None, None, Some(_)) => {}
1560        (Some(_), Some(_), Some(_)) => {
1561            return Err(invalid(
1562                path.into(),
1563                "an available derived matrix cannot have an unavailable reason",
1564            ));
1565        }
1566        _ => {
1567            return Err(invalid(
1568                path.into(),
1569                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1570            ));
1571        }
1572    }
1573    Ok(())
1574}
1575
1576fn validate_material_resources(
1577    assets: &AssetMeasurements,
1578    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1579) -> Result<(), MeasurementContractError> {
1580    let absent = assets.material_definitions.is_empty()
1581        && assets.textures.is_empty()
1582        && assets.images.is_empty();
1583    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1584        return Err(invalid(
1585            "material_resource_coverage".into(),
1586            "unavailable resource coverage requires empty material, texture, and image arrays",
1587        ));
1588    }
1589
1590    for (offset, material) in assets.material_definitions.iter().enumerate() {
1591        if material.material_index != offset {
1592            return Err(invalid(
1593                format!("material_definitions[{offset}].material_index"),
1594                "material_index must be contiguous and match source order",
1595            ));
1596        }
1597        let mut previous_slot = None;
1598        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1599            if binding.texture_index >= assets.textures.len() {
1600                return Err(invalid(
1601                    format!(
1602                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1603                    ),
1604                    "texture_index must reference a source texture",
1605                ));
1606            }
1607            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1608                return Err(invalid(
1609                    format!(
1610                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1611                    ),
1612                    "texture bindings must be strictly ordered by slot and unique",
1613                ));
1614            }
1615            previous_slot = Some(binding.slot);
1616        }
1617    }
1618    for (offset, texture) in assets.textures.iter().enumerate() {
1619        if texture.texture_index != offset {
1620            return Err(invalid(
1621                format!("textures[{offset}].texture_index"),
1622                "texture_index must be contiguous and match source order",
1623            ));
1624        }
1625        if texture.image_index >= assets.images.len() {
1626            return Err(invalid(
1627                format!("textures[{offset}].image_index"),
1628                "image_index must reference a source image",
1629            ));
1630        }
1631    }
1632    for (offset, image) in assets.images.iter().enumerate() {
1633        validate_image_measurement(image, offset, invalid)?;
1634    }
1635    Ok(())
1636}
1637
1638fn validate_image_measurement(
1639    image: &ImageMeasurements,
1640    offset: usize,
1641    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1642) -> Result<(), MeasurementContractError> {
1643    if image.image_index != offset {
1644        return Err(invalid(
1645            format!("images[{offset}].image_index"),
1646            "image_index must be contiguous and match source order",
1647        ));
1648    }
1649    let available = [
1650        image.width.is_some(),
1651        image.height.is_some(),
1652        image.channel_count.is_some(),
1653        image.decoded_color_type.is_some(),
1654    ];
1655    match (
1656        available.into_iter().all(|value| value),
1657        image.unavailable_reason,
1658    ) {
1659        (true, None) => {
1660            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1661                image.width,
1662                image.height,
1663                image.channel_count,
1664                image.decoded_color_type,
1665            ) else {
1666                return Err(invalid(
1667                    format!("images[{offset}]"),
1668                    "available image metadata must include width, height, channel_count, and decoded_color_type",
1669                ));
1670            };
1671            if width == 0 || height == 0 {
1672                return Err(invalid(
1673                    format!("images[{offset}]"),
1674                    "available image dimensions must be greater than zero",
1675                ));
1676            }
1677            if channel_count != color_type_channel_count(decoded_color_type) {
1678                return Err(invalid(
1679                    format!("images[{offset}].channel_count"),
1680                    "channel_count must match decoded_color_type",
1681                ));
1682            }
1683            if image.detected_container.is_none() {
1684                return Err(invalid(
1685                    format!("images[{offset}].detected_container"),
1686                    "available image metadata requires a detected_container",
1687                ));
1688            }
1689        }
1690        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1691        (true, Some(_)) => {
1692            return Err(invalid(
1693                format!("images[{offset}]"),
1694                "available image metadata cannot have an unavailable_reason",
1695            ));
1696        }
1697        (false, None) if available.into_iter().all(|value| !value) => {
1698            return Err(invalid(
1699                format!("images[{offset}]"),
1700                "missing image metadata requires an unavailable_reason",
1701            ));
1702        }
1703        (false, _) => {
1704            return Err(invalid(
1705                format!("images[{offset}]"),
1706                "available image metadata must include width, height, channel_count, and decoded_color_type",
1707            ));
1708        }
1709    }
1710    match image.unavailable_reason {
1711        Some(crate::model::ImageUnavailableReason::DecodeFailed)
1712            if image.detected_container.is_none() =>
1713        {
1714            return Err(invalid(
1715                format!("images[{offset}].detected_container"),
1716                "decode_failed requires a detected_container",
1717            ));
1718        }
1719        Some(
1720            crate::model::ImageUnavailableReason::SourceUnavailable
1721            | crate::model::ImageUnavailableReason::InvalidDataUri
1722            | crate::model::ImageUnavailableReason::UnsupportedContainer,
1723        ) if image.detected_container.is_some() => {
1724            return Err(invalid(
1725                format!("images[{offset}].detected_container"),
1726                "this unavailable_reason cannot have a detected_container",
1727            ));
1728        }
1729        _ => {}
1730    }
1731    Ok(())
1732}
1733
1734fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1735    match color_type {
1736        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1737        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1738        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1739        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1740    }
1741}
1742
1743/// Typed read-side subset accepted when a consumer needs measurements from a
1744/// current `measure` or `lint` report.
1745///
1746/// This intentionally models only the fields needed to recover the nested
1747/// measurement contract while retaining every legitimate output-v10 root
1748/// field. The frozen schema is closed, while all protocol identities and
1749/// command constraints are validated by [`MeasurementReportInput::into_files`].
1750#[derive(Debug)]
1751pub struct MeasurementReportInput {
1752    schema_version: Option<u32>,
1753    schema: Option<String>,
1754    _tool: Option<Box<RawValue>>,
1755    command: Option<String>,
1756    summary: Option<MeasurementReportSummaryInput>,
1757    files: Option<Vec<Box<RawValue>>>,
1758    _inputs: Option<Box<RawValue>>,
1759    _deltas: Option<Box<RawValue>>,
1760    extra: BTreeMap<String, Box<RawValue>>,
1761}
1762
1763impl<'de> Deserialize<'de> for MeasurementReportInput {
1764    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1765    where
1766        D: Deserializer<'de>,
1767    {
1768        struct MeasurementReportInputVisitor;
1769
1770        impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
1771            type Value = MeasurementReportInput;
1772
1773            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1774                formatter.write_str("an output report object")
1775            }
1776
1777            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1778            where
1779                A: MapAccess<'de>,
1780            {
1781                let mut schema_version = None;
1782                let mut schema = None;
1783                let mut tool = None;
1784                let mut command = None;
1785                let mut summary = None;
1786                let mut files = None;
1787                let mut inputs = None;
1788                let mut deltas = None;
1789                let mut extra = BTreeMap::new();
1790                while let Some(field) = map.next_key::<String>()? {
1791                    match field.as_str() {
1792                        "schema_version" => {
1793                            if schema_version.is_some() {
1794                                return Err(serde::de::Error::duplicate_field("schema_version"));
1795                            }
1796                            schema_version = Some(map.next_value()?);
1797                        }
1798                        "schema" => {
1799                            if schema.is_some() {
1800                                return Err(serde::de::Error::duplicate_field("schema"));
1801                            }
1802                            schema = Some(map.next_value()?);
1803                        }
1804                        "tool" => {
1805                            if tool.is_some() {
1806                                return Err(serde::de::Error::duplicate_field("tool"));
1807                            }
1808                            tool = Some(map.next_value()?);
1809                        }
1810                        "command" => {
1811                            if command.is_some() {
1812                                return Err(serde::de::Error::duplicate_field("command"));
1813                            }
1814                            command = Some(map.next_value()?);
1815                        }
1816                        "summary" => {
1817                            if summary.is_some() {
1818                                return Err(serde::de::Error::duplicate_field("summary"));
1819                            }
1820                            summary = Some(map.next_value()?);
1821                        }
1822                        "files" => {
1823                            if files.is_some() {
1824                                return Err(serde::de::Error::duplicate_field("files"));
1825                            }
1826                            files = Some(map.next_value()?);
1827                        }
1828                        "inputs" => {
1829                            if inputs.is_some() {
1830                                return Err(serde::de::Error::duplicate_field("inputs"));
1831                            }
1832                            inputs = Some(map.next_value()?);
1833                        }
1834                        "deltas" => {
1835                            if deltas.is_some() {
1836                                return Err(serde::de::Error::duplicate_field("deltas"));
1837                            }
1838                            deltas = Some(map.next_value()?);
1839                        }
1840                        _ => {
1841                            extra.insert(field, map.next_value()?);
1842                        }
1843                    }
1844                }
1845                Ok(MeasurementReportInput {
1846                    schema_version: schema_version.unwrap_or_default(),
1847                    schema: schema.unwrap_or_default(),
1848                    _tool: tool,
1849                    command: command.unwrap_or_default(),
1850                    summary: summary.unwrap_or_default(),
1851                    files: files.unwrap_or_default(),
1852                    _inputs: inputs.unwrap_or_default(),
1853                    _deltas: deltas.unwrap_or_default(),
1854                    extra,
1855                })
1856            }
1857        }
1858
1859        deserializer.deserialize_map(MeasurementReportInputVisitor)
1860    }
1861}
1862
1863#[derive(Debug, Deserialize)]
1864#[serde(deny_unknown_fields)]
1865struct MeasurementFileWireInput {
1866    path: Option<String>,
1867    input: Option<InputIdentityInput>,
1868    #[serde(rename = "rig")]
1869    _rig: Box<RawValue>,
1870    measurements: Option<Box<RawValue>>,
1871    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1872    prediction_provenance: RequiredNullable<Box<RawValue>>,
1873    checks: Option<Vec<Box<RawValue>>>,
1874}
1875
1876#[derive(Debug)]
1877struct MeasurementFileInput {
1878    path: Option<String>,
1879    input: Option<InputIdentityInput>,
1880    measurements: Option<Box<RawValue>>,
1881    prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
1882    checks: Option<Vec<PredictionCheckInput>>,
1883}
1884
1885#[derive(Debug, Default)]
1886enum RequiredNullable<T> {
1887    #[default]
1888    Missing,
1889    Present(Option<T>),
1890}
1891
1892fn deserialize_required_nullable<'de, D, T>(
1893    deserializer: D,
1894) -> Result<RequiredNullable<T>, D::Error>
1895where
1896    D: Deserializer<'de>,
1897    T: Deserialize<'de>,
1898{
1899    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
1900}
1901
1902#[derive(Debug, Deserialize)]
1903#[serde(deny_unknown_fields)]
1904struct MeasurementReportSummaryInput {
1905    #[serde(rename = "files")]
1906    _files: Option<Box<RawValue>>,
1907    #[serde(rename = "findings")]
1908    _findings: Option<Box<RawValue>>,
1909    #[serde(rename = "checks")]
1910    _checks: Option<Box<RawValue>>,
1911    #[serde(rename = "deltas")]
1912    _deltas: Option<Box<RawValue>>,
1913    prediction_facets: Option<PredictionFacetSummaryInput>,
1914}
1915
1916#[derive(Debug, Deserialize)]
1917#[serde(deny_unknown_fields)]
1918struct PredictionFacetSummaryInput {
1919    available: usize,
1920    required_prediction_unavailable: usize,
1921}
1922
1923#[derive(Debug, Deserialize)]
1924#[serde(deny_unknown_fields)]
1925struct PredictionCheckWireInput {
1926    check_id: String,
1927    selection: SelectionState,
1928    configuration: ConfigurationState,
1929    applicability: Applicability,
1930    evaluation: EvaluationState,
1931    findings: Vec<PredictionFindingInput>,
1932    #[serde(default)]
1933    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
1934    #[serde(default)]
1935    gaps: Vec<PredictionGapInput>,
1936    prediction: Option<Box<RawValue>>,
1937}
1938
1939#[derive(Debug)]
1940struct PredictionCheckInput {
1941    check_id: String,
1942    selection: SelectionState,
1943    configuration: ConfigurationState,
1944    applicability: Applicability,
1945    evaluation: EvaluationState,
1946    findings: Vec<PredictionFindingInput>,
1947    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
1948    gaps: Vec<PredictionGapInput>,
1949    prediction: Option<crate::prediction::EnginePredictionV1>,
1950}
1951
1952#[derive(Debug, Deserialize)]
1953#[serde(deny_unknown_fields)]
1954struct PredictionFindingInput {
1955    check_id: String,
1956    #[serde(rename = "severity")]
1957    _severity: PredictionSeverityInput,
1958    #[serde(rename = "clip")]
1959    _clip: Option<String>,
1960    #[serde(rename = "bone")]
1961    _bone: Option<String>,
1962    #[serde(rename = "node")]
1963    _node: Option<String>,
1964    prediction_scope: Option<crate::evaluation::EvaluationScope>,
1965    #[serde(rename = "time_s")]
1966    _time_s: Option<f32>,
1967    #[serde(rename = "measured")]
1968    _measured: Option<Box<RawValue>>,
1969    #[serde(rename = "expected")]
1970    _expected: Option<Box<RawValue>>,
1971    #[serde(rename = "members")]
1972    _members: Option<Box<RawValue>>,
1973    #[serde(rename = "message")]
1974    _message: String,
1975}
1976
1977#[derive(Debug, Deserialize)]
1978#[serde(deny_unknown_fields)]
1979struct PredictionGapInput {
1980    code: String,
1981    #[serde(rename = "message")]
1982    _message: String,
1983    scope: Option<crate::evaluation::EvaluationScope>,
1984}
1985
1986#[derive(Debug, Deserialize)]
1987#[serde(rename_all = "snake_case")]
1988enum PredictionSeverityInput {
1989    Error,
1990    Warning,
1991    Note,
1992}
1993
1994#[derive(Debug, Deserialize)]
1995#[serde(deny_unknown_fields)]
1996struct InputIdentityInput {
1997    sha256: Option<String>,
1998    bytes: Option<u64>,
1999}
2000
2001/// Recursively preserves the historical JSON-f64-to-Rust-f32 narrowing path
2002/// without materializing an unbounded generic JSON value.
2003///
2004/// `serde_json` rejects a finite JSON number that exceeds `f32::MAX` when it
2005/// directly services `deserialize_f32`. Output-v9 readback first retained the
2006/// number as `f64`, then narrowed it to `f32`; semantic measurement validation
2007/// consequently reported the resulting infinity as a typed non-finite value.
2008/// This adapter retains that contract while streaming directly into the bounded
2009/// typed measurement DTO.
2010struct MeasurementF32NarrowingDeserializer<D>(D);
2011
2012macro_rules! delegate_measurement_deserializer {
2013    ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2014        fn $method<V>(
2015            self,
2016            $($argument: $argument_type,)*
2017            visitor: V,
2018        ) -> Result<V::Value, Self::Error>
2019        where
2020            V: Visitor<'de>,
2021        {
2022            self.0.$method(
2023                $($argument,)*
2024                MeasurementF32NarrowingVisitor(visitor),
2025            )
2026        }
2027    };
2028}
2029
2030impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2031where
2032    D: Deserializer<'de>,
2033{
2034    type Error = D::Error;
2035
2036    delegate_measurement_deserializer!(deserialize_any);
2037    delegate_measurement_deserializer!(deserialize_bool);
2038    delegate_measurement_deserializer!(deserialize_i8);
2039    delegate_measurement_deserializer!(deserialize_i16);
2040    delegate_measurement_deserializer!(deserialize_i32);
2041    delegate_measurement_deserializer!(deserialize_i64);
2042    delegate_measurement_deserializer!(deserialize_i128);
2043    delegate_measurement_deserializer!(deserialize_u8);
2044    delegate_measurement_deserializer!(deserialize_u16);
2045    delegate_measurement_deserializer!(deserialize_u32);
2046    delegate_measurement_deserializer!(deserialize_u64);
2047    delegate_measurement_deserializer!(deserialize_u128);
2048
2049    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2050    where
2051        V: Visitor<'de>,
2052    {
2053        self.0
2054            .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2055    }
2056
2057    delegate_measurement_deserializer!(deserialize_f64);
2058    delegate_measurement_deserializer!(deserialize_char);
2059    delegate_measurement_deserializer!(deserialize_str);
2060    delegate_measurement_deserializer!(deserialize_string);
2061    delegate_measurement_deserializer!(deserialize_bytes);
2062    delegate_measurement_deserializer!(deserialize_byte_buf);
2063    delegate_measurement_deserializer!(deserialize_option);
2064    delegate_measurement_deserializer!(deserialize_unit);
2065    delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2066    delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2067    delegate_measurement_deserializer!(deserialize_seq);
2068    delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2069    delegate_measurement_deserializer!(
2070        deserialize_tuple_struct,
2071        name: &'static str,
2072        len: usize
2073    );
2074    delegate_measurement_deserializer!(deserialize_map);
2075    delegate_measurement_deserializer!(
2076        deserialize_struct,
2077        name: &'static str,
2078        fields: &'static [&'static str]
2079    );
2080    delegate_measurement_deserializer!(
2081        deserialize_enum,
2082        name: &'static str,
2083        variants: &'static [&'static str]
2084    );
2085    delegate_measurement_deserializer!(deserialize_identifier);
2086    delegate_measurement_deserializer!(deserialize_ignored_any);
2087
2088    fn is_human_readable(&self) -> bool {
2089        self.0.is_human_readable()
2090    }
2091}
2092
2093struct MeasurementF32NarrowingNumberVisitor<V>(V);
2094
2095impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2096where
2097    V: Visitor<'de>,
2098{
2099    type Value = V::Value;
2100
2101    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2102        self.0.expecting(formatter)
2103    }
2104
2105    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2106    where
2107        E: serde::de::Error,
2108    {
2109        self.0.visit_f32(value)
2110    }
2111
2112    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2113    where
2114        E: serde::de::Error,
2115    {
2116        self.0.visit_f32(value as f32)
2117    }
2118
2119    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2120    where
2121        E: serde::de::Error,
2122    {
2123        self.0.visit_f32(value as f32)
2124    }
2125
2126    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2127    where
2128        E: serde::de::Error,
2129    {
2130        self.0.visit_f32(value as f32)
2131    }
2132
2133    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2134    where
2135        E: serde::de::Error,
2136    {
2137        self.0.visit_f32(value as f32)
2138    }
2139
2140    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2141    where
2142        E: serde::de::Error,
2143    {
2144        self.0.visit_f32(value as f32)
2145    }
2146}
2147
2148struct MeasurementF32NarrowingVisitor<V>(V);
2149
2150macro_rules! delegate_measurement_visitor {
2151    ($method:ident, $value_type:ty) => {
2152        fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2153        where
2154            E: serde::de::Error,
2155        {
2156            self.0.$method(value)
2157        }
2158    };
2159}
2160
2161impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2162where
2163    V: Visitor<'de>,
2164{
2165    type Value = V::Value;
2166
2167    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2168        self.0.expecting(formatter)
2169    }
2170
2171    delegate_measurement_visitor!(visit_bool, bool);
2172    delegate_measurement_visitor!(visit_i8, i8);
2173    delegate_measurement_visitor!(visit_i16, i16);
2174    delegate_measurement_visitor!(visit_i32, i32);
2175    delegate_measurement_visitor!(visit_i64, i64);
2176    delegate_measurement_visitor!(visit_i128, i128);
2177    delegate_measurement_visitor!(visit_u8, u8);
2178    delegate_measurement_visitor!(visit_u16, u16);
2179    delegate_measurement_visitor!(visit_u32, u32);
2180    delegate_measurement_visitor!(visit_u64, u64);
2181    delegate_measurement_visitor!(visit_u128, u128);
2182    delegate_measurement_visitor!(visit_f32, f32);
2183    delegate_measurement_visitor!(visit_f64, f64);
2184    delegate_measurement_visitor!(visit_char, char);
2185
2186    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2187    where
2188        E: serde::de::Error,
2189    {
2190        self.0.visit_str(value)
2191    }
2192
2193    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2194    where
2195        E: serde::de::Error,
2196    {
2197        self.0.visit_borrowed_str(value)
2198    }
2199
2200    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2201    where
2202        E: serde::de::Error,
2203    {
2204        self.0.visit_string(value)
2205    }
2206
2207    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2208    where
2209        E: serde::de::Error,
2210    {
2211        self.0.visit_bytes(value)
2212    }
2213
2214    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2215    where
2216        E: serde::de::Error,
2217    {
2218        self.0.visit_borrowed_bytes(value)
2219    }
2220
2221    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2222    where
2223        E: serde::de::Error,
2224    {
2225        self.0.visit_byte_buf(value)
2226    }
2227
2228    fn visit_none<E>(self) -> Result<Self::Value, E>
2229    where
2230        E: serde::de::Error,
2231    {
2232        self.0.visit_none()
2233    }
2234
2235    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2236    where
2237        D: Deserializer<'de>,
2238    {
2239        self.0
2240            .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2241    }
2242
2243    fn visit_unit<E>(self) -> Result<Self::Value, E>
2244    where
2245        E: serde::de::Error,
2246    {
2247        self.0.visit_unit()
2248    }
2249
2250    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2251    where
2252        D: Deserializer<'de>,
2253    {
2254        self.0
2255            .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2256    }
2257
2258    fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2259    where
2260        A: SeqAccess<'de>,
2261    {
2262        self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2263    }
2264
2265    fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2266    where
2267        A: MapAccess<'de>,
2268    {
2269        self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2270    }
2271
2272    fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2273    where
2274        A: EnumAccess<'de>,
2275    {
2276        self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2277    }
2278}
2279
2280struct MeasurementF32NarrowingSeed<S>(S);
2281
2282impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2283where
2284    S: DeserializeSeed<'de>,
2285{
2286    type Value = S::Value;
2287
2288    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2289    where
2290        D: Deserializer<'de>,
2291    {
2292        self.0
2293            .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2294    }
2295}
2296
2297struct MeasurementF32NarrowingSeqAccess<A>(A);
2298
2299impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2300where
2301    A: SeqAccess<'de>,
2302{
2303    type Error = A::Error;
2304
2305    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2306    where
2307        T: DeserializeSeed<'de>,
2308    {
2309        self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2310    }
2311
2312    fn size_hint(&self) -> Option<usize> {
2313        self.0.size_hint()
2314    }
2315}
2316
2317struct MeasurementF32NarrowingMapAccess<A>(A);
2318
2319impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2320where
2321    A: MapAccess<'de>,
2322{
2323    type Error = A::Error;
2324
2325    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2326    where
2327        K: DeserializeSeed<'de>,
2328    {
2329        self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2330    }
2331
2332    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2333    where
2334        V: DeserializeSeed<'de>,
2335    {
2336        self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2337    }
2338
2339    fn size_hint(&self) -> Option<usize> {
2340        self.0.size_hint()
2341    }
2342}
2343
2344struct MeasurementF32NarrowingEnumAccess<A>(A);
2345
2346impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2347where
2348    A: EnumAccess<'de>,
2349{
2350    type Error = A::Error;
2351    type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2352
2353    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2354    where
2355        V: DeserializeSeed<'de>,
2356    {
2357        let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2358        Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2359    }
2360}
2361
2362struct MeasurementF32NarrowingVariantAccess<A>(A);
2363
2364impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2365where
2366    A: VariantAccess<'de>,
2367{
2368    type Error = A::Error;
2369
2370    fn unit_variant(self) -> Result<(), Self::Error> {
2371        self.0.unit_variant()
2372    }
2373
2374    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2375    where
2376        T: DeserializeSeed<'de>,
2377    {
2378        self.0
2379            .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2380    }
2381
2382    fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2383    where
2384        V: Visitor<'de>,
2385    {
2386        self.0
2387            .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2388    }
2389
2390    fn struct_variant<V>(
2391        self,
2392        fields: &'static [&'static str],
2393        visitor: V,
2394    ) -> Result<V::Value, Self::Error>
2395    where
2396        V: Visitor<'de>,
2397    {
2398        self.0
2399            .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
2400    }
2401}
2402
2403#[derive(Debug, Deserialize)]
2404#[serde(untagged)]
2405enum SkeletonNodeMeasurementInput {
2406    Current(Box<crate::measure::SkeletonNodeMeasurements>),
2407    Earlier {
2408        #[serde(rename = "node_index")]
2409        _node_index: usize,
2410    },
2411}
2412
2413#[derive(Debug, Deserialize)]
2414#[serde(untagged)]
2415enum SkinMeasurementInput {
2416    Current(Box<crate::measure::SkinMeasurements>),
2417    Earlier {
2418        #[serde(rename = "skin_index")]
2419        _skin_index: usize,
2420    },
2421}
2422
2423#[derive(Debug, Deserialize)]
2424struct MeasurementPayloadInput {
2425    schema_version: Option<u32>,
2426    schema: Option<String>,
2427    clips: Option<BTreeMap<String, ClipMeasurements>>,
2428    material_resource_coverage: Option<MaterialResourceCoverage>,
2429    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2430    textures: Option<Vec<TextureMeasurements>>,
2431    images: Option<Vec<ImageMeasurements>>,
2432    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2433    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2434    skins: Option<Vec<SkinMeasurementInput>>,
2435    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
2436    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
2437    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
2438    default_scene_index: Option<usize>,
2439}
2440
2441fn decode_measurement_payload(
2442    raw: &RawValue,
2443) -> Result<MeasurementPayloadInput, serde_json::Error> {
2444    let mut deserializer = serde_json::Deserializer::from_str(raw.get());
2445    let payload = MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
2446        &mut deserializer,
2447    ))?;
2448    deserializer.end()?;
2449    Ok(payload)
2450}
2451
2452/// One validated file record recovered from a measurement report.
2453///
2454/// The record retains its source path and full nested measurement contract so
2455/// consumers can choose the clip, mesh, and cardinality policies appropriate
2456/// to their workflow.
2457#[derive(Debug, Clone)]
2458pub struct MeasurementReportFile {
2459    path: String,
2460    input: InputIdentity,
2461    measurements: MeasurementContract,
2462}
2463
2464impl MeasurementReportFile {
2465    /// Source path recorded by the producing report.
2466    pub fn path(&self) -> &str {
2467        &self.path
2468    }
2469
2470    /// Immutable identity of the source bytes used to produce this record.
2471    pub fn input(&self) -> &InputIdentity {
2472        &self.input
2473    }
2474
2475    /// Validated nested measurement contract.
2476    pub fn measurements(&self) -> &MeasurementContract {
2477        &self.measurements
2478    }
2479
2480    /// Consume this record and return its validated measurement contract.
2481    pub fn into_measurements(self) -> MeasurementContract {
2482        self.measurements
2483    }
2484}
2485
2486/// A typed measurement-report subset failed current-contract validation.
2487#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2488#[non_exhaustive]
2489pub enum MeasurementReportError {
2490    /// The outer envelope omitted its version.
2491    #[error("report envelope has no `schema_version`")]
2492    MissingOutputVersion,
2493    /// The outer envelope uses an unsupported version.
2494    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
2495    UnsupportedOutputVersion {
2496        /// Version found in the input.
2497        found: u32,
2498    },
2499    /// The outer envelope does not carry the immutable current identity.
2500    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
2501    WrongOutputIdentity,
2502    /// The outer envelope omitted its command.
2503    #[error("report envelope has no `command`")]
2504    MissingCommand,
2505    /// The outer envelope belongs to a command without file measurements.
2506    #[error("report command {command:?} does not carry measurement file records")]
2507    UnsupportedCommand {
2508        /// Command found in the input.
2509        command: String,
2510    },
2511    /// A current output-v10 envelope carried a field outside its closed schema.
2512    #[error("report envelope has unknown field `{field}`")]
2513    UnknownOutputField {
2514        /// Lexically first unknown root field.
2515        field: String,
2516    },
2517    /// A current output-v10 envelope omitted its producer metadata.
2518    #[error("report envelope has no `tool` object")]
2519    MissingTool,
2520    /// The outer envelope omitted its file array.
2521    #[error("report envelope has no `files` array")]
2522    MissingFiles,
2523    /// The outer envelope exceeds the immutable file-record bound.
2524    #[error("report contains {found} files, exceeding the output-v10 limit of {limit}")]
2525    TooManyFiles {
2526        /// Supplied file count.
2527        found: usize,
2528        /// Immutable output-v10 limit.
2529        limit: usize,
2530    },
2531    /// A lint report omitted the derived prediction-facet summary.
2532    #[error("lint report summary has no `prediction_facets` object")]
2533    MissingPredictionFacetSummary,
2534    /// A measure report carried a lint-only prediction-facet summary.
2535    #[error("measure report summary must not carry `prediction_facets`")]
2536    UnexpectedPredictionFacetSummary,
2537    /// Derived prediction-facet totals did not match the lint summary.
2538    #[error("lint report prediction-facet summary does not match its check records")]
2539    PredictionFacetSummaryMismatch,
2540    /// One file record failed validation.
2541    #[error("files[{file_index}] {source}")]
2542    File {
2543        /// Zero-based index of the invalid file record.
2544        file_index: usize,
2545        /// Typed record-validation failure.
2546        #[source]
2547        source: MeasurementFileError,
2548    },
2549}
2550
2551/// A serialized output-v10 report could not be read within the public bound.
2552#[derive(Debug, thiserror::Error)]
2553#[non_exhaustive]
2554pub enum MeasurementReportReadError {
2555    /// Reading the bounded input failed.
2556    #[error("cannot read report: {source}")]
2557    Io {
2558        /// Underlying bounded-reader failure.
2559        #[source]
2560        source: std::io::Error,
2561    },
2562    /// The serialized report exceeded the immutable output-v10 byte limit.
2563    #[error("report exceeds the output-v10 limit of {limit} bytes")]
2564    ReportTooLarge {
2565        /// Immutable maximum accepted byte count.
2566        limit: u64,
2567    },
2568    /// The bounded bytes were not valid JSON for the output-v10 read shape.
2569    #[error("invalid report JSON: {source}")]
2570    InvalidJson {
2571        /// JSON syntax or typed-shape failure.
2572        #[source]
2573        source: serde_json::Error,
2574    },
2575}
2576
2577/// One measurement-report file record failed validation.
2578#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2579#[non_exhaustive]
2580pub enum MeasurementFileError {
2581    /// The bounded file record could not be decoded after the outer v10
2582    /// identity was accepted.
2583    #[error("has invalid output-v10 file shape: {reason}")]
2584    InvalidFileShape {
2585        /// Stable serde diagnostic for the malformed nested record.
2586        reason: String,
2587    },
2588    /// The file record omitted its source path.
2589    #[error("has no `path`")]
2590    MissingPath,
2591    /// The file record omitted its source-byte identity.
2592    #[error("has no `input`")]
2593    MissingInput,
2594    /// The source-byte identity omitted its SHA-256 digest.
2595    #[error("input has no `sha256`")]
2596    MissingSha256,
2597    /// The source-byte identity uses a malformed SHA-256 digest.
2598    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
2599    InvalidSha256,
2600    /// The source-byte identity omitted its byte count.
2601    #[error("input has no `bytes`")]
2602    MissingBytes,
2603    /// The file record omitted its nested measurement contract.
2604    #[error("has no measurements")]
2605    MissingMeasurements,
2606    /// A lint file omitted its required nullable provenance field.
2607    #[error("has no required `prediction_provenance` field")]
2608    MissingPredictionProvenance,
2609    /// A measure file carried lint-only prediction provenance.
2610    #[error("measure file must not carry `prediction_provenance`")]
2611    UnexpectedPredictionProvenance,
2612    /// A lint file omitted its check array.
2613    #[error("lint file has no `checks` array")]
2614    MissingChecks,
2615    /// A measure file carried lint-only check records.
2616    #[error("measure file must not carry `checks`")]
2617    UnexpectedChecks,
2618    /// A lint file exceeded the immutable per-file check bound.
2619    #[error("contains {found} checks, exceeding the output-v10 limit of {limit}")]
2620    TooManyChecks {
2621        /// Supplied check count.
2622        found: usize,
2623        /// Immutable output-v10 limit.
2624        limit: usize,
2625    },
2626    /// File and prediction-provenance primary identities differ.
2627    #[error("prediction provenance primary input does not match file input")]
2628    PredictionPrimaryInputMismatch,
2629    /// File-scoped prediction provenance violated its immutable contract.
2630    #[error("has invalid prediction provenance: {source}")]
2631    InvalidPredictionProvenance {
2632        /// Typed nested provenance failure.
2633        #[source]
2634        source: PredictionContractError,
2635    },
2636    /// The serialized provenance object could not be decoded as the strict V1 wire.
2637    #[error("has invalid prediction provenance shape: {reason}")]
2638    InvalidPredictionProvenanceShape {
2639        /// Stable serde diagnostic for the malformed nested object.
2640        reason: String,
2641    },
2642    /// One check carried a prediction without file provenance.
2643    #[error("checks[{check_index}] has prediction without non-null file provenance")]
2644    PredictionWithoutProvenance {
2645        /// Zero-based check index.
2646        check_index: usize,
2647    },
2648    /// One check's prediction evidence violated its immutable contract.
2649    #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
2650    InvalidPrediction {
2651        /// Zero-based check index.
2652        check_index: usize,
2653        /// Typed nested prediction failure.
2654        #[source]
2655        source: PredictionContractError,
2656    },
2657    /// One serialized check or prediction object could not be decoded as the strict V1 wire.
2658    #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
2659    InvalidPredictionShape {
2660        /// Zero-based check index.
2661        check_index: usize,
2662        /// Stable serde diagnostic for the malformed nested object.
2663        reason: String,
2664    },
2665    /// One check's prediction attachment contradicts the sole check lifecycle.
2666    #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
2667    InvalidPredictionLifecycle {
2668        /// Zero-based check index.
2669        check_index: usize,
2670        /// Stable relationship failure.
2671        reason: &'static str,
2672    },
2673    /// Aggregate prediction facets exceeded the per-file V1 bound.
2674    #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
2675    TooManyPredictionFacets {
2676        /// Supplied facet count.
2677        found: usize,
2678        /// Immutable V1 limit.
2679        limit: usize,
2680    },
2681    /// Aggregate prediction basis rows exceeded the per-file V1 bound.
2682    #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
2683    TooManyPredictionBasisReferences {
2684        /// Supplied basis-row count.
2685        found: usize,
2686        /// Immutable V1 limit.
2687        limit: usize,
2688    },
2689    /// Aggregate prediction/provenance retained text exceeded the per-file bound.
2690    #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
2691    TooMuchPredictionText {
2692        /// Supplied UTF-8 byte count.
2693        found: usize,
2694        /// Immutable V1 limit.
2695        limit: usize,
2696    },
2697    /// Checked prediction accounting overflowed.
2698    #[error("prediction bound accounting overflowed")]
2699    PredictionAccountingOverflow,
2700    /// The nested measurement contract omitted its version.
2701    #[error("has no versioned measurement contract")]
2702    MissingMeasurementVersion,
2703    /// The nested measurement contract uses an unsupported version.
2704    #[error(
2705        "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
2706    )]
2707    UnsupportedMeasurementVersion {
2708        /// Version found in the nested contract.
2709        found: u32,
2710    },
2711    /// The nested contract does not carry the immutable measurement identity.
2712    #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
2713    WrongMeasurementIdentity,
2714    /// The nested contract omitted its clip-measurement map.
2715    #[error("measurement contract has no `clips` map")]
2716    MissingClips,
2717    /// The nested contract omitted material resource coverage.
2718    #[error("measurement contract has no `material_resource_coverage`")]
2719    MissingMaterialResourceCoverage,
2720    /// The nested contract omitted its material definition array.
2721    #[error("measurement contract has no `material_definitions` array")]
2722    MissingMaterialDefinitions,
2723    /// The nested contract omitted its texture array.
2724    #[error("measurement contract has no `textures` array")]
2725    MissingTextures,
2726    /// The nested contract omitted its image array.
2727    #[error("measurement contract has no `images` array")]
2728    MissingImages,
2729    /// The nested contract omitted skeleton source coverage.
2730    #[error("measurement contract has no `skeleton_source_coverage`")]
2731    MissingSkeletonSourceCoverage,
2732    /// The nested contract omitted its source skeleton-node array.
2733    #[error("measurement contract has no `skeleton_nodes` array")]
2734    MissingSkeletonNodes,
2735    /// The nested contract omitted its source skin array.
2736    #[error("measurement contract has no `skins` array")]
2737    MissingSkins,
2738    /// The nested contract omitted its mesh-definition array.
2739    #[error("measurement contract has no `mesh_definitions` array")]
2740    MissingMeshDefinitions,
2741    /// The nested contract omitted its node-instance array.
2742    #[error("measurement contract has no `node_instances` array")]
2743    MissingNodeInstances,
2744    /// The nested contract omitted its scene array.
2745    #[error("measurement contract has no `scenes` array")]
2746    MissingScenes,
2747    /// The nested measurements object could not be decoded after prediction-independent
2748    /// validation completed.
2749    #[error("has invalid measurements shape: {reason}")]
2750    InvalidMeasurementsShape {
2751        /// Stable serde diagnostic for the malformed nested measurements.
2752        reason: String,
2753    },
2754    /// The nested measurement values do not satisfy the current contract.
2755    #[error("has invalid measurements: {source}")]
2756    InvalidMeasurements {
2757        /// Measurement validation failure.
2758        #[source]
2759        source: MeasurementContractError,
2760    },
2761}
2762
2763impl MeasurementReportError {
2764    /// Zero-based file index for an error in one report record.
2765    ///
2766    /// Envelope-level errors return `None`.
2767    pub fn file_index(&self) -> Option<usize> {
2768        match self {
2769            Self::File { file_index, .. } => Some(*file_index),
2770            _ => None,
2771        }
2772    }
2773
2774    fn file(file_index: usize, source: MeasurementFileError) -> Self {
2775        Self::File { file_index, source }
2776    }
2777}
2778
2779fn prediction_file_error(
2780    file_index: usize,
2781    source: MeasurementFileError,
2782) -> MeasurementReportError {
2783    MeasurementReportError::file(file_index, source)
2784}
2785
2786fn decode_prediction_phase_file(
2787    command: &str,
2788    file_index: usize,
2789    raw: &RawValue,
2790) -> Result<MeasurementFileInput, MeasurementReportError> {
2791    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
2792        prediction_file_error(
2793            file_index,
2794            MeasurementFileError::InvalidFileShape {
2795                reason: source.to_string(),
2796            },
2797        )
2798    })?;
2799
2800    if command == "measure" {
2801        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
2802            return Err(prediction_file_error(
2803                file_index,
2804                MeasurementFileError::UnexpectedPredictionProvenance,
2805            ));
2806        }
2807        if wire.checks.is_some() {
2808            return Err(prediction_file_error(
2809                file_index,
2810                MeasurementFileError::UnexpectedChecks,
2811            ));
2812        }
2813        return Ok(MeasurementFileInput {
2814            path: wire.path,
2815            input: wire.input,
2816            measurements: wire.measurements,
2817            prediction_provenance: RequiredNullable::Missing,
2818            checks: None,
2819        });
2820    }
2821
2822    if wire
2823        .checks
2824        .as_ref()
2825        .is_some_and(|checks| checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE)
2826    {
2827        return Err(prediction_file_error(
2828            file_index,
2829            MeasurementFileError::TooManyChecks {
2830                found: wire.checks.as_ref().map_or(0, Vec::len),
2831                limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
2832            },
2833        ));
2834    }
2835
2836    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
2837        return Err(prediction_file_error(
2838            file_index,
2839            MeasurementFileError::MissingPredictionProvenance,
2840        ));
2841    }
2842
2843    let prediction_provenance = match wire.prediction_provenance {
2844        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
2845        RequiredNullable::Present(None) => RequiredNullable::Present(None),
2846        RequiredNullable::Present(Some(raw)) => {
2847            let provenance = decode_prediction_provenance_v1(raw.get()).map_err(|error| {
2848                prediction_file_error(
2849                    file_index,
2850                    match error {
2851                        PredictionDecodeError::Shape(source) => {
2852                            MeasurementFileError::InvalidPredictionProvenanceShape {
2853                                reason: source.to_string(),
2854                            }
2855                        }
2856                        PredictionDecodeError::Semantic(source) => {
2857                            MeasurementFileError::InvalidPredictionProvenance { source }
2858                        }
2859                        PredictionDecodeError::TooManyFileFacets
2860                        | PredictionDecodeError::TooManyFileBasisReferences => {
2861                            unreachable!("provenance decoding cannot consume prediction budgets")
2862                        }
2863                    },
2864                )
2865            })?;
2866            RequiredNullable::Present(Some(provenance))
2867        }
2868    };
2869    let mut decoded_facets = 0usize;
2870    let mut decoded_references = 0usize;
2871    let mut decoded_text = match &prediction_provenance {
2872        RequiredNullable::Present(Some(provenance)) => {
2873            provenance.retained_text_bytes().map_err(|source| {
2874                prediction_file_error(
2875                    file_index,
2876                    MeasurementFileError::InvalidPredictionProvenance { source },
2877                )
2878            })?
2879        }
2880        RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
2881    };
2882    let provenance_for_checks = match &prediction_provenance {
2883        RequiredNullable::Present(provenance) => provenance.as_ref(),
2884        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
2885    };
2886    let checks = wire
2887        .checks
2888        .map(|raw_checks| {
2889            let mut checks = Vec::with_capacity(raw_checks.len());
2890            for (check_index, raw) in raw_checks.into_iter().enumerate() {
2891                let wire: PredictionCheckWireInput =
2892                    serde_json::from_str(raw.get()).map_err(|source| {
2893                        prediction_file_error(
2894                            file_index,
2895                            MeasurementFileError::InvalidPredictionShape {
2896                                check_index,
2897                                reason: source.to_string(),
2898                            },
2899                        )
2900                    })?;
2901                if provenance_for_checks.is_none() && wire.prediction.is_some() {
2902                    return Err(prediction_file_error(
2903                        file_index,
2904                        MeasurementFileError::PredictionWithoutProvenance { check_index },
2905                    ));
2906                }
2907                if (wire.selection == SelectionState::Unselected
2908                    || wire.configuration == ConfigurationState::Disabled
2909                    || wire.applicability == Applicability::NotApplicable)
2910                    && wire.prediction.is_some()
2911                {
2912                    return Err(prediction_file_error(
2913                        file_index,
2914                        MeasurementFileError::InvalidPredictionLifecycle {
2915                            check_index,
2916                            reason: "inactive check must have empty output",
2917                        },
2918                    ));
2919                }
2920                let prediction = wire
2921                    .prediction
2922                    .map(|raw| {
2923                        decode_engine_prediction_v1(
2924                            raw.get(),
2925                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
2926                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
2927                                .saturating_sub(decoded_references),
2928                        )
2929                        .map_err(|error| {
2930                            prediction_file_error(
2931                                file_index,
2932                                match error {
2933                                    PredictionDecodeError::Shape(source) => {
2934                                        MeasurementFileError::InvalidPredictionShape {
2935                                            check_index,
2936                                            reason: source.to_string(),
2937                                        }
2938                                    }
2939                                    PredictionDecodeError::Semantic(source) => {
2940                                        MeasurementFileError::InvalidPrediction {
2941                                            check_index,
2942                                            source,
2943                                        }
2944                                    }
2945                                    PredictionDecodeError::TooManyFileFacets => {
2946                                        MeasurementFileError::TooManyPredictionFacets {
2947                                            found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
2948                                            limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
2949                                        }
2950                                    }
2951                                    PredictionDecodeError::TooManyFileBasisReferences => {
2952                                        MeasurementFileError::TooManyPredictionBasisReferences {
2953                                            found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
2954                                            limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
2955                                        }
2956                                    }
2957                                },
2958                            )
2959                        })
2960                    })
2961                    .transpose()?;
2962                let check = PredictionCheckInput {
2963                    check_id: wire.check_id,
2964                    selection: wire.selection,
2965                    configuration: wire.configuration,
2966                    applicability: wire.applicability,
2967                    evaluation: wire.evaluation,
2968                    findings: wire.findings,
2969                    evaluated_scopes: wire.evaluated_scopes,
2970                    gaps: wire.gaps,
2971                    prediction,
2972                };
2973                check
2974                    .validate(check_index, provenance_for_checks)
2975                    .map_err(|source| prediction_file_error(file_index, source))?;
2976                if let Some(prediction) = &check.prediction {
2977                    decoded_facets = decoded_facets
2978                        .checked_add(prediction.facets().len())
2979                        .ok_or_else(|| {
2980                            prediction_file_error(
2981                                file_index,
2982                                MeasurementFileError::PredictionAccountingOverflow,
2983                            )
2984                        })?;
2985                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
2986                        return Err(prediction_file_error(
2987                            file_index,
2988                            MeasurementFileError::TooManyPredictionFacets {
2989                                found: decoded_facets,
2990                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
2991                            },
2992                        ));
2993                    }
2994                    decoded_references = decoded_references
2995                        .checked_add(prediction.basis_reference_count())
2996                        .ok_or_else(|| {
2997                            prediction_file_error(
2998                                file_index,
2999                                MeasurementFileError::PredictionAccountingOverflow,
3000                            )
3001                        })?;
3002                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3003                        return Err(prediction_file_error(
3004                            file_index,
3005                            MeasurementFileError::TooManyPredictionBasisReferences {
3006                                found: decoded_references,
3007                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3008                            },
3009                        ));
3010                    }
3011                    decoded_text = decoded_text
3012                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3013                            prediction_file_error(
3014                                file_index,
3015                                MeasurementFileError::InvalidPrediction {
3016                                    check_index,
3017                                    source,
3018                                },
3019                            )
3020                        })?)
3021                        .ok_or_else(|| {
3022                            prediction_file_error(
3023                                file_index,
3024                                MeasurementFileError::PredictionAccountingOverflow,
3025                            )
3026                        })?;
3027                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3028                        return Err(prediction_file_error(
3029                            file_index,
3030                            MeasurementFileError::TooMuchPredictionText {
3031                                found: decoded_text,
3032                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3033                            },
3034                        ));
3035                    }
3036                }
3037                checks.push(check);
3038            }
3039            Ok(checks)
3040        })
3041        .transpose()?;
3042    Ok(MeasurementFileInput {
3043        path: wire.path,
3044        input: wire.input,
3045        measurements: wire.measurements,
3046        prediction_provenance,
3047        checks,
3048    })
3049}
3050
3051fn validate_prediction_phase_file(
3052    command: &str,
3053    file_index: usize,
3054    file: &MeasurementFileInput,
3055) -> Result<(usize, usize), MeasurementReportError> {
3056    let mut available = 0usize;
3057    let mut unavailable = 0usize;
3058    match command {
3059        "measure" => {
3060            if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
3061                return Err(prediction_file_error(
3062                    file_index,
3063                    MeasurementFileError::UnexpectedPredictionProvenance,
3064                ));
3065            }
3066            if file.checks.is_some() {
3067                return Err(prediction_file_error(
3068                    file_index,
3069                    MeasurementFileError::UnexpectedChecks,
3070                ));
3071            }
3072        }
3073        "lint" => {
3074            let provenance = match &file.prediction_provenance {
3075                RequiredNullable::Missing => {
3076                    return Err(prediction_file_error(
3077                        file_index,
3078                        MeasurementFileError::MissingPredictionProvenance,
3079                    ));
3080                }
3081                RequiredNullable::Present(provenance) => provenance.as_ref(),
3082            };
3083            let checks = file.checks.as_ref().ok_or_else(|| {
3084                prediction_file_error(file_index, MeasurementFileError::MissingChecks)
3085            })?;
3086            if checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE {
3087                return Err(prediction_file_error(
3088                    file_index,
3089                    MeasurementFileError::TooManyChecks {
3090                        found: checks.len(),
3091                        limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
3092                    },
3093                ));
3094            }
3095            if let Some(provenance) = provenance {
3096                provenance.validate().map_err(|source| {
3097                    prediction_file_error(
3098                        file_index,
3099                        MeasurementFileError::InvalidPredictionProvenance { source },
3100                    )
3101                })?;
3102                let input = file.input.as_ref().ok_or_else(|| {
3103                    prediction_file_error(file_index, MeasurementFileError::MissingInput)
3104                })?;
3105                if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
3106                    || input.bytes != Some(provenance.raw_source().primary_input().bytes())
3107                {
3108                    return Err(prediction_file_error(
3109                        file_index,
3110                        MeasurementFileError::PredictionPrimaryInputMismatch,
3111                    ));
3112                }
3113            }
3114
3115            let mut facets = 0usize;
3116            let mut references = 0usize;
3117            let mut text = provenance
3118                .map(PredictionProvenanceV1::retained_text_bytes)
3119                .transpose()
3120                .map_err(|source| {
3121                    prediction_file_error(
3122                        file_index,
3123                        MeasurementFileError::InvalidPredictionProvenance { source },
3124                    )
3125                })?
3126                .unwrap_or(0);
3127            for (check_index, check) in checks.iter().enumerate() {
3128                if let Some(prediction) = &check.prediction {
3129                    facets = facets
3130                        .checked_add(prediction.facets().len())
3131                        .ok_or_else(|| {
3132                            prediction_file_error(
3133                                file_index,
3134                                MeasurementFileError::PredictionAccountingOverflow,
3135                            )
3136                        })?;
3137                    references = references
3138                        .checked_add(prediction.basis_reference_count())
3139                        .ok_or_else(|| {
3140                            prediction_file_error(
3141                                file_index,
3142                                MeasurementFileError::PredictionAccountingOverflow,
3143                            )
3144                        })?;
3145                    text = text
3146                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3147                            prediction_file_error(
3148                                file_index,
3149                                MeasurementFileError::InvalidPrediction {
3150                                    check_index,
3151                                    source,
3152                                },
3153                            )
3154                        })?)
3155                        .ok_or_else(|| {
3156                            prediction_file_error(
3157                                file_index,
3158                                MeasurementFileError::PredictionAccountingOverflow,
3159                            )
3160                        })?;
3161                    for facet in prediction.facets() {
3162                        match facet.state() {
3163                            EnginePredictionFacetStateV1::Available => {
3164                                available = available.checked_add(1).ok_or(
3165                                    MeasurementReportError::PredictionFacetSummaryMismatch,
3166                                )?;
3167                            }
3168                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
3169                                unavailable = unavailable.checked_add(1).ok_or(
3170                                    MeasurementReportError::PredictionFacetSummaryMismatch,
3171                                )?;
3172                            }
3173                        }
3174                    }
3175                }
3176            }
3177            if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3178                return Err(prediction_file_error(
3179                    file_index,
3180                    MeasurementFileError::TooManyPredictionFacets {
3181                        found: facets,
3182                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3183                    },
3184                ));
3185            }
3186            if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3187                return Err(prediction_file_error(
3188                    file_index,
3189                    MeasurementFileError::TooManyPredictionBasisReferences {
3190                        found: references,
3191                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3192                    },
3193                ));
3194            }
3195            if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3196                return Err(prediction_file_error(
3197                    file_index,
3198                    MeasurementFileError::TooMuchPredictionText {
3199                        found: text,
3200                        limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3201                    },
3202                ));
3203            }
3204        }
3205        _ => unreachable!("command was validated before prediction phase"),
3206    }
3207    Ok((available, unavailable))
3208}
3209
3210fn validate_prediction_summary(
3211    command: &str,
3212    summary: Option<&MeasurementReportSummaryInput>,
3213    available: usize,
3214    unavailable: usize,
3215) -> Result<(), MeasurementReportError> {
3216    let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
3217    match (command, summary) {
3218        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
3219        ("measure", None) => Ok(()),
3220        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
3221        ("lint", Some(summary))
3222            if summary.available != available
3223                || summary.required_prediction_unavailable != unavailable =>
3224        {
3225            Err(MeasurementReportError::PredictionFacetSummaryMismatch)
3226        }
3227        ("lint", Some(_)) => Ok(()),
3228        _ => unreachable!("command was validated before prediction summary"),
3229    }
3230}
3231
3232fn validate_prediction_summary_presence(
3233    command: &str,
3234    summary: Option<&MeasurementReportSummaryInput>,
3235) -> Result<(), MeasurementReportError> {
3236    match (
3237        command,
3238        summary.and_then(|summary| summary.prediction_facets.as_ref()),
3239    ) {
3240        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
3241        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
3242        ("measure", None) | ("lint", Some(_)) => Ok(()),
3243        _ => unreachable!("command was validated before prediction summary"),
3244    }
3245}
3246
3247impl PredictionCheckInput {
3248    fn validate(
3249        &self,
3250        check_index: usize,
3251        provenance: Option<&PredictionProvenanceV1>,
3252    ) -> Result<(), MeasurementFileError> {
3253        let gap_refs = self
3254            .gaps
3255            .iter()
3256            .map(|gap| CheckEvaluationGapRef {
3257                code: &gap.code,
3258                scope: gap.scope.as_ref(),
3259            })
3260            .collect::<Vec<_>>();
3261        let finding_check_ids = self
3262            .findings
3263            .iter()
3264            .map(|finding| finding.check_id.as_str())
3265            .collect::<Vec<_>>();
3266        let prediction_scopes = self
3267            .prediction
3268            .as_ref()
3269            .into_iter()
3270            .flat_map(crate::prediction::EnginePredictionV1::facets)
3271            .map(|facet| facet.scope())
3272            .collect::<Vec<_>>();
3273        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
3274            check_id: &self.check_id,
3275            selection: self.selection,
3276            configuration: self.configuration,
3277            applicability: self.applicability,
3278            finding_check_ids: &finding_check_ids,
3279            evaluated_scopes: &self.evaluated_scopes,
3280            gaps: &gap_refs,
3281            prediction_scopes: &prediction_scopes,
3282            has_prediction: self.prediction.is_some(),
3283            prediction_has_required_unavailable: self
3284                .prediction
3285                .as_ref()
3286                .is_some_and(crate::prediction::EnginePredictionV1::has_required_unavailable),
3287        })
3288        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
3289            check_index,
3290            reason: error.reason(),
3291        })?;
3292        if self.evaluation != derived {
3293            return Err(MeasurementFileError::InvalidPredictionLifecycle {
3294                check_index,
3295                reason: "evaluation does not match completed and missing prediction work",
3296            });
3297        }
3298
3299        let Some(prediction) = &self.prediction else {
3300            if self
3301                .findings
3302                .iter()
3303                .any(|finding| finding.prediction_scope.is_some())
3304            {
3305                return Err(MeasurementFileError::InvalidPredictionLifecycle {
3306                    check_index,
3307                    reason: "finding has prediction_scope without prediction",
3308                });
3309            }
3310            return Ok(());
3311        };
3312        let provenance =
3313            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
3314        prediction
3315            .validate_against_provenance(provenance)
3316            .map_err(|source| MeasurementFileError::InvalidPrediction {
3317                check_index,
3318                source,
3319            })?;
3320        for facet in prediction.facets() {
3321            let evaluated = self
3322                .evaluated_scopes
3323                .iter()
3324                .filter(|scope| *scope == facet.scope())
3325                .count();
3326            let duplicated_gap = self
3327                .gaps
3328                .iter()
3329                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
3330            match facet.state() {
3331                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
3332                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
3333                        check_index,
3334                        reason: "available facet scope must occur exactly once in evaluated_scopes",
3335                    });
3336                }
3337                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
3338                    if evaluated != 0 || duplicated_gap =>
3339                {
3340                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
3341                        check_index,
3342                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
3343                    });
3344                }
3345                _ => {}
3346            }
3347        }
3348        for finding in &self.findings {
3349            let Some(scope) = &finding.prediction_scope else {
3350                return Err(MeasurementFileError::InvalidPredictionLifecycle {
3351                    check_index,
3352                    reason: "prediction-backed finding must carry prediction_scope",
3353                });
3354            };
3355            if prediction
3356                .facets()
3357                .iter()
3358                .filter(|facet| {
3359                    facet.scope() == scope
3360                        && facet.state() == EnginePredictionFacetStateV1::Available
3361                })
3362                .count()
3363                != 1
3364            {
3365                return Err(MeasurementFileError::InvalidPredictionLifecycle {
3366                    check_index,
3367                    reason: "finding prediction_scope must name one available facet",
3368                });
3369            }
3370        }
3371        Ok(())
3372    }
3373}
3374
3375impl MeasurementReportInput {
3376    /// Read one report through the immutable output-v10 byte bound before
3377    /// UTF-8 or JSON parsing.
3378    ///
3379    /// The JSON parser receives at most [`OUTPUT_V10_MAX_REPORT_BYTES`] bytes
3380    /// and retains its recursion limit. This function never performs an
3381    /// unbounded `read_to_end` or constructs a generic JSON value.
3382    ///
3383    /// # Errors
3384    ///
3385    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic contract
3386    /// validation remains in [`Self::into_files`].
3387    pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
3388        Self::read_from_with_limit(reader, OUTPUT_V10_MAX_REPORT_BYTES)
3389    }
3390
3391    fn read_from_with_limit(
3392        reader: impl Read,
3393        limit: u64,
3394    ) -> Result<Self, MeasurementReportReadError> {
3395        let mut bounded = reader.take(limit + 1);
3396        let mut bytes = Vec::new();
3397        bounded
3398            .read_to_end(&mut bytes)
3399            .map_err(|source| MeasurementReportReadError::Io { source })?;
3400        if bytes.len() as u64 > limit {
3401            return Err(MeasurementReportReadError::ReportTooLarge { limit });
3402        }
3403        serde_json::from_slice(&bytes)
3404            .map_err(|source| MeasurementReportReadError::InvalidJson { source })
3405    }
3406
3407    /// Number of file records present before nested record validation.
3408    ///
3409    /// Returns `None` when the report omitted its file array. Consumers can
3410    /// retain this count while [`MeasurementReportInput::into_files`] performs
3411    /// full validation, then apply their own cardinality and error policy.
3412    pub fn file_count(&self) -> Option<usize> {
3413        self.files.as_ref().map(Vec::len)
3414    }
3415
3416    /// Validate current output/measurement identities and recover every file's
3417    /// complete measurement record from a `measure` or `lint` report.
3418    ///
3419    /// File order is preserved. Empty and multi-file reports are accepted so
3420    /// callers can apply their own cardinality policy.
3421    ///
3422    /// # Errors
3423    ///
3424    /// Returns a typed error for a missing or unsupported identity, command,
3425    /// file shape, nested measurement contract, or measurement payload.
3426    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
3427        match self.schema_version {
3428            Some(OUTPUT_SCHEMA_VERSION) => {}
3429            Some(found) => {
3430                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
3431            }
3432            None => return Err(MeasurementReportError::MissingOutputVersion),
3433        }
3434        if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
3435            return Err(MeasurementReportError::WrongOutputIdentity);
3436        }
3437        let command = match self.command.as_deref() {
3438            Some(command @ ("measure" | "lint")) => command,
3439            Some(command) => {
3440                return Err(MeasurementReportError::UnsupportedCommand {
3441                    command: command.to_owned(),
3442                });
3443            }
3444            None => return Err(MeasurementReportError::MissingCommand),
3445        };
3446        if let Some(field) = self.extra.keys().next() {
3447            return Err(MeasurementReportError::UnknownOutputField {
3448                field: field.clone(),
3449            });
3450        }
3451        if self._tool.is_none() {
3452            return Err(MeasurementReportError::MissingTool);
3453        }
3454        validate_prediction_summary_presence(command, self.summary.as_ref())?;
3455        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
3456        if files.len() > OUTPUT_V10_MAX_FILES {
3457            return Err(MeasurementReportError::TooManyFiles {
3458                found: files.len(),
3459                limit: OUTPUT_V10_MAX_FILES,
3460            });
3461        }
3462        let mut available = 0usize;
3463        let mut unavailable = 0usize;
3464        let mut decoded_files = Vec::with_capacity(files.len());
3465        for (file_index, raw) in files.into_iter().enumerate() {
3466            let file = decode_prediction_phase_file(command, file_index, &raw)?;
3467            let (file_available, file_unavailable) =
3468                validate_prediction_phase_file(command, file_index, &file)?;
3469            available = available
3470                .checked_add(file_available)
3471                .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
3472            unavailable = unavailable
3473                .checked_add(file_unavailable)
3474                .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
3475            decoded_files.push(file);
3476        }
3477        validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
3478        let parsed = decoded_files
3479            .into_iter()
3480            .enumerate()
3481            .map(|(file_index, file)| {
3482                let path = file.path.ok_or_else(|| {
3483                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
3484                })?;
3485                let input = file.input.ok_or_else(|| {
3486                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
3487                })?;
3488                let sha256 = input.sha256.ok_or_else(|| {
3489                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
3490                })?;
3491                if sha256.len() != 64
3492                    || !sha256
3493                        .bytes()
3494                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3495                {
3496                    return Err(MeasurementReportError::file(
3497                        file_index,
3498                        MeasurementFileError::InvalidSha256,
3499                    ));
3500                }
3501                let bytes = input.bytes.ok_or_else(|| {
3502                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
3503                })?;
3504                let measurements = file.measurements.ok_or_else(|| {
3505                    MeasurementReportError::file(
3506                        file_index,
3507                        MeasurementFileError::MissingMeasurements,
3508                    )
3509                })?;
3510                let measurements = decode_measurement_payload(&measurements).map_err(|source| {
3511                    MeasurementReportError::file(
3512                        file_index,
3513                        MeasurementFileError::InvalidMeasurementsShape {
3514                            reason: source.to_string(),
3515                        },
3516                    )
3517                })?;
3518                match measurements.schema_version {
3519                    Some(MEASUREMENTS_SCHEMA_VERSION) => {}
3520                    Some(found) => {
3521                        return Err(MeasurementReportError::file(
3522                            file_index,
3523                            MeasurementFileError::UnsupportedMeasurementVersion { found },
3524                        ));
3525                    }
3526                    None => {
3527                        return Err(MeasurementReportError::file(
3528                            file_index,
3529                            MeasurementFileError::MissingMeasurementVersion,
3530                        ));
3531                    }
3532                }
3533                if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
3534                    return Err(MeasurementReportError::file(
3535                        file_index,
3536                        MeasurementFileError::WrongMeasurementIdentity,
3537                    ));
3538                }
3539                let clips = measurements.clips.ok_or_else(|| {
3540                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
3541                })?;
3542                let material_resource_coverage =
3543                    measurements.material_resource_coverage.ok_or_else(|| {
3544                        MeasurementReportError::file(
3545                            file_index,
3546                            MeasurementFileError::MissingMaterialResourceCoverage,
3547                        )
3548                    })?;
3549                let material_definitions = measurements.material_definitions.ok_or_else(|| {
3550                    MeasurementReportError::file(
3551                        file_index,
3552                        MeasurementFileError::MissingMaterialDefinitions,
3553                    )
3554                })?;
3555                let textures = measurements.textures.ok_or_else(|| {
3556                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
3557                })?;
3558                let images = measurements.images.ok_or_else(|| {
3559                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
3560                })?;
3561                let skeleton_source_coverage =
3562                    measurements.skeleton_source_coverage.ok_or_else(|| {
3563                        MeasurementReportError::file(
3564                            file_index,
3565                            MeasurementFileError::MissingSkeletonSourceCoverage,
3566                        )
3567                    })?;
3568                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
3569                    MeasurementReportError::file(
3570                        file_index,
3571                        MeasurementFileError::MissingSkeletonNodes,
3572                    )
3573                })?;
3574                let skeleton_nodes = skeleton_nodes
3575                    .into_iter()
3576                    .enumerate()
3577                    .map(|(offset, node)| match node {
3578                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
3579                        SkeletonNodeMeasurementInput::Earlier { .. } => {
3580                            Err(MeasurementReportError::file(
3581                                file_index,
3582                                MeasurementFileError::InvalidMeasurements {
3583                                    source: MeasurementContractError::InvalidStructure {
3584                                        path: format!("skeleton_nodes[{offset}]"),
3585                                        reason: "uses a shape from an earlier measurement contract"
3586                                            .into(),
3587                                    },
3588                                },
3589                            ))
3590                        }
3591                    })
3592                    .collect::<Result<Vec<_>, _>>()?;
3593                let skins = measurements.skins.ok_or_else(|| {
3594                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
3595                })?;
3596                let skins = skins
3597                    .into_iter()
3598                    .enumerate()
3599                    .map(|(offset, skin)| match skin {
3600                        SkinMeasurementInput::Current(skin) => Ok(*skin),
3601                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
3602                            file_index,
3603                            MeasurementFileError::InvalidMeasurements {
3604                                source: MeasurementContractError::InvalidStructure {
3605                                    path: format!("skins[{offset}]"),
3606                                    reason: "uses a shape from an earlier measurement contract"
3607                                        .into(),
3608                                },
3609                            },
3610                        )),
3611                    })
3612                    .collect::<Result<Vec<_>, _>>()?;
3613                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
3614                    MeasurementReportError::file(
3615                        file_index,
3616                        MeasurementFileError::MissingMeshDefinitions,
3617                    )
3618                })?;
3619                let node_instances = measurements.node_instances.ok_or_else(|| {
3620                    MeasurementReportError::file(
3621                        file_index,
3622                        MeasurementFileError::MissingNodeInstances,
3623                    )
3624                })?;
3625                let scenes = measurements.scenes.ok_or_else(|| {
3626                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
3627                })?;
3628                let assets = AssetMeasurements {
3629                    material_resource_coverage,
3630                    material_definitions,
3631                    textures,
3632                    images,
3633                    skeleton_source_coverage,
3634                    skeleton_nodes,
3635                    skins,
3636                    mesh_definitions,
3637                    node_instances,
3638                    scenes,
3639                    default_scene_index: measurements.default_scene_index,
3640                };
3641                let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
3642                    MeasurementReportError::file(
3643                        file_index,
3644                        MeasurementFileError::InvalidMeasurements { source },
3645                    )
3646                })?;
3647                Ok((
3648                    MeasurementReportFile {
3649                        path,
3650                        input: InputIdentity { sha256, bytes },
3651                        measurements,
3652                    },
3653                    file.checks.unwrap_or_default(),
3654                ))
3655            })
3656            .collect::<Result<Vec<_>, _>>()?;
3657
3658        // Measurement-dependent basis pointers are deliberately resolved only
3659        // after every file's complete measurements-v15 contract has passed.
3660        for (file_index, (file, checks)) in parsed.iter().enumerate() {
3661            validate_measurement_references_batch(
3662                &file.measurements,
3663                checks
3664                    .iter()
3665                    .enumerate()
3666                    .filter_map(|(check_index, check)| {
3667                        check
3668                            .prediction
3669                            .as_ref()
3670                            .map(|prediction| (check_index, prediction))
3671                    }),
3672            )
3673            .map_err(|error| {
3674                MeasurementReportError::file(
3675                    file_index,
3676                    MeasurementFileError::InvalidPrediction {
3677                        check_index: error.prediction_index,
3678                        source: error.source,
3679                    },
3680                )
3681            })?;
3682        }
3683        Ok(parsed.into_iter().map(|(file, _)| file).collect())
3684    }
3685}
3686
3687#[cfg(test)]
3688mod measurement_report_input_tests {
3689    use std::collections::BTreeMap;
3690
3691    use super::*;
3692    use crate::engine_contract::{
3693        EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EnginePrimarySourceV1,
3694        EngineProfileFactV1, EngineProfileSelectionV1, ResolvedEngineProfileV1,
3695        ResolvedEngineSettingsV1,
3696    };
3697    use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
3698    use crate::measure::AssetMeasurements;
3699    use crate::prediction::{
3700        EnginePredictionBasisV1, EnginePredictionFacetV1, EnginePredictionV1,
3701        PredictionBasisReferenceV1, PredictionScalarV1, PredictionUnavailableReasonV1,
3702        RawSourceBindingV1,
3703    };
3704    use crate::source_facts::SourceFormatV1;
3705    use crate::{DependencyClosureV1, Document, ResolvedRoles};
3706
3707    fn prediction_test_profile() -> ResolvedEngineProfileV1 {
3708        let all_fact_ids = [
3709            EngineFactIdV1::AcceptedInputs,
3710            EngineFactIdV1::AnimationAddressability,
3711            EngineFactIdV1::AnimationChannelHandling,
3712            EngineFactIdV1::AnimationTargetAddressability,
3713            EngineFactIdV1::AxisConversionControl,
3714            EngineFactIdV1::ConstructHandling,
3715            EngineFactIdV1::ExactAxisConversion,
3716            EngineFactIdV1::ExtensionHandling,
3717            EngineFactIdV1::ResultingHierarchyScale,
3718            EngineFactIdV1::RootMotionAddressability,
3719            EngineFactIdV1::TargetCoordinateBasis,
3720            EngineFactIdV1::TargetLinearUnit,
3721            EngineFactIdV1::UnitConversionControl,
3722            EngineFactIdV1::WholeEndFrameRequired,
3723        ];
3724        let facts = all_fact_ids
3725            .into_iter()
3726            .map(|id| {
3727                let state = if id == EngineFactIdV1::AcceptedInputs {
3728                    EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
3729                        SourceFormatV1::Glb,
3730                    ]))
3731                } else {
3732                    EngineFactStateV1::Unknown
3733                };
3734                EngineProfileFactV1::new(id, state)
3735            })
3736            .collect();
3737        ResolvedEngineProfileV1::new(
3738            EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
3739            "urn:animsmith:engine-profile:test:1",
3740            facts,
3741            vec![],
3742            vec![
3743                EnginePrimarySourceV1::new(
3744                    "test-source",
3745                    "1",
3746                    "https://example.invalid/test",
3747                    "2026-08-20",
3748                    vec![EngineFactIdV1::AcceptedInputs],
3749                    vec![],
3750                )
3751                .unwrap(),
3752            ],
3753        )
3754        .unwrap()
3755    }
3756
3757    fn prediction_test_provenance() -> PredictionProvenanceV1 {
3758        let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
3759            "schema": crate::RAW_SOURCE_FACTS_V1_ID,
3760            "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
3761            "source_format": "glb",
3762            "linear_unit": {
3763                "state": "observed", "value": 1.0, "disposition": "preserved",
3764                "provenance": {"kind": "format_defined"}
3765            },
3766            "coordinate_basis": {
3767                "state": "observed",
3768                "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
3769                "disposition": "preserved", "provenance": {"kind": "format_defined"}
3770            },
3771            "frames_per_second": {
3772                "state": "observed", "value": 30.0, "disposition": "preserved",
3773                "provenance": {"kind": "format_defined"}
3774            },
3775            "clips_coverage": {"state": "complete"},
3776            "constructs_coverage": {"state": "complete"},
3777            "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
3778            "source_skeleton_coverage": "unavailable",
3779            "work": {
3780                "inspected_rows": 0, "retained_rows": 0,
3781                "retained_text_bytes": 0, "max_traversal_depth": 0
3782            }
3783        }))
3784        .unwrap();
3785        let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
3786        let profile = prediction_test_profile();
3787        let settings = ResolvedEngineSettingsV1::new(&profile, vec![], vec![]).unwrap();
3788        PredictionProvenanceV1::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
3789    }
3790
3791    fn prediction_test_measurements() -> MeasurementContract {
3792        MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
3793    }
3794
3795    fn prediction_test_rig() -> RigInfo {
3796        RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
3797    }
3798
3799    fn unavailable_facet(
3800        subject: String,
3801        basis: EnginePredictionBasisV1,
3802    ) -> EnginePredictionFacetV1 {
3803        EnginePredictionFacetV1::required_unavailable(
3804            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
3805                .subject(subject),
3806            basis,
3807            vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
3808        )
3809        .unwrap()
3810    }
3811
3812    fn unavailable_check(
3813        check_id: &'static str,
3814        provenance: &PredictionProvenanceV1,
3815        facets: Vec<EnginePredictionFacetV1>,
3816    ) -> CheckEvaluation {
3817        let prediction = EnginePredictionV1::new(provenance.identity().clone(), facets).unwrap();
3818        CheckEvaluation::evaluated(
3819            check_id,
3820            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
3821                .with_engine_prediction(prediction),
3822        )
3823        .unwrap()
3824    }
3825
3826    fn lint_file(
3827        provenance: &PredictionProvenanceV1,
3828        checks: Vec<CheckEvaluation>,
3829    ) -> Result<LintFileReport, OutputContractError> {
3830        LintFileReport::new(
3831            "limit.glb",
3832            provenance.raw_source().primary_input().clone(),
3833            prediction_test_rig(),
3834            Some(provenance.clone()),
3835            checks,
3836            prediction_test_measurements(),
3837        )
3838    }
3839
3840    fn validated_lint_wire(
3841        provenance: &PredictionProvenanceV1,
3842        checks: Vec<CheckEvaluation>,
3843    ) -> serde_json::Value {
3844        let file = lint_file(provenance, checks).expect("producer accepts exact N");
3845        let envelope =
3846            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
3847                .unwrap();
3848        let wire = serde_json::to_value(envelope).unwrap();
3849        let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
3850        read.into_files().expect("reader accepts exact N");
3851        wire
3852    }
3853
3854    fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
3855        let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
3856        read.into_files().expect_err("reader must reject N+1")
3857    }
3858
3859    fn prediction_with_retained_text(
3860        provenance: &PredictionProvenanceV1,
3861        retained_text: usize,
3862    ) -> EnginePredictionV1 {
3863        const FIELD_ID_BYTES: usize = 16;
3864        const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
3865        let fixed = "test:prediction-limit".len()
3866            + PredictionUnavailableReasonV1::ProjectIntentUnavailable
3867                .as_str()
3868                .len();
3869        let remaining = retained_text.checked_sub(fixed).unwrap();
3870        let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
3871        let full_rows = remaining / full_row;
3872        let remainder = remaining % full_row;
3873        let (full_rows, tail_lengths) = if remainder == 0 {
3874            (full_rows, Vec::new())
3875        } else if remainder >= FIELD_ID_BYTES {
3876            (full_rows, vec![remainder - FIELD_ID_BYTES])
3877        } else {
3878            (
3879                full_rows - 1,
3880                vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
3881            )
3882        };
3883        let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
3884        for index in 0..full_rows {
3885            references.push(
3886                PredictionBasisReferenceV1::project_field(
3887                    format!("f{index:015}"),
3888                    PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
3889                )
3890                .unwrap(),
3891            );
3892        }
3893        for length in tail_lengths {
3894            let index = references.len();
3895            references.push(
3896                PredictionBasisReferenceV1::project_field(
3897                    format!("f{index:015}"),
3898                    PredictionScalarV1::text("x".repeat(length)).unwrap(),
3899                )
3900                .unwrap(),
3901            );
3902        }
3903        let basis = EnginePredictionBasisV1::new(references).unwrap();
3904        let facet = EnginePredictionFacetV1::required_unavailable(
3905            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
3906            basis,
3907            vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
3908        )
3909        .unwrap();
3910        let prediction =
3911            EnginePredictionV1::new(provenance.identity().clone(), vec![facet]).unwrap();
3912        assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
3913        prediction
3914    }
3915
3916    #[test]
3917    fn report_reader_enforces_the_byte_cap_before_json_parsing() {
3918        let bytes = br#"{"schema_version":10,"tool":{}}"#;
3919        let report =
3920            MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
3921                .expect("exact N must parse");
3922        assert_eq!(report.schema_version, Some(10));
3923
3924        assert!(matches!(
3925            MeasurementReportInput::read_from_with_limit(
3926                bytes.as_slice(),
3927                bytes.len() as u64 - 1,
3928            ),
3929            Err(MeasurementReportReadError::ReportTooLarge { limit })
3930                if limit == bytes.len() as u64 - 1
3931        ));
3932    }
3933
3934    #[test]
3935    fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
3936        let provenance = prediction_test_provenance();
3937        let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
3938        let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
3939            .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
3940            .collect();
3941        let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
3942        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
3943        let extra = unavailable_check(
3944            "test:facet-extra",
3945            &provenance,
3946            vec![unavailable_facet("facet-extra".into(), empty_basis)],
3947        );
3948
3949        assert_eq!(
3950            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
3951            OutputContractError::TooManyPredictionFacets {
3952                found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3953                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3954            }
3955        );
3956
3957        wire["files"][0]["checks"]
3958            .as_array_mut()
3959            .unwrap()
3960            .push(serde_json::to_value(extra).unwrap());
3961        assert_eq!(
3962            lint_read_error(wire),
3963            MeasurementReportError::File {
3964                file_index: 0,
3965                source: MeasurementFileError::TooManyPredictionFacets {
3966                    found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3967                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3968                },
3969            }
3970        );
3971    }
3972
3973    #[test]
3974    fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
3975        let provenance = prediction_test_provenance();
3976        let basis = EnginePredictionBasisV1::new(
3977            (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
3978                .map(|index| {
3979                    PredictionBasisReferenceV1::project_field(
3980                        format!("project.field.{index:04}"),
3981                        PredictionScalarV1::Null,
3982                    )
3983                    .unwrap()
3984                })
3985                .collect(),
3986        )
3987        .unwrap();
3988        let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3989            / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
3990        let facets = (0..facet_count)
3991            .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
3992            .collect();
3993        let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
3994        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
3995        let extra_basis = EnginePredictionBasisV1::new(vec![
3996            PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
3997                .unwrap(),
3998        ])
3999        .unwrap();
4000        let extra = unavailable_check(
4001            "test:basis-extra",
4002            &provenance,
4003            vec![unavailable_facet("basis-extra".into(), extra_basis)],
4004        );
4005
4006        assert_eq!(
4007            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
4008            OutputContractError::TooManyPredictionBasisReferences {
4009                found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4010                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4011            }
4012        );
4013
4014        wire["files"][0]["checks"]
4015            .as_array_mut()
4016            .unwrap()
4017            .push(serde_json::to_value(extra).unwrap());
4018        *wire["files"][0]["checks"]
4019            .as_array_mut()
4020            .unwrap()
4021            .last_mut()
4022            .unwrap()
4023            .get_mut("prediction")
4024            .unwrap()
4025            .get_mut("facets")
4026            .and_then(serde_json::Value::as_array_mut)
4027            .and_then(|facets| facets.first_mut())
4028            .and_then(|facet| facet.get_mut("basis"))
4029            .and_then(|basis| basis.get_mut("references"))
4030            .and_then(serde_json::Value::as_array_mut)
4031            .and_then(|references| references.first_mut())
4032            .unwrap() = serde_json::Value::Null;
4033        assert_eq!(
4034            lint_read_error(wire),
4035            MeasurementReportError::File {
4036                file_index: 0,
4037                source: MeasurementFileError::TooManyPredictionBasisReferences {
4038                    found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4039                    limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4040                },
4041            }
4042        );
4043    }
4044
4045    #[test]
4046    fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
4047        let provenance = prediction_test_provenance();
4048        let provenance_text = provenance.retained_text_bytes().unwrap();
4049        let at_limit_prediction = prediction_with_retained_text(
4050            &provenance,
4051            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
4052        );
4053        let at_limit = CheckEvaluation::evaluated(
4054            "test:text-limit",
4055            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
4056                .with_engine_prediction(at_limit_prediction),
4057        )
4058        .unwrap();
4059        let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
4060
4061        let above_limit_prediction = prediction_with_retained_text(
4062            &provenance,
4063            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
4064        );
4065        let above_limit = CheckEvaluation::evaluated(
4066            "test:text-limit",
4067            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
4068                .with_engine_prediction(above_limit_prediction),
4069        )
4070        .unwrap();
4071        let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
4072        assert_eq!(
4073            lint_file(&provenance, vec![above_limit]).unwrap_err(),
4074            OutputContractError::TooMuchPredictionText {
4075                found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
4076                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4077            }
4078        );
4079
4080        wire["files"][0]["checks"][0] = above_limit_wire;
4081        assert_eq!(
4082            lint_read_error(wire),
4083            MeasurementReportError::File {
4084                file_index: 0,
4085                source: MeasurementFileError::TooMuchPredictionText {
4086                    found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
4087                    limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4088                },
4089            }
4090        );
4091    }
4092
4093    fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
4094        serde_json::from_value::<MeasurementReportInput>(wire)
4095            .expect("outer v10 shape remains valid")
4096            .into_files()
4097            .expect_err("mutated report must fail")
4098    }
4099
4100    fn empty_check(check_id: &'static str) -> CheckEvaluation {
4101        CheckEvaluation::evaluated(
4102            check_id,
4103            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
4104        )
4105        .unwrap()
4106    }
4107
4108    #[test]
4109    fn staged_reader_rejects_unknown_root_file_and_check_fields() {
4110        let provenance = prediction_test_provenance();
4111        let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4112
4113        let mut root = wire.clone();
4114        root["unknown_root"] = serde_json::json!(true);
4115        let bytes = serde_json::to_vec(&root).unwrap();
4116        assert_eq!(
4117            MeasurementReportInput::read_from(bytes.as_slice())
4118                .expect("unknown root fields are retained through the staged read")
4119                .into_files()
4120                .unwrap_err(),
4121            MeasurementReportError::UnknownOutputField {
4122                field: "unknown_root".into(),
4123            }
4124        );
4125
4126        let mut missing_tool = wire.clone();
4127        missing_tool.as_object_mut().unwrap().remove("tool");
4128        assert_eq!(
4129            reader_error(missing_tool),
4130            MeasurementReportError::MissingTool
4131        );
4132
4133        let bare = br#"{"walk":true}"#;
4134        assert_eq!(
4135            MeasurementReportInput::read_from(bare.as_slice())
4136                .expect("unknown root fields remain staged until header validation")
4137                .into_files()
4138                .unwrap_err(),
4139            MeasurementReportError::MissingOutputVersion,
4140        );
4141
4142        let unsupported = br#"{"schema_version":9,"walk":true}"#;
4143        assert_eq!(
4144            MeasurementReportInput::read_from(unsupported.as_slice())
4145                .expect("unknown root fields remain staged until header validation")
4146                .into_files()
4147                .unwrap_err(),
4148            MeasurementReportError::UnsupportedOutputVersion { found: 9 },
4149        );
4150
4151        let mut file = wire.clone();
4152        file["files"][0]["unknown_file"] = serde_json::json!(true);
4153        assert!(matches!(
4154            reader_error(file),
4155            MeasurementReportError::File {
4156                file_index: 0,
4157                source: MeasurementFileError::InvalidFileShape { reason },
4158            } if reason.contains("unknown field `unknown_file`")
4159        ));
4160
4161        let mut check = wire;
4162        check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
4163        assert!(matches!(
4164            reader_error(check),
4165            MeasurementReportError::File {
4166                file_index: 0,
4167                source: MeasurementFileError::InvalidPredictionShape {
4168                    check_index: 0,
4169                    reason,
4170                },
4171            } if reason.contains("unknown field `unknown_check`")
4172        ));
4173
4174        let provenance = prediction_test_provenance();
4175        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4176        summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
4177        let bytes = serde_json::to_vec(&summary).unwrap();
4178        assert!(matches!(
4179            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
4180            MeasurementReportReadError::InvalidJson { source }
4181                if source.to_string().contains("unknown field `unknown_prediction_total`")
4182        ));
4183
4184        let provenance = prediction_test_provenance();
4185        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4186        summary["summary"]["unknown_summary"] = serde_json::json!(0);
4187        let bytes = serde_json::to_vec(&summary).unwrap();
4188        assert!(matches!(
4189            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
4190            MeasurementReportReadError::InvalidJson { source }
4191                if source.to_string().contains("unknown field `unknown_summary`")
4192        ));
4193    }
4194
4195    #[test]
4196    fn staged_reader_preserves_typed_prediction_semantic_errors() {
4197        let provenance = prediction_test_provenance();
4198        let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
4199        provenance_wire["files"][0]["prediction_provenance"]["schema"] =
4200            serde_json::json!("urn:changed");
4201        assert!(matches!(
4202            reader_error(provenance_wire),
4203            MeasurementReportError::File {
4204                file_index: 0,
4205                source: MeasurementFileError::InvalidPredictionProvenance {
4206                    source: PredictionContractError::InvalidSchema {
4207                        field: "provenance.schema",
4208                        ..
4209                    },
4210                },
4211            }
4212        ));
4213
4214        let basis = EnginePredictionBasisV1::new(vec![
4215            PredictionBasisReferenceV1::project_field(
4216                "test:project",
4217                PredictionScalarV1::Boolean { value: true },
4218            )
4219            .unwrap(),
4220        ])
4221        .unwrap();
4222        let facet = EnginePredictionFacetV1::required_unavailable(
4223            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
4224            basis,
4225            vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
4226        )
4227        .unwrap();
4228        let prediction_wire = validated_lint_wire(
4229            &provenance,
4230            vec![unavailable_check("test:reader", &provenance, vec![facet])],
4231        );
4232
4233        let mut wrong_emitter = prediction_wire.clone();
4234        wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
4235            serde_json::json!("member_existence");
4236        assert!(matches!(
4237            reader_error(wrong_emitter),
4238            MeasurementReportError::File {
4239                file_index: 0,
4240                source: MeasurementFileError::InvalidPredictionLifecycle {
4241                    check_index: 0,
4242                    reason: "prediction facet scope code is invalid for its parent check",
4243                },
4244            }
4245        ));
4246
4247        let mut empty_scope = prediction_wire.clone();
4248        empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
4249            serde_json::json!("");
4250        assert!(matches!(
4251            reader_error(empty_scope),
4252            MeasurementReportError::File {
4253                file_index: 0,
4254                source: MeasurementFileError::InvalidPrediction {
4255                    check_index: 0,
4256                    source: PredictionContractError::InvalidToken {
4257                        field: "facet scope code",
4258                        ..
4259                    },
4260                },
4261            }
4262        ));
4263
4264        let mut prediction_wire = prediction_wire;
4265        prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
4266            ["bytes"] = serde_json::json!(0);
4267        assert!(matches!(
4268            reader_error(prediction_wire),
4269            MeasurementReportError::File {
4270                file_index: 0,
4271                source: MeasurementFileError::InvalidPrediction {
4272                    check_index: 0,
4273                    source: PredictionContractError::IdentityMismatch {
4274                        contract: "engine prediction basis v1",
4275                    },
4276                },
4277            }
4278        ));
4279    }
4280
4281    #[test]
4282    fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
4283        let provenance = prediction_test_provenance();
4284        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4285
4286        for (field, state) in [
4287            ("selection", "unselected"),
4288            ("configuration", "disabled"),
4289            ("applicability", "not_applicable"),
4290        ] {
4291            let mut inactive = base.clone();
4292            inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
4293            assert!(matches!(
4294                reader_error(inactive),
4295                MeasurementReportError::File {
4296                    file_index: 0,
4297                    source: MeasurementFileError::InvalidPredictionLifecycle {
4298                        check_index: 0,
4299                        reason: "evaluation does not match completed and missing prediction work",
4300                    },
4301                }
4302            ));
4303        }
4304
4305        let mut inactive = base.clone();
4306        inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
4307        inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4308        serde_json::from_value::<MeasurementReportInput>(inactive)
4309            .unwrap()
4310            .into_files()
4311            .expect("empty inactive record is valid");
4312
4313        let mut not_evaluated = base.clone();
4314        not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4315            "code": "test:missing",
4316            "message": "missing",
4317        }]);
4318        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4319        serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
4320            .unwrap()
4321            .into_files()
4322            .expect("missing-only active record derives not_evaluated");
4323        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
4324        assert!(matches!(
4325            reader_error(not_evaluated),
4326            MeasurementReportError::File {
4327                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4328                ..
4329            }
4330        ));
4331
4332        let mut partial = base.clone();
4333        partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4334            "code": "test:missing",
4335            "message": "missing",
4336        }]);
4337        partial["files"][0]["checks"][0]["evaluated_scopes"] =
4338            serde_json::json!([{ "code": "test:completed" }]);
4339        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4340        serde_json::from_value::<MeasurementReportInput>(partial.clone())
4341            .unwrap()
4342            .into_files()
4343            .expect("mixed active record derives partial");
4344        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
4345        assert!(matches!(
4346            reader_error(partial),
4347            MeasurementReportError::File {
4348                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4349                ..
4350            }
4351        ));
4352
4353        let mut wrong_complete = base;
4354        wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4355        assert!(matches!(
4356            reader_error(wrong_complete),
4357            MeasurementReportError::File {
4358                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4359                ..
4360            }
4361        ));
4362    }
4363
4364    #[test]
4365    fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
4366        let provenance = prediction_test_provenance();
4367        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4368
4369        let mut empty_scope = base.clone();
4370        empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
4371            serde_json::json!([{ "code": "" }]);
4372        assert!(matches!(
4373            reader_error(empty_scope),
4374            MeasurementReportError::File {
4375                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4376                ..
4377            }
4378        ));
4379
4380        let mut malformed_gap = base.clone();
4381        malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
4382            "code": "",
4383            "message": "missing",
4384        }]);
4385        malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
4386        assert!(matches!(
4387            reader_error(malformed_gap),
4388            MeasurementReportError::File {
4389                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4390                ..
4391            }
4392        ));
4393
4394        let mut incomplete_finding = base;
4395        incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
4396            "check_id": "test:reader",
4397        }]);
4398        assert!(matches!(
4399            reader_error(incomplete_finding),
4400            MeasurementReportError::File {
4401                source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
4402                ..
4403            }
4404        ));
4405    }
4406
4407    #[test]
4408    fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
4409        let provenance = prediction_test_provenance();
4410        let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
4411        let mut later_file = wire["files"][0].clone();
4412        later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
4413        wire["files"].as_array_mut().unwrap().push(later_file);
4414        wire["summary"]["files"] = serde_json::json!(2);
4415        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4416
4417        assert!(matches!(
4418            reader_error(wire),
4419            MeasurementReportError::File {
4420                file_index: 0,
4421                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4422            }
4423        ));
4424    }
4425
4426    #[test]
4427    fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
4428        let provenance = prediction_test_provenance();
4429        let basis = EnginePredictionBasisV1::new(vec![
4430            PredictionBasisReferenceV1::project_field(
4431                "test:project",
4432                PredictionScalarV1::Boolean { value: true },
4433            )
4434            .unwrap(),
4435        ])
4436        .unwrap();
4437        let facet = EnginePredictionFacetV1::required_unavailable(
4438            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
4439            basis,
4440            vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
4441        )
4442        .unwrap();
4443        let mut wire = validated_lint_wire(
4444            &provenance,
4445            vec![
4446                empty_check("test:first"),
4447                unavailable_check("test:second", &provenance, vec![facet]),
4448            ],
4449        );
4450        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
4451        wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
4452
4453        assert!(matches!(
4454            reader_error(wire),
4455            MeasurementReportError::File {
4456                file_index: 0,
4457                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
4458            }
4459        ));
4460    }
4461
4462    #[test]
4463    fn v11_nested_version_is_rejected_before_current_shape_decode() {
4464        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
4465            "schema_version": OUTPUT_SCHEMA_VERSION,
4466            "schema": OUTPUT_SCHEMA_ID,
4467            "tool": {},
4468            "command": "measure",
4469            "files": [{
4470                "path": "measurements-v11.json",
4471                "input": { "sha256": "0".repeat(64), "bytes": 0 },
4472                "rig": {},
4473                "measurements": {
4474                    "schema_version": 11,
4475                    "schema": "urn:animsmith:schema:measurements:11",
4476                    "skeleton_nodes": [{
4477                        "node_index": 0,
4478                        "scene_root_indices": [],
4479                        "local_rest": {
4480                            "kind": "trs",
4481                            "translation_m": [0.0, 0.0, 0.0],
4482                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
4483                            "scale": [1.0, 1.0, 1.0]
4484                        },
4485                        "rest_world_matrix": [
4486                            1.0, 0.0, 0.0, 0.0,
4487                            0.0, 1.0, 0.0, 0.0,
4488                            0.0, 0.0, 1.0, 0.0,
4489                            0.0, 0.0, 0.0, 1.0
4490                        ]
4491                    }],
4492                    "skins": [{ "skin_index": 0 }]
4493                }
4494            }]
4495        }))
4496        .expect("unsupported payload shapes remain decodable for version rejection");
4497
4498        assert!(matches!(
4499            report.into_files(),
4500            Err(MeasurementReportError::File {
4501                file_index: 0,
4502                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
4503            })
4504        ));
4505    }
4506}
4507
4508#[derive(Debug, Clone, Serialize)]
4509struct FileEvidence {
4510    path: String,
4511    input: InputIdentity,
4512    rig: RigInfo,
4513    measurements: MeasurementContract,
4514}
4515
4516impl FileEvidence {
4517    fn new(
4518        path: impl Into<String>,
4519        input: InputIdentity,
4520        rig: RigInfo,
4521        measurements: MeasurementContract,
4522    ) -> Self {
4523        Self {
4524            path: path.into(),
4525            input,
4526            rig,
4527            measurements,
4528        }
4529    }
4530}
4531
4532/// One source file and its measurement-command evidence.
4533#[derive(Debug, Clone, Serialize)]
4534pub struct MeasureFileReport {
4535    #[serde(flatten)]
4536    evidence: FileEvidence,
4537}
4538
4539impl MeasureFileReport {
4540    /// Construct a measurement-command file report.
4541    pub fn new(
4542        path: impl Into<String>,
4543        input: InputIdentity,
4544        rig: RigInfo,
4545        measurements: MeasurementContract,
4546    ) -> Self {
4547        Self {
4548            evidence: FileEvidence::new(path, input, rig, measurements),
4549        }
4550    }
4551
4552    /// Display path supplied by the producer.
4553    pub fn path(&self) -> &str {
4554        &self.evidence.path
4555    }
4556
4557    /// Immutable identity of the source bytes used to produce this record.
4558    pub fn input(&self) -> &InputIdentity {
4559        &self.evidence.input
4560    }
4561
4562    /// Nested measurement evidence.
4563    pub fn measurements(&self) -> &MeasurementContract {
4564        &self.evidence.measurements
4565    }
4566}
4567
4568/// One source file and its lint-command evidence.
4569#[derive(Debug, Clone, Serialize)]
4570pub struct LintFileReport {
4571    #[serde(flatten)]
4572    evidence: FileEvidence,
4573    prediction_provenance: Option<PredictionProvenanceV1>,
4574    checks: Vec<CheckEvaluation>,
4575}
4576
4577impl LintFileReport {
4578    /// Construct and validate a lint file report with one record per catalog check.
4579    ///
4580    /// # Errors
4581    ///
4582    /// Returns [`OutputContractError`] when the file exceeds an output-v10
4583    /// bound or prediction evidence does not bind to the same file/provenance.
4584    pub fn new(
4585        path: impl Into<String>,
4586        input: InputIdentity,
4587        rig: RigInfo,
4588        prediction_provenance: Option<PredictionProvenanceV1>,
4589        checks: Vec<CheckEvaluation>,
4590        measurements: MeasurementContract,
4591    ) -> Result<Self, OutputContractError> {
4592        let report = Self {
4593            evidence: FileEvidence::new(path, input, rig, measurements),
4594            prediction_provenance,
4595            checks,
4596        };
4597        report.validate()?;
4598        Ok(report)
4599    }
4600
4601    /// Display path supplied by the producer.
4602    pub fn path(&self) -> &str {
4603        &self.evidence.path
4604    }
4605
4606    /// Immutable identity of the source bytes used to produce this record.
4607    pub fn input(&self) -> &InputIdentity {
4608        &self.evidence.input
4609    }
4610
4611    /// Check records in catalog order.
4612    pub fn checks(&self) -> &[CheckEvaluation] {
4613        &self.checks
4614    }
4615
4616    /// File-scoped prediction provenance, or `None` for engine-neutral lint.
4617    pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV1> {
4618        self.prediction_provenance.as_ref()
4619    }
4620
4621    /// Nested measurement evidence.
4622    pub fn measurements(&self) -> &MeasurementContract {
4623        &self.evidence.measurements
4624    }
4625
4626    fn validate(&self) -> Result<(), OutputContractError> {
4627        if self.checks.len() > OUTPUT_V10_MAX_CHECKS_PER_FILE {
4628            return Err(OutputContractError::TooManyChecks {
4629                found: self.checks.len(),
4630                limit: OUTPUT_V10_MAX_CHECKS_PER_FILE,
4631            });
4632        }
4633        if let Some(provenance) = &self.prediction_provenance {
4634            provenance.validate()?;
4635            if provenance.raw_source().primary_input() != &self.evidence.input {
4636                return Err(OutputContractError::PredictionPrimaryInputMismatch);
4637            }
4638        }
4639
4640        let mut facets = 0usize;
4641        let mut references = 0usize;
4642        let mut text = self
4643            .prediction_provenance
4644            .as_ref()
4645            .map(PredictionProvenanceV1::retained_text_bytes)
4646            .transpose()?
4647            .unwrap_or(0);
4648        for check in &self.checks {
4649            let Some(prediction) = check.engine_prediction() else {
4650                continue;
4651            };
4652            let provenance = self
4653                .prediction_provenance
4654                .as_ref()
4655                .ok_or(OutputContractError::PredictionWithoutProvenance)?;
4656            prediction.validate_against_provenance(provenance)?;
4657            facets = facets
4658                .checked_add(prediction.facets().len())
4659                .ok_or(OutputContractError::ArithmeticOverflow)?;
4660            references = references
4661                .checked_add(prediction.basis_reference_count())
4662                .ok_or(OutputContractError::ArithmeticOverflow)?;
4663            text = text
4664                .checked_add(prediction.retained_text_bytes()?)
4665                .ok_or(OutputContractError::ArithmeticOverflow)?;
4666        }
4667        validate_measurement_references_batch(
4668            &self.evidence.measurements,
4669            self.checks
4670                .iter()
4671                .enumerate()
4672                .filter_map(|(check_index, check)| {
4673                    check
4674                        .engine_prediction()
4675                        .map(|prediction| (check_index, prediction))
4676                }),
4677        )
4678        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
4679        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4680            return Err(OutputContractError::TooManyPredictionFacets {
4681                found: facets,
4682                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4683            });
4684        }
4685        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4686            return Err(OutputContractError::TooManyPredictionBasisReferences {
4687                found: references,
4688                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4689            });
4690        }
4691        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4692            return Err(OutputContractError::TooMuchPredictionText {
4693                found: text,
4694                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4695            });
4696        }
4697        Ok(())
4698    }
4699}
4700
4701/// A producer attempted to construct output outside the immutable v10 contract.
4702#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4703#[non_exhaustive]
4704pub enum OutputContractError {
4705    /// One envelope carried too many file records.
4706    #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
4707    TooManyFiles {
4708        /// Supplied file count.
4709        found: usize,
4710        /// Immutable v10 limit.
4711        limit: usize,
4712    },
4713    /// One lint file carried too many check records.
4714    #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
4715    TooManyChecks {
4716        /// Supplied check count.
4717        found: usize,
4718        /// Immutable v10 limit.
4719        limit: usize,
4720    },
4721    /// A check carried prediction evidence without its file-scoped authority.
4722    #[error("engine prediction requires non-null file prediction_provenance")]
4723    PredictionWithoutProvenance,
4724    /// File and provenance primary-input identities differed.
4725    #[error("prediction provenance primary input does not match the lint file input")]
4726    PredictionPrimaryInputMismatch,
4727    /// Aggregate prediction facets exceeded the V1 file limit.
4728    #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
4729    TooManyPredictionFacets {
4730        /// Supplied facet count.
4731        found: usize,
4732        /// Immutable V1 limit.
4733        limit: usize,
4734    },
4735    /// Aggregate basis references exceeded the V1 file limit.
4736    #[error(
4737        "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
4738    )]
4739    TooManyPredictionBasisReferences {
4740        /// Supplied reference count.
4741        found: usize,
4742        /// Immutable V1 limit.
4743        limit: usize,
4744    },
4745    /// Aggregate retained provenance/prediction text exceeded the V1 limit.
4746    #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
4747    TooMuchPredictionText {
4748        /// Supplied UTF-8 byte count.
4749        found: usize,
4750        /// Immutable V1 limit.
4751        limit: usize,
4752    },
4753    /// Checked aggregate accounting overflowed.
4754    #[error("checked arithmetic overflow while validating output-v10 bounds")]
4755    ArithmeticOverflow,
4756    /// Nested prediction evidence violated its contract.
4757    #[error("invalid prediction evidence: {0}")]
4758    InvalidPrediction(#[from] PredictionContractError),
4759}
4760
4761#[derive(Debug, Clone, Serialize)]
4762struct EnvelopeHeader {
4763    schema_version: u32,
4764    schema: &'static str,
4765    tool: ToolInfo,
4766    command: &'static str,
4767}
4768
4769impl EnvelopeHeader {
4770    fn new(tool: ToolInfo, command: &'static str) -> Self {
4771        Self {
4772            schema_version: OUTPUT_SCHEMA_VERSION,
4773            schema: OUTPUT_SCHEMA_ID,
4774            tool,
4775            command,
4776        }
4777    }
4778}
4779
4780#[derive(Debug, Clone, Serialize)]
4781struct MeasureSummary {
4782    files: usize,
4783}
4784
4785#[derive(Debug, Clone, Default, Serialize)]
4786struct FindingSummary {
4787    error: usize,
4788    warning: usize,
4789    note: usize,
4790}
4791
4792impl FindingSummary {
4793    fn add(&mut self, severity: Severity) {
4794        match severity {
4795            Severity::Error => self.error += 1,
4796            Severity::Warning => self.warning += 1,
4797            Severity::Note => self.note += 1,
4798        }
4799    }
4800}
4801
4802#[derive(Debug, Clone, Default, Serialize)]
4803struct SelectionSummary {
4804    selected: usize,
4805    unselected: usize,
4806}
4807
4808#[derive(Debug, Clone, Default, Serialize)]
4809struct ConfigurationSummary {
4810    enabled: usize,
4811    disabled: usize,
4812}
4813
4814#[derive(Debug, Clone, Default, Serialize)]
4815struct ApplicabilitySummary {
4816    applicable: usize,
4817    not_applicable: usize,
4818}
4819
4820#[derive(Debug, Clone, Default, Serialize)]
4821struct EvaluationStateSummary {
4822    complete: usize,
4823    partial: usize,
4824    not_evaluated: usize,
4825}
4826
4827#[derive(Debug, Clone, Default, Serialize)]
4828struct CheckSummary {
4829    total: usize,
4830    selection: SelectionSummary,
4831    configuration: ConfigurationSummary,
4832    applicability: ApplicabilitySummary,
4833    evaluation: EvaluationStateSummary,
4834    gaps: usize,
4835}
4836
4837#[derive(Debug, Clone, Serialize)]
4838struct LintSummary {
4839    files: usize,
4840    findings: FindingSummary,
4841    checks: CheckSummary,
4842    prediction_facets: PredictionFacetSummary,
4843}
4844
4845#[derive(Debug, Clone, Default, Serialize)]
4846struct PredictionFacetSummary {
4847    available: usize,
4848    required_prediction_unavailable: usize,
4849}
4850
4851/// Current measure-command result envelope.
4852#[derive(Debug, Clone, Serialize)]
4853pub struct MeasureEnvelope {
4854    #[serde(flatten)]
4855    header: EnvelopeHeader,
4856    summary: MeasureSummary,
4857    files: Vec<MeasureFileReport>,
4858}
4859
4860impl MeasureEnvelope {
4861    /// Construct a schema-valid measurement envelope.
4862    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
4863        if files.len() > OUTPUT_V10_MAX_FILES {
4864            return Err(OutputContractError::TooManyFiles {
4865                found: files.len(),
4866                limit: OUTPUT_V10_MAX_FILES,
4867            });
4868        }
4869        Ok(Self {
4870            header: EnvelopeHeader::new(tool, "measure"),
4871            summary: MeasureSummary { files: files.len() },
4872            files,
4873        })
4874    }
4875}
4876
4877/// Current lint-command result envelope.
4878#[derive(Debug, Clone, Serialize)]
4879pub struct LintEnvelope {
4880    #[serde(flatten)]
4881    header: EnvelopeHeader,
4882    summary: LintSummary,
4883    files: Vec<LintFileReport>,
4884}
4885
4886impl LintEnvelope {
4887    /// Construct a schema-valid lint envelope and derive its summary from the
4888    /// supplied check records.
4889    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
4890        if files.len() > OUTPUT_V10_MAX_FILES {
4891            return Err(OutputContractError::TooManyFiles {
4892                found: files.len(),
4893                limit: OUTPUT_V10_MAX_FILES,
4894            });
4895        }
4896        let mut findings = FindingSummary::default();
4897        let mut checks = CheckSummary::default();
4898        let mut prediction_facets = PredictionFacetSummary::default();
4899        for file in &files {
4900            file.validate()?;
4901            for check in file.checks() {
4902                checks.total += 1;
4903                for finding in check.findings() {
4904                    findings.add(finding.severity);
4905                }
4906                match check.selection() {
4907                    SelectionState::Selected => checks.selection.selected += 1,
4908                    SelectionState::Unselected => checks.selection.unselected += 1,
4909                }
4910                match check.configuration() {
4911                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
4912                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
4913                }
4914                match check.applicability() {
4915                    Applicability::Applicable => checks.applicability.applicable += 1,
4916                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
4917                }
4918                match check.evaluation() {
4919                    EvaluationState::Complete => checks.evaluation.complete += 1,
4920                    EvaluationState::Partial => checks.evaluation.partial += 1,
4921                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
4922                }
4923                checks.gaps += check.gaps().len();
4924                if let Some(prediction) = check.engine_prediction() {
4925                    for facet in prediction.facets() {
4926                        match facet.state() {
4927                            EnginePredictionFacetStateV1::Available => {
4928                                prediction_facets.available += 1;
4929                            }
4930                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
4931                                prediction_facets.required_prediction_unavailable += 1;
4932                            }
4933                        }
4934                    }
4935                }
4936            }
4937        }
4938        Ok(Self {
4939            header: EnvelopeHeader::new(tool, "lint"),
4940            summary: LintSummary {
4941                files: files.len(),
4942                findings,
4943                checks,
4944                prediction_facets,
4945            },
4946            files,
4947        })
4948    }
4949}
4950
4951#[derive(Debug, Clone, Serialize)]
4952struct DiffInputs {
4953    before: String,
4954    after: String,
4955}
4956
4957#[derive(Debug, Clone, Serialize)]
4958struct DiffSummary {
4959    deltas: usize,
4960}
4961
4962/// Current diff-command result envelope.
4963#[derive(Debug, Serialize)]
4964pub struct DiffEnvelope {
4965    #[serde(flatten)]
4966    header: EnvelopeHeader,
4967    inputs: DiffInputs,
4968    summary: DiffSummary,
4969    deltas: Vec<MetricDelta>,
4970}
4971
4972impl DiffEnvelope {
4973    /// Construct a schema-valid diff envelope.
4974    pub fn new(
4975        tool: ToolInfo,
4976        before: impl Into<String>,
4977        after: impl Into<String>,
4978        deltas: Vec<MetricDelta>,
4979    ) -> Self {
4980        Self {
4981            header: EnvelopeHeader::new(tool, "diff"),
4982            inputs: DiffInputs {
4983                before: before.into(),
4984                after: after.into(),
4985            },
4986            summary: DiffSummary {
4987                deltas: deltas.len(),
4988            },
4989            deltas,
4990        }
4991    }
4992}