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