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::dependency_closure::DependencyClosureCoverageV1;
18use crate::diff::MetricDelta;
19use crate::engine_contract::{
20    EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EngineSettingIdV2, EngineSettingValueV2,
21};
22use crate::evaluation::{
23    Applicability, CheckEvaluation, CheckEvaluationGapRef, CheckEvaluationValidationInput,
24    ConfigurationState, EvaluationScope, EvaluationState, SelectionState,
25    validate_and_derive_check_evaluation,
26};
27use crate::measure::{
28    Aabb, AdditionalInfluenceSetMeasurements, AssetMeasurements, ClipMeasurements,
29    ImageMeasurements, LinearTransformClassification, LinearTransformMeasurements,
30    MaterialDefinitionMeasurements, MeasurementAvailability, MeshDefinitionMeasurements,
31    NodeInstanceMeasurements, PrimitiveMeasurements, SceneMeasurements,
32    SkeletonNodeLocalRestMeasurements, SkeletonRestWorldMatrixUnavailableReason,
33    SkinDerivedMatrixMeasurements, SkinDerivedMatrixUnavailableReason,
34    StaticNodeAabbUnavailableReason, TextureMeasurements, assess_inverse_bind,
35    measure_linear_transform, summarize_skin_bind_linear,
36};
37use crate::metrics::canonical_net_yaw_deg;
38use crate::model::{
39    DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
40    SourceSkeletonCoverage,
41};
42use crate::prediction::{
43    EngineMachineResultV1, EnginePredictionBasisV2, EnginePredictionBasisV4,
44    EnginePredictionFacetStateV1, EnginePredictionV1, EnginePredictionV4, EnginePredictionV5,
45    EnginePredictionV6, ExactSourceTimingBasisReferenceV1, ExactSourceTimingBindingV1,
46    ExactSourceTimingDomainV1, ExactSourceTimingKeyV1, ExactSourceTimingObservationStateWireV1,
47    PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE, PREDICTION_V1_MAX_FACETS_PER_FILE,
48    PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE, PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE,
49    PredictionBasisReferenceV4, PredictionContractError, PredictionDecodeError,
50    PredictionProvenanceV4, PredictionProvenanceV5, PredictionProvenanceV6, PredictionScalarV1,
51    RawSceneAttachmentBasisDomainV1, RawSceneAttachmentBasisReferenceV1, RawSourceBasisReferenceV1,
52    ResolvedSettingLocationV1, TransformScaleDomainV1, TransformScaleResultV1,
53    TransformScaleSubjectKindV1, UnitMappingResultV1,
54    decode_engine_prediction_v1_with_measurement_schema,
55    decode_engine_prediction_v2_with_measurement_schema, decode_engine_prediction_v3,
56    decode_engine_prediction_v4, decode_prediction_provenance_v1_with_measurement_schema,
57    decode_prediction_provenance_v2_with_measurement_schema, decode_prediction_provenance_v3,
58    decode_prediction_provenance_v4, validate_measurement_references_batch,
59    validate_measurement_references_batch_v2, validate_measurement_references_batch_v3,
60    validate_measurement_references_batch_v4,
61};
62use crate::profile::ResolvedRoles;
63use crate::source_facts::SourceFormatV1;
64use crate::{Document, Severity};
65use crate::{
66    EnginePredictionV2, EnginePredictionV3, ImporterSubjectCreationV1, InventoryCoverageResultV1,
67    PredictionBasisReferenceV1, PredictionBasisReferenceV2, PredictionInventoryCoverageStateV1,
68    PredictionInventoryDomainV1, PredictionProvenanceV1, PredictionProvenanceV2,
69    PredictionProvenanceV3, PredictionUnavailableReasonV2, RawAnimationChannelInventoryV1,
70    RawSceneAttachmentCoverageV1, RawSourceDomainV1, RawSourceFieldIdV1, RawSourceKeyV1,
71    RawSourceSetCoverageStateV1, ResolvedEngineSettingsCoverageStateV2,
72    SourceImportDispositionResultV1, SourceImportDispositionV1, SourceImportSubjectKindV1,
73    SourceSkeletonRowKindV1,
74};
75
76/// Current outer result-envelope version.
77pub const OUTPUT_SCHEMA_VERSION: u32 = 18;
78/// Immutable identity of the current outer result envelope.
79pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:18";
80/// Immutable output-v17 identity retained for historical V6 prediction evidence.
81pub const OUTPUT_V17_SCHEMA_ID: &str = "urn:animsmith:schema:output:17";
82/// Schema version of output-v17.
83pub const OUTPUT_V17_SCHEMA_VERSION: u32 = 17;
84/// Immutable output-v16 identity retained for historical V5 prediction evidence.
85pub const OUTPUT_V16_SCHEMA_ID: &str = "urn:animsmith:schema:output:16";
86/// Schema version of output-v16.
87pub const OUTPUT_V16_SCHEMA_VERSION: u32 = 16;
88/// Immutable output-v15 identity retained for historical V4 prediction evidence.
89pub const OUTPUT_V15_SCHEMA_ID: &str = "urn:animsmith:schema:output:15";
90/// Schema version of output-v15.
91pub const OUTPUT_V15_SCHEMA_VERSION: u32 = 15;
92/// Immutable output-v10 identity retained by V1 dependent contracts.
93pub const OUTPUT_V10_SCHEMA_ID: &str = "urn:animsmith:schema:output:10";
94/// Immutable output-v11 identity retained as historical schema evidence.
95pub const OUTPUT_V11_SCHEMA_ID: &str = "urn:animsmith:schema:output:11";
96/// Immutable output-v11 version retained as historical schema evidence.
97pub const OUTPUT_V11_SCHEMA_VERSION: u32 = 11;
98/// Immutable identity of the bounded-overflow outer result envelope.
99pub const OUTPUT_V12_SCHEMA_ID: &str = "urn:animsmith:schema:output:12";
100/// Schema version of output-v12.
101pub const OUTPUT_V12_SCHEMA_VERSION: u32 = 12;
102/// Immutable output-v13 identity retained as historical V2 prediction evidence.
103pub const OUTPUT_V13_SCHEMA_ID: &str = "urn:animsmith:schema:output:13";
104/// Schema version of output-v13.
105pub const OUTPUT_V13_SCHEMA_VERSION: u32 = 13;
106/// Immutable output-v14 identity retained for historical V3 prediction evidence.
107pub const OUTPUT_V14_SCHEMA_ID: &str = "urn:animsmith:schema:output:14";
108/// Schema version of output-v14.
109pub const OUTPUT_V14_SCHEMA_VERSION: u32 = 14;
110/// Maximum serialized bytes accepted by the output-v11 report reader.
111pub const OUTPUT_V11_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
112/// Maximum file records carried by one output-v11 envelope.
113pub const OUTPUT_V11_MAX_FILES: usize = 4_096;
114/// Maximum check records carried by one output-v11 lint file.
115pub const OUTPUT_V11_MAX_CHECKS_PER_FILE: usize = 4_096;
116/// Current nested measurement-contract version.
117pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 17;
118/// Immutable identity of the current nested measurement contract.
119pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:17";
120/// Immutable measurements-v16 identity retained for output-v13 through output-v17 readers.
121pub const MEASUREMENTS_V16_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:16";
122/// Immutable measurements-v16 version retained for historical report readers.
123pub const MEASUREMENTS_V16_SCHEMA_VERSION: u32 = 16;
124/// Immutable measurements-v15 identity retained for output-v11 and output-v12 readers.
125pub const MEASUREMENTS_V15_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:15";
126/// Immutable measurements-v15 version retained for historical report readers.
127pub const MEASUREMENTS_V15_SCHEMA_VERSION: u32 = 15;
128
129/// Source checkout identity for the producing animsmith build.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct ToolSource {
132    revision: Option<String>,
133    dirty: Option<bool>,
134}
135
136impl ToolSource {
137    /// Construct source identity from a full Git revision and dirty bit.
138    ///
139    /// Packaged or otherwise provenance-free builds use `None` for fields they
140    /// cannot establish rather than claiming a clean checkout. Revisions that
141    /// are not full 40-character hexadecimal Git object ids are dropped so an
142    /// envelope constructed through this API remains within output v11.
143    pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
144        let revision = revision.filter(|revision| {
145            revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
146        });
147        Self { revision, dirty }
148    }
149}
150
151/// Identity of the animsmith producer that emitted an envelope.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153pub struct ToolInfo {
154    name: &'static str,
155    version: &'static str,
156    source: ToolSource,
157}
158
159impl ToolInfo {
160    /// Construct animsmith producer identity from this package's version and
161    /// optional source-checkout metadata.
162    pub fn animsmith(source: ToolSource) -> Self {
163        Self {
164            name: "animsmith",
165            version: env!("CARGO_PKG_VERSION"),
166            source,
167        }
168    }
169}
170
171/// Immutable identity of the bytes used to produce one file report.
172///
173/// The digest is lowercase hexadecimal SHA-256 so consumers can compare
174/// identities without retaining the source bytes themselves.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
176pub struct InputIdentity {
177    sha256: String,
178    bytes: u64,
179}
180
181/// Lowercase hexadecimal SHA-256 digest of exactly these bytes.
182///
183/// The digest type carries no `LowerHex` impl, so every producer of a
184/// contract `sha256` field goes through this one formatter rather than
185/// re-deriving the encoding.
186#[must_use]
187pub fn sha256_hex(bytes: &[u8]) -> String {
188    sha256_digest_hex(Sha256::digest(bytes).into())
189}
190
191fn sha256_digest_hex(digest: [u8; 32]) -> String {
192    let mut hex = String::with_capacity(64);
193    for byte in digest {
194        let _ = write!(hex, "{byte:02x}");
195    }
196    hex
197}
198
199impl InputIdentity {
200    /// Calculate the identity for source bytes.
201    pub fn from_bytes(bytes: &[u8]) -> Self {
202        Self {
203            sha256: sha256_hex(bytes),
204            bytes: bytes.len() as u64,
205        }
206    }
207
208    /// Construct an identity from an already-computed SHA-256 digest and exact byte count.
209    ///
210    /// This keeps streaming and bounded readers on the same lowercase digest
211    /// authority without exposing an unchecked string constructor.
212    pub fn from_sha256_digest(digest: [u8; 32], bytes: u64) -> Self {
213        Self {
214            sha256: sha256_digest_hex(digest),
215            bytes,
216        }
217    }
218
219    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
220    pub fn sha256(&self) -> &str {
221        &self.sha256
222    }
223
224    /// Number of source bytes represented by this identity.
225    pub fn bytes(&self) -> u64 {
226        self.bytes
227    }
228}
229
230/// Rig profile and resolved semantic-role bindings for one input file.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct RigInfo {
234    profile: String,
235    resolution_outcome: String,
236    resolved_roles: BTreeMap<String, String>,
237    resolved_role_policies: BTreeMap<String, String>,
238}
239
240/// Resolved-role evidence did not belong to the supplied document.
241#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
242#[non_exhaustive]
243pub enum RigInfoError {
244    /// A resolved role referenced a bone outside the document's skeleton.
245    #[error(
246        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
247    )]
248    InvalidBoneId {
249        /// Stable semantic role name.
250        role: &'static str,
251        /// Invalid bone index carried by the resolution.
252        bone: usize,
253        /// Number of bones available in the supplied document.
254        bone_count: usize,
255    },
256    /// A valid bone index now names a different bone than the resolution did.
257    #[error(
258        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
259    )]
260    BoneNameMismatch {
261        /// Stable semantic role name.
262        role: &'static str,
263        /// Bone index carried by the resolution.
264        bone: usize,
265        /// Bone name captured when the role was resolved.
266        expected: String,
267        /// Bone name at that index in the supplied document.
268        found: String,
269    },
270}
271
272impl RigInfo {
273    /// Project resolved roles into their stable role names and source bone
274    /// names for the result contract.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`RigInfoError`] when `roles` references a bone outside the
279    /// supplied document, such as a resolution produced from another
280    /// skeleton.
281    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
282        let resolved = roles
283            .iter_with_details()
284            .map(|(role, bone, expected_name, policy)| {
285                let name = doc
286                    .skeleton
287                    .bones
288                    .get(bone)
289                    .ok_or(RigInfoError::InvalidBoneId {
290                        role: role.as_str(),
291                        bone,
292                        bone_count: doc.skeleton.bones.len(),
293                    })?;
294                if name.name != expected_name {
295                    return Err(RigInfoError::BoneNameMismatch {
296                        role: role.as_str(),
297                        bone,
298                        expected: expected_name.to_owned(),
299                        found: name.name.clone(),
300                    });
301                }
302                Ok((role.as_str(), (name.name.clone(), policy.as_str())))
303            })
304            .collect::<Result<BTreeMap<_, _>, _>>()?;
305        Ok(Self {
306            profile: roles.profile.clone(),
307            resolution_outcome: roles.outcome().as_str().to_owned(),
308            resolved_roles: resolved
309                .iter()
310                .map(|(&role, (name, _))| (role.to_owned(), name.clone()))
311                .collect(),
312            resolved_role_policies: resolved
313                .into_iter()
314                .map(|(role, (_, policy))| (role.to_owned(), policy.to_owned()))
315                .collect(),
316        })
317    }
318}
319
320/// Independently versioned measurement payload nested in measure and lint
321/// file records.
322#[derive(Debug, Clone, Serialize)]
323pub struct MeasurementContract {
324    schema_version: u32,
325    schema: &'static str,
326    clips: BTreeMap<String, ClipMeasurements>,
327    #[serde(flatten)]
328    assets: AssetMeasurements,
329}
330
331/// Measurement evidence could not satisfy the current measurement contract.
332#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
333#[non_exhaustive]
334pub enum MeasurementContractError {
335    /// A required or present numeric value was non-finite.
336    #[error("measurement value {path} must be finite")]
337    NonFiniteValue {
338        /// Human-readable location within the measurement contract.
339        path: String,
340    },
341    /// Related measurement fields were structurally inconsistent.
342    #[error("measurement structure {path} is invalid: {reason}")]
343    InvalidStructure {
344        /// Human-readable location within the measurement contract.
345        path: String,
346        /// Stable explanation of the violated relationship.
347        reason: String,
348    },
349}
350
351impl MeasurementContract {
352    /// Construct the current measurement contract.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`MeasurementContractError`] when required or present numeric
357    /// evidence is non-finite or structurally inconsistent.
358    pub fn new(
359        clips: BTreeMap<String, ClipMeasurements>,
360        assets: AssetMeasurements,
361    ) -> Result<Self, MeasurementContractError> {
362        validate_measurements(&clips, &assets, MeasurementRevision::V17)?;
363        Ok(Self {
364            schema_version: MEASUREMENTS_SCHEMA_VERSION,
365            schema: MEASUREMENTS_SCHEMA_ID,
366            clips,
367            assets,
368        })
369    }
370
371    pub(crate) fn historical_v15(
372        clips: BTreeMap<String, ClipMeasurements>,
373        assets: AssetMeasurements,
374    ) -> Result<Self, MeasurementContractError> {
375        validate_measurements(&clips, &assets, MeasurementRevision::V15)?;
376        Ok(Self {
377            schema_version: MEASUREMENTS_V15_SCHEMA_VERSION,
378            schema: MEASUREMENTS_V15_SCHEMA_ID,
379            clips,
380            assets,
381        })
382    }
383
384    pub(crate) fn historical_v16(
385        clips: BTreeMap<String, ClipMeasurements>,
386        assets: AssetMeasurements,
387    ) -> Result<Self, MeasurementContractError> {
388        validate_measurements(&clips, &assets, MeasurementRevision::V16)?;
389        Ok(Self {
390            schema_version: MEASUREMENTS_V16_SCHEMA_VERSION,
391            schema: MEASUREMENTS_V16_SCHEMA_ID,
392            clips,
393            assets,
394        })
395    }
396
397    /// Reconstruct the measurements-v16 evidence view consumed by immutable
398    /// prediction contracts embedded in current output. V16 represented loop
399    /// continuity as all-or-nothing, so any unavailable V17 bone projects to
400    /// an unavailable outer fact; fully measured rows simply omit the V17-only
401    /// per-bone availability field.
402    fn prediction_v16_projection(&self) -> Result<Self, MeasurementContractError> {
403        let mut clips = self.clips.clone();
404        for clip in clips.values_mut() {
405            let has_unavailable_bone = clip.loop_continuity.as_ref().is_some_and(|continuity| {
406                continuity
407                    .bones
408                    .iter()
409                    .any(|bone| bone.availability != MeasurementAvailability::Measured)
410            });
411            if has_unavailable_bone {
412                clip.loop_continuity = None;
413                clip.loop_continuity_availability = MeasurementAvailability::Unavailable;
414            } else if let Some(continuity) = &mut clip.loop_continuity {
415                for bone in &mut continuity.bones {
416                    bone.availability_was_present = false;
417                }
418            }
419        }
420        Self::historical_v16(clips, self.assets.clone())
421    }
422
423    /// Per-clip measurements keyed by clip name.
424    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
425        &self.clips
426    }
427
428    /// Static source-geometry, node-instance, and declared-scene evidence.
429    pub fn assets(&self) -> &AssetMeasurements {
430        &self.assets
431    }
432
433    /// Consume the contract and return its clip and static asset measurements.
434    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
435        (self.clips, self.assets)
436    }
437}
438
439#[derive(Clone, Copy, PartialEq, Eq)]
440enum MeasurementRevision {
441    V15,
442    V16,
443    V17,
444}
445
446fn validate_measurements(
447    clips: &BTreeMap<String, ClipMeasurements>,
448    assets: &AssetMeasurements,
449    revision: MeasurementRevision,
450) -> Result<(), MeasurementContractError> {
451    let finite = |value: f64, path: String| {
452        value
453            .is_finite()
454            .then_some(())
455            .ok_or(MeasurementContractError::NonFiniteValue { path })
456    };
457    let permits_roundoff = |observed: f64, lower_bound: f64| {
458        let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
459        observed + tolerance >= lower_bound
460    };
461    let check_availability = |value_present: bool,
462                              availability: MeasurementAvailability,
463                              path: String| {
464        match (value_present, availability) {
465            (true, MeasurementAvailability::Measured) => Ok(()),
466            (
467                false,
468                MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
469            ) => Ok(()),
470            _ => Err(MeasurementContractError::InvalidStructure {
471                path,
472                reason: "value presence must match availability status".into(),
473            }),
474        }
475    };
476    for (clip_name, clip) in clips {
477        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
478        let mut previous_bone_index = None;
479        let mut covered_bone_names = BTreeSet::new();
480        for (offset, bone) in clip.bone_channels.iter().enumerate() {
481            let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
482            if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
483                return Err(MeasurementContractError::InvalidStructure {
484                    path: format!("{path}.bone_index"),
485                    reason: "bone channel entries must use strictly increasing unique bone indices"
486                        .into(),
487                });
488            }
489            previous_bone_index = Some(bone.bone_index);
490            if bone.properties.is_empty() {
491                return Err(MeasurementContractError::InvalidStructure {
492                    path: format!("{path}.properties"),
493                    reason: "bone channel coverage must contain at least one property".into(),
494                });
495            }
496            if bone
497                .properties
498                .windows(2)
499                .any(|properties| properties[0] >= properties[1])
500            {
501                return Err(MeasurementContractError::InvalidStructure {
502                    path: format!("{path}.properties"),
503                    reason:
504                        "channel properties must be unique and ordered translation, rotation, scale"
505                            .into(),
506                });
507            }
508            covered_bone_names.insert(bone.bone_name.clone());
509        }
510        let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
511        if clip.animated_bones != expected_animated_bones {
512            return Err(MeasurementContractError::InvalidStructure {
513                path: format!("clips[{clip_name:?}].animated_bones"),
514                reason: "animated_bones must equal the sorted unique bone names in bone_channels"
515                    .into(),
516            });
517        }
518        for (bone, value) in &clip.bone_rotation_range_deg {
519            if clip.animated_bones.binary_search(bone).is_err() {
520                return Err(MeasurementContractError::InvalidStructure {
521                    path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
522                    reason: "rotation-range bones must be present in animated_bones".into(),
523                });
524            }
525            finite(
526                *value,
527                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
528            )?;
529        }
530        check_availability(
531            clip.loop_continuity.is_some(),
532            clip.loop_continuity_availability,
533            format!("clips[{clip_name:?}].loop_continuity"),
534        )?;
535        check_availability(
536            clip.loop_endpoint_mode.is_some(),
537            clip.loop_endpoint_mode_availability,
538            format!("clips[{clip_name:?}].loop_endpoint_mode"),
539        )?;
540        check_availability(
541            clip.frame_grid.is_some(),
542            clip.frame_grid_availability,
543            format!("clips[{clip_name:?}].frame_grid"),
544        )?;
545        check_availability(
546            clip.loop_seam_ratio.is_some(),
547            clip.loop_seam_ratio_availability,
548            format!("clips[{clip_name:?}].loop_seam_ratio"),
549        )?;
550        check_availability(
551            clip.gait.is_some(),
552            clip.gait_availability,
553            format!("clips[{clip_name:?}].gait"),
554        )?;
555        check_availability(
556            clip.root_trajectory.is_some(),
557            clip.root_trajectory_availability,
558            format!("clips[{clip_name:?}].root_trajectory"),
559        )?;
560        check_availability(
561            clip.speed_mps.is_some(),
562            clip.speed_mps_availability,
563            format!("clips[{clip_name:?}].speed_mps"),
564        )?;
565        if let Some(gait) = &clip.gait {
566            check_availability(
567                gait.phase.is_some(),
568                gait.phase_availability,
569                format!("clips[{clip_name:?}].gait.phase"),
570            )?;
571        }
572        if let Some(trajectory) = &clip.root_trajectory {
573            let path = format!("clips[{clip_name:?}].root_trajectory");
574            check_availability(
575                trajectory.translation.is_some(),
576                trajectory.translation_availability,
577                format!("{path}.translation"),
578            )?;
579            check_availability(
580                trajectory.yaw.is_some(),
581                trajectory.yaw_availability,
582                format!("{path}.yaw"),
583            )?;
584            if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
585                return Err(MeasurementContractError::InvalidStructure {
586                    path: format!("{path}.translation_availability"),
587                    reason:
588                        "translation remains applicable when a root-trajectory bone is selected"
589                            .into(),
590                });
591            }
592            if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
593                return Err(MeasurementContractError::InvalidStructure {
594                    path: format!("{path}.yaw_availability"),
595                    reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
596                });
597            }
598            if let Some(translation) = trajectory.translation {
599                for (field, value) in [
600                    (
601                        "horizontal_displacement_x_m",
602                        translation.horizontal_displacement_x_m,
603                    ),
604                    (
605                        "horizontal_displacement_z_m",
606                        translation.horizontal_displacement_z_m,
607                    ),
608                    ("horizontal_travel_m", translation.horizontal_travel_m),
609                    (
610                        "vertical_displacement_m",
611                        translation.vertical_displacement_m,
612                    ),
613                    (
614                        "vertical_min_displacement_m",
615                        translation.vertical_min_displacement_m,
616                    ),
617                    (
618                        "vertical_max_displacement_m",
619                        translation.vertical_max_displacement_m,
620                    ),
621                ] {
622                    finite(value, format!("{path}.translation.{field}"))?;
623                }
624                if translation.horizontal_travel_m < 0.0 {
625                    return Err(MeasurementContractError::InvalidStructure {
626                        path: format!("{path}.translation.horizontal_travel_m"),
627                        reason: "sampled horizontal travel must be non-negative".into(),
628                    });
629                }
630                let horizontal_displacement_m = translation
631                    .horizontal_displacement_x_m
632                    .hypot(translation.horizontal_displacement_z_m);
633                if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
634                    return Err(MeasurementContractError::InvalidStructure {
635                        path: format!("{path}.translation.horizontal_travel_m"),
636                        reason: "sampled horizontal travel must contain endpoint displacement"
637                            .into(),
638                    });
639                }
640                if translation.vertical_min_displacement_m > 0.0
641                    || translation.vertical_max_displacement_m < 0.0
642                    || translation.vertical_displacement_m < translation.vertical_min_displacement_m
643                    || translation.vertical_displacement_m > translation.vertical_max_displacement_m
644                {
645                    return Err(MeasurementContractError::InvalidStructure {
646                        path: format!("{path}.translation"),
647                        reason: "vertical extrema must include zero and the endpoint displacement"
648                            .into(),
649                    });
650                }
651            }
652            if let Some(yaw) = trajectory.yaw {
653                finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
654                finite(
655                    yaw.unwrapped_yaw_deg,
656                    format!("{path}.yaw.unwrapped_yaw_deg"),
657                )?;
658                finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
659                if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
660                    return Err(MeasurementContractError::InvalidStructure {
661                        path: format!("{path}.yaw.net_yaw_deg"),
662                        reason: "net yaw must be in the inclusive range [-180, 180]".into(),
663                    });
664                }
665                if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
666                    return Err(MeasurementContractError::InvalidStructure {
667                        path: format!("{path}.yaw.net_yaw_deg"),
668                        reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
669                            .into(),
670                    });
671                }
672                if yaw.yaw_travel_deg < 0.0 {
673                    return Err(MeasurementContractError::InvalidStructure {
674                        path: format!("{path}.yaw.yaw_travel_deg"),
675                        reason: "sampled yaw travel must be non-negative".into(),
676                    });
677                }
678                if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
679                    return Err(MeasurementContractError::InvalidStructure {
680                        path: format!("{path}.yaw.yaw_travel_deg"),
681                        reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
682                    });
683                }
684            }
685        }
686        if let Some(loop_continuity) = &clip.loop_continuity {
687            if loop_continuity.bones.is_empty() {
688                return Err(MeasurementContractError::InvalidStructure {
689                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
690                    reason: "present loop-continuity evidence must contain at least one bone"
691                        .into(),
692                });
693            }
694            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
695                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
696                if usize::try_from(bone.bone_index) != Ok(expected_index) {
697                    return Err(MeasurementContractError::InvalidStructure {
698                        path: format!("{path}.bone_index"),
699                        reason: format!(
700                            "expected skeleton-order index {expected_index}, found {}",
701                            bone.bone_index
702                        ),
703                    });
704                }
705                if revision == MeasurementRevision::V17 && !bone.availability_was_present {
706                    return Err(MeasurementContractError::InvalidStructure {
707                        path: format!("{path}.availability"),
708                        reason: "measurements-v17 requires explicit per-bone availability".into(),
709                    });
710                }
711                if revision != MeasurementRevision::V17 && bone.availability_was_present {
712                    return Err(MeasurementContractError::InvalidStructure {
713                        path: format!("{path}.availability"),
714                        reason:
715                            "per-bone loop-continuity availability is exclusive to measurements-v17"
716                                .into(),
717                    });
718                }
719                if revision != MeasurementRevision::V17
720                    && bone.availability != MeasurementAvailability::Measured
721                {
722                    return Err(MeasurementContractError::InvalidStructure {
723                        path: format!("{path}.availability"),
724                        reason: "historical measurement contracts require every present loop-continuity row to be measured"
725                            .into(),
726                    });
727                }
728                if bone.availability == MeasurementAvailability::NotApplicable {
729                    return Err(MeasurementContractError::InvalidStructure {
730                        path: format!("{path}.availability"),
731                        reason: "an existing bone's loop-continuity evidence remains applicable"
732                            .into(),
733                    });
734                }
735                let values = [
736                    ("position_delta_m", bone.position_delta_m),
737                    ("rotation_delta_deg", bone.rotation_delta_deg),
738                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
739                    (
740                        "seam_angular_velocity_delta_degps",
741                        bone.seam_angular_velocity_delta_degps,
742                    ),
743                ];
744                let values_present = values.iter().filter(|(_, value)| value.is_some()).count();
745                match (bone.availability, values_present) {
746                    (MeasurementAvailability::Measured, 4)
747                    | (MeasurementAvailability::Unavailable, 0) => {}
748                    _ => {
749                        return Err(MeasurementContractError::InvalidStructure {
750                            path: path.clone(),
751                            reason: "all loop-continuity values must be present exactly when the bone is measured"
752                                .into(),
753                        });
754                    }
755                }
756                for (field, value) in values {
757                    let Some(value) = value else { continue };
758                    finite(value, format!("{path}.{field}"))?;
759                    if value < 0.0 {
760                        return Err(MeasurementContractError::InvalidStructure {
761                            path: format!("{path}.{field}"),
762                            reason: "loop-continuity deltas must be non-negative".into(),
763                        });
764                    }
765                }
766            }
767        }
768        if let Some(frame_grid) = &clip.frame_grid {
769            let path = format!("clips[{clip_name:?}].frame_grid");
770            finite(frame_grid.fps, format!("{path}.fps"))?;
771            if frame_grid.fps <= 0.0 {
772                return Err(MeasurementContractError::InvalidStructure {
773                    path: format!("{path}.fps"),
774                    reason: "declared frame-grid FPS must be positive".into(),
775                });
776            }
777            if frame_grid.frame_intervals == 0 {
778                return Err(MeasurementContractError::InvalidStructure {
779                    path: format!("{path}.frame_intervals"),
780                    reason: "declared frame-grid evidence must contain at least one interval"
781                        .into(),
782                });
783            }
784        }
785        if let Some(value) = clip.loop_seam_ratio {
786            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
787        }
788        if let Some(gait) = &clip.gait {
789            if let Some(value) = gait.phase {
790                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
791            }
792            finite(
793                gait.lr_amplitude_m,
794                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
795            )?;
796        }
797        if let Some(value) = clip.speed_mps {
798            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
799        }
800    }
801    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
802        path,
803        reason: reason.to_owned(),
804    };
805    let finite_aabb = |aabb: &Aabb, path: &str| {
806        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
807            for (axis, value) in values.into_iter().enumerate() {
808                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
809            }
810        }
811        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
812            if min > max {
813                return Err(invalid(
814                    format!("{path}.min[{axis}]"),
815                    "AABB minimum cannot exceed maximum",
816                ));
817            }
818        }
819        Ok(())
820    };
821
822    let mut mesh_indices = BTreeSet::new();
823    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
824        if !mesh_indices.insert(mesh.mesh_index) {
825            return Err(invalid(
826                format!("mesh_definitions[{index}].mesh_index"),
827                "mesh_index must be unique",
828            ));
829        }
830        if let Some(aabb) = &mesh.geometry_aabb {
831            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
832        }
833        if let Some(centroid) = mesh.geometry_centroid {
834            for (axis, value) in centroid.into_iter().enumerate() {
835                finite(
836                    f64::from(value),
837                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
838                )?;
839            }
840        }
841        if let Some(value) = mesh.weight_sum_min {
842            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
843        }
844        if let Some(value) = mesh.weight_sum_max {
845            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
846        }
847        if revision == MeasurementRevision::V15 && mesh.vertex_count > u64::from(u32::MAX) {
848            return Err(invalid(
849                format!("mesh_definitions[{index}].vertex_count"),
850                "measurements-v15 vertex_count cannot exceed its historical u32 maximum",
851            ));
852        }
853        match (&mesh.primitives, revision) {
854            (None, MeasurementRevision::V16 | MeasurementRevision::V17) => {
855                return Err(invalid(
856                    format!("mesh_definitions[{index}].primitives"),
857                    "measurements-v16 requires per-primitive evidence",
858                ));
859            }
860            (Some(_), MeasurementRevision::V15) => {
861                return Err(invalid(
862                    format!("mesh_definitions[{index}].primitives"),
863                    "measurements-v15 cannot carry per-primitive evidence",
864                ));
865            }
866            (Some(primitives), MeasurementRevision::V16 | MeasurementRevision::V17) => {
867                let mut summed_vertex_count = 0u64;
868                let mut summed_finite_vertex_count = 0u64;
869                let mut aggregate_min = [f32::INFINITY; 3];
870                let mut aggregate_max = [f32::NEG_INFINITY; 3];
871                let mut weighted_centroid_sum = [0.0f64; 3];
872                let mut previous_primitive_index = None;
873                for (primitive_offset, primitive) in primitives.iter().enumerate() {
874                    let path = format!("mesh_definitions[{index}].primitives[{primitive_offset}]");
875                    if previous_primitive_index
876                        .is_some_and(|previous| previous >= primitive.primitive_index)
877                    {
878                        return Err(invalid(
879                            format!("{path}.primitive_index"),
880                            "primitive_index must be unique and strictly increasing in source order",
881                        ));
882                    }
883                    previous_primitive_index = Some(primitive.primitive_index);
884                    if primitive.finite_vertex_count > primitive.vertex_count {
885                        return Err(invalid(
886                            format!("{path}.finite_vertex_count"),
887                            "finite_vertex_count cannot exceed vertex_count",
888                        ));
889                    }
890                    match (
891                        primitive.finite_vertex_count,
892                        primitive.geometry_aabb.as_ref(),
893                        primitive.geometry_centroid,
894                    ) {
895                        (0, None, None) => {}
896                        (1.., Some(aabb), Some(centroid)) => {
897                            finite_aabb(aabb, &format!("{path}.geometry_aabb"))?;
898                            for (axis, value) in centroid.into_iter().enumerate() {
899                                finite(
900                                    f64::from(value),
901                                    format!("{path}.geometry_centroid[{axis}]"),
902                                )?;
903                                if value < aabb.min[axis] || value > aabb.max[axis] {
904                                    return Err(invalid(
905                                        format!("{path}.geometry_centroid[{axis}]"),
906                                        "primitive centroid must lie inside its geometry AABB",
907                                    ));
908                                }
909                                aggregate_min[axis] = aggregate_min[axis].min(aabb.min[axis]);
910                                aggregate_max[axis] = aggregate_max[axis].max(aabb.max[axis]);
911                                weighted_centroid_sum[axis] +=
912                                    f64::from(value) * primitive.finite_vertex_count as f64;
913                            }
914                        }
915                        (0, _, _) => {
916                            return Err(invalid(
917                                path,
918                                "a primitive with no finite vertices cannot carry geometry facts",
919                            ));
920                        }
921                        (1.., _, _) => {
922                            return Err(invalid(
923                                path,
924                                "a primitive with finite vertices requires both geometry facts",
925                            ));
926                        }
927                    }
928                    if assets.material_resource_coverage == MaterialResourceCoverage::Complete
929                        && primitive.material_index.is_some_and(|material_index| {
930                            material_index >= assets.material_definitions.len()
931                        })
932                    {
933                        return Err(invalid(
934                            format!("{path}.material_index"),
935                            "material_index must reference a source material when material resource coverage is complete",
936                        ));
937                    }
938                    summed_vertex_count = summed_vertex_count
939                        .checked_add(primitive.vertex_count)
940                        .ok_or_else(|| {
941                        invalid(
942                            format!("mesh_definitions[{index}].vertex_count"),
943                            "primitive vertex-count sum overflows u64",
944                        )
945                    })?;
946                    summed_finite_vertex_count = summed_finite_vertex_count
947                        .checked_add(primitive.finite_vertex_count)
948                        .ok_or_else(|| {
949                            invalid(
950                                format!("mesh_definitions[{index}].primitives"),
951                                "primitive finite-vertex-count sum overflows u64",
952                            )
953                        })?;
954                }
955                if summed_vertex_count != mesh.vertex_count {
956                    return Err(invalid(
957                        format!("mesh_definitions[{index}].vertex_count"),
958                        "vertex_count must equal the checked sum of primitive vertex counts",
959                    ));
960                }
961                let expected_aabb = (summed_finite_vertex_count != 0).then_some(Aabb {
962                    min: aggregate_min,
963                    max: aggregate_max,
964                });
965                let expected_centroid = (summed_finite_vertex_count != 0).then(|| {
966                    let count = summed_finite_vertex_count as f64;
967                    weighted_centroid_sum.map(|sum| (sum / count) as f32)
968                });
969                match (
970                    summed_finite_vertex_count,
971                    mesh.geometry_aabb.as_ref(),
972                    mesh.geometry_centroid,
973                ) {
974                    (0, None, None) | (1.., Some(_), Some(_)) => {}
975                    (0, _, _) => {
976                        return Err(invalid(
977                            format!("mesh_definitions[{index}]"),
978                            "a mesh with no finite primitive vertices cannot carry geometry facts",
979                        ));
980                    }
981                    (1.., _, _) => {
982                        return Err(invalid(
983                            format!("mesh_definitions[{index}]"),
984                            "a mesh with finite primitive vertices requires both geometry facts",
985                        ));
986                    }
987                }
988                if mesh.geometry_aabb != expected_aabb {
989                    return Err(invalid(
990                        format!("mesh_definitions[{index}].geometry_aabb"),
991                        "mesh AABB must equal the exact union of primitive AABBs",
992                    ));
993                }
994                if mesh.geometry_centroid != expected_centroid {
995                    return Err(invalid(
996                        format!("mesh_definitions[{index}].geometry_centroid"),
997                        "mesh centroid must equal the finite-count-weighted primitive centroids",
998                    ));
999                }
1000            }
1001            (None, MeasurementRevision::V15) => {}
1002        }
1003        let mut previous_set_index = None;
1004        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
1005            let path = format!(
1006                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
1007            );
1008            if set.set_index == 0 {
1009                return Err(invalid(path, "set_index must be at least 1"));
1010            }
1011            if !set.joints_present && !set.weights_present {
1012                return Err(invalid(
1013                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
1014                    "an additional influence set must declare joints, weights, or both",
1015                ));
1016            }
1017            if set.joints_without_weights_present && !set.joints_present {
1018                return Err(invalid(
1019                    format!(
1020                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1021                    ),
1022                    "joints_without_weights_present requires joints_present",
1023                ));
1024            }
1025            if set.weights_without_joints_present && !set.weights_present {
1026                return Err(invalid(
1027                    format!(
1028                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1029                    ),
1030                    "weights_without_joints_present requires weights_present",
1031                ));
1032            }
1033            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
1034                return Err(invalid(
1035                    format!(
1036                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1037                    ),
1038                    "joints_without_weights_present is required when weights_present is false",
1039                ));
1040            }
1041            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
1042                return Err(invalid(
1043                    format!(
1044                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1045                    ),
1046                    "weights_without_joints_present is required when joints_present is false",
1047                ));
1048            }
1049            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
1050                return Err(invalid(
1051                    path,
1052                    "set_index values must be strictly increasing and unique",
1053                ));
1054            }
1055            previous_set_index = Some(set.set_index);
1056        }
1057    }
1058
1059    let mut node_indices = BTreeSet::new();
1060    for (index, instance) in assets.node_instances.iter().enumerate() {
1061        if !node_indices.insert(instance.node_index) {
1062            return Err(invalid(
1063                format!("node_instances[{index}].node_index"),
1064                "node_index must be unique",
1065            ));
1066        }
1067        if !mesh_indices.contains(&instance.mesh_index) {
1068            return Err(invalid(
1069                format!("node_instances[{index}].mesh_index"),
1070                "mesh_index must reference a mesh definition",
1071            ));
1072        }
1073        match (
1074            instance.static_node_world_aabb.as_ref(),
1075            instance.static_node_world_aabb_unavailable_reason,
1076        ) {
1077            (Some(aabb), None) => finite_aabb(
1078                aabb,
1079                &format!("node_instances[{index}].static_node_world_aabb"),
1080            )?,
1081            (None, Some(_)) => {}
1082            (Some(_), Some(_)) => {
1083                return Err(invalid(
1084                    format!("node_instances[{index}]"),
1085                    "an available static node AABB cannot have an unavailable reason",
1086                ));
1087            }
1088            (None, None) => {
1089                return Err(invalid(
1090                    format!("node_instances[{index}]"),
1091                    "a missing static node AABB requires an unavailable reason",
1092                ));
1093            }
1094        }
1095    }
1096
1097    let mut scene_indices = BTreeSet::new();
1098    for (index, scene) in assets.scenes.iter().enumerate() {
1099        if !scene_indices.insert(scene.scene_index) {
1100            return Err(invalid(
1101                format!("scenes[{index}].scene_index"),
1102                "scene_index must be unique",
1103            ));
1104        }
1105        if scene.excluded_instance_count > scene.instance_count {
1106            return Err(invalid(
1107                format!("scenes[{index}].excluded_instance_count"),
1108                "excluded_instance_count cannot exceed instance_count",
1109            ));
1110        }
1111        let available = scene.instance_count - scene.excluded_instance_count;
1112        match (&scene.static_scene_world_aabb, available) {
1113            (Some(aabb), 1..) => {
1114                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
1115            }
1116            (None, 0) => {}
1117            (Some(_), 0) => {
1118                return Err(invalid(
1119                    format!("scenes[{index}].static_scene_world_aabb"),
1120                    "a scene with no available instances cannot have an AABB",
1121                ));
1122            }
1123            (None, _) => {
1124                return Err(invalid(
1125                    format!("scenes[{index}].static_scene_world_aabb"),
1126                    "a scene with available instances requires an AABB",
1127                ));
1128            }
1129        }
1130    }
1131    if let Some(default_scene_index) = assets.default_scene_index
1132        && !scene_indices.contains(&default_scene_index)
1133    {
1134        return Err(invalid(
1135            "default_scene_index".into(),
1136            "default_scene_index must reference a declared scene",
1137        ));
1138    }
1139    validate_skeleton_measurements(assets, &invalid)?;
1140    validate_material_resources(assets, revision, &invalid)?;
1141    Ok(())
1142}
1143
1144fn validate_linear_transform_fields(
1145    linear: &LinearTransformMeasurements,
1146    path: &str,
1147    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1148) -> Result<(), MeasurementContractError> {
1149    let numeric_fields_present = linear.axis_lengths.is_some()
1150        && linear.determinant.is_some()
1151        && linear.orientation.is_some();
1152    if linear.classification == LinearTransformClassification::NonFinite {
1153        if linear.axis_lengths.is_some()
1154            || linear.determinant.is_some()
1155            || linear.orientation.is_some()
1156            || linear.uniform_scale.is_some()
1157        {
1158            return Err(invalid(
1159                path.into(),
1160                "a non_finite classification cannot carry numeric linear-transform facts",
1161            ));
1162        }
1163        return Ok(());
1164    }
1165    if !numeric_fields_present {
1166        return Err(invalid(
1167            path.into(),
1168            "a finite classification requires axis_lengths, determinant, and orientation",
1169        ));
1170    }
1171    for (axis, value) in linear
1172        .axis_lengths
1173        .expect("presence checked")
1174        .into_iter()
1175        .enumerate()
1176    {
1177        if !value.is_finite() {
1178            return Err(MeasurementContractError::NonFiniteValue {
1179                path: format!("{path}.axis_lengths[{axis}]"),
1180            });
1181        }
1182        if value < 0.0 {
1183            return Err(invalid(
1184                format!("{path}.axis_lengths[{axis}]"),
1185                "axis lengths must be non-negative",
1186            ));
1187        }
1188    }
1189    if !linear.determinant.expect("presence checked").is_finite() {
1190        return Err(MeasurementContractError::NonFiniteValue {
1191            path: format!("{path}.determinant"),
1192        });
1193    }
1194    if let Some(scale) = linear.uniform_scale {
1195        if !scale.is_finite() {
1196            return Err(MeasurementContractError::NonFiniteValue {
1197                path: format!("{path}.uniform_scale"),
1198            });
1199        }
1200        if scale < 0.0 {
1201            return Err(invalid(
1202                format!("{path}.uniform_scale"),
1203                "uniform scale must be non-negative",
1204            ));
1205        }
1206    }
1207    Ok(())
1208}
1209
1210fn validate_skeleton_measurements(
1211    assets: &AssetMeasurements,
1212    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1213) -> Result<(), MeasurementContractError> {
1214    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
1215        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
1216            return Err(invalid(
1217                "skeleton_source_coverage".into(),
1218                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
1219            ));
1220        }
1221        return Ok(());
1222    }
1223
1224    let finite_matrix = |matrix: &[f32; 16], path: &str| {
1225        for (component, value) in matrix.iter().enumerate() {
1226            if !value.is_finite() {
1227                return Err(MeasurementContractError::NonFiniteValue {
1228                    path: format!("{path}[{component}]"),
1229                });
1230            }
1231        }
1232        Ok(())
1233    };
1234    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1235        if node.node_index != offset {
1236            return Err(invalid(
1237                format!("skeleton_nodes[{offset}].node_index"),
1238                "node_index must be contiguous and match source order",
1239            ));
1240        }
1241        match &node.local_rest {
1242            SkeletonNodeLocalRestMeasurements::Trs {
1243                translation_parent_space_m,
1244                rotation_xyzw,
1245                scale,
1246            } => {
1247                for (field, values) in [
1248                    (
1249                        "translation_parent_space_m",
1250                        translation_parent_space_m.as_slice(),
1251                    ),
1252                    ("rotation_xyzw", rotation_xyzw.as_slice()),
1253                    ("scale", scale.as_slice()),
1254                ] {
1255                    for (component, value) in values.iter().enumerate() {
1256                        if !value.is_finite() {
1257                            return Err(MeasurementContractError::NonFiniteValue {
1258                                path: format!(
1259                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
1260                                ),
1261                            });
1262                        }
1263                    }
1264                }
1265            }
1266            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
1267                matrix,
1268                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
1269            )?,
1270            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
1271        }
1272        let node_path = format!("skeleton_nodes[{offset}]");
1273        validate_linear_transform_fields(
1274            &node.rest_world_linear,
1275            &format!("{node_path}.rest_world_linear"),
1276            invalid,
1277        )?;
1278        match (
1279            node.rest_world_matrix.as_ref(),
1280            node.rest_world_translation_m.as_ref(),
1281            node.rest_world_matrix_unavailable_reason,
1282        ) {
1283            (Some(matrix), Some(translation), None) => {
1284                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
1285                for (component, value) in translation.iter().enumerate() {
1286                    if !value.is_finite() {
1287                        return Err(MeasurementContractError::NonFiniteValue {
1288                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
1289                        });
1290                    }
1291                }
1292                let expected_translation = [matrix[12], matrix[13], matrix[14]];
1293                if *translation != expected_translation {
1294                    return Err(invalid(
1295                        format!("{node_path}.rest_world_translation_m"),
1296                        "rest_world_translation_m must equal the rest-world matrix translation column",
1297                    ));
1298                }
1299                let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
1300                if node.rest_world_linear != expected_linear {
1301                    return Err(invalid(
1302                        format!("{node_path}.rest_world_linear"),
1303                        "rest_world_linear must be derived from rest_world_matrix",
1304                    ));
1305                }
1306            }
1307            (None, None, Some(_)) => {
1308                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
1309                {
1310                    return Err(invalid(
1311                        format!("{node_path}.rest_world_linear"),
1312                        "an unavailable rest-world matrix requires a non_finite linear classification",
1313                    ));
1314                }
1315            }
1316            (Some(_), Some(_), Some(_)) => {
1317                return Err(invalid(
1318                    node_path,
1319                    "an available rest_world_matrix cannot have an unavailable reason",
1320                ));
1321            }
1322            _ => {
1323                return Err(invalid(
1324                    node_path,
1325                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
1326                ));
1327            }
1328        }
1329    }
1330    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1331        if let Some(parent) = node.parent_node_index
1332            && parent >= assets.skeleton_nodes.len()
1333        {
1334            return Err(invalid(
1335                format!("skeleton_nodes[{offset}].parent_node_index"),
1336                "parent_node_index must reference a skeleton node",
1337            ));
1338        }
1339        let mut previous_scene = None;
1340        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1341            if !assets
1342                .scenes
1343                .iter()
1344                .any(|scene| scene.scene_index == *scene_index)
1345            {
1346                return Err(invalid(
1347                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1348                    "scene_root_indices values must reference declared scenes",
1349                ));
1350            }
1351            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1352                return Err(invalid(
1353                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1354                    "scene_root_indices values must be strictly increasing and unique",
1355                ));
1356            }
1357            previous_scene = Some(*scene_index);
1358        }
1359    }
1360    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1361    for start in 0..assets.skeleton_nodes.len() {
1362        if visits.get(start) != Some(&ParentVisit::Unvisited) {
1363            continue;
1364        }
1365        let mut path = Vec::new();
1366        let mut current = start;
1367        loop {
1368            match visits.get(current).copied().ok_or_else(|| {
1369                invalid(
1370                    format!("skeleton_nodes[{current}].parent_node_index"),
1371                    "parent_node_index must reference a skeleton node",
1372                )
1373            })? {
1374                ParentVisit::Done => break,
1375                ParentVisit::Visiting => {
1376                    return Err(invalid(
1377                        format!("skeleton_nodes[{current}].parent_node_index"),
1378                        "source node parent graph must be acyclic",
1379                    ));
1380                }
1381                ParentVisit::Unvisited => {
1382                    *visits.get_mut(current).ok_or_else(|| {
1383                        invalid(
1384                            format!("skeleton_nodes[{current}].parent_node_index"),
1385                            "parent_node_index must reference a skeleton node",
1386                        )
1387                    })? = ParentVisit::Visiting;
1388                    path.push(current);
1389                    match assets
1390                        .skeleton_nodes
1391                        .get(current)
1392                        .ok_or_else(|| {
1393                            invalid(
1394                                format!("skeleton_nodes[{current}].parent_node_index"),
1395                                "parent_node_index must reference a skeleton node",
1396                            )
1397                        })?
1398                        .parent_node_index
1399                    {
1400                        Some(parent) => current = parent,
1401                        None => break,
1402                    }
1403                }
1404            }
1405        }
1406        for node_index in path {
1407            *visits.get_mut(node_index).ok_or_else(|| {
1408                invalid(
1409                    format!("skeleton_nodes[{node_index}].parent_node_index"),
1410                    "parent_node_index must reference a skeleton node",
1411                )
1412            })? = ParentVisit::Done;
1413        }
1414    }
1415
1416    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1417        let local_rest_available = !matches!(
1418            node.local_rest,
1419            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1420        );
1421        let path = format!("skeleton_nodes[{offset}]");
1422        if !local_rest_available {
1423            if node.rest_world_matrix.is_some()
1424                || node.rest_world_matrix_unavailable_reason
1425                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1426            {
1427                return Err(invalid(
1428                    path,
1429                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1430                ));
1431            }
1432            continue;
1433        }
1434
1435        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1436            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1437                invalid(
1438                    format!("skeleton_nodes[{offset}].parent_node_index"),
1439                    "parent_node_index must reference a skeleton node",
1440                )
1441            })?;
1442            if parent.rest_world_matrix.is_none() {
1443                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1444            } else {
1445                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1446            }
1447        } else {
1448            None
1449        };
1450        match (
1451            node.rest_world_matrix.is_some(),
1452            expected_unavailable_reason,
1453        ) {
1454            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1455            }
1456            (false, Some(expected))
1457                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1458            _ => {
1459                return Err(invalid(
1460                    path,
1461                    "rest-world availability must agree with local rest and parent rest-world evidence",
1462                ));
1463            }
1464        }
1465    }
1466
1467    for (offset, skin) in assets.skins.iter().enumerate() {
1468        if skin.skin_index != offset {
1469            return Err(invalid(
1470                format!("skins[{offset}].skin_index"),
1471                "skin_index must be contiguous and match source order",
1472            ));
1473        }
1474        if let Some(root) = skin.skeleton_root_node_index
1475            && root >= assets.skeleton_nodes.len()
1476        {
1477            return Err(invalid(
1478                format!("skins[{offset}].skeleton_root_node_index"),
1479                "skeleton_root_node_index must reference a skeleton node",
1480            ));
1481        }
1482        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1483            if joint.joint_index != joint_offset {
1484                return Err(invalid(
1485                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1486                    "joint_index must be contiguous and match declared skin order",
1487                ));
1488            }
1489            if joint.node_index >= assets.skeleton_nodes.len() {
1490                return Err(invalid(
1491                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
1492                    "joint node_index must reference a skeleton node",
1493                ));
1494            }
1495        }
1496        match skin.inverse_bind_accessor.status {
1497            SourceInverseBindAccessorStatus::Absent => {
1498                if skin.inverse_bind_accessor.declared_count.is_some()
1499                    || !skin.inverse_bind_accessor.matrices.is_empty()
1500                {
1501                    return Err(invalid(
1502                        format!("skins[{offset}].inverse_bind_accessor"),
1503                        "an absent inverse-bind declaration has no declared count or matrices",
1504                    ));
1505                }
1506            }
1507            SourceInverseBindAccessorStatus::EmptyAccessor => {
1508                if skin.inverse_bind_accessor.declared_count != Some(0)
1509                    || !skin.inverse_bind_accessor.matrices.is_empty()
1510                {
1511                    return Err(invalid(
1512                        format!("skins[{offset}].inverse_bind_accessor"),
1513                        "an empty inverse-bind declaration has declared_count 0 and no matrices",
1514                    ));
1515                }
1516            }
1517            SourceInverseBindAccessorStatus::Available => {
1518                if skin.inverse_bind_accessor.declared_count
1519                    != Some(skin.inverse_bind_accessor.matrices.len())
1520                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1521                {
1522                    return Err(invalid(
1523                        format!("skins[{offset}].inverse_bind_accessor"),
1524                        "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1525                    ));
1526                }
1527            }
1528            SourceInverseBindAccessorStatus::CountMismatch => {
1529                if skin.inverse_bind_accessor.declared_count
1530                    != Some(skin.inverse_bind_accessor.matrices.len())
1531                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1532                {
1533                    return Err(invalid(
1534                        format!("skins[{offset}].inverse_bind_accessor"),
1535                        "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1536                    ));
1537                }
1538            }
1539            SourceInverseBindAccessorStatus::Unreadable => {
1540                if skin.inverse_bind_accessor.declared_count.is_none()
1541                    || !skin.inverse_bind_accessor.matrices.is_empty()
1542                {
1543                    return Err(invalid(
1544                        format!("skins[{offset}].inverse_bind_accessor"),
1545                        "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1546                    ));
1547                }
1548            }
1549        }
1550        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1551            finite_matrix(
1552                matrix,
1553                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1554            )?;
1555        }
1556        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1557            let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1558            let joint_bind_path =
1559                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1560            validate_derived_matrix(
1561                &joint.joint_bind_to_mesh,
1562                &joint_bind_path,
1563                &finite_matrix,
1564                invalid,
1565            )?;
1566            validate_derived_reason_compatibility(
1567                &joint.joint_bind_to_mesh,
1568                skin.inverse_bind_accessor.status,
1569                skin.inverse_bind_accessor.matrices.len(),
1570                joint_offset,
1571                &joint_bind_path,
1572                DerivedMatrixDomain::JointBindToMesh,
1573                invalid,
1574            )?;
1575            validate_derived_source(
1576                &joint.joint_bind_to_mesh,
1577                expected_source,
1578                None,
1579                &joint_bind_path,
1580                DerivedMatrixDomain::JointBindToMesh,
1581                invalid,
1582            )?;
1583
1584            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1585            validate_derived_matrix(
1586                &joint.mesh_bind_world,
1587                &mesh_bind_path,
1588                &finite_matrix,
1589                invalid,
1590            )?;
1591            validate_derived_reason_compatibility(
1592                &joint.mesh_bind_world,
1593                skin.inverse_bind_accessor.status,
1594                skin.inverse_bind_accessor.matrices.len(),
1595                joint_offset,
1596                &mesh_bind_path,
1597                DerivedMatrixDomain::MeshBindWorld,
1598                invalid,
1599            )?;
1600            let joint_rest_world_available = assets
1601                .skeleton_nodes
1602                .get(joint.node_index)
1603                .ok_or_else(|| {
1604                    invalid(
1605                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
1606                        "joint node_index must reference a skeleton node",
1607                    )
1608                })?
1609                .rest_world_matrix
1610                .is_some();
1611            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1612                .rest_world_matrix
1613                .as_ref();
1614            validate_mesh_bind_world_reason_compatibility(
1615                &joint.mesh_bind_world,
1616                joint_rest_world_available,
1617                &mesh_bind_path,
1618                invalid,
1619            )?;
1620            validate_derived_source(
1621                &joint.mesh_bind_world,
1622                expected_source,
1623                joint_rest_world,
1624                &mesh_bind_path,
1625                DerivedMatrixDomain::MeshBindWorld,
1626                invalid,
1627            )?;
1628        }
1629        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1630            && !scale.is_finite()
1631        {
1632            return Err(MeasurementContractError::NonFiniteValue {
1633                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1634            });
1635        }
1636        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1637        if skin.joint_bind_linear_summary != expected_summary {
1638            return Err(invalid(
1639                format!("skins[{offset}].joint_bind_linear_summary"),
1640                "joint-bind linear summary must match the skin joint observations",
1641            ));
1642        }
1643        let mut previous_attachment_node = None;
1644        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1645            if attachment.node_index >= assets.skeleton_nodes.len() {
1646                return Err(invalid(
1647                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1648                    "attachment node_index must reference a skeleton node",
1649                ));
1650            }
1651            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1652                return Err(invalid(
1653                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1654                    "attachment node_index values must be strictly increasing and unique",
1655                ));
1656            }
1657            previous_attachment_node = Some(attachment.node_index);
1658        }
1659    }
1660    Ok(())
1661}
1662
1663fn validate_derived_reason_compatibility(
1664    matrix: &SkinDerivedMatrixMeasurements,
1665    status: SourceInverseBindAccessorStatus,
1666    readable_matrix_count: usize,
1667    joint_index: usize,
1668    path: &str,
1669    domain: DerivedMatrixDomain,
1670    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1671) -> Result<(), MeasurementContractError> {
1672    let requires_accessor_reason = match status {
1673        SourceInverseBindAccessorStatus::Absent => {
1674            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1675        }
1676        SourceInverseBindAccessorStatus::EmptyAccessor => {
1677            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1678        }
1679        SourceInverseBindAccessorStatus::Unreadable => {
1680            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1681        }
1682        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1683            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1684        }
1685        SourceInverseBindAccessorStatus::Available
1686        | SourceInverseBindAccessorStatus::CountMismatch => None,
1687    };
1688    if let Some(expected) = requires_accessor_reason {
1689        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1690            return Err(invalid(
1691                path.into(),
1692                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1693            ));
1694        }
1695    } else {
1696        match (domain, matrix.unavailable_reason) {
1697            (
1698                _,
1699                Some(
1700                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1701                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1702                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1703                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1704                ),
1705            ) => {
1706                return Err(invalid(
1707                    format!("{path}.unavailable_reason"),
1708                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1709                ));
1710            }
1711            (
1712                DerivedMatrixDomain::JointBindToMesh,
1713                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1714            ) => {
1715                return Err(invalid(
1716                    format!("{path}.unavailable_reason"),
1717                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1718                ));
1719            }
1720            (
1721                DerivedMatrixDomain::MeshBindWorld,
1722                Some(
1723                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1724                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1725                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1726                ),
1727            ) => {
1728                return Err(invalid(
1729                    format!("{path}.unavailable_reason"),
1730                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1731                ));
1732            }
1733            _ => {}
1734        }
1735    }
1736    Ok(())
1737}
1738
1739fn validate_mesh_bind_world_reason_compatibility(
1740    matrix: &SkinDerivedMatrixMeasurements,
1741    joint_rest_world_available: bool,
1742    path: &str,
1743    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1744) -> Result<(), MeasurementContractError> {
1745    match matrix.unavailable_reason {
1746        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1747            if joint_rest_world_available =>
1748        {
1749            Err(invalid(
1750                format!("{path}.unavailable_reason"),
1751                "an available joint rest-world matrix cannot be reported as unavailable",
1752            ))
1753        }
1754        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1755            if !joint_rest_world_available =>
1756        {
1757            Err(invalid(
1758                format!("{path}.unavailable_reason"),
1759                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1760            ))
1761        }
1762        _ => Ok(()),
1763    }
1764}
1765
1766#[derive(Clone, Copy, PartialEq, Eq)]
1767enum ParentVisit {
1768    Unvisited,
1769    Visiting,
1770    Done,
1771}
1772
1773#[derive(Clone, Copy)]
1774enum DerivedMatrixDomain {
1775    JointBindToMesh,
1776    MeshBindWorld,
1777}
1778
1779fn validate_derived_source(
1780    measurements: &SkinDerivedMatrixMeasurements,
1781    expected_source: Option<&[f32; 16]>,
1782    joint_rest_world: Option<&[f32; 16]>,
1783    path: &str,
1784    domain: DerivedMatrixDomain,
1785    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1786) -> Result<(), MeasurementContractError> {
1787    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1788        return Err(invalid(
1789            format!("{path}.source_inverse_bind_matrix"),
1790            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1791        ));
1792    }
1793    let Some(source) = expected_source else {
1794        if measurements.inversion_quality.is_some() {
1795            return Err(invalid(
1796                format!("{path}.inversion_quality"),
1797                "inversion quality requires a readable source inverse-bind matrix",
1798            ));
1799        }
1800        return Ok(());
1801    };
1802    let raw = Mat4::from_cols_array(source);
1803    match domain {
1804        DerivedMatrixDomain::JointBindToMesh => {
1805            let assessment = assess_inverse_bind(raw);
1806            if measurements.inversion_quality != assessment.quality {
1807                return Err(invalid(
1808                    format!("{path}.inversion_quality"),
1809                    "inversion quality must be derived from the source linear 3x3",
1810                ));
1811            }
1812            match assessment.inverse {
1813                Ok(inverse) => {
1814                    if measurements.matrix != Some(inverse.to_cols_array())
1815                        || measurements.unavailable_reason.is_some()
1816                    {
1817                        return Err(invalid(
1818                            path.into(),
1819                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1820                        ));
1821                    }
1822                }
1823                Err(reason) => {
1824                    if measurements.matrix.is_some()
1825                        || measurements.unavailable_reason != Some(reason)
1826                    {
1827                        return Err(invalid(
1828                            path.into(),
1829                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1830                        ));
1831                    }
1832                }
1833            }
1834        }
1835        DerivedMatrixDomain::MeshBindWorld => {
1836            if measurements.inversion_quality.is_some() {
1837                return Err(invalid(
1838                    format!("{path}.inversion_quality"),
1839                    "mesh_bind_world does not invert its source matrix",
1840                ));
1841            }
1842            if let Some(world) = joint_rest_world {
1843                let expected = Mat4::from_cols_array(world) * raw;
1844                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1845                    if measurements.matrix != Some(expected.to_cols_array())
1846                        || measurements.unavailable_reason.is_some()
1847                    {
1848                        return Err(invalid(
1849                            path.into(),
1850                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1851                        ));
1852                    }
1853                } else if measurements.unavailable_reason
1854                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1855                {
1856                    return Err(invalid(
1857                        format!("{path}.unavailable_reason"),
1858                        "a non-finite mesh-bind product requires its typed unavailable reason",
1859                    ));
1860                }
1861            }
1862        }
1863    }
1864    Ok(())
1865}
1866
1867fn validate_derived_matrix(
1868    matrix: &SkinDerivedMatrixMeasurements,
1869    path: &str,
1870    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1871    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1872) -> Result<(), MeasurementContractError> {
1873    if let Some(source) = &matrix.source_inverse_bind_matrix {
1874        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1875    }
1876    if let Some(quality) = matrix.inversion_quality {
1877        let value = quality.reciprocal_condition_number_inf;
1878        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1879            return Err(invalid(
1880                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1881                "reciprocal condition number must be finite and between zero and one",
1882            ));
1883        }
1884    }
1885    match (
1886        &matrix.matrix,
1887        matrix.linear.as_ref(),
1888        matrix.unavailable_reason,
1889    ) {
1890        (Some(matrix), Some(linear), None) => {
1891            finite_matrix(matrix, &format!("{path}.matrix"))?;
1892            validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1893            if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1894                return Err(invalid(
1895                    format!("{path}.linear"),
1896                    "linear facts must be derived from the available matrix",
1897                ));
1898            }
1899        }
1900        (None, None, Some(_)) => {}
1901        (Some(_), Some(_), Some(_)) => {
1902            return Err(invalid(
1903                path.into(),
1904                "an available derived matrix cannot have an unavailable reason",
1905            ));
1906        }
1907        _ => {
1908            return Err(invalid(
1909                path.into(),
1910                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1911            ));
1912        }
1913    }
1914    Ok(())
1915}
1916
1917fn validate_material_resources(
1918    assets: &AssetMeasurements,
1919    revision: MeasurementRevision,
1920    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1921) -> Result<(), MeasurementContractError> {
1922    let absent = assets.material_definitions.is_empty()
1923        && assets.textures.is_empty()
1924        && assets.images.is_empty();
1925    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1926        return Err(invalid(
1927            "material_resource_coverage".into(),
1928            "unavailable resource coverage requires empty material, texture, and image arrays",
1929        ));
1930    }
1931
1932    for (offset, material) in assets.material_definitions.iter().enumerate() {
1933        if material.material_index != offset {
1934            return Err(invalid(
1935                format!("material_definitions[{offset}].material_index"),
1936                "material_index must be contiguous and match source order",
1937            ));
1938        }
1939        let mut previous_slot = None;
1940        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1941            if binding.texture_index >= assets.textures.len() {
1942                return Err(invalid(
1943                    format!(
1944                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1945                    ),
1946                    "texture_index must reference a source texture",
1947                ));
1948            }
1949            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1950                return Err(invalid(
1951                    format!(
1952                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1953                    ),
1954                    "texture bindings must be strictly ordered by slot and unique",
1955                ));
1956            }
1957            previous_slot = Some(binding.slot);
1958        }
1959    }
1960    for (offset, texture) in assets.textures.iter().enumerate() {
1961        if texture.texture_index != offset {
1962            return Err(invalid(
1963                format!("textures[{offset}].texture_index"),
1964                "texture_index must be contiguous and match source order",
1965            ));
1966        }
1967        if texture.image_index >= assets.images.len() {
1968            return Err(invalid(
1969                format!("textures[{offset}].image_index"),
1970                "image_index must reference a source image",
1971            ));
1972        }
1973    }
1974    for (offset, image) in assets.images.iter().enumerate() {
1975        validate_image_measurement(image, offset, revision, invalid)?;
1976    }
1977    Ok(())
1978}
1979
1980fn validate_image_measurement(
1981    image: &ImageMeasurements,
1982    offset: usize,
1983    revision: MeasurementRevision,
1984    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1985) -> Result<(), MeasurementContractError> {
1986    if image.image_index != offset {
1987        return Err(invalid(
1988            format!("images[{offset}].image_index"),
1989            "image_index must be contiguous and match source order",
1990        ));
1991    }
1992    let available = [
1993        image.width.is_some(),
1994        image.height.is_some(),
1995        image.channel_count.is_some(),
1996        image.decoded_color_type.is_some(),
1997    ];
1998    match (
1999        available.into_iter().all(|value| value),
2000        image.unavailable_reason,
2001    ) {
2002        (true, None) => {
2003            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
2004                image.width,
2005                image.height,
2006                image.channel_count,
2007                image.decoded_color_type,
2008            ) else {
2009                return Err(invalid(
2010                    format!("images[{offset}]"),
2011                    "available image metadata must include width, height, channel_count, and decoded_color_type",
2012                ));
2013            };
2014            if width == 0 || height == 0 {
2015                return Err(invalid(
2016                    format!("images[{offset}]"),
2017                    "available image dimensions must be greater than zero",
2018                ));
2019            }
2020            if channel_count != color_type_channel_count(decoded_color_type) {
2021                return Err(invalid(
2022                    format!("images[{offset}].channel_count"),
2023                    "channel_count must match decoded_color_type",
2024                ));
2025            }
2026            if image.detected_container.is_none() {
2027                return Err(invalid(
2028                    format!("images[{offset}].detected_container"),
2029                    "available image metadata requires a detected_container",
2030                ));
2031            }
2032        }
2033        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
2034        (true, Some(_)) => {
2035            return Err(invalid(
2036                format!("images[{offset}]"),
2037                "available image metadata cannot have an unavailable_reason",
2038            ));
2039        }
2040        (false, None) if available.into_iter().all(|value| !value) => {
2041            return Err(invalid(
2042                format!("images[{offset}]"),
2043                "missing image metadata requires an unavailable_reason",
2044            ));
2045        }
2046        (false, _) => {
2047            return Err(invalid(
2048                format!("images[{offset}]"),
2049                "available image metadata must include width, height, channel_count, and decoded_color_type",
2050            ));
2051        }
2052    }
2053    match image.unavailable_reason {
2054        Some(crate::model::ImageUnavailableReason::DecodeFailed)
2055            if image.detected_container.is_none() =>
2056        {
2057            return Err(invalid(
2058                format!("images[{offset}].detected_container"),
2059                "decode_failed requires a detected_container",
2060            ));
2061        }
2062        Some(
2063            crate::model::ImageUnavailableReason::SourceUnavailable
2064            | crate::model::ImageUnavailableReason::InvalidDataUri
2065            | crate::model::ImageUnavailableReason::UnsupportedContainer,
2066        ) if image.detected_container.is_some() => {
2067            return Err(invalid(
2068                format!("images[{offset}].detected_container"),
2069                "this unavailable_reason cannot have a detected_container",
2070            ));
2071        }
2072        _ => {}
2073    }
2074    match (revision, image.unavailable_reason, &image.leading_magic_hex) {
2075        (MeasurementRevision::V15, _, Some(_)) => {
2076            return Err(invalid(
2077                format!("images[{offset}].leading_magic_hex"),
2078                "measurements-v15 cannot carry leading-magic evidence",
2079            ));
2080        }
2081        (
2082            MeasurementRevision::V16 | MeasurementRevision::V17,
2083            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2084            Some(magic),
2085        ) => {
2086            if magic.is_empty()
2087                || magic.len() > 32
2088                || !magic.len().is_multiple_of(2)
2089                || !magic
2090                    .bytes()
2091                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2092            {
2093                return Err(invalid(
2094                    format!("images[{offset}].leading_magic_hex"),
2095                    "leading_magic_hex must be nonempty lowercase even-length hex for at most 16 bytes",
2096                ));
2097            }
2098        }
2099        (
2100            MeasurementRevision::V16 | MeasurementRevision::V17,
2101            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2102            None,
2103        )
2104        | (MeasurementRevision::V15, _, None) => {}
2105        (MeasurementRevision::V16 | MeasurementRevision::V17, _, Some(_)) => {
2106            return Err(invalid(
2107                format!("images[{offset}].leading_magic_hex"),
2108                "leading_magic_hex is permitted only for unsupported_container",
2109            ));
2110        }
2111        (MeasurementRevision::V16 | MeasurementRevision::V17, _, None) => {}
2112    }
2113    Ok(())
2114}
2115
2116fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
2117    match color_type {
2118        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
2119        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
2120        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
2121        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
2122    }
2123}
2124
2125/// Typed read-side subset accepted when a consumer needs measurements from a
2126/// current `measure` or `lint` report.
2127///
2128/// This intentionally models only the fields needed to recover the nested
2129/// measurement contract while retaining every legitimate output-v11 root
2130/// field. The frozen schema is closed, while all protocol identities and
2131/// command constraints are validated by [`MeasurementReportInput::into_files`].
2132#[derive(Debug)]
2133pub struct MeasurementReportInput {
2134    schema_version: Option<u32>,
2135    schema: Option<String>,
2136    _tool: Option<Box<RawValue>>,
2137    command: Option<String>,
2138    summary: Option<MeasurementReportSummaryInput>,
2139    files: Option<Vec<Box<RawValue>>>,
2140    _inputs: Option<Box<RawValue>>,
2141    _deltas: Option<Box<RawValue>>,
2142    extra: BTreeMap<String, Box<RawValue>>,
2143}
2144
2145impl<'de> Deserialize<'de> for MeasurementReportInput {
2146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2147    where
2148        D: Deserializer<'de>,
2149    {
2150        struct MeasurementReportInputVisitor;
2151
2152        impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
2153            type Value = MeasurementReportInput;
2154
2155            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2156                formatter.write_str("an output report object")
2157            }
2158
2159            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
2160            where
2161                A: MapAccess<'de>,
2162            {
2163                let mut schema_version = None;
2164                let mut schema = None;
2165                let mut tool = None;
2166                let mut command = None;
2167                let mut summary = None;
2168                let mut files = None;
2169                let mut inputs = None;
2170                let mut deltas = None;
2171                let mut extra = BTreeMap::new();
2172                while let Some(field) = map.next_key::<String>()? {
2173                    match field.as_str() {
2174                        "schema_version" => {
2175                            if schema_version.is_some() {
2176                                return Err(serde::de::Error::duplicate_field("schema_version"));
2177                            }
2178                            schema_version = Some(map.next_value()?);
2179                        }
2180                        "schema" => {
2181                            if schema.is_some() {
2182                                return Err(serde::de::Error::duplicate_field("schema"));
2183                            }
2184                            schema = Some(map.next_value()?);
2185                        }
2186                        "tool" => {
2187                            if tool.is_some() {
2188                                return Err(serde::de::Error::duplicate_field("tool"));
2189                            }
2190                            tool = Some(map.next_value()?);
2191                        }
2192                        "command" => {
2193                            if command.is_some() {
2194                                return Err(serde::de::Error::duplicate_field("command"));
2195                            }
2196                            command = Some(map.next_value()?);
2197                        }
2198                        "summary" => {
2199                            if summary.is_some() {
2200                                return Err(serde::de::Error::duplicate_field("summary"));
2201                            }
2202                            summary = Some(map.next_value()?);
2203                        }
2204                        "files" => {
2205                            if files.is_some() {
2206                                return Err(serde::de::Error::duplicate_field("files"));
2207                            }
2208                            files = Some(map.next_value()?);
2209                        }
2210                        "inputs" => {
2211                            if inputs.is_some() {
2212                                return Err(serde::de::Error::duplicate_field("inputs"));
2213                            }
2214                            inputs = Some(map.next_value()?);
2215                        }
2216                        "deltas" => {
2217                            if deltas.is_some() {
2218                                return Err(serde::de::Error::duplicate_field("deltas"));
2219                            }
2220                            deltas = Some(map.next_value()?);
2221                        }
2222                        _ => {
2223                            extra.insert(field, map.next_value()?);
2224                        }
2225                    }
2226                }
2227                Ok(MeasurementReportInput {
2228                    schema_version: schema_version.unwrap_or_default(),
2229                    schema: schema.unwrap_or_default(),
2230                    _tool: tool,
2231                    command: command.unwrap_or_default(),
2232                    summary: summary.unwrap_or_default(),
2233                    files: files.unwrap_or_default(),
2234                    _inputs: inputs.unwrap_or_default(),
2235                    _deltas: deltas.unwrap_or_default(),
2236                    extra,
2237                })
2238            }
2239        }
2240
2241        deserializer.deserialize_map(MeasurementReportInputVisitor)
2242    }
2243}
2244
2245#[derive(Debug, Deserialize)]
2246#[serde(deny_unknown_fields)]
2247struct MeasurementFileWireInput {
2248    path: Option<String>,
2249    input: Option<InputIdentityInput>,
2250    rig: Box<RawValue>,
2251    measurements: Option<Box<RawValue>>,
2252    #[serde(default, deserialize_with = "deserialize_required_nullable")]
2253    prediction_provenance: RequiredNullable<Box<RawValue>>,
2254    checks: Option<Vec<Box<RawValue>>>,
2255}
2256
2257#[derive(Debug)]
2258struct MeasurementFileInput {
2259    path: Option<String>,
2260    input: Option<InputIdentityInput>,
2261    /// V17 alone retains the strictly decoded rig needed by the shared
2262    /// root-motion reconstruction hook. Historical readers keep treating rig
2263    /// as opaque evidence, preserving their released acceptance behavior.
2264    rig_v17: Option<RigInfo>,
2265    measurements: Option<Box<RawValue>>,
2266    prediction_provenance: RequiredNullable<PredictionProvenanceV2>,
2267    checks: Option<Vec<PredictionCheckInput>>,
2268    legacy_prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
2269    legacy_checks: Option<Vec<LegacyPredictionCheckInput>>,
2270    prediction_provenance_v3: RequiredNullable<PredictionProvenanceV3>,
2271    checks_v3: Option<Vec<PredictionCheckInputV3>>,
2272    prediction_provenance_v4: RequiredNullable<PredictionProvenanceV4>,
2273    checks_v4: Option<Vec<PredictionCheckInputV4>>,
2274    prediction_provenance_v5: RequiredNullable<PredictionProvenanceV5>,
2275    checks_v5: Option<Vec<PredictionCheckInputV5>>,
2276    prediction_provenance_v6: RequiredNullable<PredictionProvenanceV6>,
2277    checks_v6: Option<Vec<PredictionCheckInputV6>>,
2278}
2279
2280#[derive(Debug, Deserialize)]
2281#[serde(deny_unknown_fields)]
2282struct LegacyPredictionCheckWireV11 {
2283    check_id: String,
2284    selection: SelectionState,
2285    configuration: ConfigurationState,
2286    applicability: Applicability,
2287    evaluation: EvaluationState,
2288    findings: Vec<PredictionFindingInput>,
2289    #[serde(default)]
2290    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2291    #[serde(default)]
2292    gaps: Vec<PredictionGapInput>,
2293    prediction: Option<Box<RawValue>>,
2294}
2295
2296/// The immutable V11 check attachment.  This deliberately remains a separate
2297/// internal shape: V11 evidence is validated with its V1 identity and staged
2298/// decoding rules, never converted into a V2 attachment.
2299#[derive(Debug)]
2300struct LegacyPredictionCheckInput {
2301    check_id: String,
2302    selection: SelectionState,
2303    configuration: ConfigurationState,
2304    applicability: Applicability,
2305    evaluation: EvaluationState,
2306    findings: Vec<PredictionFindingInput>,
2307    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2308    gaps: Vec<PredictionGapInput>,
2309    prediction: Option<EnginePredictionV1>,
2310}
2311
2312#[derive(Debug, Default)]
2313enum RequiredNullable<T> {
2314    #[default]
2315    Missing,
2316    Present(Option<T>),
2317}
2318
2319impl<T> RequiredNullable<T> {
2320    fn as_present(&self) -> Option<&T> {
2321        match self {
2322            Self::Missing | Self::Present(None) => None,
2323            Self::Present(Some(value)) => Some(value),
2324        }
2325    }
2326}
2327
2328fn deserialize_required_nullable<'de, D, T>(
2329    deserializer: D,
2330) -> Result<RequiredNullable<T>, D::Error>
2331where
2332    D: Deserializer<'de>,
2333    T: Deserialize<'de>,
2334{
2335    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
2336}
2337
2338#[derive(Debug, Deserialize)]
2339#[serde(deny_unknown_fields)]
2340struct MeasurementReportSummaryInput {
2341    #[serde(rename = "files")]
2342    _files: Option<Box<RawValue>>,
2343    #[serde(rename = "findings")]
2344    _findings: Option<Box<RawValue>>,
2345    #[serde(rename = "checks")]
2346    _checks: Option<Box<RawValue>>,
2347    #[serde(rename = "deltas")]
2348    _deltas: Option<Box<RawValue>>,
2349    prediction_facets: Option<PredictionFacetSummaryInput>,
2350}
2351
2352#[derive(Debug, Deserialize)]
2353#[serde(deny_unknown_fields)]
2354struct PredictionFacetSummaryInput {
2355    available: usize,
2356    required_prediction_unavailable: usize,
2357}
2358
2359#[derive(Debug, Deserialize)]
2360#[serde(deny_unknown_fields)]
2361struct PredictionCheckWireInput {
2362    check_id: String,
2363    selection: SelectionState,
2364    configuration: ConfigurationState,
2365    applicability: Applicability,
2366    evaluation: EvaluationState,
2367    findings: Vec<PredictionFindingInput>,
2368    #[serde(default)]
2369    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2370    #[serde(default)]
2371    gaps: Vec<PredictionGapInput>,
2372    prediction: Option<Box<RawValue>>,
2373}
2374
2375#[derive(Debug)]
2376struct PredictionCheckInput {
2377    check_id: String,
2378    selection: SelectionState,
2379    configuration: ConfigurationState,
2380    applicability: Applicability,
2381    evaluation: EvaluationState,
2382    findings: Vec<PredictionFindingInput>,
2383    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2384    gaps: Vec<PredictionGapInput>,
2385    prediction: Option<EnginePredictionV2>,
2386}
2387
2388#[derive(Debug)]
2389struct PredictionCheckInputV3 {
2390    check_id: String,
2391    selection: SelectionState,
2392    configuration: ConfigurationState,
2393    applicability: Applicability,
2394    evaluation: EvaluationState,
2395    findings: Vec<PredictionFindingInput>,
2396    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2397    gaps: Vec<PredictionGapInput>,
2398    prediction: Option<EnginePredictionV3>,
2399}
2400
2401#[derive(Debug)]
2402struct PredictionCheckInputV4 {
2403    check_id: String,
2404    selection: SelectionState,
2405    configuration: ConfigurationState,
2406    applicability: Applicability,
2407    evaluation: EvaluationState,
2408    findings: Vec<PredictionFindingInput>,
2409    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2410    gaps: Vec<PredictionGapInput>,
2411    prediction: Option<EnginePredictionV4>,
2412}
2413
2414#[derive(Debug)]
2415struct PredictionCheckInputV5 {
2416    check_id: String,
2417    selection: SelectionState,
2418    configuration: ConfigurationState,
2419    applicability: Applicability,
2420    evaluation: EvaluationState,
2421    findings: Vec<PredictionFindingInput>,
2422    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2423    gaps: Vec<PredictionGapInput>,
2424    prediction: Option<EnginePredictionV5>,
2425}
2426
2427#[derive(Debug)]
2428struct PredictionCheckInputV6 {
2429    check_id: String,
2430    selection: SelectionState,
2431    configuration: ConfigurationState,
2432    applicability: Applicability,
2433    evaluation: EvaluationState,
2434    findings: Vec<PredictionFindingInput>,
2435    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2436    gaps: Vec<PredictionGapInput>,
2437    prediction: Option<EnginePredictionV6>,
2438}
2439
2440#[derive(Debug, Serialize, Deserialize)]
2441#[serde(deny_unknown_fields)]
2442struct PredictionFindingInput {
2443    check_id: String,
2444    #[serde(rename = "severity")]
2445    _severity: PredictionSeverityInput,
2446    #[serde(rename = "clip", skip_serializing_if = "Option::is_none")]
2447    _clip: Option<String>,
2448    #[serde(rename = "bone", skip_serializing_if = "Option::is_none")]
2449    _bone: Option<String>,
2450    #[serde(rename = "node", skip_serializing_if = "Option::is_none")]
2451    _node: Option<String>,
2452    #[serde(skip_serializing_if = "Option::is_none")]
2453    prediction_scope: Option<crate::evaluation::EvaluationScope>,
2454    #[serde(rename = "time_s", skip_serializing_if = "Option::is_none")]
2455    _time_s: Option<f32>,
2456    #[serde(rename = "measured", skip_serializing_if = "Option::is_none")]
2457    _measured: Option<Box<RawValue>>,
2458    #[serde(rename = "expected", skip_serializing_if = "Option::is_none")]
2459    _expected: Option<Box<RawValue>>,
2460    #[serde(rename = "members", skip_serializing_if = "Option::is_none")]
2461    _members: Option<Box<RawValue>>,
2462    #[serde(rename = "message")]
2463    _message: String,
2464}
2465
2466#[derive(Debug, Deserialize)]
2467#[serde(deny_unknown_fields)]
2468struct PredictionGapInput {
2469    code: String,
2470    #[serde(rename = "message")]
2471    _message: String,
2472    scope: Option<crate::evaluation::EvaluationScope>,
2473}
2474
2475#[derive(Debug, Serialize, Deserialize)]
2476#[serde(rename_all = "snake_case")]
2477enum PredictionSeverityInput {
2478    Error,
2479    Warning,
2480    Note,
2481}
2482
2483#[derive(Debug, Deserialize)]
2484#[serde(deny_unknown_fields)]
2485struct InputIdentityInput {
2486    sha256: Option<String>,
2487    bytes: Option<u64>,
2488}
2489
2490/// Recursively preserves the historical JSON-f64-to-Rust-f32 narrowing path
2491/// without materializing an unbounded generic JSON value.
2492///
2493/// `serde_json` rejects a finite JSON number that exceeds `f32::MAX` when it
2494/// directly services `deserialize_f32`. Output-v9 readback first retained the
2495/// number as `f64`, then narrowed it to `f32`; semantic measurement validation
2496/// consequently reported the resulting infinity as a typed non-finite value.
2497/// This adapter retains that contract while streaming directly into the bounded
2498/// typed measurement DTO.
2499struct MeasurementF32NarrowingDeserializer<D>(D);
2500
2501macro_rules! delegate_measurement_deserializer {
2502    ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2503        fn $method<V>(
2504            self,
2505            $($argument: $argument_type,)*
2506            visitor: V,
2507        ) -> Result<V::Value, Self::Error>
2508        where
2509            V: Visitor<'de>,
2510        {
2511            self.0.$method(
2512                $($argument,)*
2513                MeasurementF32NarrowingVisitor(visitor),
2514            )
2515        }
2516    };
2517}
2518
2519impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2520where
2521    D: Deserializer<'de>,
2522{
2523    type Error = D::Error;
2524
2525    delegate_measurement_deserializer!(deserialize_any);
2526    delegate_measurement_deserializer!(deserialize_bool);
2527    delegate_measurement_deserializer!(deserialize_i8);
2528    delegate_measurement_deserializer!(deserialize_i16);
2529    delegate_measurement_deserializer!(deserialize_i32);
2530    delegate_measurement_deserializer!(deserialize_i64);
2531    delegate_measurement_deserializer!(deserialize_i128);
2532    delegate_measurement_deserializer!(deserialize_u8);
2533    delegate_measurement_deserializer!(deserialize_u16);
2534    delegate_measurement_deserializer!(deserialize_u32);
2535    delegate_measurement_deserializer!(deserialize_u64);
2536    delegate_measurement_deserializer!(deserialize_u128);
2537
2538    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2539    where
2540        V: Visitor<'de>,
2541    {
2542        self.0
2543            .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2544    }
2545
2546    delegate_measurement_deserializer!(deserialize_f64);
2547    delegate_measurement_deserializer!(deserialize_char);
2548    delegate_measurement_deserializer!(deserialize_str);
2549    delegate_measurement_deserializer!(deserialize_string);
2550    delegate_measurement_deserializer!(deserialize_bytes);
2551    delegate_measurement_deserializer!(deserialize_byte_buf);
2552    delegate_measurement_deserializer!(deserialize_option);
2553    delegate_measurement_deserializer!(deserialize_unit);
2554    delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2555    delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2556    delegate_measurement_deserializer!(deserialize_seq);
2557    delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2558    delegate_measurement_deserializer!(
2559        deserialize_tuple_struct,
2560        name: &'static str,
2561        len: usize
2562    );
2563    delegate_measurement_deserializer!(deserialize_map);
2564    delegate_measurement_deserializer!(
2565        deserialize_struct,
2566        name: &'static str,
2567        fields: &'static [&'static str]
2568    );
2569    delegate_measurement_deserializer!(
2570        deserialize_enum,
2571        name: &'static str,
2572        variants: &'static [&'static str]
2573    );
2574    delegate_measurement_deserializer!(deserialize_identifier);
2575    delegate_measurement_deserializer!(deserialize_ignored_any);
2576
2577    fn is_human_readable(&self) -> bool {
2578        self.0.is_human_readable()
2579    }
2580}
2581
2582struct MeasurementF32NarrowingNumberVisitor<V>(V);
2583
2584impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2585where
2586    V: Visitor<'de>,
2587{
2588    type Value = V::Value;
2589
2590    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2591        self.0.expecting(formatter)
2592    }
2593
2594    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2595    where
2596        E: serde::de::Error,
2597    {
2598        self.0.visit_f32(value)
2599    }
2600
2601    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2602    where
2603        E: serde::de::Error,
2604    {
2605        self.0.visit_f32(value as f32)
2606    }
2607
2608    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2609    where
2610        E: serde::de::Error,
2611    {
2612        self.0.visit_f32(value as f32)
2613    }
2614
2615    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2616    where
2617        E: serde::de::Error,
2618    {
2619        self.0.visit_f32(value as f32)
2620    }
2621
2622    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2623    where
2624        E: serde::de::Error,
2625    {
2626        self.0.visit_f32(value as f32)
2627    }
2628
2629    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2630    where
2631        E: serde::de::Error,
2632    {
2633        self.0.visit_f32(value as f32)
2634    }
2635}
2636
2637struct MeasurementF32NarrowingVisitor<V>(V);
2638
2639macro_rules! delegate_measurement_visitor {
2640    ($method:ident, $value_type:ty) => {
2641        fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2642        where
2643            E: serde::de::Error,
2644        {
2645            self.0.$method(value)
2646        }
2647    };
2648}
2649
2650impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2651where
2652    V: Visitor<'de>,
2653{
2654    type Value = V::Value;
2655
2656    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2657        self.0.expecting(formatter)
2658    }
2659
2660    delegate_measurement_visitor!(visit_bool, bool);
2661    delegate_measurement_visitor!(visit_i8, i8);
2662    delegate_measurement_visitor!(visit_i16, i16);
2663    delegate_measurement_visitor!(visit_i32, i32);
2664    delegate_measurement_visitor!(visit_i64, i64);
2665    delegate_measurement_visitor!(visit_i128, i128);
2666    delegate_measurement_visitor!(visit_u8, u8);
2667    delegate_measurement_visitor!(visit_u16, u16);
2668    delegate_measurement_visitor!(visit_u32, u32);
2669    delegate_measurement_visitor!(visit_u64, u64);
2670    delegate_measurement_visitor!(visit_u128, u128);
2671    delegate_measurement_visitor!(visit_f32, f32);
2672    delegate_measurement_visitor!(visit_f64, f64);
2673    delegate_measurement_visitor!(visit_char, char);
2674
2675    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2676    where
2677        E: serde::de::Error,
2678    {
2679        self.0.visit_str(value)
2680    }
2681
2682    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2683    where
2684        E: serde::de::Error,
2685    {
2686        self.0.visit_borrowed_str(value)
2687    }
2688
2689    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2690    where
2691        E: serde::de::Error,
2692    {
2693        self.0.visit_string(value)
2694    }
2695
2696    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2697    where
2698        E: serde::de::Error,
2699    {
2700        self.0.visit_bytes(value)
2701    }
2702
2703    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2704    where
2705        E: serde::de::Error,
2706    {
2707        self.0.visit_borrowed_bytes(value)
2708    }
2709
2710    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2711    where
2712        E: serde::de::Error,
2713    {
2714        self.0.visit_byte_buf(value)
2715    }
2716
2717    fn visit_none<E>(self) -> Result<Self::Value, E>
2718    where
2719        E: serde::de::Error,
2720    {
2721        self.0.visit_none()
2722    }
2723
2724    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2725    where
2726        D: Deserializer<'de>,
2727    {
2728        self.0
2729            .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2730    }
2731
2732    fn visit_unit<E>(self) -> Result<Self::Value, E>
2733    where
2734        E: serde::de::Error,
2735    {
2736        self.0.visit_unit()
2737    }
2738
2739    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2740    where
2741        D: Deserializer<'de>,
2742    {
2743        self.0
2744            .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2745    }
2746
2747    fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2748    where
2749        A: SeqAccess<'de>,
2750    {
2751        self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2752    }
2753
2754    fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2755    where
2756        A: MapAccess<'de>,
2757    {
2758        self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2759    }
2760
2761    fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2762    where
2763        A: EnumAccess<'de>,
2764    {
2765        self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2766    }
2767}
2768
2769struct MeasurementF32NarrowingSeed<S>(S);
2770
2771impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2772where
2773    S: DeserializeSeed<'de>,
2774{
2775    type Value = S::Value;
2776
2777    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2778    where
2779        D: Deserializer<'de>,
2780    {
2781        self.0
2782            .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2783    }
2784}
2785
2786struct MeasurementF32NarrowingSeqAccess<A>(A);
2787
2788impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2789where
2790    A: SeqAccess<'de>,
2791{
2792    type Error = A::Error;
2793
2794    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2795    where
2796        T: DeserializeSeed<'de>,
2797    {
2798        self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2799    }
2800
2801    fn size_hint(&self) -> Option<usize> {
2802        self.0.size_hint()
2803    }
2804}
2805
2806struct MeasurementF32NarrowingMapAccess<A>(A);
2807
2808impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2809where
2810    A: MapAccess<'de>,
2811{
2812    type Error = A::Error;
2813
2814    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2815    where
2816        K: DeserializeSeed<'de>,
2817    {
2818        self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2819    }
2820
2821    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2822    where
2823        V: DeserializeSeed<'de>,
2824    {
2825        self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2826    }
2827
2828    fn size_hint(&self) -> Option<usize> {
2829        self.0.size_hint()
2830    }
2831}
2832
2833struct MeasurementF32NarrowingEnumAccess<A>(A);
2834
2835impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2836where
2837    A: EnumAccess<'de>,
2838{
2839    type Error = A::Error;
2840    type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2841
2842    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2843    where
2844        V: DeserializeSeed<'de>,
2845    {
2846        let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2847        Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2848    }
2849}
2850
2851struct MeasurementF32NarrowingVariantAccess<A>(A);
2852
2853impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2854where
2855    A: VariantAccess<'de>,
2856{
2857    type Error = A::Error;
2858
2859    fn unit_variant(self) -> Result<(), Self::Error> {
2860        self.0.unit_variant()
2861    }
2862
2863    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2864    where
2865        T: DeserializeSeed<'de>,
2866    {
2867        self.0
2868            .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2869    }
2870
2871    fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2872    where
2873        V: Visitor<'de>,
2874    {
2875        self.0
2876            .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2877    }
2878
2879    fn struct_variant<V>(
2880        self,
2881        fields: &'static [&'static str],
2882        visitor: V,
2883    ) -> Result<V::Value, Self::Error>
2884    where
2885        V: Visitor<'de>,
2886    {
2887        self.0
2888            .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
2889    }
2890}
2891
2892#[derive(Debug, Deserialize)]
2893#[serde(untagged)]
2894enum SkeletonNodeMeasurementInput {
2895    Current(Box<crate::measure::SkeletonNodeMeasurements>),
2896    Earlier {
2897        #[serde(rename = "node_index")]
2898        _node_index: usize,
2899    },
2900}
2901
2902#[derive(Debug, Deserialize)]
2903#[serde(untagged)]
2904enum SkinMeasurementInput {
2905    Current(Box<crate::measure::SkinMeasurements>),
2906    Earlier {
2907        #[serde(rename = "skin_index")]
2908        _skin_index: usize,
2909    },
2910}
2911
2912#[derive(Debug, Deserialize)]
2913struct MeasurementPayloadInput {
2914    schema_version: Option<u32>,
2915    schema: Option<String>,
2916    clips: Option<BTreeMap<String, ClipMeasurements>>,
2917    material_resource_coverage: Option<MaterialResourceCoverage>,
2918    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2919    textures: Option<Vec<TextureMeasurements>>,
2920    images: Option<Vec<ImageMeasurements>>,
2921    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2922    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2923    skins: Option<Vec<SkinMeasurementInput>>,
2924    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
2925    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
2926    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
2927    default_scene_index: Option<usize>,
2928}
2929
2930/// Current measurement payload readback is closed at the root and at every
2931/// domain introduced or extended by measurements-v16. Historical readers use
2932/// [`MeasurementPayloadInput`] directly so their accepted JSON shape does not
2933/// change retroactively.
2934#[derive(Debug, Deserialize)]
2935#[serde(deny_unknown_fields)]
2936struct MeasurementPayloadV16Input {
2937    schema_version: Option<u32>,
2938    schema: Option<String>,
2939    clips: Option<BTreeMap<String, ClipMeasurements>>,
2940    material_resource_coverage: Option<MaterialResourceCoverage>,
2941    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2942    textures: Option<Vec<TextureMeasurements>>,
2943    images: Option<Vec<ImageMeasurementsV16Input>>,
2944    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2945    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2946    skins: Option<Vec<SkinMeasurementInput>>,
2947    mesh_definitions: Option<Vec<MeshDefinitionMeasurementsV16Input>>,
2948    node_instances: Option<Vec<NodeInstanceMeasurementsV16Input>>,
2949    scenes: Option<Vec<SceneMeasurementsV16Input>>,
2950    default_scene_index: Option<usize>,
2951}
2952
2953#[derive(Debug, Deserialize)]
2954#[serde(deny_unknown_fields)]
2955struct AabbV16Input {
2956    min: [f32; 3],
2957    max: [f32; 3],
2958}
2959
2960impl From<AabbV16Input> for Aabb {
2961    fn from(value: AabbV16Input) -> Self {
2962        Self {
2963            min: value.min,
2964            max: value.max,
2965        }
2966    }
2967}
2968
2969#[derive(Debug, Deserialize)]
2970#[serde(deny_unknown_fields)]
2971struct PrimitiveMeasurementsV16Input {
2972    primitive_index: usize,
2973    #[serde(deserialize_with = "deserialize_required_optional_usize")]
2974    material_index: Option<usize>,
2975    vertex_count: u64,
2976    finite_vertex_count: u64,
2977    geometry_aabb: Option<AabbV16Input>,
2978    geometry_centroid: Option<[f32; 3]>,
2979}
2980
2981fn deserialize_required_optional_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2982where
2983    D: Deserializer<'de>,
2984{
2985    Option::<usize>::deserialize(deserializer)
2986}
2987
2988impl From<PrimitiveMeasurementsV16Input> for PrimitiveMeasurements {
2989    fn from(value: PrimitiveMeasurementsV16Input) -> Self {
2990        Self {
2991            primitive_index: value.primitive_index,
2992            material_index: value.material_index,
2993            vertex_count: value.vertex_count,
2994            finite_vertex_count: value.finite_vertex_count,
2995            geometry_aabb: value.geometry_aabb.map(Into::into),
2996            geometry_centroid: value.geometry_centroid,
2997        }
2998    }
2999}
3000
3001#[derive(Debug, Deserialize)]
3002#[serde(deny_unknown_fields)]
3003struct MeshDefinitionMeasurementsV16Input {
3004    mesh_index: usize,
3005    name: String,
3006    primitives: Option<Vec<PrimitiveMeasurementsV16Input>>,
3007    vertex_count: u64,
3008    geometry_aabb: Option<AabbV16Input>,
3009    geometry_centroid: Option<[f32; 3]>,
3010    max_joints_per_vertex: u32,
3011    weight_sum_min: Option<f64>,
3012    weight_sum_max: Option<f64>,
3013    additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
3014}
3015
3016impl From<MeshDefinitionMeasurementsV16Input> for MeshDefinitionMeasurements {
3017    fn from(value: MeshDefinitionMeasurementsV16Input) -> Self {
3018        Self {
3019            mesh_index: value.mesh_index,
3020            name: value.name,
3021            primitives: value
3022                .primitives
3023                .map(|primitives| primitives.into_iter().map(Into::into).collect()),
3024            vertex_count: value.vertex_count,
3025            geometry_aabb: value.geometry_aabb.map(Into::into),
3026            geometry_centroid: value.geometry_centroid,
3027            max_joints_per_vertex: value.max_joints_per_vertex,
3028            weight_sum_min: value.weight_sum_min,
3029            weight_sum_max: value.weight_sum_max,
3030            additional_influence_sets: value.additional_influence_sets,
3031        }
3032    }
3033}
3034
3035#[derive(Debug, Deserialize)]
3036#[serde(deny_unknown_fields)]
3037struct ImageMeasurementsV16Input {
3038    image_index: usize,
3039    name: Option<String>,
3040    source_kind: crate::model::ImageSourceKind,
3041    declared_mime_type: Option<String>,
3042    detected_container: Option<crate::model::ImageContainerFormat>,
3043    leading_magic_hex: Option<String>,
3044    width: Option<u32>,
3045    height: Option<u32>,
3046    channel_count: Option<u8>,
3047    decoded_color_type: Option<DecodedImageColorType>,
3048    unavailable_reason: Option<crate::model::ImageUnavailableReason>,
3049}
3050
3051impl From<ImageMeasurementsV16Input> for ImageMeasurements {
3052    fn from(value: ImageMeasurementsV16Input) -> Self {
3053        Self {
3054            image_index: value.image_index,
3055            name: value.name,
3056            source_kind: value.source_kind,
3057            declared_mime_type: value.declared_mime_type,
3058            detected_container: value.detected_container,
3059            leading_magic_hex: value.leading_magic_hex,
3060            width: value.width,
3061            height: value.height,
3062            channel_count: value.channel_count,
3063            decoded_color_type: value.decoded_color_type,
3064            unavailable_reason: value.unavailable_reason,
3065        }
3066    }
3067}
3068
3069#[derive(Debug, Deserialize)]
3070#[serde(deny_unknown_fields)]
3071struct NodeInstanceMeasurementsV16Input {
3072    node_index: usize,
3073    node_name: String,
3074    mesh_index: usize,
3075    static_node_world_aabb: Option<AabbV16Input>,
3076    static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
3077}
3078
3079impl From<NodeInstanceMeasurementsV16Input> for NodeInstanceMeasurements {
3080    fn from(value: NodeInstanceMeasurementsV16Input) -> Self {
3081        Self {
3082            node_index: value.node_index,
3083            node_name: value.node_name,
3084            mesh_index: value.mesh_index,
3085            static_node_world_aabb: value.static_node_world_aabb.map(Into::into),
3086            static_node_world_aabb_unavailable_reason: value
3087                .static_node_world_aabb_unavailable_reason,
3088        }
3089    }
3090}
3091
3092#[derive(Debug, Deserialize)]
3093#[serde(deny_unknown_fields)]
3094struct SceneMeasurementsV16Input {
3095    scene_index: usize,
3096    name: Option<String>,
3097    instance_count: usize,
3098    static_scene_world_aabb: Option<AabbV16Input>,
3099    excluded_instance_count: usize,
3100}
3101
3102impl From<SceneMeasurementsV16Input> for SceneMeasurements {
3103    fn from(value: SceneMeasurementsV16Input) -> Self {
3104        Self {
3105            scene_index: value.scene_index,
3106            name: value.name,
3107            instance_count: value.instance_count,
3108            static_scene_world_aabb: value.static_scene_world_aabb.map(Into::into),
3109            excluded_instance_count: value.excluded_instance_count,
3110        }
3111    }
3112}
3113
3114impl From<MeasurementPayloadV16Input> for MeasurementPayloadInput {
3115    fn from(value: MeasurementPayloadV16Input) -> Self {
3116        Self {
3117            schema_version: value.schema_version,
3118            schema: value.schema,
3119            clips: value.clips,
3120            material_resource_coverage: value.material_resource_coverage,
3121            material_definitions: value.material_definitions,
3122            textures: value.textures,
3123            images: value
3124                .images
3125                .map(|images| images.into_iter().map(Into::into).collect()),
3126            skeleton_source_coverage: value.skeleton_source_coverage,
3127            skeleton_nodes: value.skeleton_nodes,
3128            skins: value.skins,
3129            mesh_definitions: value
3130                .mesh_definitions
3131                .map(|meshes| meshes.into_iter().map(Into::into).collect()),
3132            node_instances: value
3133                .node_instances
3134                .map(|instances| instances.into_iter().map(Into::into).collect()),
3135            scenes: value
3136                .scenes
3137                .map(|scenes| scenes.into_iter().map(Into::into).collect()),
3138            default_scene_index: value.default_scene_index,
3139        }
3140    }
3141}
3142
3143fn decode_measurement_payload(
3144    raw: &RawValue,
3145    strict_v16: bool,
3146) -> Result<MeasurementPayloadInput, serde_json::Error> {
3147    let mut deserializer = serde_json::Deserializer::from_str(raw.get());
3148    let payload = if strict_v16 {
3149        MeasurementPayloadV16Input::deserialize(MeasurementF32NarrowingDeserializer(
3150            &mut deserializer,
3151        ))?
3152        .into()
3153    } else {
3154        MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
3155            &mut deserializer,
3156        ))?
3157    };
3158    deserializer.end()?;
3159    Ok(payload)
3160}
3161
3162/// One validated file record recovered from a measurement report.
3163///
3164/// The record retains its source path and full nested measurement contract so
3165/// consumers can choose the clip, mesh, and cardinality policies appropriate
3166/// to their workflow.
3167#[derive(Debug, Clone)]
3168pub struct MeasurementReportFile {
3169    path: String,
3170    input: InputIdentity,
3171    measurements: MeasurementContract,
3172}
3173
3174impl MeasurementReportFile {
3175    /// Source path recorded by the producing report.
3176    pub fn path(&self) -> &str {
3177        &self.path
3178    }
3179
3180    /// Immutable identity of the source bytes used to produce this record.
3181    pub fn input(&self) -> &InputIdentity {
3182        &self.input
3183    }
3184
3185    /// Validated nested measurement contract.
3186    pub fn measurements(&self) -> &MeasurementContract {
3187        &self.measurements
3188    }
3189
3190    /// Consume this record and return its validated measurement contract.
3191    pub fn into_measurements(self) -> MeasurementContract {
3192        self.measurements
3193    }
3194}
3195
3196/// A typed measurement-report subset failed current-contract validation.
3197#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3198#[non_exhaustive]
3199pub enum MeasurementReportError {
3200    /// The outer envelope omitted its version.
3201    #[error("report envelope has no `schema_version`")]
3202    MissingOutputVersion,
3203    /// The outer envelope uses an unsupported version.
3204    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
3205    UnsupportedOutputVersion {
3206        /// Version found in the input.
3207        found: u32,
3208    },
3209    /// The outer envelope does not carry the immutable current identity.
3210    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
3211    WrongOutputIdentity,
3212    /// The outer envelope omitted its command.
3213    #[error("report envelope has no `command`")]
3214    MissingCommand,
3215    /// The outer envelope belongs to a command without file measurements.
3216    #[error("report command {command:?} does not carry measurement file records")]
3217    UnsupportedCommand {
3218        /// Command found in the input.
3219        command: String,
3220    },
3221    /// A current output-v11 envelope carried a field outside its closed schema.
3222    #[error("report envelope has unknown field `{field}`")]
3223    UnknownOutputField {
3224        /// Lexically first unknown root field.
3225        field: String,
3226    },
3227    /// A current output-v11 envelope omitted its producer metadata.
3228    #[error("report envelope has no `tool` object")]
3229    MissingTool,
3230    /// The outer envelope omitted its file array.
3231    #[error("report envelope has no `files` array")]
3232    MissingFiles,
3233    /// The outer envelope exceeds the immutable file-record bound.
3234    #[error("report contains {found} files, exceeding the output-v11 limit of {limit}")]
3235    TooManyFiles {
3236        /// Supplied file count.
3237        found: usize,
3238        /// Immutable output-v11 limit.
3239        limit: usize,
3240    },
3241    /// A lint report omitted the derived prediction-facet summary.
3242    #[error("lint report summary has no `prediction_facets` object")]
3243    MissingPredictionFacetSummary,
3244    /// A measure report carried a lint-only prediction-facet summary.
3245    #[error("measure report summary must not carry `prediction_facets`")]
3246    UnexpectedPredictionFacetSummary,
3247    /// Derived prediction-facet totals did not match the lint summary.
3248    #[error("lint report prediction-facet summary does not match its check records")]
3249    PredictionFacetSummaryMismatch,
3250    /// One file record failed validation.
3251    #[error("files[{file_index}] {source}")]
3252    File {
3253        /// Zero-based index of the invalid file record.
3254        file_index: usize,
3255        /// Typed record-validation failure.
3256        #[source]
3257        source: MeasurementFileError,
3258    },
3259}
3260
3261/// A serialized output-v11 report could not be read within the public bound.
3262#[derive(Debug, thiserror::Error)]
3263#[non_exhaustive]
3264pub enum MeasurementReportReadError {
3265    /// Reading the bounded input failed.
3266    #[error("cannot read report: {source}")]
3267    Io {
3268        /// Underlying bounded-reader failure.
3269        #[source]
3270        source: std::io::Error,
3271    },
3272    /// The serialized report exceeded the immutable output-v11 byte limit.
3273    #[error("report exceeds the output-v11 limit of {limit} bytes")]
3274    ReportTooLarge {
3275        /// Immutable maximum accepted byte count.
3276        limit: u64,
3277    },
3278    /// The bounded bytes were not valid JSON for the output-v11 read shape.
3279    #[error("invalid report JSON: {source}")]
3280    InvalidJson {
3281        /// JSON syntax or typed-shape failure.
3282        #[source]
3283        source: serde_json::Error,
3284    },
3285}
3286
3287/// One measurement-report file record failed validation.
3288#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3289#[non_exhaustive]
3290pub enum MeasurementFileError {
3291    /// The bounded file record could not be decoded after the outer v11
3292    /// identity was accepted.
3293    #[error("has invalid output-v11 file shape: {reason}")]
3294    InvalidFileShape {
3295        /// Stable serde diagnostic for the malformed nested record.
3296        reason: String,
3297    },
3298    /// The file record omitted its source path.
3299    #[error("has no `path`")]
3300    MissingPath,
3301    /// The file record omitted its source-byte identity.
3302    #[error("has no `input`")]
3303    MissingInput,
3304    /// The source-byte identity omitted its SHA-256 digest.
3305    #[error("input has no `sha256`")]
3306    MissingSha256,
3307    /// The source-byte identity uses a malformed SHA-256 digest.
3308    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
3309    InvalidSha256,
3310    /// The source-byte identity omitted its byte count.
3311    #[error("input has no `bytes`")]
3312    MissingBytes,
3313    /// The file record omitted its nested measurement contract.
3314    #[error("has no measurements")]
3315    MissingMeasurements,
3316    /// A lint file omitted its required nullable provenance field.
3317    #[error("has no required `prediction_provenance` field")]
3318    MissingPredictionProvenance,
3319    /// A measure file carried lint-only prediction provenance.
3320    #[error("measure file must not carry `prediction_provenance`")]
3321    UnexpectedPredictionProvenance,
3322    /// A lint file omitted its check array.
3323    #[error("lint file has no `checks` array")]
3324    MissingChecks,
3325    /// A measure file carried lint-only check records.
3326    #[error("measure file must not carry `checks`")]
3327    UnexpectedChecks,
3328    /// A lint file exceeded the immutable per-file check bound.
3329    #[error("contains {found} checks, exceeding the output-v11 limit of {limit}")]
3330    TooManyChecks {
3331        /// Supplied check count.
3332        found: usize,
3333        /// Immutable output-v11 limit.
3334        limit: usize,
3335    },
3336    /// File and prediction-provenance primary identities differ.
3337    #[error("prediction provenance primary input does not match file input")]
3338    PredictionPrimaryInputMismatch,
3339    /// File-scoped prediction provenance violated its immutable contract.
3340    #[error("has invalid prediction provenance: {source}")]
3341    InvalidPredictionProvenance {
3342        /// Typed nested provenance failure.
3343        #[source]
3344        source: PredictionContractError,
3345    },
3346    /// The serialized provenance object could not be decoded as the strict V1 wire.
3347    #[error("has invalid prediction provenance shape: {reason}")]
3348    InvalidPredictionProvenanceShape {
3349        /// Stable serde diagnostic for the malformed nested object.
3350        reason: String,
3351    },
3352    /// One check carried a prediction without file provenance.
3353    #[error("checks[{check_index}] has prediction without non-null file provenance")]
3354    PredictionWithoutProvenance {
3355        /// Zero-based check index.
3356        check_index: usize,
3357    },
3358    /// One check's prediction evidence violated its immutable contract.
3359    #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
3360    InvalidPrediction {
3361        /// Zero-based check index.
3362        check_index: usize,
3363        /// Typed nested prediction failure.
3364        #[source]
3365        source: PredictionContractError,
3366    },
3367    /// One serialized check or prediction object could not be decoded as the strict V1 wire.
3368    #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
3369    InvalidPredictionShape {
3370        /// Zero-based check index.
3371        check_index: usize,
3372        /// Stable serde diagnostic for the malformed nested object.
3373        reason: String,
3374    },
3375    /// One check's prediction attachment contradicts the sole check lifecycle.
3376    #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
3377    InvalidPredictionLifecycle {
3378        /// Zero-based check index.
3379        check_index: usize,
3380        /// Stable relationship failure.
3381        reason: &'static str,
3382    },
3383    /// Aggregate prediction facets exceeded the per-file V1 bound.
3384    #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
3385    TooManyPredictionFacets {
3386        /// Supplied facet count.
3387        found: usize,
3388        /// Immutable V1 limit.
3389        limit: usize,
3390    },
3391    /// A decoded V2 budget summary did not coincide with an exhausted shared
3392    /// file facet budget.
3393    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
3394    FacetBudgetSummaryWithoutExhaustedFileBudget {
3395        /// Aggregate facet count.
3396        found: usize,
3397        /// Immutable shared file limit.
3398        limit: usize,
3399    },
3400    /// Aggregate prediction basis rows exceeded the per-file V1 bound.
3401    #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
3402    TooManyPredictionBasisReferences {
3403        /// Supplied basis-row count.
3404        found: usize,
3405        /// Immutable V1 limit.
3406        limit: usize,
3407    },
3408    /// Aggregate prediction/provenance retained text exceeded the per-file bound.
3409    #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
3410    TooMuchPredictionText {
3411        /// Supplied UTF-8 byte count.
3412        found: usize,
3413        /// Immutable V1 limit.
3414        limit: usize,
3415    },
3416    /// Checked prediction accounting overflowed.
3417    #[error("prediction bound accounting overflowed")]
3418    PredictionAccountingOverflow,
3419    /// The nested measurement contract omitted its version.
3420    #[error("has no versioned measurement contract")]
3421    MissingMeasurementVersion,
3422    /// The nested measurement contract uses an unsupported version.
3423    #[error(
3424        "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
3425    )]
3426    UnsupportedMeasurementVersion {
3427        /// Version found in the nested contract.
3428        found: u32,
3429    },
3430    /// The nested contract does not carry the immutable measurement identity.
3431    #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
3432    WrongMeasurementIdentity,
3433    /// The nested contract omitted its clip-measurement map.
3434    #[error("measurement contract has no `clips` map")]
3435    MissingClips,
3436    /// The nested contract omitted material resource coverage.
3437    #[error("measurement contract has no `material_resource_coverage`")]
3438    MissingMaterialResourceCoverage,
3439    /// The nested contract omitted its material definition array.
3440    #[error("measurement contract has no `material_definitions` array")]
3441    MissingMaterialDefinitions,
3442    /// The nested contract omitted its texture array.
3443    #[error("measurement contract has no `textures` array")]
3444    MissingTextures,
3445    /// The nested contract omitted its image array.
3446    #[error("measurement contract has no `images` array")]
3447    MissingImages,
3448    /// The nested contract omitted skeleton source coverage.
3449    #[error("measurement contract has no `skeleton_source_coverage`")]
3450    MissingSkeletonSourceCoverage,
3451    /// The nested contract omitted its source skeleton-node array.
3452    #[error("measurement contract has no `skeleton_nodes` array")]
3453    MissingSkeletonNodes,
3454    /// The nested contract omitted its source skin array.
3455    #[error("measurement contract has no `skins` array")]
3456    MissingSkins,
3457    /// The nested contract omitted its mesh-definition array.
3458    #[error("measurement contract has no `mesh_definitions` array")]
3459    MissingMeshDefinitions,
3460    /// The nested contract omitted its node-instance array.
3461    #[error("measurement contract has no `node_instances` array")]
3462    MissingNodeInstances,
3463    /// The nested contract omitted its scene array.
3464    #[error("measurement contract has no `scenes` array")]
3465    MissingScenes,
3466    /// The nested measurements object could not be decoded after prediction-independent
3467    /// validation completed.
3468    #[error("has invalid measurements shape: {reason}")]
3469    InvalidMeasurementsShape {
3470        /// Stable serde diagnostic for the malformed nested measurements.
3471        reason: String,
3472    },
3473    /// The nested measurement values do not satisfy the current contract.
3474    #[error("has invalid measurements: {source}")]
3475    InvalidMeasurements {
3476        /// Measurement validation failure.
3477        #[source]
3478        source: MeasurementContractError,
3479    },
3480}
3481
3482impl MeasurementReportError {
3483    /// Zero-based file index for an error in one report record.
3484    ///
3485    /// Envelope-level errors return `None`.
3486    pub fn file_index(&self) -> Option<usize> {
3487        match self {
3488            Self::File { file_index, .. } => Some(*file_index),
3489            _ => None,
3490        }
3491    }
3492
3493    fn file(file_index: usize, source: MeasurementFileError) -> Self {
3494        Self::File { file_index, source }
3495    }
3496}
3497
3498fn prediction_file_error(
3499    file_index: usize,
3500    source: MeasurementFileError,
3501) -> MeasurementReportError {
3502    MeasurementReportError::file(file_index, source)
3503}
3504
3505fn decode_prediction_phase_file(
3506    command: &str,
3507    file_index: usize,
3508    raw: &RawValue,
3509    expected_measurement_schema: &'static str,
3510) -> Result<MeasurementFileInput, MeasurementReportError> {
3511    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3512        prediction_file_error(
3513            file_index,
3514            MeasurementFileError::InvalidFileShape {
3515                reason: source.to_string(),
3516            },
3517        )
3518    })?;
3519
3520    if command == "measure" {
3521        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3522            return Err(prediction_file_error(
3523                file_index,
3524                MeasurementFileError::UnexpectedPredictionProvenance,
3525            ));
3526        }
3527        if wire.checks.is_some() {
3528            return Err(prediction_file_error(
3529                file_index,
3530                MeasurementFileError::UnexpectedChecks,
3531            ));
3532        }
3533        return Ok(MeasurementFileInput {
3534            path: wire.path,
3535            input: wire.input,
3536            rig_v17: None,
3537            measurements: wire.measurements,
3538            prediction_provenance: RequiredNullable::Missing,
3539            checks: None,
3540            legacy_prediction_provenance: RequiredNullable::Missing,
3541            legacy_checks: None,
3542            prediction_provenance_v3: RequiredNullable::Missing,
3543            checks_v3: None,
3544            prediction_provenance_v4: RequiredNullable::Missing,
3545            checks_v4: None,
3546            prediction_provenance_v5: RequiredNullable::Missing,
3547            checks_v5: None,
3548            prediction_provenance_v6: RequiredNullable::Missing,
3549            checks_v6: None,
3550        });
3551    }
3552
3553    if wire
3554        .checks
3555        .as_ref()
3556        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3557    {
3558        return Err(prediction_file_error(
3559            file_index,
3560            MeasurementFileError::TooManyChecks {
3561                found: wire.checks.as_ref().map_or(0, Vec::len),
3562                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3563            },
3564        ));
3565    }
3566
3567    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3568        return Err(prediction_file_error(
3569            file_index,
3570            MeasurementFileError::MissingPredictionProvenance,
3571        ));
3572    }
3573
3574    let prediction_provenance = match wire.prediction_provenance {
3575        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3576        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3577        RequiredNullable::Present(Some(raw)) => {
3578            let provenance = decode_prediction_provenance_v2_with_measurement_schema(
3579                raw.get(),
3580                expected_measurement_schema,
3581            )
3582            .map_err(|error| {
3583                let source = match error {
3584                    PredictionDecodeError::Shape(source) => {
3585                        MeasurementFileError::InvalidPredictionProvenanceShape {
3586                            reason: source.to_string(),
3587                        }
3588                    }
3589                    PredictionDecodeError::Semantic(source) => {
3590                        MeasurementFileError::InvalidPredictionProvenance { source }
3591                    }
3592                    PredictionDecodeError::TooManyFileFacets
3593                    | PredictionDecodeError::TooManyFileBasisReferences => {
3594                        unreachable!("provenance never consumes prediction budgets")
3595                    }
3596                };
3597                prediction_file_error(file_index, source)
3598            })?;
3599            RequiredNullable::Present(Some(provenance))
3600        }
3601    };
3602    let mut decoded_facets = 0usize;
3603    let mut decoded_references = 0usize;
3604    let mut has_facet_budget_summary = false;
3605    let mut decoded_text = match &prediction_provenance {
3606        RequiredNullable::Present(Some(provenance)) => {
3607            provenance.retained_text_bytes().map_err(|source| {
3608                prediction_file_error(
3609                    file_index,
3610                    MeasurementFileError::InvalidPredictionProvenance { source },
3611                )
3612            })?
3613        }
3614        RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
3615    };
3616    let provenance_for_checks = match &prediction_provenance {
3617        RequiredNullable::Present(provenance) => provenance.as_ref(),
3618        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3619    };
3620    let checks = wire
3621        .checks
3622        .map(|raw_checks| {
3623            let mut checks = Vec::with_capacity(raw_checks.len());
3624            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3625                let wire: PredictionCheckWireInput =
3626                    serde_json::from_str(raw.get()).map_err(|source| {
3627                        prediction_file_error(
3628                            file_index,
3629                            MeasurementFileError::InvalidPredictionShape {
3630                                check_index,
3631                                reason: source.to_string(),
3632                            },
3633                        )
3634                    })?;
3635                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3636                    return Err(prediction_file_error(
3637                        file_index,
3638                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3639                    ));
3640                }
3641                if (wire.selection == SelectionState::Unselected
3642                    || wire.configuration == ConfigurationState::Disabled
3643                    || wire.applicability == Applicability::NotApplicable)
3644                    && wire.prediction.is_some()
3645                {
3646                    return Err(prediction_file_error(
3647                        file_index,
3648                        MeasurementFileError::InvalidPredictionLifecycle {
3649                            check_index,
3650                            reason: "inactive check must have empty output",
3651                        },
3652                    ));
3653                }
3654                // Allocate the remaining aggregate facet/reference budgets
3655                // before parsing this attachment.  This prevents an over-limit
3656                // later prediction from retaining a prefix whose findings can
3657                // no longer be represented by the file contract.
3658                let prediction = wire
3659                    .prediction
3660                    .map(|raw| {
3661                        let facet_limit =
3662                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets);
3663                        let reference_limit = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3664                            .saturating_sub(decoded_references);
3665                        decode_engine_prediction_v2_with_measurement_schema(
3666                            raw.get(),
3667                            facet_limit,
3668                            reference_limit,
3669                            expected_measurement_schema,
3670                        )
3671                        .map_err(|error| {
3672                            let source = match error {
3673                                PredictionDecodeError::Shape(source) => {
3674                                    MeasurementFileError::InvalidPredictionShape {
3675                                        check_index,
3676                                        reason: source.to_string(),
3677                                    }
3678                                }
3679                                PredictionDecodeError::Semantic(source) => {
3680                                    MeasurementFileError::InvalidPrediction {
3681                                        check_index,
3682                                        source,
3683                                    }
3684                                }
3685                                PredictionDecodeError::TooManyFileFacets => {
3686                                    MeasurementFileError::TooManyPredictionFacets {
3687                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3688                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3689                                    }
3690                                }
3691                                PredictionDecodeError::TooManyFileBasisReferences => {
3692                                    MeasurementFileError::TooManyPredictionBasisReferences {
3693                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3694                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3695                                    }
3696                                }
3697                            };
3698                            prediction_file_error(file_index, source)
3699                        })
3700                    })
3701                    .transpose()?;
3702                let check = PredictionCheckInput {
3703                    check_id: wire.check_id,
3704                    selection: wire.selection,
3705                    configuration: wire.configuration,
3706                    applicability: wire.applicability,
3707                    evaluation: wire.evaluation,
3708                    findings: wire.findings,
3709                    evaluated_scopes: wire.evaluated_scopes,
3710                    gaps: wire.gaps,
3711                    prediction,
3712                };
3713                check
3714                    .validate(
3715                        check_index,
3716                        provenance_for_checks,
3717                        expected_measurement_schema,
3718                    )
3719                    .map_err(|source| prediction_file_error(file_index, source))?;
3720                if let Some(prediction) = &check.prediction {
3721                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
3722                    decoded_facets = decoded_facets
3723                        .checked_add(prediction.facets().len())
3724                        .ok_or_else(|| {
3725                            prediction_file_error(
3726                                file_index,
3727                                MeasurementFileError::PredictionAccountingOverflow,
3728                            )
3729                        })?;
3730                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3731                        return Err(prediction_file_error(
3732                            file_index,
3733                            MeasurementFileError::TooManyPredictionFacets {
3734                                found: decoded_facets,
3735                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3736                            },
3737                        ));
3738                    }
3739                    decoded_references = decoded_references
3740                        .checked_add(prediction.basis_reference_count())
3741                        .ok_or_else(|| {
3742                            prediction_file_error(
3743                                file_index,
3744                                MeasurementFileError::PredictionAccountingOverflow,
3745                            )
3746                        })?;
3747                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3748                        return Err(prediction_file_error(
3749                            file_index,
3750                            MeasurementFileError::TooManyPredictionBasisReferences {
3751                                found: decoded_references,
3752                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3753                            },
3754                        ));
3755                    }
3756                    decoded_text = decoded_text
3757                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3758                            prediction_file_error(
3759                                file_index,
3760                                MeasurementFileError::InvalidPrediction {
3761                                    check_index,
3762                                    source,
3763                                },
3764                            )
3765                        })?)
3766                        .ok_or_else(|| {
3767                            prediction_file_error(
3768                                file_index,
3769                                MeasurementFileError::PredictionAccountingOverflow,
3770                            )
3771                        })?;
3772                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3773                        return Err(prediction_file_error(
3774                            file_index,
3775                            MeasurementFileError::TooMuchPredictionText {
3776                                found: decoded_text,
3777                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3778                            },
3779                        ));
3780                    }
3781                }
3782                checks.push(check);
3783            }
3784            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
3785                return Err(prediction_file_error(
3786                    file_index,
3787                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
3788                        found: decoded_facets,
3789                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3790                    },
3791                ));
3792            }
3793            Ok(checks)
3794        })
3795        .transpose()?;
3796    Ok(MeasurementFileInput {
3797        path: wire.path,
3798        input: wire.input,
3799        rig_v17: None,
3800        measurements: wire.measurements,
3801        prediction_provenance,
3802        checks,
3803        legacy_prediction_provenance: RequiredNullable::Missing,
3804        legacy_checks: None,
3805        prediction_provenance_v3: RequiredNullable::Missing,
3806        checks_v3: None,
3807        prediction_provenance_v4: RequiredNullable::Missing,
3808        checks_v4: None,
3809        prediction_provenance_v5: RequiredNullable::Missing,
3810        checks_v5: None,
3811        prediction_provenance_v6: RequiredNullable::Missing,
3812        checks_v6: None,
3813    })
3814}
3815
3816fn decode_prediction_phase_file_v14(
3817    command: &str,
3818    file_index: usize,
3819    raw: &RawValue,
3820) -> Result<MeasurementFileInput, MeasurementReportError> {
3821    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3822        prediction_file_error(
3823            file_index,
3824            MeasurementFileError::InvalidFileShape {
3825                reason: source.to_string(),
3826            },
3827        )
3828    })?;
3829    if command == "measure" {
3830        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3831            return Err(prediction_file_error(
3832                file_index,
3833                MeasurementFileError::UnexpectedPredictionProvenance,
3834            ));
3835        }
3836        if wire.checks.is_some() {
3837            return Err(prediction_file_error(
3838                file_index,
3839                MeasurementFileError::UnexpectedChecks,
3840            ));
3841        }
3842        return Ok(MeasurementFileInput {
3843            path: wire.path,
3844            input: wire.input,
3845            rig_v17: None,
3846            measurements: wire.measurements,
3847            prediction_provenance: RequiredNullable::Missing,
3848            checks: None,
3849            legacy_prediction_provenance: RequiredNullable::Missing,
3850            legacy_checks: None,
3851            prediction_provenance_v3: RequiredNullable::Missing,
3852            checks_v3: None,
3853            prediction_provenance_v4: RequiredNullable::Missing,
3854            checks_v4: None,
3855            prediction_provenance_v5: RequiredNullable::Missing,
3856            checks_v5: None,
3857            prediction_provenance_v6: RequiredNullable::Missing,
3858            checks_v6: None,
3859        });
3860    }
3861    if wire
3862        .checks
3863        .as_ref()
3864        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3865    {
3866        return Err(prediction_file_error(
3867            file_index,
3868            MeasurementFileError::TooManyChecks {
3869                found: wire.checks.as_ref().map_or(0, Vec::len),
3870                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3871            },
3872        ));
3873    }
3874    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3875        return Err(prediction_file_error(
3876            file_index,
3877            MeasurementFileError::MissingPredictionProvenance,
3878        ));
3879    }
3880    let prediction_provenance_v3 = match wire.prediction_provenance {
3881        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3882        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3883        RequiredNullable::Present(Some(raw)) => {
3884            let provenance = decode_prediction_provenance_v3(raw.get()).map_err(|error| {
3885                let source = match error {
3886                    PredictionDecodeError::Shape(source) => {
3887                        MeasurementFileError::InvalidPredictionProvenanceShape {
3888                            reason: source.to_string(),
3889                        }
3890                    }
3891                    PredictionDecodeError::Semantic(source) => {
3892                        MeasurementFileError::InvalidPredictionProvenance { source }
3893                    }
3894                    PredictionDecodeError::TooManyFileFacets
3895                    | PredictionDecodeError::TooManyFileBasisReferences => {
3896                        unreachable!("provenance never consumes prediction budgets")
3897                    }
3898                };
3899                prediction_file_error(file_index, source)
3900            })?;
3901            RequiredNullable::Present(Some(provenance))
3902        }
3903    };
3904    let provenance_for_checks = match &prediction_provenance_v3 {
3905        RequiredNullable::Present(provenance) => provenance.as_ref(),
3906        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3907    };
3908    let mut decoded_facets = 0usize;
3909    let mut decoded_references = 0usize;
3910    let mut has_facet_budget_summary = false;
3911    let mut decoded_text = provenance_for_checks
3912        .map(PredictionProvenanceV3::retained_text_bytes)
3913        .transpose()
3914        .map_err(|source| {
3915            prediction_file_error(
3916                file_index,
3917                MeasurementFileError::InvalidPredictionProvenance { source },
3918            )
3919        })?
3920        .unwrap_or(0);
3921    let checks_v3 = wire
3922        .checks
3923        .map(|raw_checks| {
3924            let mut checks = Vec::with_capacity(raw_checks.len());
3925            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3926                let wire: PredictionCheckWireInput =
3927                    serde_json::from_str(raw.get()).map_err(|source| {
3928                        prediction_file_error(
3929                            file_index,
3930                            MeasurementFileError::InvalidPredictionShape {
3931                                check_index,
3932                                reason: source.to_string(),
3933                            },
3934                        )
3935                    })?;
3936                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3937                    return Err(prediction_file_error(
3938                        file_index,
3939                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3940                    ));
3941                }
3942                if (wire.selection == SelectionState::Unselected
3943                    || wire.configuration == ConfigurationState::Disabled
3944                    || wire.applicability == Applicability::NotApplicable)
3945                    && wire.prediction.is_some()
3946                {
3947                    return Err(prediction_file_error(
3948                        file_index,
3949                        MeasurementFileError::InvalidPredictionLifecycle {
3950                            check_index,
3951                            reason: "inactive check must have empty output",
3952                        },
3953                    ));
3954                }
3955                let prediction = wire
3956                    .prediction
3957                    .map(|raw| {
3958                        decode_engine_prediction_v3(
3959                            raw.get(),
3960                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
3961                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3962                                .saturating_sub(decoded_references),
3963                        )
3964                        .map_err(|error| {
3965                            let source = match error {
3966                                PredictionDecodeError::Shape(source) => {
3967                                    MeasurementFileError::InvalidPredictionShape {
3968                                        check_index,
3969                                        reason: source.to_string(),
3970                                    }
3971                                }
3972                                PredictionDecodeError::Semantic(source) => {
3973                                    MeasurementFileError::InvalidPrediction {
3974                                        check_index,
3975                                        source,
3976                                    }
3977                                }
3978                                PredictionDecodeError::TooManyFileFacets => {
3979                                    MeasurementFileError::TooManyPredictionFacets {
3980                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3981                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3982                                    }
3983                                }
3984                                PredictionDecodeError::TooManyFileBasisReferences => {
3985                                    MeasurementFileError::TooManyPredictionBasisReferences {
3986                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3987                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3988                                    }
3989                                }
3990                            };
3991                            prediction_file_error(file_index, source)
3992                        })
3993                    })
3994                    .transpose()?;
3995                let check = PredictionCheckInputV3 {
3996                    check_id: wire.check_id,
3997                    selection: wire.selection,
3998                    configuration: wire.configuration,
3999                    applicability: wire.applicability,
4000                    evaluation: wire.evaluation,
4001                    findings: wire.findings,
4002                    evaluated_scopes: wire.evaluated_scopes,
4003                    gaps: wire.gaps,
4004                    prediction,
4005                };
4006                check
4007                    .validate(check_index, provenance_for_checks)
4008                    .map_err(|source| prediction_file_error(file_index, source))?;
4009                if let Some(prediction) = &check.prediction {
4010                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
4011                    decoded_facets = decoded_facets
4012                        .checked_add(prediction.facets().len())
4013                        .ok_or_else(|| {
4014                            prediction_file_error(
4015                                file_index,
4016                                MeasurementFileError::PredictionAccountingOverflow,
4017                            )
4018                        })?;
4019                    decoded_references = decoded_references
4020                        .checked_add(prediction.basis_reference_count())
4021                        .ok_or_else(|| {
4022                            prediction_file_error(
4023                                file_index,
4024                                MeasurementFileError::PredictionAccountingOverflow,
4025                            )
4026                        })?;
4027                    decoded_text = decoded_text
4028                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4029                            prediction_file_error(
4030                                file_index,
4031                                MeasurementFileError::InvalidPrediction {
4032                                    check_index,
4033                                    source,
4034                                },
4035                            )
4036                        })?)
4037                        .ok_or_else(|| {
4038                            prediction_file_error(
4039                                file_index,
4040                                MeasurementFileError::PredictionAccountingOverflow,
4041                            )
4042                        })?;
4043                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4044                        return Err(prediction_file_error(
4045                            file_index,
4046                            MeasurementFileError::TooMuchPredictionText {
4047                                found: decoded_text,
4048                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4049                            },
4050                        ));
4051                    }
4052                }
4053                checks.push(check);
4054            }
4055            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
4056                return Err(prediction_file_error(
4057                    file_index,
4058                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
4059                        found: decoded_facets,
4060                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4061                    },
4062                ));
4063            }
4064            Ok(checks)
4065        })
4066        .transpose()?;
4067    Ok(MeasurementFileInput {
4068        path: wire.path,
4069        input: wire.input,
4070        rig_v17: None,
4071        measurements: wire.measurements,
4072        prediction_provenance: RequiredNullable::Missing,
4073        checks: None,
4074        legacy_prediction_provenance: RequiredNullable::Missing,
4075        legacy_checks: None,
4076        prediction_provenance_v3,
4077        checks_v3,
4078        prediction_provenance_v4: RequiredNullable::Missing,
4079        checks_v4: None,
4080        prediction_provenance_v5: RequiredNullable::Missing,
4081        checks_v5: None,
4082        prediction_provenance_v6: RequiredNullable::Missing,
4083        checks_v6: None,
4084    })
4085}
4086
4087fn decode_prediction_phase_file_v15(
4088    command: &str,
4089    file_index: usize,
4090    raw: &RawValue,
4091) -> Result<MeasurementFileInput, MeasurementReportError> {
4092    #[derive(Deserialize)]
4093    struct SchemaProbe {
4094        schema: String,
4095    }
4096
4097    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4098        prediction_file_error(
4099            file_index,
4100            MeasurementFileError::InvalidFileShape {
4101                reason: source.to_string(),
4102            },
4103        )
4104    })?;
4105    if command == "measure"
4106        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4107    {
4108        return decode_prediction_phase_file_v14(command, file_index, raw);
4109    }
4110    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4111        return decode_prediction_phase_file_v14(command, file_index, raw);
4112    };
4113    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4114        prediction_file_error(
4115            file_index,
4116            MeasurementFileError::InvalidPredictionProvenanceShape {
4117                reason: source.to_string(),
4118            },
4119        )
4120    })?;
4121    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4122        return decode_prediction_phase_file_v14(command, file_index, raw);
4123    }
4124    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V4_ID {
4125        return Err(prediction_file_error(
4126            file_index,
4127            MeasurementFileError::InvalidPredictionProvenance {
4128                source: PredictionContractError::InvalidSchema {
4129                    field: "prediction provenance.schema",
4130                    expected: crate::prediction::PREDICTION_PROVENANCE_V4_ID,
4131                    found: schema.schema,
4132                },
4133            },
4134        ));
4135    }
4136
4137    let MeasurementFileWireInput {
4138        path,
4139        input,
4140        measurements,
4141        prediction_provenance,
4142        checks,
4143        ..
4144    } = probe;
4145    if checks
4146        .as_ref()
4147        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4148    {
4149        return Err(prediction_file_error(
4150            file_index,
4151            MeasurementFileError::TooManyChecks {
4152                found: checks.as_ref().map_or(0, Vec::len),
4153                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4154            },
4155        ));
4156    }
4157    let RequiredNullable::Present(Some(provenance_raw)) = prediction_provenance else {
4158        unreachable!("V4 provenance was probed above")
4159    };
4160    let provenance = decode_prediction_provenance_v4(provenance_raw.get()).map_err(|error| {
4161        let source = match error {
4162            PredictionDecodeError::Shape(source) => {
4163                MeasurementFileError::InvalidPredictionProvenanceShape {
4164                    reason: source.to_string(),
4165                }
4166            }
4167            PredictionDecodeError::Semantic(source) => {
4168                MeasurementFileError::InvalidPredictionProvenance { source }
4169            }
4170            PredictionDecodeError::TooManyFileFacets
4171            | PredictionDecodeError::TooManyFileBasisReferences => unreachable!(),
4172        };
4173        prediction_file_error(file_index, source)
4174    })?;
4175    let mut decoded_facets = 0usize;
4176    let mut decoded_references = 0usize;
4177    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4178        prediction_file_error(
4179            file_index,
4180            MeasurementFileError::InvalidPredictionProvenance { source },
4181        )
4182    })?;
4183    let checks_v4 = checks
4184        .map(|raw_checks| {
4185            let mut decoded = Vec::with_capacity(raw_checks.len());
4186            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4187                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4188                    .map_err(|source| {
4189                        prediction_file_error(
4190                            file_index,
4191                            MeasurementFileError::InvalidPredictionShape {
4192                                check_index,
4193                                reason: source.to_string(),
4194                            },
4195                        )
4196                    })?;
4197                if (wire.selection == SelectionState::Unselected
4198                    || wire.configuration == ConfigurationState::Disabled
4199                    || wire.applicability == Applicability::NotApplicable)
4200                    && wire.prediction.is_some()
4201                {
4202                    return Err(prediction_file_error(
4203                        file_index,
4204                        MeasurementFileError::InvalidPredictionLifecycle {
4205                            check_index,
4206                            reason: "inactive check must have empty output",
4207                        },
4208                    ));
4209                }
4210                let prediction = wire
4211                    .prediction
4212                    .map(|prediction_raw| {
4213                        decode_engine_prediction_v4(
4214                            prediction_raw.get(),
4215                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4216                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4217                                .saturating_sub(decoded_references),
4218                        )
4219                        .map_err(|error| {
4220                            let source = match error {
4221                                PredictionDecodeError::Shape(source) => {
4222                                    MeasurementFileError::InvalidPredictionShape {
4223                                        check_index,
4224                                        reason: source.to_string(),
4225                                    }
4226                                }
4227                                PredictionDecodeError::Semantic(source) => {
4228                                    MeasurementFileError::InvalidPrediction {
4229                                        check_index,
4230                                        source,
4231                                    }
4232                                }
4233                                PredictionDecodeError::TooManyFileFacets => {
4234                                    MeasurementFileError::TooManyPredictionFacets {
4235                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4236                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4237                                    }
4238                                }
4239                                PredictionDecodeError::TooManyFileBasisReferences => {
4240                                    MeasurementFileError::TooManyPredictionBasisReferences {
4241                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4242                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4243                                    }
4244                                }
4245                            };
4246                            prediction_file_error(file_index, source)
4247                        })
4248                    })
4249                    .transpose()?;
4250                let check = PredictionCheckInputV4 {
4251                    check_id: wire.check_id,
4252                    selection: wire.selection,
4253                    configuration: wire.configuration,
4254                    applicability: wire.applicability,
4255                    evaluation: wire.evaluation,
4256                    findings: wire.findings,
4257                    evaluated_scopes: wire.evaluated_scopes,
4258                    gaps: wire.gaps,
4259                    prediction,
4260                };
4261                check
4262                    .validate(check_index, Some(&provenance))
4263                    .map_err(|source| prediction_file_error(file_index, source))?;
4264                if let Some(prediction) = &check.prediction {
4265                    decoded_facets = decoded_facets
4266                        .checked_add(prediction.facets().len())
4267                        .ok_or_else(|| {
4268                            prediction_file_error(
4269                                file_index,
4270                                MeasurementFileError::PredictionAccountingOverflow,
4271                            )
4272                        })?;
4273                    decoded_references = decoded_references
4274                        .checked_add(prediction.basis_reference_count())
4275                        .ok_or_else(|| {
4276                            prediction_file_error(
4277                                file_index,
4278                                MeasurementFileError::PredictionAccountingOverflow,
4279                            )
4280                        })?;
4281                    decoded_text = decoded_text
4282                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4283                            prediction_file_error(
4284                                file_index,
4285                                MeasurementFileError::InvalidPrediction {
4286                                    check_index,
4287                                    source,
4288                                },
4289                            )
4290                        })?)
4291                        .ok_or_else(|| {
4292                            prediction_file_error(
4293                                file_index,
4294                                MeasurementFileError::PredictionAccountingOverflow,
4295                            )
4296                        })?;
4297                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4298                        return Err(prediction_file_error(
4299                            file_index,
4300                            MeasurementFileError::TooMuchPredictionText {
4301                                found: decoded_text,
4302                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4303                            },
4304                        ));
4305                    }
4306                }
4307                decoded.push(check);
4308            }
4309            Ok(decoded)
4310        })
4311        .transpose()?;
4312    Ok(MeasurementFileInput {
4313        path,
4314        input,
4315        rig_v17: None,
4316        measurements,
4317        prediction_provenance: RequiredNullable::Missing,
4318        checks: None,
4319        legacy_prediction_provenance: RequiredNullable::Missing,
4320        legacy_checks: None,
4321        prediction_provenance_v3: RequiredNullable::Missing,
4322        checks_v3: None,
4323        prediction_provenance_v4: RequiredNullable::Present(Some(provenance)),
4324        checks_v4,
4325        prediction_provenance_v5: RequiredNullable::Missing,
4326        checks_v5: None,
4327        prediction_provenance_v6: RequiredNullable::Missing,
4328        checks_v6: None,
4329    })
4330}
4331
4332fn decode_prediction_phase_file_v16(
4333    command: &str,
4334    file_index: usize,
4335    raw: &RawValue,
4336) -> Result<MeasurementFileInput, MeasurementReportError> {
4337    #[derive(Deserialize)]
4338    struct SchemaProbe {
4339        schema: String,
4340    }
4341
4342    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4343        prediction_file_error(
4344            file_index,
4345            MeasurementFileError::InvalidFileShape {
4346                reason: source.to_string(),
4347            },
4348        )
4349    })?;
4350    if command == "measure"
4351        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4352    {
4353        return decode_prediction_phase_file_v15(command, file_index, raw);
4354    }
4355    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4356        return decode_prediction_phase_file_v15(command, file_index, raw);
4357    };
4358    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4359        prediction_file_error(
4360            file_index,
4361            MeasurementFileError::InvalidPredictionProvenanceShape {
4362                reason: source.to_string(),
4363            },
4364        )
4365    })?;
4366    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4367        return decode_prediction_phase_file_v15(command, file_index, raw);
4368    }
4369    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V5_ID {
4370        return Err(prediction_file_error(
4371            file_index,
4372            MeasurementFileError::InvalidPredictionProvenance {
4373                source: PredictionContractError::InvalidSchema {
4374                    field: "prediction provenance.schema",
4375                    expected: crate::prediction::PREDICTION_PROVENANCE_V5_ID,
4376                    found: schema.schema,
4377                },
4378            },
4379        ));
4380    }
4381    let MeasurementFileWireInput {
4382        path,
4383        input,
4384        measurements,
4385        prediction_provenance,
4386        checks,
4387        ..
4388    } = probe;
4389    if checks
4390        .as_ref()
4391        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4392    {
4393        return Err(prediction_file_error(
4394            file_index,
4395            MeasurementFileError::TooManyChecks {
4396                found: checks.as_ref().map_or(0, Vec::len),
4397                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4398            },
4399        ));
4400    }
4401    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4402        unreachable!()
4403    };
4404    let provenance: PredictionProvenanceV5 =
4405        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4406            prediction_file_error(
4407                file_index,
4408                MeasurementFileError::InvalidPredictionProvenanceShape {
4409                    reason: source.to_string(),
4410                },
4411            )
4412        })?;
4413    provenance.validate().map_err(|source| {
4414        prediction_file_error(
4415            file_index,
4416            MeasurementFileError::InvalidPredictionProvenance { source },
4417        )
4418    })?;
4419    let mut decoded_facets = 0usize;
4420    let mut decoded_references = 0usize;
4421    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4422        prediction_file_error(
4423            file_index,
4424            MeasurementFileError::InvalidPredictionProvenance { source },
4425        )
4426    })?;
4427    let checks_v5 = checks
4428        .map(|raw_checks| {
4429            let mut decoded = Vec::with_capacity(raw_checks.len());
4430            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4431                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4432                    .map_err(|source| {
4433                        prediction_file_error(
4434                            file_index,
4435                            MeasurementFileError::InvalidPredictionShape {
4436                                check_index,
4437                                reason: source.to_string(),
4438                            },
4439                        )
4440                    })?;
4441                if (wire.selection == SelectionState::Unselected
4442                    || wire.configuration == ConfigurationState::Disabled
4443                    || wire.applicability == Applicability::NotApplicable)
4444                    && wire.prediction.is_some()
4445                {
4446                    return Err(prediction_file_error(
4447                        file_index,
4448                        MeasurementFileError::InvalidPredictionLifecycle {
4449                            check_index,
4450                            reason: "inactive check must have empty output",
4451                        },
4452                    ));
4453                }
4454                let prediction =
4455                    wire.prediction
4456                        .map(|raw_prediction| {
4457                            serde_json::from_str::<EnginePredictionV5>(raw_prediction.get())
4458                                .map_err(|source| {
4459                                    prediction_file_error(
4460                                        file_index,
4461                                        MeasurementFileError::InvalidPredictionShape {
4462                                            check_index,
4463                                            reason: source.to_string(),
4464                                        },
4465                                    )
4466                                })
4467                        })
4468                        .transpose()?;
4469                let check = PredictionCheckInputV5 {
4470                    check_id: wire.check_id,
4471                    selection: wire.selection,
4472                    configuration: wire.configuration,
4473                    applicability: wire.applicability,
4474                    evaluation: wire.evaluation,
4475                    findings: wire.findings,
4476                    evaluated_scopes: wire.evaluated_scopes,
4477                    gaps: wire.gaps,
4478                    prediction,
4479                };
4480                check
4481                    .validate(check_index, Some(&provenance))
4482                    .map_err(|source| prediction_file_error(file_index, source))?;
4483                if let Some(prediction) = &check.prediction {
4484                    decoded_facets = decoded_facets
4485                        .checked_add(prediction.facets().len())
4486                        .ok_or_else(|| {
4487                            prediction_file_error(
4488                                file_index,
4489                                MeasurementFileError::PredictionAccountingOverflow,
4490                            )
4491                        })?;
4492                    decoded_references = decoded_references
4493                        .checked_add(prediction.basis_reference_count())
4494                        .ok_or_else(|| {
4495                            prediction_file_error(
4496                                file_index,
4497                                MeasurementFileError::PredictionAccountingOverflow,
4498                            )
4499                        })?;
4500                    decoded_text = decoded_text
4501                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4502                            prediction_file_error(
4503                                file_index,
4504                                MeasurementFileError::InvalidPrediction {
4505                                    check_index,
4506                                    source,
4507                                },
4508                            )
4509                        })?)
4510                        .ok_or_else(|| {
4511                            prediction_file_error(
4512                                file_index,
4513                                MeasurementFileError::PredictionAccountingOverflow,
4514                            )
4515                        })?;
4516                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4517                        return Err(prediction_file_error(
4518                            file_index,
4519                            MeasurementFileError::TooManyPredictionFacets {
4520                                found: decoded_facets,
4521                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4522                            },
4523                        ));
4524                    }
4525                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4526                        return Err(prediction_file_error(
4527                            file_index,
4528                            MeasurementFileError::TooManyPredictionBasisReferences {
4529                                found: decoded_references,
4530                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4531                            },
4532                        ));
4533                    }
4534                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4535                        return Err(prediction_file_error(
4536                            file_index,
4537                            MeasurementFileError::TooMuchPredictionText {
4538                                found: decoded_text,
4539                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4540                            },
4541                        ));
4542                    }
4543                }
4544                decoded.push(check);
4545            }
4546            Ok(decoded)
4547        })
4548        .transpose()?;
4549    Ok(MeasurementFileInput {
4550        path,
4551        input,
4552        rig_v17: None,
4553        measurements,
4554        prediction_provenance: RequiredNullable::Missing,
4555        checks: None,
4556        legacy_prediction_provenance: RequiredNullable::Missing,
4557        legacy_checks: None,
4558        prediction_provenance_v3: RequiredNullable::Missing,
4559        checks_v3: None,
4560        prediction_provenance_v4: RequiredNullable::Missing,
4561        checks_v4: None,
4562        prediction_provenance_v5: RequiredNullable::Present(Some(provenance)),
4563        checks_v5,
4564        prediction_provenance_v6: RequiredNullable::Missing,
4565        checks_v6: None,
4566    })
4567}
4568
4569fn decode_prediction_phase_file_v17(
4570    command: &str,
4571    file_index: usize,
4572    raw: &RawValue,
4573) -> Result<MeasurementFileInput, MeasurementReportError> {
4574    #[derive(Deserialize)]
4575    struct SchemaProbe {
4576        schema: String,
4577    }
4578    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4579        prediction_file_error(
4580            file_index,
4581            MeasurementFileError::InvalidFileShape {
4582                reason: source.to_string(),
4583            },
4584        )
4585    })?;
4586    if command == "measure"
4587        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4588    {
4589        return decode_prediction_phase_file_v16(command, file_index, raw);
4590    }
4591    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4592        return decode_prediction_phase_file_v16(command, file_index, raw);
4593    };
4594    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4595        prediction_file_error(
4596            file_index,
4597            MeasurementFileError::InvalidPredictionProvenanceShape {
4598                reason: source.to_string(),
4599            },
4600        )
4601    })?;
4602    if matches!(
4603        schema.schema.as_str(),
4604        crate::prediction::PREDICTION_PROVENANCE_V3_ID
4605            | crate::prediction::PREDICTION_PROVENANCE_V5_ID
4606    ) {
4607        return decode_prediction_phase_file_v16(command, file_index, raw);
4608    }
4609    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V6_ID {
4610        return Err(prediction_file_error(
4611            file_index,
4612            MeasurementFileError::InvalidPredictionProvenance {
4613                source: PredictionContractError::InvalidSchema {
4614                    field: "prediction provenance.schema",
4615                    expected: crate::prediction::PREDICTION_PROVENANCE_V6_ID,
4616                    found: schema.schema,
4617                },
4618            },
4619        ));
4620    }
4621    let MeasurementFileWireInput {
4622        path,
4623        input,
4624        rig,
4625        measurements,
4626        prediction_provenance,
4627        checks,
4628    } = probe;
4629    let rig_v17 = serde_json::from_str::<RigInfo>(rig.get()).map_err(|source| {
4630        prediction_file_error(
4631            file_index,
4632            MeasurementFileError::InvalidFileShape {
4633                reason: format!("invalid output-v17 rig evidence: {source}"),
4634            },
4635        )
4636    })?;
4637    if checks
4638        .as_ref()
4639        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4640    {
4641        return Err(prediction_file_error(
4642            file_index,
4643            MeasurementFileError::TooManyChecks {
4644                found: checks.as_ref().map_or(0, Vec::len),
4645                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4646            },
4647        ));
4648    }
4649    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4650        unreachable!()
4651    };
4652    let provenance: PredictionProvenanceV6 =
4653        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4654            prediction_file_error(
4655                file_index,
4656                MeasurementFileError::InvalidPredictionProvenanceShape {
4657                    reason: source.to_string(),
4658                },
4659            )
4660        })?;
4661    provenance.validate().map_err(|source| {
4662        prediction_file_error(
4663            file_index,
4664            MeasurementFileError::InvalidPredictionProvenance { source },
4665        )
4666    })?;
4667    let mut decoded_facets = 0usize;
4668    let mut decoded_references = 0usize;
4669    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4670        prediction_file_error(
4671            file_index,
4672            MeasurementFileError::InvalidPredictionProvenance { source },
4673        )
4674    })?;
4675    let checks_v6 = checks
4676        .map(|raw_checks| {
4677            let mut decoded = Vec::with_capacity(raw_checks.len());
4678            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4679                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4680                    .map_err(|source| {
4681                        prediction_file_error(
4682                            file_index,
4683                            MeasurementFileError::InvalidPredictionShape {
4684                                check_index,
4685                                reason: source.to_string(),
4686                            },
4687                        )
4688                    })?;
4689                if (wire.selection == SelectionState::Unselected
4690                    || wire.configuration == ConfigurationState::Disabled
4691                    || wire.applicability == Applicability::NotApplicable)
4692                    && wire.prediction.is_some()
4693                {
4694                    return Err(prediction_file_error(
4695                        file_index,
4696                        MeasurementFileError::InvalidPredictionLifecycle {
4697                            check_index,
4698                            reason: "inactive check must have empty output",
4699                        },
4700                    ));
4701                }
4702                let prediction =
4703                    wire.prediction
4704                        .map(|raw_prediction| {
4705                            serde_json::from_str::<EnginePredictionV6>(raw_prediction.get())
4706                                .map_err(|source| {
4707                                    prediction_file_error(
4708                                        file_index,
4709                                        MeasurementFileError::InvalidPredictionShape {
4710                                            check_index,
4711                                            reason: source.to_string(),
4712                                        },
4713                                    )
4714                                })
4715                        })
4716                        .transpose()?;
4717                let check = PredictionCheckInputV6 {
4718                    check_id: wire.check_id,
4719                    selection: wire.selection,
4720                    configuration: wire.configuration,
4721                    applicability: wire.applicability,
4722                    evaluation: wire.evaluation,
4723                    findings: wire.findings,
4724                    evaluated_scopes: wire.evaluated_scopes,
4725                    gaps: wire.gaps,
4726                    prediction,
4727                };
4728                check
4729                    .validate(check_index, Some(&provenance))
4730                    .map_err(|source| prediction_file_error(file_index, source))?;
4731                if let Some(prediction) = &check.prediction {
4732                    decoded_facets = decoded_facets
4733                        .checked_add(prediction.facets().len())
4734                        .ok_or_else(|| {
4735                            prediction_file_error(
4736                                file_index,
4737                                MeasurementFileError::PredictionAccountingOverflow,
4738                            )
4739                        })?;
4740                    decoded_references = decoded_references
4741                        .checked_add(prediction.basis_reference_count())
4742                        .ok_or_else(|| {
4743                            prediction_file_error(
4744                                file_index,
4745                                MeasurementFileError::PredictionAccountingOverflow,
4746                            )
4747                        })?;
4748                    decoded_text = decoded_text
4749                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4750                            prediction_file_error(
4751                                file_index,
4752                                MeasurementFileError::InvalidPrediction {
4753                                    check_index,
4754                                    source,
4755                                },
4756                            )
4757                        })?)
4758                        .ok_or_else(|| {
4759                            prediction_file_error(
4760                                file_index,
4761                                MeasurementFileError::PredictionAccountingOverflow,
4762                            )
4763                        })?;
4764                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4765                        return Err(prediction_file_error(
4766                            file_index,
4767                            MeasurementFileError::TooManyPredictionFacets {
4768                                found: decoded_facets,
4769                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4770                            },
4771                        ));
4772                    }
4773                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4774                        return Err(prediction_file_error(
4775                            file_index,
4776                            MeasurementFileError::TooManyPredictionBasisReferences {
4777                                found: decoded_references,
4778                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4779                            },
4780                        ));
4781                    }
4782                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4783                        return Err(prediction_file_error(
4784                            file_index,
4785                            MeasurementFileError::TooMuchPredictionText {
4786                                found: decoded_text,
4787                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4788                            },
4789                        ));
4790                    }
4791                }
4792                decoded.push(check);
4793            }
4794            Ok(decoded)
4795        })
4796        .transpose()?;
4797    Ok(MeasurementFileInput {
4798        path,
4799        input,
4800        rig_v17: Some(rig_v17),
4801        measurements,
4802        prediction_provenance: RequiredNullable::Missing,
4803        checks: None,
4804        legacy_prediction_provenance: RequiredNullable::Missing,
4805        legacy_checks: None,
4806        prediction_provenance_v3: RequiredNullable::Missing,
4807        checks_v3: None,
4808        prediction_provenance_v4: RequiredNullable::Missing,
4809        checks_v4: None,
4810        prediction_provenance_v5: RequiredNullable::Missing,
4811        checks_v5: None,
4812        prediction_provenance_v6: RequiredNullable::Present(Some(provenance)),
4813        checks_v6,
4814    })
4815}
4816
4817/// Decode the immutable output-v11 file envelope with its original V1 staged
4818/// reader.  Historical evidence remains V1 all the way through validation;
4819/// accepting it must be neither weaker nor a reinterpretation as V2.
4820fn decode_legacy_v11_file(
4821    command: &str,
4822    file_index: usize,
4823    raw: &RawValue,
4824) -> Result<MeasurementFileInput, MeasurementReportError> {
4825    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4826        prediction_file_error(
4827            file_index,
4828            MeasurementFileError::InvalidFileShape {
4829                reason: source.to_string(),
4830            },
4831        )
4832    })?;
4833    if command == "measure" {
4834        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4835            return Err(prediction_file_error(
4836                file_index,
4837                MeasurementFileError::UnexpectedPredictionProvenance,
4838            ));
4839        }
4840        if wire.checks.is_some() {
4841            return Err(prediction_file_error(
4842                file_index,
4843                MeasurementFileError::UnexpectedChecks,
4844            ));
4845        }
4846        return Ok(MeasurementFileInput {
4847            path: wire.path,
4848            input: wire.input,
4849            rig_v17: None,
4850            measurements: wire.measurements,
4851            prediction_provenance: RequiredNullable::Missing,
4852            checks: None,
4853            legacy_prediction_provenance: RequiredNullable::Missing,
4854            legacy_checks: None,
4855            prediction_provenance_v3: RequiredNullable::Missing,
4856            checks_v3: None,
4857            prediction_provenance_v4: RequiredNullable::Missing,
4858            checks_v4: None,
4859            prediction_provenance_v5: RequiredNullable::Missing,
4860            checks_v5: None,
4861            prediction_provenance_v6: RequiredNullable::Missing,
4862            checks_v6: None,
4863        });
4864    }
4865
4866    if wire
4867        .checks
4868        .as_ref()
4869        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4870    {
4871        return Err(prediction_file_error(
4872            file_index,
4873            MeasurementFileError::TooManyChecks {
4874                found: wire.checks.as_ref().map_or(0, Vec::len),
4875                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4876            },
4877        ));
4878    }
4879    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4880        return Err(prediction_file_error(
4881            file_index,
4882            MeasurementFileError::MissingPredictionProvenance,
4883        ));
4884    }
4885    let legacy_prediction_provenance = match wire.prediction_provenance {
4886        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4887        RequiredNullable::Present(None) => RequiredNullable::Present(None),
4888        RequiredNullable::Present(Some(raw)) => RequiredNullable::Present(Some(
4889            decode_prediction_provenance_v1_with_measurement_schema(
4890                raw.get(),
4891                MEASUREMENTS_V15_SCHEMA_ID,
4892            )
4893            .map_err(|error| {
4894                prediction_file_error(
4895                    file_index,
4896                    match error {
4897                        PredictionDecodeError::Shape(source) => {
4898                            MeasurementFileError::InvalidPredictionProvenanceShape {
4899                                reason: source.to_string(),
4900                            }
4901                        }
4902                        PredictionDecodeError::Semantic(source) => {
4903                            MeasurementFileError::InvalidPredictionProvenance { source }
4904                        }
4905                        PredictionDecodeError::TooManyFileFacets
4906                        | PredictionDecodeError::TooManyFileBasisReferences => {
4907                            unreachable!("provenance decoding cannot consume prediction budgets")
4908                        }
4909                    },
4910                )
4911            })?,
4912        )),
4913    };
4914    let mut decoded_facets = 0usize;
4915    let mut decoded_references = 0usize;
4916    let mut decoded_text = legacy_prediction_provenance
4917        .as_present()
4918        .map(PredictionProvenanceV1::retained_text_bytes)
4919        .transpose()
4920        .map_err(|source| {
4921            prediction_file_error(
4922                file_index,
4923                MeasurementFileError::InvalidPredictionProvenance { source },
4924            )
4925        })?
4926        .unwrap_or(0);
4927    let provenance_for_checks = legacy_prediction_provenance.as_present();
4928    let legacy_checks = wire
4929        .checks
4930        .map(|raw_checks| {
4931            let mut checks = Vec::with_capacity(raw_checks.len());
4932            for (check_index, raw) in raw_checks.into_iter().enumerate() {
4933                let wire: LegacyPredictionCheckWireV11 =
4934                    serde_json::from_str(raw.get()).map_err(|source| {
4935                        prediction_file_error(
4936                            file_index,
4937                            MeasurementFileError::InvalidPredictionShape {
4938                                check_index,
4939                                reason: source.to_string(),
4940                            },
4941                        )
4942                    })?;
4943                // Preserve the released V11 reader's precedence: these
4944                // lifecycle violations are decided from the raw attachment
4945                // presence before a malformed prediction can be decoded.
4946                if provenance_for_checks.is_none() && wire.prediction.is_some() {
4947                    return Err(prediction_file_error(
4948                        file_index,
4949                        MeasurementFileError::PredictionWithoutProvenance { check_index },
4950                    ));
4951                }
4952                if (wire.selection == SelectionState::Unselected
4953                    || wire.configuration == ConfigurationState::Disabled
4954                    || wire.applicability == Applicability::NotApplicable)
4955                    && wire.prediction.is_some()
4956                {
4957                    return Err(prediction_file_error(
4958                        file_index,
4959                        MeasurementFileError::InvalidPredictionLifecycle {
4960                            check_index,
4961                            reason: "inactive check must have empty output",
4962                        },
4963                    ));
4964                }
4965                let prediction = wire
4966                    .prediction
4967                    .map(|raw| {
4968                        decode_engine_prediction_v1_with_measurement_schema(
4969                            raw.get(),
4970                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4971                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4972                                .saturating_sub(decoded_references),
4973                            MEASUREMENTS_V15_SCHEMA_ID,
4974                        )
4975                        .map_err(|error| {
4976                            prediction_file_error(
4977                                file_index,
4978                                match error {
4979                                    PredictionDecodeError::Shape(source) => {
4980                                        MeasurementFileError::InvalidPredictionShape {
4981                                            check_index,
4982                                            reason: source.to_string(),
4983                                        }
4984                                    }
4985                                    PredictionDecodeError::Semantic(source) => {
4986                                        MeasurementFileError::InvalidPrediction {
4987                                            check_index,
4988                                            source,
4989                                        }
4990                                    }
4991                                    PredictionDecodeError::TooManyFileFacets => {
4992                                        MeasurementFileError::TooManyPredictionFacets {
4993                                            found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4994                                            limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4995                                        }
4996                                    }
4997                                    PredictionDecodeError::TooManyFileBasisReferences => {
4998                                        MeasurementFileError::TooManyPredictionBasisReferences {
4999                                            found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
5000                                            limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5001                                        }
5002                                    }
5003                                },
5004                            )
5005                        })
5006                    })
5007                    .transpose()?;
5008                let check = LegacyPredictionCheckInput {
5009                    check_id: wire.check_id,
5010                    selection: wire.selection,
5011                    configuration: wire.configuration,
5012                    applicability: wire.applicability,
5013                    evaluation: wire.evaluation,
5014                    findings: wire.findings,
5015                    evaluated_scopes: wire.evaluated_scopes,
5016                    gaps: wire.gaps,
5017                    prediction,
5018                };
5019                check
5020                    .validate(
5021                        check_index,
5022                        provenance_for_checks,
5023                        MEASUREMENTS_V15_SCHEMA_ID,
5024                    )
5025                    .map_err(|source| prediction_file_error(file_index, source))?;
5026                if let Some(prediction) = &check.prediction {
5027                    decoded_facets = decoded_facets
5028                        .checked_add(prediction.facets().len())
5029                        .ok_or_else(|| {
5030                            prediction_file_error(
5031                                file_index,
5032                                MeasurementFileError::PredictionAccountingOverflow,
5033                            )
5034                        })?;
5035                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5036                        return Err(prediction_file_error(
5037                            file_index,
5038                            MeasurementFileError::TooManyPredictionFacets {
5039                                found: decoded_facets,
5040                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5041                            },
5042                        ));
5043                    }
5044                    decoded_references = decoded_references
5045                        .checked_add(prediction.basis_reference_count())
5046                        .ok_or_else(|| {
5047                            prediction_file_error(
5048                                file_index,
5049                                MeasurementFileError::PredictionAccountingOverflow,
5050                            )
5051                        })?;
5052                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5053                        return Err(prediction_file_error(
5054                            file_index,
5055                            MeasurementFileError::TooManyPredictionBasisReferences {
5056                                found: decoded_references,
5057                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5058                            },
5059                        ));
5060                    }
5061                    decoded_text = decoded_text
5062                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5063                            prediction_file_error(
5064                                file_index,
5065                                MeasurementFileError::InvalidPrediction {
5066                                    check_index,
5067                                    source,
5068                                },
5069                            )
5070                        })?)
5071                        .ok_or_else(|| {
5072                            prediction_file_error(
5073                                file_index,
5074                                MeasurementFileError::PredictionAccountingOverflow,
5075                            )
5076                        })?;
5077                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5078                        return Err(prediction_file_error(
5079                            file_index,
5080                            MeasurementFileError::TooMuchPredictionText {
5081                                found: decoded_text,
5082                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5083                            },
5084                        ));
5085                    }
5086                }
5087                checks.push(check);
5088            }
5089            Ok(checks)
5090        })
5091        .transpose()?;
5092    Ok(MeasurementFileInput {
5093        path: wire.path,
5094        input: wire.input,
5095        rig_v17: None,
5096        measurements: wire.measurements,
5097        prediction_provenance: RequiredNullable::Missing,
5098        checks: None,
5099        legacy_prediction_provenance,
5100        legacy_checks,
5101        prediction_provenance_v3: RequiredNullable::Missing,
5102        checks_v3: None,
5103        prediction_provenance_v4: RequiredNullable::Missing,
5104        checks_v4: None,
5105        prediction_provenance_v5: RequiredNullable::Missing,
5106        checks_v5: None,
5107        prediction_provenance_v6: RequiredNullable::Missing,
5108        checks_v6: None,
5109    })
5110}
5111
5112fn validate_legacy_v11_prediction_phase_file(
5113    command: &str,
5114    file_index: usize,
5115    file: &MeasurementFileInput,
5116) -> Result<(usize, usize), MeasurementReportError> {
5117    match command {
5118        "measure" => return Ok((0, 0)),
5119        "lint" => {}
5120        _ => unreachable!("command was validated before prediction phase"),
5121    }
5122    let provenance = match &file.legacy_prediction_provenance {
5123        RequiredNullable::Missing => {
5124            return Err(prediction_file_error(
5125                file_index,
5126                MeasurementFileError::MissingPredictionProvenance,
5127            ));
5128        }
5129        RequiredNullable::Present(provenance) => provenance.as_ref(),
5130    };
5131    let checks = file
5132        .legacy_checks
5133        .as_ref()
5134        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5135    if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5136        return Err(prediction_file_error(
5137            file_index,
5138            MeasurementFileError::TooManyChecks {
5139                found: checks.len(),
5140                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5141            },
5142        ));
5143    }
5144    if let Some(provenance) = provenance {
5145        provenance
5146            .validate_with_measurement_schema(MEASUREMENTS_V15_SCHEMA_ID)
5147            .map_err(|source| {
5148                prediction_file_error(
5149                    file_index,
5150                    MeasurementFileError::InvalidPredictionProvenance { source },
5151                )
5152            })?;
5153        let input = file
5154            .input
5155            .as_ref()
5156            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5157        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5158            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5159        {
5160            return Err(prediction_file_error(
5161                file_index,
5162                MeasurementFileError::PredictionPrimaryInputMismatch,
5163            ));
5164        }
5165    }
5166    let mut facets = 0usize;
5167    let mut references = 0usize;
5168    let mut text = provenance
5169        .map(PredictionProvenanceV1::retained_text_bytes)
5170        .transpose()
5171        .map_err(|source| {
5172            prediction_file_error(
5173                file_index,
5174                MeasurementFileError::InvalidPredictionProvenance { source },
5175            )
5176        })?
5177        .unwrap_or(0);
5178    let mut available = 0usize;
5179    let mut unavailable = 0usize;
5180    for (check_index, check) in checks.iter().enumerate() {
5181        if let Some(prediction) = &check.prediction {
5182            facets = facets
5183                .checked_add(prediction.facets().len())
5184                .ok_or_else(|| {
5185                    prediction_file_error(
5186                        file_index,
5187                        MeasurementFileError::PredictionAccountingOverflow,
5188                    )
5189                })?;
5190            references = references
5191                .checked_add(prediction.basis_reference_count())
5192                .ok_or_else(|| {
5193                    prediction_file_error(
5194                        file_index,
5195                        MeasurementFileError::PredictionAccountingOverflow,
5196                    )
5197                })?;
5198            text = text
5199                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5200                    prediction_file_error(
5201                        file_index,
5202                        MeasurementFileError::InvalidPrediction {
5203                            check_index,
5204                            source,
5205                        },
5206                    )
5207                })?)
5208                .ok_or_else(|| {
5209                    prediction_file_error(
5210                        file_index,
5211                        MeasurementFileError::PredictionAccountingOverflow,
5212                    )
5213                })?;
5214            for facet in prediction.facets() {
5215                match facet.state() {
5216                    EnginePredictionFacetStateV1::Available => {
5217                        available = available
5218                            .checked_add(1)
5219                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5220                    }
5221                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5222                        unavailable = unavailable
5223                            .checked_add(1)
5224                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5225                    }
5226                }
5227            }
5228        }
5229    }
5230    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5231        return Err(prediction_file_error(
5232            file_index,
5233            MeasurementFileError::TooManyPredictionFacets {
5234                found: facets,
5235                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5236            },
5237        ));
5238    }
5239    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5240        return Err(prediction_file_error(
5241            file_index,
5242            MeasurementFileError::TooManyPredictionBasisReferences {
5243                found: references,
5244                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5245            },
5246        ));
5247    }
5248    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5249        return Err(prediction_file_error(
5250            file_index,
5251            MeasurementFileError::TooMuchPredictionText {
5252                found: text,
5253                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5254            },
5255        ));
5256    }
5257    Ok((available, unavailable))
5258}
5259
5260impl LegacyPredictionCheckInput {
5261    fn validate(
5262        &self,
5263        check_index: usize,
5264        provenance: Option<&PredictionProvenanceV1>,
5265        expected_measurement_schema: &'static str,
5266    ) -> Result<(), MeasurementFileError> {
5267        let gap_refs = self
5268            .gaps
5269            .iter()
5270            .map(|gap| CheckEvaluationGapRef {
5271                code: &gap.code,
5272                scope: gap.scope.as_ref(),
5273            })
5274            .collect::<Vec<_>>();
5275        let finding_check_ids = self
5276            .findings
5277            .iter()
5278            .map(|finding| finding.check_id.as_str())
5279            .collect::<Vec<_>>();
5280        let prediction_scopes = self
5281            .prediction
5282            .as_ref()
5283            .into_iter()
5284            .flat_map(EnginePredictionV1::facets)
5285            .map(|facet| facet.scope())
5286            .collect::<Vec<_>>();
5287        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
5288            check_id: &self.check_id,
5289            selection: self.selection,
5290            configuration: self.configuration,
5291            applicability: self.applicability,
5292            finding_check_ids: &finding_check_ids,
5293            evaluated_scopes: &self.evaluated_scopes,
5294            gaps: &gap_refs,
5295            prediction_scopes: &prediction_scopes,
5296            has_prediction: self.prediction.is_some(),
5297            prediction_has_required_unavailable: self
5298                .prediction
5299                .as_ref()
5300                .is_some_and(EnginePredictionV1::has_required_unavailable),
5301        })
5302        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
5303            check_index,
5304            reason: error.reason(),
5305        })?;
5306        if self.evaluation != derived {
5307            return Err(MeasurementFileError::InvalidPredictionLifecycle {
5308                check_index,
5309                reason: "evaluation does not match completed and missing prediction work",
5310            });
5311        }
5312        let Some(prediction) = &self.prediction else {
5313            if self
5314                .findings
5315                .iter()
5316                .any(|finding| finding.prediction_scope.is_some())
5317            {
5318                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5319                    check_index,
5320                    reason: "finding has prediction_scope without prediction",
5321                });
5322            }
5323            return Ok(());
5324        };
5325        let provenance =
5326            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
5327        prediction
5328            .validate_against_provenance_with_measurement_schema(
5329                provenance,
5330                expected_measurement_schema,
5331            )
5332            .map_err(|source| MeasurementFileError::InvalidPrediction {
5333                check_index,
5334                source,
5335            })?;
5336        for facet in prediction.facets() {
5337            let evaluated = self
5338                .evaluated_scopes
5339                .iter()
5340                .filter(|scope| *scope == facet.scope())
5341                .count();
5342            let duplicated_gap = self
5343                .gaps
5344                .iter()
5345                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
5346            match facet.state() {
5347                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
5348                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5349                        check_index,
5350                        reason: "available facet scope must occur exactly once in evaluated_scopes",
5351                    });
5352                }
5353                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
5354                    if evaluated != 0 || duplicated_gap =>
5355                {
5356                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5357                        check_index,
5358                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
5359                    });
5360                }
5361                _ => {}
5362            }
5363        }
5364        for finding in &self.findings {
5365            let Some(scope) = &finding.prediction_scope else {
5366                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5367                    check_index,
5368                    reason: "prediction-backed finding must carry prediction_scope",
5369                });
5370            };
5371            if prediction
5372                .facets()
5373                .iter()
5374                .filter(|facet| {
5375                    facet.scope() == scope
5376                        && facet.state() == EnginePredictionFacetStateV1::Available
5377                })
5378                .count()
5379                != 1
5380            {
5381                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5382                    check_index,
5383                    reason: "finding prediction_scope must name one available facet",
5384                });
5385            }
5386        }
5387        Ok(())
5388    }
5389}
5390
5391fn validate_prediction_phase_file(
5392    command: &str,
5393    file_index: usize,
5394    file: &MeasurementFileInput,
5395    expected_measurement_schema: &'static str,
5396) -> Result<(usize, usize), MeasurementReportError> {
5397    let mut available = 0usize;
5398    let mut unavailable = 0usize;
5399    match command {
5400        "measure" => {
5401            if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
5402                return Err(prediction_file_error(
5403                    file_index,
5404                    MeasurementFileError::UnexpectedPredictionProvenance,
5405                ));
5406            }
5407            if file.checks.is_some() {
5408                return Err(prediction_file_error(
5409                    file_index,
5410                    MeasurementFileError::UnexpectedChecks,
5411                ));
5412            }
5413        }
5414        "lint" => {
5415            let provenance = match &file.prediction_provenance {
5416                RequiredNullable::Missing => {
5417                    return Err(prediction_file_error(
5418                        file_index,
5419                        MeasurementFileError::MissingPredictionProvenance,
5420                    ));
5421                }
5422                RequiredNullable::Present(provenance) => provenance.as_ref(),
5423            };
5424            let checks = file.checks.as_ref().ok_or_else(|| {
5425                prediction_file_error(file_index, MeasurementFileError::MissingChecks)
5426            })?;
5427            if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5428                return Err(prediction_file_error(
5429                    file_index,
5430                    MeasurementFileError::TooManyChecks {
5431                        found: checks.len(),
5432                        limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5433                    },
5434                ));
5435            }
5436            if let Some(provenance) = provenance {
5437                provenance
5438                    .validate_with_measurement_schema(expected_measurement_schema)
5439                    .map_err(|source| {
5440                        prediction_file_error(
5441                            file_index,
5442                            MeasurementFileError::InvalidPredictionProvenance { source },
5443                        )
5444                    })?;
5445                let input = file.input.as_ref().ok_or_else(|| {
5446                    prediction_file_error(file_index, MeasurementFileError::MissingInput)
5447                })?;
5448                if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5449                    || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5450                {
5451                    return Err(prediction_file_error(
5452                        file_index,
5453                        MeasurementFileError::PredictionPrimaryInputMismatch,
5454                    ));
5455                }
5456            }
5457
5458            let mut facets = 0usize;
5459            let mut references = 0usize;
5460            let mut text = provenance
5461                .map(PredictionProvenanceV2::retained_text_bytes)
5462                .transpose()
5463                .map_err(|source| {
5464                    prediction_file_error(
5465                        file_index,
5466                        MeasurementFileError::InvalidPredictionProvenance { source },
5467                    )
5468                })?
5469                .unwrap_or(0);
5470            for (check_index, check) in checks.iter().enumerate() {
5471                if let Some(prediction) = &check.prediction {
5472                    facets = facets
5473                        .checked_add(prediction.facets().len())
5474                        .ok_or_else(|| {
5475                            prediction_file_error(
5476                                file_index,
5477                                MeasurementFileError::PredictionAccountingOverflow,
5478                            )
5479                        })?;
5480                    references = references
5481                        .checked_add(prediction.basis_reference_count())
5482                        .ok_or_else(|| {
5483                            prediction_file_error(
5484                                file_index,
5485                                MeasurementFileError::PredictionAccountingOverflow,
5486                            )
5487                        })?;
5488                    text = text
5489                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5490                            prediction_file_error(
5491                                file_index,
5492                                MeasurementFileError::InvalidPrediction {
5493                                    check_index,
5494                                    source,
5495                                },
5496                            )
5497                        })?)
5498                        .ok_or_else(|| {
5499                            prediction_file_error(
5500                                file_index,
5501                                MeasurementFileError::PredictionAccountingOverflow,
5502                            )
5503                        })?;
5504                    for facet in prediction.facets() {
5505                        match facet.state() {
5506                            EnginePredictionFacetStateV1::Available => {
5507                                available = available.checked_add(1).ok_or(
5508                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5509                                )?;
5510                            }
5511                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5512                                unavailable = unavailable.checked_add(1).ok_or(
5513                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5514                                )?;
5515                            }
5516                        }
5517                    }
5518                }
5519            }
5520            if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5521                return Err(prediction_file_error(
5522                    file_index,
5523                    MeasurementFileError::TooManyPredictionFacets {
5524                        found: facets,
5525                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5526                    },
5527                ));
5528            }
5529            if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5530                return Err(prediction_file_error(
5531                    file_index,
5532                    MeasurementFileError::TooManyPredictionBasisReferences {
5533                        found: references,
5534                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5535                    },
5536                ));
5537            }
5538            if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5539                return Err(prediction_file_error(
5540                    file_index,
5541                    MeasurementFileError::TooMuchPredictionText {
5542                        found: text,
5543                        limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5544                    },
5545                ));
5546            }
5547        }
5548        _ => unreachable!("command was validated before prediction phase"),
5549    }
5550    Ok((available, unavailable))
5551}
5552
5553fn validate_prediction_phase_file_v14(
5554    command: &str,
5555    file_index: usize,
5556    file: &MeasurementFileInput,
5557) -> Result<(usize, usize), MeasurementReportError> {
5558    if command == "measure" {
5559        if !matches!(file.prediction_provenance_v3, RequiredNullable::Missing) {
5560            return Err(prediction_file_error(
5561                file_index,
5562                MeasurementFileError::UnexpectedPredictionProvenance,
5563            ));
5564        }
5565        if file.checks_v3.is_some() {
5566            return Err(prediction_file_error(
5567                file_index,
5568                MeasurementFileError::UnexpectedChecks,
5569            ));
5570        }
5571        return Ok((0, 0));
5572    }
5573    let provenance = match &file.prediction_provenance_v3 {
5574        RequiredNullable::Missing => {
5575            return Err(prediction_file_error(
5576                file_index,
5577                MeasurementFileError::MissingPredictionProvenance,
5578            ));
5579        }
5580        RequiredNullable::Present(provenance) => provenance.as_ref(),
5581    };
5582    let checks = file
5583        .checks_v3
5584        .as_ref()
5585        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5586    if let Some(provenance) = provenance {
5587        provenance.validate().map_err(|source| {
5588            prediction_file_error(
5589                file_index,
5590                MeasurementFileError::InvalidPredictionProvenance { source },
5591            )
5592        })?;
5593        let input = file
5594            .input
5595            .as_ref()
5596            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5597        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5598            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5599        {
5600            return Err(prediction_file_error(
5601                file_index,
5602                MeasurementFileError::PredictionPrimaryInputMismatch,
5603            ));
5604        }
5605    }
5606    let mut available = 0usize;
5607    let mut unavailable = 0usize;
5608    let mut facets = 0usize;
5609    let mut references = 0usize;
5610    let mut text = provenance
5611        .map(PredictionProvenanceV3::retained_text_bytes)
5612        .transpose()
5613        .map_err(|source| {
5614            prediction_file_error(
5615                file_index,
5616                MeasurementFileError::InvalidPredictionProvenance { source },
5617            )
5618        })?
5619        .unwrap_or(0);
5620    for (check_index, check) in checks.iter().enumerate() {
5621        check
5622            .validate(check_index, provenance)
5623            .map_err(|source| prediction_file_error(file_index, source))?;
5624        if let Some(prediction) = &check.prediction {
5625            facets = facets
5626                .checked_add(prediction.facets().len())
5627                .ok_or_else(|| {
5628                    prediction_file_error(
5629                        file_index,
5630                        MeasurementFileError::PredictionAccountingOverflow,
5631                    )
5632                })?;
5633            references = references
5634                .checked_add(prediction.basis_reference_count())
5635                .ok_or_else(|| {
5636                    prediction_file_error(
5637                        file_index,
5638                        MeasurementFileError::PredictionAccountingOverflow,
5639                    )
5640                })?;
5641            text = text
5642                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5643                    prediction_file_error(
5644                        file_index,
5645                        MeasurementFileError::InvalidPrediction {
5646                            check_index,
5647                            source,
5648                        },
5649                    )
5650                })?)
5651                .ok_or_else(|| {
5652                    prediction_file_error(
5653                        file_index,
5654                        MeasurementFileError::PredictionAccountingOverflow,
5655                    )
5656                })?;
5657            for facet in prediction.facets() {
5658                match facet.state() {
5659                    EnginePredictionFacetStateV1::Available => available += 1,
5660                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5661                }
5662            }
5663        }
5664    }
5665    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5666        return Err(prediction_file_error(
5667            file_index,
5668            MeasurementFileError::TooManyPredictionFacets {
5669                found: facets,
5670                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5671            },
5672        ));
5673    }
5674    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5675        return Err(prediction_file_error(
5676            file_index,
5677            MeasurementFileError::TooManyPredictionBasisReferences {
5678                found: references,
5679                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5680            },
5681        ));
5682    }
5683    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5684        return Err(prediction_file_error(
5685            file_index,
5686            MeasurementFileError::TooMuchPredictionText {
5687                found: text,
5688                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5689            },
5690        ));
5691    }
5692    Ok((available, unavailable))
5693}
5694
5695fn validate_prediction_phase_file_v15(
5696    command: &str,
5697    file_index: usize,
5698    file: &MeasurementFileInput,
5699) -> Result<(usize, usize), MeasurementReportError> {
5700    if matches!(file.prediction_provenance_v4, RequiredNullable::Missing) {
5701        return validate_prediction_phase_file_v14(command, file_index, file);
5702    }
5703    if command == "measure" {
5704        return Err(prediction_file_error(
5705            file_index,
5706            MeasurementFileError::UnexpectedPredictionProvenance,
5707        ));
5708    }
5709    let provenance = match &file.prediction_provenance_v4 {
5710        RequiredNullable::Present(provenance) => provenance.as_ref(),
5711        RequiredNullable::Missing => unreachable!(),
5712    };
5713    let checks = file
5714        .checks_v4
5715        .as_ref()
5716        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5717    if let Some(provenance) = provenance {
5718        provenance.validate().map_err(|source| {
5719            prediction_file_error(
5720                file_index,
5721                MeasurementFileError::InvalidPredictionProvenance { source },
5722            )
5723        })?;
5724        let input = file
5725            .input
5726            .as_ref()
5727            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5728        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5729            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5730        {
5731            return Err(prediction_file_error(
5732                file_index,
5733                MeasurementFileError::PredictionPrimaryInputMismatch,
5734            ));
5735        }
5736    }
5737    let mut available = 0usize;
5738    let mut unavailable = 0usize;
5739    let mut has_facet_budget_summary = false;
5740    let mut facets = 0usize;
5741    let mut references = 0usize;
5742    let mut text = provenance
5743        .map(PredictionProvenanceV4::retained_text_bytes)
5744        .transpose()
5745        .map_err(|source| {
5746            prediction_file_error(
5747                file_index,
5748                MeasurementFileError::InvalidPredictionProvenance { source },
5749            )
5750        })?
5751        .unwrap_or(0);
5752    for (check_index, check) in checks.iter().enumerate() {
5753        check
5754            .validate(check_index, provenance)
5755            .map_err(|source| prediction_file_error(file_index, source))?;
5756        if let Some(prediction) = &check.prediction {
5757            has_facet_budget_summary |= prediction.has_facet_budget_summary();
5758            facets = facets
5759                .checked_add(prediction.facets().len())
5760                .ok_or_else(|| {
5761                    prediction_file_error(
5762                        file_index,
5763                        MeasurementFileError::PredictionAccountingOverflow,
5764                    )
5765                })?;
5766            references = references
5767                .checked_add(prediction.basis_reference_count())
5768                .ok_or_else(|| {
5769                    prediction_file_error(
5770                        file_index,
5771                        MeasurementFileError::PredictionAccountingOverflow,
5772                    )
5773                })?;
5774            text = text
5775                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5776                    prediction_file_error(
5777                        file_index,
5778                        MeasurementFileError::InvalidPrediction {
5779                            check_index,
5780                            source,
5781                        },
5782                    )
5783                })?)
5784                .ok_or_else(|| {
5785                    prediction_file_error(
5786                        file_index,
5787                        MeasurementFileError::PredictionAccountingOverflow,
5788                    )
5789                })?;
5790            for facet in prediction.facets() {
5791                match facet.state() {
5792                    EnginePredictionFacetStateV1::Available => available += 1,
5793                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5794                }
5795            }
5796        }
5797    }
5798    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5799        return Err(prediction_file_error(
5800            file_index,
5801            MeasurementFileError::TooManyPredictionFacets {
5802                found: facets,
5803                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5804            },
5805        ));
5806    }
5807    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5808        return Err(prediction_file_error(
5809            file_index,
5810            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5811                found: facets,
5812                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5813            },
5814        ));
5815    }
5816    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5817        return Err(prediction_file_error(
5818            file_index,
5819            MeasurementFileError::TooManyPredictionBasisReferences {
5820                found: references,
5821                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5822            },
5823        ));
5824    }
5825    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5826        return Err(prediction_file_error(
5827            file_index,
5828            MeasurementFileError::TooMuchPredictionText {
5829                found: text,
5830                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5831            },
5832        ));
5833    }
5834    Ok((available, unavailable))
5835}
5836
5837fn validate_prediction_phase_file_v16(
5838    command: &str,
5839    file_index: usize,
5840    file: &MeasurementFileInput,
5841) -> Result<(usize, usize), MeasurementReportError> {
5842    if matches!(file.prediction_provenance_v5, RequiredNullable::Missing) {
5843        return validate_prediction_phase_file_v15(command, file_index, file);
5844    }
5845    if command == "measure" {
5846        return Err(prediction_file_error(
5847            file_index,
5848            MeasurementFileError::UnexpectedPredictionProvenance,
5849        ));
5850    }
5851    let provenance = match &file.prediction_provenance_v5 {
5852        RequiredNullable::Present(provenance) => provenance.as_ref(),
5853        RequiredNullable::Missing => unreachable!(),
5854    };
5855    let checks = file
5856        .checks_v5
5857        .as_ref()
5858        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5859    if let Some(provenance) = provenance {
5860        provenance.validate().map_err(|source| {
5861            prediction_file_error(
5862                file_index,
5863                MeasurementFileError::InvalidPredictionProvenance { source },
5864            )
5865        })?;
5866        let input = file
5867            .input
5868            .as_ref()
5869            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5870        if input.sha256.as_deref()
5871            != Some(provenance.raw_animation_channels().primary_input().sha256())
5872            || input.bytes != Some(provenance.raw_animation_channels().primary_input().bytes())
5873        {
5874            return Err(prediction_file_error(
5875                file_index,
5876                MeasurementFileError::PredictionPrimaryInputMismatch,
5877            ));
5878        }
5879    }
5880    let mut available = 0usize;
5881    let mut unavailable = 0usize;
5882    let mut facets = 0usize;
5883    let mut references = 0usize;
5884    let mut has_facet_budget_summary = false;
5885    let mut text = provenance
5886        .map(PredictionProvenanceV5::retained_text_bytes)
5887        .transpose()
5888        .map_err(|source| {
5889            prediction_file_error(
5890                file_index,
5891                MeasurementFileError::InvalidPredictionProvenance { source },
5892            )
5893        })?
5894        .unwrap_or(0);
5895    for (check_index, check) in checks.iter().enumerate() {
5896        check
5897            .validate(check_index, provenance)
5898            .map_err(|source| prediction_file_error(file_index, source))?;
5899        validate_current_engine_track_support_prediction_v5(
5900            &check.check_id,
5901            check.selection,
5902            check.configuration,
5903            check.applicability,
5904            check.prediction.as_ref(),
5905            provenance,
5906            check.findings.is_empty(),
5907        )
5908        .map_err(|source| {
5909            prediction_file_error(
5910                file_index,
5911                MeasurementFileError::InvalidPrediction {
5912                    check_index,
5913                    source,
5914                },
5915            )
5916        })?;
5917        if let Some(prediction) = &check.prediction {
5918            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
5919            facets = facets
5920                .checked_add(prediction.facets().len())
5921                .ok_or_else(|| {
5922                    prediction_file_error(
5923                        file_index,
5924                        MeasurementFileError::PredictionAccountingOverflow,
5925                    )
5926                })?;
5927            references = references
5928                .checked_add(prediction.basis_reference_count())
5929                .ok_or_else(|| {
5930                    prediction_file_error(
5931                        file_index,
5932                        MeasurementFileError::PredictionAccountingOverflow,
5933                    )
5934                })?;
5935            text = text
5936                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5937                    prediction_file_error(
5938                        file_index,
5939                        MeasurementFileError::InvalidPrediction {
5940                            check_index,
5941                            source,
5942                        },
5943                    )
5944                })?)
5945                .ok_or_else(|| {
5946                    prediction_file_error(
5947                        file_index,
5948                        MeasurementFileError::PredictionAccountingOverflow,
5949                    )
5950                })?;
5951            for facet in prediction.facets() {
5952                match facet.state() {
5953                    EnginePredictionFacetStateV1::Available => available += 1,
5954                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5955                }
5956            }
5957        }
5958    }
5959    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5960        return Err(prediction_file_error(
5961            file_index,
5962            MeasurementFileError::TooManyPredictionFacets {
5963                found: facets,
5964                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5965            },
5966        ));
5967    }
5968    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5969        return Err(prediction_file_error(
5970            file_index,
5971            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5972                found: facets,
5973                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5974            },
5975        ));
5976    }
5977    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5978        return Err(prediction_file_error(
5979            file_index,
5980            MeasurementFileError::TooManyPredictionBasisReferences {
5981                found: references,
5982                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5983            },
5984        ));
5985    }
5986    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5987        return Err(prediction_file_error(
5988            file_index,
5989            MeasurementFileError::TooMuchPredictionText {
5990                found: text,
5991                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5992            },
5993        ));
5994    }
5995    Ok((available, unavailable))
5996}
5997
5998fn validate_prediction_phase_file_v17(
5999    command: &str,
6000    file_index: usize,
6001    file: &MeasurementFileInput,
6002) -> Result<(usize, usize), MeasurementReportError> {
6003    if matches!(file.prediction_provenance_v6, RequiredNullable::Missing) {
6004        return validate_prediction_phase_file_v16(command, file_index, file);
6005    }
6006    if command == "measure" {
6007        return Err(prediction_file_error(
6008            file_index,
6009            MeasurementFileError::UnexpectedPredictionProvenance,
6010        ));
6011    }
6012    let provenance = match &file.prediction_provenance_v6 {
6013        RequiredNullable::Present(provenance) => provenance.as_ref(),
6014        RequiredNullable::Missing => unreachable!(),
6015    };
6016    let checks = file
6017        .checks_v6
6018        .as_ref()
6019        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
6020    if let Some(provenance) = provenance {
6021        provenance.validate().map_err(|source| {
6022            prediction_file_error(
6023                file_index,
6024                MeasurementFileError::InvalidPredictionProvenance { source },
6025            )
6026        })?;
6027        let input = file
6028            .input
6029            .as_ref()
6030            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
6031        let primary = provenance.raw_transform_paths().primary_input();
6032        if input.sha256.as_deref() != Some(primary.sha256()) || input.bytes != Some(primary.bytes())
6033        {
6034            return Err(prediction_file_error(
6035                file_index,
6036                MeasurementFileError::PredictionPrimaryInputMismatch,
6037            ));
6038        }
6039    }
6040    let mut available = 0usize;
6041    let mut unavailable = 0usize;
6042    let mut facets = 0usize;
6043    let mut references = 0usize;
6044    let mut has_facet_budget_summary = false;
6045    let mut text = provenance
6046        .map(PredictionProvenanceV6::retained_text_bytes)
6047        .transpose()
6048        .map_err(|source| {
6049            prediction_file_error(
6050                file_index,
6051                MeasurementFileError::InvalidPredictionProvenance { source },
6052            )
6053        })?
6054        .unwrap_or(0);
6055    for (check_index, check) in checks.iter().enumerate() {
6056        check
6057            .validate(check_index, provenance)
6058            .map_err(|source| prediction_file_error(file_index, source))?;
6059        if let Some(prediction) = &check.prediction {
6060            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
6061            facets = facets
6062                .checked_add(prediction.facets().len())
6063                .ok_or_else(|| {
6064                    prediction_file_error(
6065                        file_index,
6066                        MeasurementFileError::PredictionAccountingOverflow,
6067                    )
6068                })?;
6069            references = references
6070                .checked_add(prediction.basis_reference_count())
6071                .ok_or_else(|| {
6072                    prediction_file_error(
6073                        file_index,
6074                        MeasurementFileError::PredictionAccountingOverflow,
6075                    )
6076                })?;
6077            text = text
6078                .checked_add(prediction.retained_text_bytes().map_err(|source| {
6079                    prediction_file_error(
6080                        file_index,
6081                        MeasurementFileError::InvalidPrediction {
6082                            check_index,
6083                            source,
6084                        },
6085                    )
6086                })?)
6087                .ok_or_else(|| {
6088                    prediction_file_error(
6089                        file_index,
6090                        MeasurementFileError::PredictionAccountingOverflow,
6091                    )
6092                })?;
6093            for facet in prediction.facets() {
6094                match facet.state() {
6095                    EnginePredictionFacetStateV1::Available => available += 1,
6096                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6097                }
6098            }
6099        }
6100    }
6101    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6102        return Err(prediction_file_error(
6103            file_index,
6104            MeasurementFileError::TooManyPredictionFacets {
6105                found: facets,
6106                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6107            },
6108        ));
6109    }
6110    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6111        return Err(prediction_file_error(
6112            file_index,
6113            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6114                found: facets,
6115                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6116            },
6117        ));
6118    }
6119    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6120        return Err(prediction_file_error(
6121            file_index,
6122            MeasurementFileError::TooManyPredictionBasisReferences {
6123                found: references,
6124                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6125            },
6126        ));
6127    }
6128    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6129        return Err(prediction_file_error(
6130            file_index,
6131            MeasurementFileError::TooMuchPredictionText {
6132                found: text,
6133                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6134            },
6135        ));
6136    }
6137    Ok((available, unavailable))
6138}
6139
6140fn validate_prediction_summary(
6141    command: &str,
6142    summary: Option<&MeasurementReportSummaryInput>,
6143    available: usize,
6144    unavailable: usize,
6145) -> Result<(), MeasurementReportError> {
6146    let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
6147    match (command, summary) {
6148        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6149        ("measure", None) => Ok(()),
6150        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6151        ("lint", Some(summary))
6152            if summary.available != available
6153                || summary.required_prediction_unavailable != unavailable =>
6154        {
6155            Err(MeasurementReportError::PredictionFacetSummaryMismatch)
6156        }
6157        ("lint", Some(_)) => Ok(()),
6158        _ => unreachable!("command was validated before prediction summary"),
6159    }
6160}
6161
6162fn validate_prediction_summary_presence(
6163    command: &str,
6164    summary: Option<&MeasurementReportSummaryInput>,
6165) -> Result<(), MeasurementReportError> {
6166    match (
6167        command,
6168        summary.and_then(|summary| summary.prediction_facets.as_ref()),
6169    ) {
6170        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6171        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6172        ("measure", None) | ("lint", Some(_)) => Ok(()),
6173        _ => unreachable!("command was validated before prediction summary"),
6174    }
6175}
6176
6177impl PredictionCheckInput {
6178    fn validate(
6179        &self,
6180        check_index: usize,
6181        provenance: Option<&PredictionProvenanceV2>,
6182        expected_measurement_schema: &'static str,
6183    ) -> Result<(), MeasurementFileError> {
6184        let gap_refs = self
6185            .gaps
6186            .iter()
6187            .map(|gap| CheckEvaluationGapRef {
6188                code: &gap.code,
6189                scope: gap.scope.as_ref(),
6190            })
6191            .collect::<Vec<_>>();
6192        let finding_check_ids = self
6193            .findings
6194            .iter()
6195            .map(|finding| finding.check_id.as_str())
6196            .collect::<Vec<_>>();
6197        let prediction_scopes = self
6198            .prediction
6199            .as_ref()
6200            .into_iter()
6201            .flat_map(EnginePredictionV2::facets)
6202            .map(|facet| facet.scope())
6203            .collect::<Vec<_>>();
6204        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6205            check_id: &self.check_id,
6206            selection: self.selection,
6207            configuration: self.configuration,
6208            applicability: self.applicability,
6209            finding_check_ids: &finding_check_ids,
6210            evaluated_scopes: &self.evaluated_scopes,
6211            gaps: &gap_refs,
6212            prediction_scopes: &prediction_scopes,
6213            has_prediction: self.prediction.is_some(),
6214            prediction_has_required_unavailable: self
6215                .prediction
6216                .as_ref()
6217                .is_some_and(EnginePredictionV2::has_required_unavailable),
6218        })
6219        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6220            check_index,
6221            reason: error.reason(),
6222        })?;
6223        if self.evaluation != derived {
6224            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6225                check_index,
6226                reason: "evaluation does not match completed and missing prediction work",
6227            });
6228        }
6229
6230        let Some(prediction) = &self.prediction else {
6231            if self
6232                .findings
6233                .iter()
6234                .any(|finding| finding.prediction_scope.is_some())
6235            {
6236                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6237                    check_index,
6238                    reason: "finding has prediction_scope without prediction",
6239                });
6240            }
6241            return Ok(());
6242        };
6243        let provenance =
6244            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6245        prediction
6246            .validate_against_provenance_with_measurement_schema(
6247                provenance,
6248                expected_measurement_schema,
6249            )
6250            .map_err(|source| MeasurementFileError::InvalidPrediction {
6251                check_index,
6252                source,
6253            })?;
6254        prediction
6255            .validate_facet_budget_summary_for_check(&self.check_id)
6256            .map_err(|source| MeasurementFileError::InvalidPrediction {
6257                check_index,
6258                source,
6259            })?;
6260        validate_current_engine_addressability_prediction_v2(
6261            &self.check_id,
6262            prediction,
6263            provenance,
6264        )
6265        .map_err(|source| MeasurementFileError::InvalidPrediction {
6266            check_index,
6267            source,
6268        })?;
6269        for facet in prediction.facets() {
6270            let evaluated = self
6271                .evaluated_scopes
6272                .iter()
6273                .filter(|scope| *scope == facet.scope())
6274                .count();
6275            let duplicated_gap = self
6276                .gaps
6277                .iter()
6278                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6279            match facet.state() {
6280                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6281                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6282                        check_index,
6283                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6284                    });
6285                }
6286                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6287                    if evaluated != 0 || duplicated_gap =>
6288                {
6289                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6290                        check_index,
6291                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6292                    });
6293                }
6294                _ => {}
6295            }
6296        }
6297        for finding in &self.findings {
6298            let Some(scope) = &finding.prediction_scope else {
6299                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6300                    check_index,
6301                    reason: "prediction-backed finding must carry prediction_scope",
6302                });
6303            };
6304            if prediction
6305                .facets()
6306                .iter()
6307                .filter(|facet| {
6308                    facet.scope() == scope
6309                        && facet.state() == EnginePredictionFacetStateV1::Available
6310                })
6311                .count()
6312                != 1
6313            {
6314                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6315                    check_index,
6316                    reason: "finding prediction_scope must name one available facet",
6317                });
6318            }
6319        }
6320        Ok(())
6321    }
6322}
6323
6324impl PredictionCheckInputV3 {
6325    fn validate(
6326        &self,
6327        check_index: usize,
6328        provenance: Option<&PredictionProvenanceV3>,
6329    ) -> Result<(), MeasurementFileError> {
6330        let gap_refs = self
6331            .gaps
6332            .iter()
6333            .map(|gap| CheckEvaluationGapRef {
6334                code: &gap.code,
6335                scope: gap.scope.as_ref(),
6336            })
6337            .collect::<Vec<_>>();
6338        let finding_check_ids = self
6339            .findings
6340            .iter()
6341            .map(|finding| finding.check_id.as_str())
6342            .collect::<Vec<_>>();
6343        let prediction_scopes = self
6344            .prediction
6345            .as_ref()
6346            .into_iter()
6347            .flat_map(EnginePredictionV3::facets)
6348            .map(|facet| facet.scope())
6349            .collect::<Vec<_>>();
6350        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6351            check_id: &self.check_id,
6352            selection: self.selection,
6353            configuration: self.configuration,
6354            applicability: self.applicability,
6355            finding_check_ids: &finding_check_ids,
6356            evaluated_scopes: &self.evaluated_scopes,
6357            gaps: &gap_refs,
6358            prediction_scopes: &prediction_scopes,
6359            has_prediction: self.prediction.is_some(),
6360            prediction_has_required_unavailable: self
6361                .prediction
6362                .as_ref()
6363                .is_some_and(EnginePredictionV3::has_required_unavailable),
6364        })
6365        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6366            check_index,
6367            reason: error.reason(),
6368        })?;
6369        if self.evaluation != derived {
6370            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6371                check_index,
6372                reason: "evaluation does not match completed and missing prediction work",
6373            });
6374        }
6375        validate_current_engine_clip_boundary_applicability_v3(
6376            &self.check_id,
6377            self.applicability,
6378            provenance,
6379        )
6380        .map_err(|source| MeasurementFileError::InvalidPrediction {
6381            check_index,
6382            source,
6383        })?;
6384        let Some(prediction) = &self.prediction else {
6385            if self
6386                .findings
6387                .iter()
6388                .any(|finding| finding.prediction_scope.is_some())
6389            {
6390                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6391                    check_index,
6392                    reason: "finding has prediction_scope without prediction",
6393                });
6394            }
6395            if self.check_id == "engine-clip-boundary"
6396                && self.selection == SelectionState::Selected
6397                && self.configuration == ConfigurationState::Enabled
6398                && self.applicability == Applicability::Applicable
6399            {
6400                return Err(MeasurementFileError::InvalidPrediction {
6401                    check_index,
6402                    source: PredictionContractError::EngineClipBoundaryFacetMismatch,
6403                });
6404            }
6405            return Ok(());
6406        };
6407        let provenance =
6408            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6409        prediction
6410            .validate_against_provenance(provenance)
6411            .map_err(|source| MeasurementFileError::InvalidPrediction {
6412                check_index,
6413                source,
6414            })?;
6415        prediction
6416            .validate_facet_budget_summary_for_check(&self.check_id)
6417            .map_err(|source| MeasurementFileError::InvalidPrediction {
6418                check_index,
6419                source,
6420            })?;
6421        validate_current_engine_addressability_prediction_v3(
6422            &self.check_id,
6423            prediction,
6424            provenance,
6425        )
6426        .map_err(|source| MeasurementFileError::InvalidPrediction {
6427            check_index,
6428            source,
6429        })?;
6430        for facet in prediction.facets() {
6431            let evaluated = self
6432                .evaluated_scopes
6433                .iter()
6434                .filter(|scope| *scope == facet.scope())
6435                .count();
6436            let duplicated_gap = self
6437                .gaps
6438                .iter()
6439                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6440            match facet.state() {
6441                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6442                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6443                        check_index,
6444                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6445                    });
6446                }
6447                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6448                    if evaluated != 0 || duplicated_gap =>
6449                {
6450                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6451                        check_index,
6452                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6453                    });
6454                }
6455                _ => {}
6456            }
6457        }
6458        for finding in &self.findings {
6459            let Some(scope) = &finding.prediction_scope else {
6460                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6461                    check_index,
6462                    reason: "prediction-backed finding must carry prediction_scope",
6463                });
6464            };
6465            if prediction
6466                .facets()
6467                .iter()
6468                .filter(|facet| {
6469                    facet.scope() == scope
6470                        && facet.state() == EnginePredictionFacetStateV1::Available
6471                })
6472                .count()
6473                != 1
6474            {
6475                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6476                    check_index,
6477                    reason: "finding prediction_scope must name one available facet",
6478                });
6479            }
6480        }
6481        let finding_scopes = self
6482            .findings
6483            .iter()
6484            .filter_map(|finding| finding.prediction_scope.as_ref())
6485            .collect::<Vec<_>>();
6486        validate_current_engine_clip_boundary_prediction_v3(
6487            &self.check_id,
6488            prediction,
6489            provenance,
6490            &self.evaluated_scopes,
6491            &finding_scopes,
6492        )
6493        .map_err(|source| MeasurementFileError::InvalidPrediction {
6494            check_index,
6495            source,
6496        })?;
6497        Ok(())
6498    }
6499}
6500
6501impl PredictionCheckInputV4 {
6502    fn validate(
6503        &self,
6504        check_index: usize,
6505        provenance: Option<&PredictionProvenanceV4>,
6506    ) -> Result<(), MeasurementFileError> {
6507        let gap_refs = self
6508            .gaps
6509            .iter()
6510            .map(|gap| CheckEvaluationGapRef {
6511                code: &gap.code,
6512                scope: gap.scope.as_ref(),
6513            })
6514            .collect::<Vec<_>>();
6515        let finding_check_ids = self
6516            .findings
6517            .iter()
6518            .map(|finding| finding.check_id.as_str())
6519            .collect::<Vec<_>>();
6520        let prediction_scopes = self
6521            .prediction
6522            .as_ref()
6523            .into_iter()
6524            .flat_map(EnginePredictionV4::facets)
6525            .map(|facet| facet.scope())
6526            .collect::<Vec<_>>();
6527        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6528            check_id: &self.check_id,
6529            selection: self.selection,
6530            configuration: self.configuration,
6531            applicability: self.applicability,
6532            finding_check_ids: &finding_check_ids,
6533            evaluated_scopes: &self.evaluated_scopes,
6534            gaps: &gap_refs,
6535            prediction_scopes: &prediction_scopes,
6536            has_prediction: self.prediction.is_some(),
6537            prediction_has_required_unavailable: self
6538                .prediction
6539                .as_ref()
6540                .is_some_and(EnginePredictionV4::has_required_unavailable),
6541        })
6542        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6543            check_index,
6544            reason: error.reason(),
6545        })?;
6546        if self.evaluation != derived {
6547            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6548                check_index,
6549                reason: "evaluation does not match completed and missing prediction work",
6550            });
6551        }
6552        let Some(prediction) = &self.prediction else {
6553            if self
6554                .findings
6555                .iter()
6556                .any(|finding| finding.prediction_scope.is_some())
6557            {
6558                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6559                    check_index,
6560                    reason: "finding has prediction_scope without prediction",
6561                });
6562            }
6563            return Ok(());
6564        };
6565        let provenance =
6566            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6567        prediction
6568            .validate_against_provenance(provenance)
6569            .map_err(|source| MeasurementFileError::InvalidPrediction {
6570                check_index,
6571                source,
6572            })?;
6573        prediction
6574            .validate_facet_budget_summary_for_check(&self.check_id)
6575            .map_err(|source| MeasurementFileError::InvalidPrediction {
6576                check_index,
6577                source,
6578            })?;
6579        for facet in prediction.facets() {
6580            let evaluated = self
6581                .evaluated_scopes
6582                .iter()
6583                .filter(|scope| *scope == facet.scope())
6584                .count();
6585            let duplicated_gap = self
6586                .gaps
6587                .iter()
6588                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6589            match facet.state() {
6590                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6591                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6592                        check_index,
6593                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6594                    });
6595                }
6596                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6597                    if evaluated != 0 || duplicated_gap =>
6598                {
6599                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6600                        check_index,
6601                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6602                    });
6603                }
6604                _ => {}
6605            }
6606        }
6607        for finding in &self.findings {
6608            let Some(scope) = &finding.prediction_scope else {
6609                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6610                    check_index,
6611                    reason: "prediction-backed finding must carry prediction_scope",
6612                });
6613            };
6614            if prediction
6615                .facets()
6616                .iter()
6617                .filter(|facet| {
6618                    facet.scope() == scope
6619                        && facet.state() == EnginePredictionFacetStateV1::Available
6620                })
6621                .count()
6622                != 1
6623            {
6624                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6625                    check_index,
6626                    reason: "finding prediction_scope must name one available facet",
6627                });
6628            }
6629        }
6630        Ok(())
6631    }
6632}
6633
6634impl PredictionCheckInputV5 {
6635    fn validate(
6636        &self,
6637        check_index: usize,
6638        provenance: Option<&PredictionProvenanceV5>,
6639    ) -> Result<(), MeasurementFileError> {
6640        let gap_refs = self
6641            .gaps
6642            .iter()
6643            .map(|gap| CheckEvaluationGapRef {
6644                code: &gap.code,
6645                scope: gap.scope.as_ref(),
6646            })
6647            .collect::<Vec<_>>();
6648        let finding_check_ids = self
6649            .findings
6650            .iter()
6651            .map(|finding| finding.check_id.as_str())
6652            .collect::<Vec<_>>();
6653        let prediction_scopes = self
6654            .prediction
6655            .as_ref()
6656            .into_iter()
6657            .flat_map(EnginePredictionV5::facets)
6658            .map(|facet| facet.scope())
6659            .collect::<Vec<_>>();
6660        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6661            check_id: &self.check_id,
6662            selection: self.selection,
6663            configuration: self.configuration,
6664            applicability: self.applicability,
6665            finding_check_ids: &finding_check_ids,
6666            evaluated_scopes: &self.evaluated_scopes,
6667            gaps: &gap_refs,
6668            prediction_scopes: &prediction_scopes,
6669            has_prediction: self.prediction.is_some(),
6670            prediction_has_required_unavailable: self
6671                .prediction
6672                .as_ref()
6673                .is_some_and(EnginePredictionV5::has_required_unavailable),
6674        })
6675        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6676            check_index,
6677            reason: error.reason(),
6678        })?;
6679        if self.evaluation != derived {
6680            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6681                check_index,
6682                reason: "evaluation does not match completed and missing prediction work",
6683            });
6684        }
6685        let Some(prediction) = &self.prediction else {
6686            if self
6687                .findings
6688                .iter()
6689                .any(|finding| finding.prediction_scope.is_some())
6690            {
6691                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6692                    check_index,
6693                    reason: "finding has prediction_scope without prediction",
6694                });
6695            }
6696            return Ok(());
6697        };
6698        let provenance =
6699            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6700        prediction
6701            .validate_against_provenance(provenance)
6702            .map_err(|source| MeasurementFileError::InvalidPrediction {
6703                check_index,
6704                source,
6705            })?;
6706        prediction
6707            .base_prediction()
6708            .validate_facet_budget_summary_for_check(&self.check_id)
6709            .map_err(|source| MeasurementFileError::InvalidPrediction {
6710                check_index,
6711                source,
6712            })?;
6713        for facet in prediction.facets() {
6714            let evaluated = self
6715                .evaluated_scopes
6716                .iter()
6717                .filter(|scope| *scope == facet.scope())
6718                .count();
6719            let duplicated_gap = self
6720                .gaps
6721                .iter()
6722                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6723            match facet.state() {
6724                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6725                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6726                        check_index,
6727                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6728                    });
6729                }
6730                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6731                    if evaluated != 0 || duplicated_gap =>
6732                {
6733                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6734                        check_index,
6735                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6736                    });
6737                }
6738                _ => {}
6739            }
6740        }
6741        for finding in &self.findings {
6742            let Some(scope) = &finding.prediction_scope else {
6743                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6744                    check_index,
6745                    reason: "prediction-backed finding must carry prediction_scope",
6746                });
6747            };
6748            if prediction
6749                .facets()
6750                .iter()
6751                .filter(|facet| {
6752                    facet.scope() == scope
6753                        && facet.state() == EnginePredictionFacetStateV1::Available
6754                })
6755                .count()
6756                != 1
6757            {
6758                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6759                    check_index,
6760                    reason: "finding prediction_scope must name one available facet",
6761                });
6762            }
6763        }
6764        Ok(())
6765    }
6766}
6767
6768impl PredictionCheckInputV6 {
6769    fn validate(
6770        &self,
6771        check_index: usize,
6772        provenance: Option<&PredictionProvenanceV6>,
6773    ) -> Result<(), MeasurementFileError> {
6774        let gap_refs = self
6775            .gaps
6776            .iter()
6777            .map(|gap| CheckEvaluationGapRef {
6778                code: &gap.code,
6779                scope: gap.scope.as_ref(),
6780            })
6781            .collect::<Vec<_>>();
6782        let finding_check_ids = self
6783            .findings
6784            .iter()
6785            .map(|finding| finding.check_id.as_str())
6786            .collect::<Vec<_>>();
6787        let prediction_scopes = self
6788            .prediction
6789            .as_ref()
6790            .into_iter()
6791            .flat_map(EnginePredictionV6::facets)
6792            .map(|facet| facet.scope())
6793            .collect::<Vec<_>>();
6794        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6795            check_id: &self.check_id,
6796            selection: self.selection,
6797            configuration: self.configuration,
6798            applicability: self.applicability,
6799            finding_check_ids: &finding_check_ids,
6800            evaluated_scopes: &self.evaluated_scopes,
6801            gaps: &gap_refs,
6802            prediction_scopes: &prediction_scopes,
6803            has_prediction: self.prediction.is_some(),
6804            prediction_has_required_unavailable: self
6805                .prediction
6806                .as_ref()
6807                .is_some_and(EnginePredictionV6::has_required_unavailable),
6808        })
6809        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6810            check_index,
6811            reason: error.reason(),
6812        })?;
6813        if self.evaluation != derived {
6814            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6815                check_index,
6816                reason: "evaluation does not match completed and missing prediction work",
6817            });
6818        }
6819        let Some(prediction) = &self.prediction else {
6820            if self
6821                .findings
6822                .iter()
6823                .any(|finding| finding.prediction_scope.is_some())
6824            {
6825                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6826                    check_index,
6827                    reason: "finding has prediction_scope without prediction",
6828                });
6829            }
6830            return Ok(());
6831        };
6832        let provenance =
6833            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6834        prediction
6835            .validate_against_provenance(provenance)
6836            .map_err(|source| MeasurementFileError::InvalidPrediction {
6837                check_index,
6838                source,
6839            })?;
6840        prediction
6841            .base_prediction()
6842            .validate_facet_budget_summary_for_check(&self.check_id)
6843            .map_err(|source| MeasurementFileError::InvalidPrediction {
6844                check_index,
6845                source,
6846            })?;
6847        for facet in prediction.facets() {
6848            let evaluated = self
6849                .evaluated_scopes
6850                .iter()
6851                .filter(|scope| *scope == facet.scope())
6852                .count();
6853            let duplicated_gap = self
6854                .gaps
6855                .iter()
6856                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6857            match facet.state() {
6858                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6859                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6860                        check_index,
6861                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6862                    });
6863                }
6864                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6865                    if evaluated != 0 || duplicated_gap =>
6866                {
6867                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6868                        check_index,
6869                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6870                    });
6871                }
6872                _ => {}
6873            }
6874        }
6875        for finding in &self.findings {
6876            let Some(scope) = &finding.prediction_scope else {
6877                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6878                    check_index,
6879                    reason: "prediction-backed finding must carry prediction_scope",
6880                });
6881            };
6882            if prediction
6883                .facets()
6884                .iter()
6885                .filter(|facet| {
6886                    facet.scope() == scope
6887                        && facet.state() == EnginePredictionFacetStateV1::Available
6888                })
6889                .count()
6890                != 1
6891            {
6892                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6893                    check_index,
6894                    reason: "finding prediction_scope must name one available facet",
6895                });
6896            }
6897        }
6898        Ok(())
6899    }
6900}
6901
6902/// Validate the frozen current-lint addressability inventory contract.  This
6903/// is deliberately output-facing: standalone V1 engine artifacts retain their
6904/// historic provenance and reason vocabulary.
6905fn validate_current_engine_addressability_prediction_v2(
6906    check_id: &str,
6907    prediction: &EnginePredictionV2,
6908    provenance: &PredictionProvenanceV2,
6909) -> Result<(), PredictionContractError> {
6910    if check_id != "engine-addressability" {
6911        return Ok(());
6912    }
6913    let raw_partial =
6914        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
6915    let settings_partial = matches!(
6916        provenance.settings().clip_coverage().state(),
6917        ResolvedEngineSettingsCoverageStateV2::Partial
6918    );
6919    let inventories = prediction
6920        .facets()
6921        .iter()
6922        .filter(|facet| {
6923            facet.scope().code.as_str() == "animation_asset_label_inventory"
6924                && facet.scope().subject.is_none()
6925        })
6926        .collect::<Vec<_>>();
6927    if !raw_partial && !settings_partial {
6928        if prediction.facets().iter().any(|facet| {
6929            facet
6930                .reasons()
6931                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
6932        }) {
6933            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6934        }
6935        let available = prediction
6936            .facets()
6937            .iter()
6938            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
6939            .collect::<Vec<_>>();
6940        let expected_rows = provenance.settings().clips().len();
6941        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
6942            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
6943        {
6944            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6945        }
6946        let mut seen = vec![false; available.len()];
6947        for facet in available {
6948            let Some(ordinal) = facet
6949                .scope()
6950                .subject
6951                .as_deref()
6952                .and_then(|subject| subject.strip_prefix("Animation"))
6953                .and_then(|ordinal| ordinal.parse::<usize>().ok())
6954            else {
6955                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6956            };
6957            if facet.scope().code.as_str() != "animation_asset_label"
6958                || ordinal >= seen.len()
6959                || std::mem::replace(&mut seen[ordinal], true)
6960                || !facet.basis().references().iter().any(|reference| {
6961                    matches!(
6962                        reference,
6963                        PredictionBasisReferenceV1::RawSource { reference }
6964                            if reference.domain() == RawSourceDomainV1::Clip
6965                                && matches!(
6966                                    reference.key(),
6967                                    RawSourceKeyV1::Clip { source_clip_index }
6968                                        if *source_clip_index == ordinal as u64
6969                                )
6970                                && reference.field().as_str() == "source_name.state"
6971                    )
6972                })
6973            {
6974                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6975            }
6976        }
6977        if seen.iter().any(|seen| !seen) {
6978            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6979        }
6980        return Ok(());
6981    }
6982
6983    let mut expected = Vec::new();
6984    if raw_partial {
6985        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
6986    }
6987    if settings_partial {
6988        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
6989    }
6990    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
6991    {
6992        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6993    }
6994    if let Some(inventory) = inventories.first()
6995        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6996            || inventory.reasons() != expected)
6997    {
6998        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6999    }
7000    if inventories.is_empty() {
7001        // The allocator may replace this sole incomplete-inventory candidate
7002        // with its canonical budget summary.  The enclosing file validator
7003        // separately proves that the shared 4,096-slot budget was exhausted.
7004        return Ok(());
7005    }
7006    Ok(())
7007}
7008
7009/// Validate the frozen V14/V3 addressability inventory without retargeting
7010/// the immutable V13/V2 contract.
7011fn validate_current_engine_addressability_prediction_v3(
7012    check_id: &str,
7013    prediction: &EnginePredictionV3,
7014    provenance: &PredictionProvenanceV3,
7015) -> Result<(), PredictionContractError> {
7016    if check_id != "engine-addressability" {
7017        return Ok(());
7018    }
7019    let raw_partial =
7020        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
7021    let settings_partial = matches!(
7022        provenance.settings().clip_coverage().state(),
7023        ResolvedEngineSettingsCoverageStateV2::Partial
7024    );
7025    let inventories = prediction
7026        .facets()
7027        .iter()
7028        .filter(|facet| {
7029            facet.scope().code.as_str() == "animation_asset_label_inventory"
7030                && facet.scope().subject.is_none()
7031        })
7032        .collect::<Vec<_>>();
7033    if !raw_partial && !settings_partial {
7034        if prediction.facets().iter().any(|facet| {
7035            facet
7036                .reasons()
7037                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
7038        }) {
7039            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7040        }
7041        let available = prediction
7042            .facets()
7043            .iter()
7044            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
7045            .collect::<Vec<_>>();
7046        let expected_rows = provenance.settings().clips().len();
7047        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
7048            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
7049        {
7050            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7051        }
7052        let mut seen = vec![false; available.len()];
7053        for facet in available {
7054            let Some(ordinal) = facet
7055                .scope()
7056                .subject
7057                .as_deref()
7058                .and_then(|subject| subject.strip_prefix("Animation"))
7059                .and_then(|ordinal| ordinal.parse::<usize>().ok())
7060            else {
7061                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7062            };
7063            if facet.scope().code.as_str() != "animation_asset_label"
7064                || ordinal >= seen.len()
7065                || std::mem::replace(&mut seen[ordinal], true)
7066                || !facet.basis().references().iter().any(|reference| {
7067                    matches!(
7068                        reference,
7069                        PredictionBasisReferenceV2::V1(PredictionBasisReferenceV1::RawSource { reference })
7070                            if reference.domain() == RawSourceDomainV1::Clip
7071                                && matches!(
7072                                    reference.key(),
7073                                    RawSourceKeyV1::Clip { source_clip_index }
7074                                        if *source_clip_index == ordinal as u64
7075                                )
7076                                && reference.field().as_str() == "source_name.state"
7077                    )
7078                })
7079            {
7080                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7081            }
7082        }
7083        if seen.iter().any(|seen| !seen) {
7084            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7085        }
7086        return Ok(());
7087    }
7088
7089    let mut expected = Vec::new();
7090    if raw_partial {
7091        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7092    }
7093    if settings_partial {
7094        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7095    }
7096    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7097    {
7098        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7099    }
7100    if let Some(inventory) = inventories.first()
7101        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7102            || inventory.reasons() != expected)
7103    {
7104        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7105    }
7106    Ok(())
7107}
7108
7109const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
7110const ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE: &str = "engine-track-support:animation";
7111const ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
7112const ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE: &str = "engine-track-support:inventory";
7113const ENGINE_TRACK_SUPPORT_BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
7114
7115fn validate_current_engine_track_support_prediction_v5(
7116    check_id: &str,
7117    selection_state: SelectionState,
7118    configuration: ConfigurationState,
7119    applicability: Applicability,
7120    prediction: Option<&EnginePredictionV5>,
7121    provenance: Option<&PredictionProvenanceV5>,
7122    findings_empty: bool,
7123) -> Result<(), PredictionContractError> {
7124    if check_id != ENGINE_TRACK_SUPPORT_CHECK_ID {
7125        if prediction.is_some_and(|prediction| {
7126            prediction.facets().iter().any(|facet| {
7127                matches!(
7128                    facet.result(),
7129                    Some(EngineMachineResultV1::SourceImportDisposition(_))
7130                )
7131            })
7132        }) {
7133            return Err(PredictionContractError::InvalidMachineResult(
7134                "source-import disposition is confined to engine-track-support",
7135            ));
7136        }
7137        return Ok(());
7138    }
7139    let exact_profile = provenance.is_some_and(|provenance| {
7140        let base = provenance.base();
7141        let selected = base.profile().selection();
7142        selected.family() == "bevy"
7143            && selected.profile_revision() == 3
7144            && selected.engine_version() == "0.19.0"
7145            && selected.importer() == "gltf-asset-loader"
7146            && base.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
7147            && base.profile().facts_identity().sha256()
7148                == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
7149            && base.profile().facts_identity().bytes() == 4_849
7150            && matches!(
7151                base.source_format(),
7152                SourceFormatV1::GltfJson | SourceFormatV1::Glb
7153            )
7154            && !provenance.raw_animation_channels().is_complete_empty()
7155    });
7156    let expected_applicability = if exact_profile {
7157        Applicability::Applicable
7158    } else {
7159        Applicability::NotApplicable
7160    };
7161    if applicability != expected_applicability {
7162        return Err(PredictionContractError::InvalidMachineResult(
7163            "engine-track-support applicability mismatch",
7164        ));
7165    }
7166    if !exact_profile
7167        || selection_state != SelectionState::Selected
7168        || configuration != ConfigurationState::Enabled
7169    {
7170        return if prediction.is_none() {
7171            Ok(())
7172        } else {
7173            Err(PredictionContractError::InvalidMachineResult(
7174                "inactive engine-track-support carried prediction",
7175            ))
7176        };
7177    }
7178    if !findings_empty {
7179        return Err(PredictionContractError::InvalidMachineResult(
7180            "engine-track-support must not emit findings",
7181        ));
7182    }
7183    let provenance = provenance.ok_or(PredictionContractError::ProvenanceIdentityMismatch)?;
7184    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7185        "applicable engine-track-support check requires prediction",
7186    ))?;
7187    let inventory = provenance.raw_animation_channels();
7188    let mut expected = Vec::new();
7189    let has_summary = prediction.base_prediction().has_facet_budget_summary();
7190    let expected_demand = if !inventory.source_coverage_complete() {
7191        1
7192    } else {
7193        inventory.rows().len()
7194    };
7195    if inventory.source_coverage_complete() && inventory.candidate_overflow() && !has_summary {
7196        return Err(PredictionContractError::InvalidMachineResult(
7197            "saturated engine-track-support demand requires facet-budget summary",
7198        ));
7199    }
7200    let candidate_capacity = if has_summary {
7201        prediction.facets().len().saturating_sub(1)
7202    } else {
7203        expected_demand
7204    };
7205    if !inventory.is_complete_empty() && candidate_capacity != 0 {
7206        if !inventory.source_coverage_complete() {
7207            expected.push(track_unavailable(
7208                EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7209                    ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE,
7210                )),
7211                track_inventory_basis(inventory),
7212                PredictionUnavailableReasonV2::RawSourceIncomplete,
7213            ));
7214        } else {
7215            for row in inventory.rows().iter().take(candidate_capacity) {
7216                let animation = row.source_animation_index();
7217                if let Some(channel) = row.source_channel_index() {
7218                    expected.push(track_subject_facet(
7219                        track_scope(
7220                            ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE,
7221                            format!("source_animation:{animation}:source_channel:{channel}"),
7222                        ),
7223                        track_row_basis(inventory, animation, Some(channel)),
7224                        SourceImportSubjectKindV1::AnimationChannel,
7225                        track_gate(provenance),
7226                    ));
7227                } else {
7228                    expected.push(track_subject_facet(
7229                        track_scope(
7230                            ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE,
7231                            format!("source_animation:{animation}"),
7232                        ),
7233                        track_row_basis(inventory, animation, None),
7234                        SourceImportSubjectKindV1::Animation,
7235                        track_gate(provenance),
7236                    ));
7237                }
7238            }
7239        }
7240    }
7241    if has_summary {
7242        expected.push(track_unavailable(
7243            EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7244                ENGINE_TRACK_SUPPORT_BUDGET_SCOPE,
7245            )),
7246            track_static_basis(),
7247            PredictionUnavailableReasonV2::FacetBudgetExceeded,
7248        ));
7249    }
7250    let expected = EnginePredictionV4::new(provenance.base().identity().clone(), expected)?
7251        .facets()
7252        .to_vec();
7253    if prediction.facets() != expected {
7254        return Err(PredictionContractError::InvalidMachineResult(
7255            "engine-track-support facets do not reconstruct from V5 provenance",
7256        ));
7257    }
7258    Ok(())
7259}
7260
7261/// Shared producer/readback hook for the immutable engine-root-motion rule.
7262///
7263/// The exact Unity revision-2 reconstruction is filled beside the production
7264/// rule once its final profile identity and facet grammar are frozen. Keeping
7265/// this hook at the common boundary prevents the writer and strict reader from
7266/// acquiring separate acceptance paths in the meantime.
7267#[allow(clippy::too_many_arguments)]
7268fn validate_current_engine_root_motion_prediction_v6<F: RootMotionFindingEvidence>(
7269    check_id: &str,
7270    selection: SelectionState,
7271    configuration: ConfigurationState,
7272    applicability: Applicability,
7273    prediction: Option<&EnginePredictionV6>,
7274    provenance: Option<&PredictionProvenanceV6>,
7275    findings: &[F],
7276    rig: &RigInfo,
7277    measurements: &MeasurementContract,
7278) -> Result<(), PredictionContractError> {
7279    const CHECK_ID: &str = "engine-root-motion";
7280    if check_id != CHECK_ID
7281        && prediction.is_some_and(|prediction| {
7282            prediction.facets().iter().any(|facet| {
7283                matches!(
7284                    facet.result(),
7285                    Some(EngineMachineResultV1::RootMotionRouting(_))
7286                )
7287            })
7288        })
7289    {
7290        return Err(PredictionContractError::InvalidMachineResult(
7291            "root-motion routing is confined to engine-root-motion",
7292        ));
7293    }
7294    if check_id != CHECK_ID {
7295        return Ok(());
7296    }
7297    let active =
7298        selection == SelectionState::Selected && configuration == ConfigurationState::Enabled;
7299    let exact = provenance.is_some_and(root_motion_is_exact_unity_v2);
7300    let has_work = provenance.is_some_and(root_motion_has_work);
7301    let expected_applicability = if exact && has_work {
7302        Applicability::Applicable
7303    } else {
7304        Applicability::NotApplicable
7305    };
7306    if applicability != expected_applicability {
7307        return Err(PredictionContractError::InvalidMachineResult(
7308            "engine-root-motion applicability does not reconstruct from V6 provenance",
7309        ));
7310    }
7311    if !active || expected_applicability == Applicability::NotApplicable {
7312        if prediction.is_some() || !findings.is_empty() {
7313            return Err(PredictionContractError::InvalidMachineResult(
7314                "inactive or inapplicable engine-root-motion must carry no prediction or findings",
7315            ));
7316        }
7317        return Ok(());
7318    }
7319    let provenance = provenance.ok_or(PredictionContractError::InvalidMachineResult(
7320        "applicable engine-root-motion has no V6 provenance",
7321    ))?;
7322    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7323        "applicable engine-root-motion has no V6 prediction",
7324    ))?;
7325    validate_root_motion_facets_v6(prediction, provenance, findings, rig, measurements)
7326}
7327
7328/// An exact profile remains applicable while declaration evidence needed to
7329/// prove complete-empty intent is incomplete. This keeps the strict reader
7330/// aligned with the producer: an unvisited ownerless tail is unavailable work,
7331/// not N/A.
7332fn root_motion_has_work(provenance: &PredictionProvenanceV6) -> bool {
7333    let intent = provenance.root_motion_project_intent();
7334    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete
7335        || provenance.base().base().settings().clip_coverage().state()
7336            != ResolvedEngineSettingsCoverageStateV2::Complete
7337    {
7338        return true;
7339    }
7340    match intent.declared_axis_candidates() {
7341        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
7342        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => true,
7343    }
7344}
7345
7346fn root_motion_is_exact_unity_v2(provenance: &PredictionProvenanceV6) -> bool {
7347    let profile = provenance.base().base().profile();
7348    let selection = profile.selection();
7349    selection.family() == "unity-generic"
7350        && selection.profile_revision() == 2
7351        && selection.engine_version() == "6000.3"
7352        && selection.importer() == "fbx-model-importer"
7353        && profile.fact_bundle_urn() == "urn:animsmith:engine-profile:unity-generic:2"
7354        && profile.facts_identity().sha256()
7355            == "740e1c324a7a5b13efa2d9980fe255a6245d858adec55fb3387614a3ff45274c"
7356        && profile.facts_identity().bytes() == 2_776
7357        && provenance.base().base().source_format() == SourceFormatV1::Fbx
7358        && profile.setting_descriptors().len() == 7
7359        && profile.primary_sources().len() == 3
7360        && [
7361            "unity-fbx-animation-clip-6000.3",
7362            "unity-fbx-model-importer-6000.3",
7363            "unity-fbx-motion-node-6000.3",
7364        ]
7365        .into_iter()
7366        .all(|id| profile.source(id).is_some())
7367}
7368
7369fn validate_root_motion_facets_v6<F: RootMotionFindingEvidence>(
7370    prediction: &EnginePredictionV6,
7371    provenance: &PredictionProvenanceV6,
7372    findings: &[F],
7373    rig: &RigInfo,
7374    measurements: &MeasurementContract,
7375) -> Result<(), PredictionContractError> {
7376    const INVENTORY_SCOPE: &str = "engine-root-motion:inventory";
7377    const AXIS_SCOPE: &str = "engine-root-motion:clip-axis";
7378    const BUDGET_SCOPE: &str = "engine-root-motion:facet-budget";
7379    let intent = provenance.root_motion_project_intent();
7380    let mut atomic = Vec::new();
7381    if provenance
7382        .base()
7383        .base()
7384        .raw_source()
7385        .clips_coverage()
7386        .state()
7387        != RawSourceSetCoverageStateV1::Complete
7388        || !matches!(
7389            provenance.raw_transform_paths().coverage(),
7390            crate::RawTransformPathCoverageV1::Complete
7391        )
7392    {
7393        atomic.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7394    }
7395    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete {
7396        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7397    }
7398    if intent.declared_axis_candidates().overflowed()
7399        || intent.unmapped_declared_axis_candidates().overflowed()
7400    {
7401        atomic.push(PredictionUnavailableReasonV2::custom(
7402            "animsmith:root_motion_intent_work_budget_exceeded",
7403        )?);
7404    }
7405    if !matches!(
7406        intent.unmapped_declared_axis_candidates(),
7407        crate::EngineRootMotionProjectIntentCountV1::Exact { count: 0 }
7408    ) {
7409        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7410    }
7411    if provenance.base().base().settings().clip_coverage().state()
7412        != ResolvedEngineSettingsCoverageStateV2::Complete
7413    {
7414        atomic.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7415    }
7416    atomic.sort_by(|left, right| left.as_str().cmp(right.as_str()));
7417    atomic.dedup();
7418    if !atomic.is_empty() {
7419        let facets = prediction.facets();
7420        let valid_inventory = facets.len() == 1
7421            && facets[0].scope().code.as_str() == INVENTORY_SCOPE
7422            && facets[0].scope().subject.is_none()
7423            && facets[0].state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7424            && facets[0].reasons() == atomic
7425            && facets[0].basis() == &root_motion_inventory_basis(provenance)?;
7426        if !valid_inventory || !findings.is_empty() {
7427            return Err(PredictionContractError::InvalidMachineResult(
7428                "engine-root-motion atomic unavailable summary is not canonical",
7429            ));
7430        }
7431        return Ok(());
7432    }
7433
7434    let configured_path = provenance
7435        .base()
7436        .base()
7437        .settings()
7438        .document_setting(EngineSettingIdV2::RootMotionSource)
7439        .and_then(|row| match row.value() {
7440            EngineSettingValueV2::SourceTransformPath(path) => {
7441                crate::RawTransformPathV1::parse(path).ok()
7442            }
7443            _ => None,
7444        });
7445    let path_resolution = configured_path
7446        .as_ref()
7447        .map(|path| provenance.raw_transform_paths().resolve(path));
7448    let root_name = rig.resolved_roles.get("root").map(String::as_str);
7449    let mut name_counts = BTreeMap::new();
7450    for clip in intent.clips() {
7451        if let Some(name) = clip.normalized_clip_name() {
7452            *name_counts.entry(name).or_insert(0usize) += 1;
7453        }
7454    }
7455    let mut expected = Vec::new();
7456    for clip in intent.clips() {
7457        let Some(name) = clip.normalized_clip_name() else {
7458            continue;
7459        };
7460        for (axis, owner) in [
7461            (
7462                crate::RootMotionAxisV1::HorizontalXz,
7463                clip.movement_owner_xz(),
7464            ),
7465            (crate::RootMotionAxisV1::VerticalY, clip.movement_owner_y()),
7466            (crate::RootMotionAxisV1::Yaw, clip.movement_owner_yaw()),
7467        ]
7468        .into_iter()
7469        .filter_map(|(axis, owner)| owner.map(|owner| (axis, owner)))
7470        {
7471            let axis_name = match axis {
7472                crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7473                crate::RootMotionAxisV1::VerticalY => "vertical_y",
7474                crate::RootMotionAxisV1::Yaw => "yaw",
7475            };
7476            let scope = EvaluationScope::new(crate::EvaluationScopeCode::custom(AXIS_SCOPE))
7477                .subject(format!(
7478                    "source_clip:{:020}:axis:{axis_name}",
7479                    clip.source_clip_index()
7480                ));
7481            let setting_id = match axis {
7482                crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7483                crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7484                crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7485            };
7486            let setting = provenance
7487                .base()
7488                .base()
7489                .settings()
7490                .clip_row(
7491                    clip.normalized_clip_index().ok_or(
7492                        PredictionContractError::InvalidMachineResult(
7493                            "mapped root-motion intent is missing normalized clip index",
7494                        ),
7495                    )?,
7496                    name,
7497                )
7498                .and_then(|row| row.setting(setting_id));
7499            let measurement = measurements.clips().get(name);
7500            let reason = if name_counts.get(name).copied().unwrap_or(0) > 1 {
7501                Some(PredictionUnavailableReasonV2::MeasurementUnavailable)
7502            } else {
7503                root_motion_unavailable_reason(
7504                    path_resolution.as_ref(),
7505                    intent.resolved_root_bone_index(),
7506                    root_name,
7507                    measurement,
7508                    setting.map(|row| row.value()),
7509                    axis,
7510                )?
7511            };
7512            let basis = root_motion_candidate_basis(
7513                provenance,
7514                clip,
7515                name,
7516                name_counts.get(name).copied().unwrap_or(0),
7517                axis,
7518                owner,
7519                configured_path.as_ref(),
7520                path_resolution.as_ref(),
7521                setting.map(|row| row.value_origin()),
7522                measurement,
7523            )?;
7524            expected.push((
7525                scope.clone(),
7526                name.to_owned(),
7527                axis,
7528                owner,
7529                setting.map(|row| row.value()).cloned(),
7530                reason,
7531                basis,
7532            ));
7533        }
7534    }
7535    let facets = prediction.facets();
7536    let has_budget = facets
7537        .last()
7538        .is_some_and(|facet| facet.scope().code.as_str() == BUDGET_SCOPE);
7539    let candidates = if has_budget {
7540        &facets[..facets.len() - 1]
7541    } else {
7542        facets
7543    };
7544    if candidates.len() > expected.len() || (candidates.len() < expected.len() && !has_budget) {
7545        return Err(PredictionContractError::InvalidMachineResult(
7546            "engine-root-motion facet allocation is not a canonical prefix",
7547        ));
7548    }
7549    if has_budget {
7550        let facet = facets.last().unwrap();
7551        if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7552            || facet.scope().subject.is_some()
7553            || facet.reasons() != [PredictionUnavailableReasonV2::FacetBudgetExceeded]
7554            || facet.basis() != &root_motion_static_basis()?
7555        {
7556            return Err(PredictionContractError::InvalidMachineResult(
7557                "engine-root-motion facet-budget summary is invalid",
7558            ));
7559        }
7560    }
7561    let mut retained_conflicts = Vec::new();
7562    for (facet, (scope, clip_name, axis, owner, setting, reason, basis)) in
7563        candidates.iter().zip(expected.iter())
7564    {
7565        if facet.scope() != scope || facet.basis() != basis {
7566            return Err(PredictionContractError::InvalidMachineResult(
7567                "engine-root-motion scope or basis is not canonical",
7568            ));
7569        }
7570        match reason {
7571            Some(reason)
7572                if facet.state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7573                    && facet.reasons() == std::slice::from_ref(reason) => {}
7574            None => {
7575                let disposition = root_motion_disposition(setting.as_ref().unwrap()).unwrap();
7576                let compatible = matches!(
7577                    (owner, disposition),
7578                    (
7579                        crate::RootMotionProjectOwnerV1::Gameplay,
7580                        crate::RootMotionImporterDispositionV1::BakedIntoPose
7581                    ) | (
7582                        crate::RootMotionProjectOwnerV1::Animation,
7583                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion
7584                    )
7585                );
7586                let expected_result = crate::RootMotionRoutingResultV1 {
7587                    axis: *axis,
7588                    project_owner: *owner,
7589                    importer_disposition: disposition,
7590                    compatibility: if compatible {
7591                        crate::RootMotionCompatibilityV1::Compatible
7592                    } else {
7593                        crate::RootMotionCompatibilityV1::Conflict
7594                    },
7595                };
7596                if !matches!(facet.result(), Some(EngineMachineResultV1::RootMotionRouting(result)) if result == &expected_result)
7597                {
7598                    return Err(PredictionContractError::InvalidMachineResult(
7599                        "engine-root-motion result is not canonical",
7600                    ));
7601                }
7602                if !compatible {
7603                    let axis_label = match axis {
7604                        crate::RootMotionAxisV1::HorizontalXz => "horizontal XZ",
7605                        crate::RootMotionAxisV1::VerticalY => "vertical Y",
7606                        crate::RootMotionAxisV1::Yaw => "yaw",
7607                    };
7608                    let owner_label = match owner {
7609                        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7610                        crate::RootMotionProjectOwnerV1::Animation => "animation",
7611                    };
7612                    let disposition_label = match disposition {
7613                        crate::RootMotionImporterDispositionV1::BakedIntoPose => {
7614                            "baked into the pose"
7615                        }
7616                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion => {
7617                            "stored as root motion"
7618                        }
7619                    };
7620                    retained_conflicts.push(serde_json::json!({
7621                        "check_id": "engine-root-motion",
7622                        "severity": "error",
7623                        "clip": clip_name,
7624                        "prediction_scope": scope,
7625                        "message": format!("clip {:?} assigns {} movement to {}, but Unity imports that axis as {}", clip_name, axis_label, owner_label, disposition_label),
7626                    }));
7627                }
7628            }
7629            _ => {
7630                return Err(PredictionContractError::InvalidMachineResult(
7631                    "engine-root-motion unavailable facet is not canonical",
7632                ));
7633            }
7634        }
7635    }
7636    let actual_findings = findings
7637        .iter()
7638        .map(RootMotionFindingEvidence::root_motion_wire_value)
7639        .collect::<Vec<_>>();
7640    if actual_findings != retained_conflicts {
7641        return Err(PredictionContractError::InvalidMachineResult(
7642            "engine-root-motion conflict findings are not canonical",
7643        ));
7644    }
7645    Ok(())
7646}
7647
7648fn root_motion_lift(reference: PredictionBasisReferenceV1) -> PredictionBasisReferenceV4 {
7649    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference))
7650}
7651
7652fn root_motion_static_basis() -> Result<EnginePredictionBasisV4, PredictionContractError> {
7653    let mut references = vec![root_motion_lift(PredictionBasisReferenceV1::profile_fact(
7654        "root_motion_addressability",
7655    )?)];
7656    for source in [
7657        "unity-fbx-model-importer-6000.3",
7658        "unity-fbx-animation-clip-6000.3",
7659        "unity-fbx-motion-node-6000.3",
7660    ] {
7661        references.push(root_motion_lift(
7662            PredictionBasisReferenceV1::primary_source(source)?,
7663        ));
7664    }
7665    for setting in [
7666        EngineSettingIdV2::AnimationType,
7667        EngineSettingIdV2::AvatarSetup,
7668        EngineSettingIdV2::ImportAnimation,
7669        EngineSettingIdV2::RootMotionSource,
7670    ] {
7671        references.push(root_motion_lift(
7672            PredictionBasisReferenceV1::resolved_setting(
7673                ResolvedSettingLocationV1::Document,
7674                setting.as_str(),
7675            )?,
7676        ));
7677    }
7678    EnginePredictionBasisV4::new(references)
7679}
7680
7681fn root_motion_inventory_basis(
7682    provenance: &PredictionProvenanceV6,
7683) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7684    let mut references = root_motion_static_basis()?.references().to_vec();
7685    let path_coverage = match provenance.raw_transform_paths().coverage() {
7686        crate::RawTransformPathCoverageV1::Complete => "complete",
7687        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7688        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7689    };
7690    let clip_coverage = match provenance.root_motion_project_intent().clip_coverage() {
7691        crate::EngineRootMotionProjectIntentCoverageV1::Complete => "complete",
7692        crate::EngineRootMotionProjectIntentCoverageV1::PartialProjectionBudgetExceeded => {
7693            "partial_projection_budget_exceeded"
7694        }
7695    };
7696    let count = match provenance
7697        .root_motion_project_intent()
7698        .declared_axis_candidates()
7699    {
7700        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7701            PredictionScalarV1::UnsignedInteger { value: count }
7702        }
7703        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7704            PredictionScalarV1::token("n_plus_one")?
7705        }
7706    };
7707    let unmapped_count = match provenance
7708        .root_motion_project_intent()
7709        .unmapped_declared_axis_candidates()
7710    {
7711        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7712            PredictionScalarV1::UnsignedInteger { value: count }
7713        }
7714        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7715            PredictionScalarV1::token("n_plus_one")?
7716        }
7717    };
7718    let raw_clip_coverage = match provenance
7719        .base()
7720        .base()
7721        .raw_source()
7722        .clips_coverage()
7723        .state()
7724    {
7725        RawSourceSetCoverageStateV1::Complete => "complete",
7726        RawSourceSetCoverageStateV1::Partial => "partial",
7727        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7728    };
7729    let settings_coverage = match provenance.base().base().settings().clip_coverage().state() {
7730        ResolvedEngineSettingsCoverageStateV2::Complete => "complete",
7731        ResolvedEngineSettingsCoverageStateV2::Partial => "partial",
7732    };
7733    for (field, value) in [
7734        (
7735            "raw_source.clips.coverage",
7736            PredictionScalarV1::token(raw_clip_coverage)?,
7737        ),
7738        (
7739            "raw_transform_path_inventory.coverage",
7740            PredictionScalarV1::token(path_coverage)?,
7741        ),
7742        (
7743            "root_motion_project_intent.clip_coverage",
7744            PredictionScalarV1::token(clip_coverage)?,
7745        ),
7746        ("root_motion_project_intent.declared_axis_candidates", count),
7747        (
7748            "root_motion_project_intent.unmapped_declared_axis_candidates",
7749            unmapped_count,
7750        ),
7751        (
7752            "resolved_settings.clips.coverage",
7753            PredictionScalarV1::token(settings_coverage)?,
7754        ),
7755    ] {
7756        references.push(root_motion_lift(PredictionBasisReferenceV1::project_field(
7757            field, value,
7758        )?));
7759    }
7760    EnginePredictionBasisV4::new(references)
7761}
7762
7763fn root_motion_project_reference(
7764    field: &'static str,
7765    value: PredictionScalarV1,
7766) -> Result<PredictionBasisReferenceV4, PredictionContractError> {
7767    Ok(root_motion_lift(PredictionBasisReferenceV1::project_field(
7768        field, value,
7769    )?))
7770}
7771
7772fn root_motion_token(value: &'static str) -> Result<PredictionScalarV1, PredictionContractError> {
7773    PredictionScalarV1::token(value)
7774}
7775
7776#[allow(clippy::too_many_arguments)]
7777fn root_motion_candidate_basis(
7778    provenance: &PredictionProvenanceV6,
7779    clip: &crate::EngineRootMotionClipIntentV1,
7780    name: &str,
7781    duplicate_count: usize,
7782    axis: crate::RootMotionAxisV1,
7783    owner: crate::RootMotionProjectOwnerV1,
7784    configured_path: Option<&crate::RawTransformPathV1>,
7785    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
7786    setting_origin: Option<crate::EngineSettingValueOriginV3>,
7787    measurement: Option<&ClipMeasurements>,
7788) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7789    let mut refs = root_motion_static_basis()?.references().to_vec();
7790    let settings = provenance.base().base().settings();
7791    for (id, field) in [
7792        (
7793            EngineSettingIdV2::AnimationType,
7794            "resolved_setting.document.animation_type.value_origin",
7795        ),
7796        (
7797            EngineSettingIdV2::AvatarSetup,
7798            "resolved_setting.document.avatar_setup.value_origin",
7799        ),
7800        (
7801            EngineSettingIdV2::ImportAnimation,
7802            "resolved_setting.document.import_animation.value_origin",
7803        ),
7804        (
7805            EngineSettingIdV2::RootMotionSource,
7806            "resolved_setting.document.root_motion_source.value_origin",
7807        ),
7808    ] {
7809        let value = settings
7810            .document_setting(id)
7811            .map_or(PredictionScalarV1::Null, |row| {
7812                root_motion_token(match row.value_origin() {
7813                    crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7814                    crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7815                })
7816                .expect("static origin token")
7817            });
7818        refs.push(root_motion_project_reference(field, value)?);
7819    }
7820    let setting_id = match axis {
7821        crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7822        crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7823        crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7824    };
7825    if let Some(index) = clip.normalized_clip_index() {
7826        refs.push(root_motion_lift(
7827            PredictionBasisReferenceV1::resolved_setting(
7828                ResolvedSettingLocationV1::Clip {
7829                    clip_ordinal: index,
7830                    clip_name: name.to_owned(),
7831                },
7832                setting_id.as_str(),
7833            )?,
7834        ));
7835    }
7836    refs.push(root_motion_project_reference(
7837        "root_motion_project_intent.source_clip_index",
7838        PredictionScalarV1::UnsignedInteger {
7839            value: clip.source_clip_index(),
7840        },
7841    )?);
7842    let mapping_state = match clip.normalized_clip_mapping_state() {
7843        crate::EngineRootMotionClipMappingStateV1::Observed => "observed",
7844        crate::EngineRootMotionClipMappingStateV1::ProvenAbsent => "proven_absent",
7845        crate::EngineRootMotionClipMappingStateV1::Unavailable => "unavailable",
7846    };
7847    for field in ["normalized_clip_index.state", "normalized_clip_index.value"] {
7848        if field.ends_with(".value") && clip.normalized_clip_index().is_none() {
7849            continue;
7850        }
7851        let value = if field.ends_with(".state") {
7852            root_motion_token(mapping_state)?
7853        } else {
7854            PredictionScalarV1::UnsignedInteger {
7855                value: clip.normalized_clip_index().unwrap(),
7856            }
7857        };
7858        let raw: RawSourceBasisReferenceV1 = serde_json::from_value(serde_json::json!({
7859            "domain": "clip", "key": {"kind": "clip", "source_clip_index": clip.source_clip_index()}, "field": field, "value": value
7860        })).map_err(|_| PredictionContractError::InvalidMachineResult("failed to reconstruct root-motion raw clip reference"))?;
7861        refs.push(root_motion_lift(PredictionBasisReferenceV1::raw_source(
7862            raw,
7863        )));
7864    }
7865    let axis_name = match axis {
7866        crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7867        crate::RootMotionAxisV1::VerticalY => "vertical_y",
7868        crate::RootMotionAxisV1::Yaw => "yaw",
7869    };
7870    let owner_name = match owner {
7871        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7872        crate::RootMotionProjectOwnerV1::Animation => "animation",
7873    };
7874    let origin = setting_origin.map_or(PredictionScalarV1::Null, |origin| {
7875        root_motion_token(match origin {
7876            crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7877            crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7878        })
7879        .expect("origin token")
7880    });
7881    let path_coverage = match provenance.raw_transform_paths().coverage() {
7882        crate::RawTransformPathCoverageV1::Complete => "complete",
7883        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7884        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7885    };
7886    let raw_coverage = match provenance
7887        .base()
7888        .base()
7889        .raw_source()
7890        .clips_coverage()
7891        .state()
7892    {
7893        RawSourceSetCoverageStateV1::Complete => "complete",
7894        RawSourceSetCoverageStateV1::Partial => "partial",
7895        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7896    };
7897    let resolution = match path_resolution {
7898        Some(crate::RawTransformPathResolutionV1::Exact(_)) => "exact",
7899        Some(crate::RawTransformPathResolutionV1::NoMatch) => "no_match",
7900        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => "ambiguous",
7901        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
7902            "coverage_incomplete"
7903        }
7904        None => "invalid_configured_path",
7905    };
7906    for (field, value) in [
7907        (
7908            "root_motion_project_intent.clip_mapping_state",
7909            root_motion_token(mapping_state)?,
7910        ),
7911        (
7912            "root_motion_project_intent.normalized_clip_index",
7913            clip.normalized_clip_index()
7914                .map_or(PredictionScalarV1::Null, |value| {
7915                    PredictionScalarV1::UnsignedInteger { value }
7916                }),
7917        ),
7918        (
7919            "root_motion_project_intent.normalized_clip_name",
7920            PredictionScalarV1::text(name)?,
7921        ),
7922        (
7923            "measurement.clip_name_match_count",
7924            PredictionScalarV1::UnsignedInteger {
7925                value: duplicate_count as u64,
7926            },
7927        ),
7928        (
7929            "measurement.clip_identity_state",
7930            root_motion_token(if duplicate_count > 1 {
7931                "duplicate"
7932            } else {
7933                "unique"
7934            })?,
7935        ),
7936        (
7937            "root_motion_project_intent.axis",
7938            root_motion_token(axis_name)?,
7939        ),
7940        (
7941            "root_motion_project_intent.owner",
7942            root_motion_token(owner_name)?,
7943        ),
7944        ("resolved_setting.clip.value_origin", origin),
7945        (
7946            "raw_source.clips.coverage",
7947            root_motion_token(raw_coverage)?,
7948        ),
7949        (
7950            "raw_transform_path_inventory.coverage",
7951            root_motion_token(path_coverage)?,
7952        ),
7953        (
7954            "resolved_role.root.bone_index",
7955            provenance
7956                .root_motion_project_intent()
7957                .resolved_root_bone_index()
7958                .map_or(PredictionScalarV1::Null, |value| {
7959                    PredictionScalarV1::UnsignedInteger { value }
7960                }),
7961        ),
7962        (
7963            "root_motion_source.configured_path",
7964            configured_path.map_or(PredictionScalarV1::Null, |path| {
7965                PredictionScalarV1::text(path.as_str()).expect("path text")
7966            }),
7967        ),
7968        (
7969            "root_motion_source.resolution_state",
7970            root_motion_token(resolution)?,
7971        ),
7972    ] {
7973        refs.push(root_motion_project_reference(field, value)?);
7974    }
7975    if let Some(crate::RawTransformPathResolutionV1::Ambiguous { matches }) = path_resolution {
7976        refs.push(root_motion_project_reference(
7977            "root_motion_source.match_count",
7978            PredictionScalarV1::UnsignedInteger {
7979                value: matches.len() as u64,
7980            },
7981        )?);
7982    }
7983    if let Some(crate::RawTransformPathResolutionV1::Exact(path_match)) = path_resolution {
7984        let digest = crate::sha256_hex(
7985            &serde_json::to_vec(path_match.parent_chain()).expect("parent chain serializes"),
7986        );
7987        for (field, value) in [
7988            (
7989                "root_motion_source.source_node_index",
7990                PredictionScalarV1::UnsignedInteger {
7991                    value: path_match.source_node_index(),
7992                },
7993            ),
7994            (
7995                "root_motion_source.projected_bone_index",
7996                path_match
7997                    .projected_bone_index()
7998                    .map_or(PredictionScalarV1::Null, |value| {
7999                        PredictionScalarV1::UnsignedInteger { value }
8000                    }),
8001            ),
8002            (
8003                "root_motion_source.path",
8004                PredictionScalarV1::text(path_match.path().as_str())?,
8005            ),
8006            ("root_motion_source.node_kind", root_motion_token("source")?),
8007            (
8008                "root_motion_source.parent_chain_count",
8009                PredictionScalarV1::UnsignedInteger {
8010                    value: path_match.parent_chain().len() as u64,
8011                },
8012            ),
8013            (
8014                "root_motion_source.parent_chain_sha256",
8015                PredictionScalarV1::text(digest)?,
8016            ),
8017        ] {
8018            refs.push(root_motion_project_reference(field, value)?);
8019        }
8020    }
8021    if duplicate_count == 1
8022        && let Some(measurement) = measurement
8023    {
8024        let escaped = name.replace('~', "~0").replace('/', "~1");
8025        let prefix = format!("/measurements/clips/{escaped}/root_trajectory");
8026        let availability = |value| {
8027            root_motion_token(match value {
8028                MeasurementAvailability::Measured => "measured",
8029                MeasurementAvailability::NotApplicable => "not_applicable",
8030                MeasurementAvailability::Unavailable => "unavailable",
8031            })
8032        };
8033        let mut add_measurement = |pointer: String, value| -> Result<(), PredictionContractError> {
8034            refs.push(root_motion_lift(
8035                PredictionBasisReferenceV1::measurement_v16(
8036                    crate::MeasurementPointerV1::new(pointer)?,
8037                    value,
8038                ),
8039            ));
8040            Ok(())
8041        };
8042        add_measurement(
8043            format!("{prefix}_availability"),
8044            availability(measurement.root_trajectory_availability)?,
8045        )?;
8046        if let Some(trajectory) = measurement.root_trajectory.as_ref() {
8047            add_measurement(
8048                format!("{prefix}/bone_index"),
8049                PredictionScalarV1::UnsignedInteger {
8050                    value: u64::from(trajectory.bone_index),
8051                },
8052            )?;
8053            add_measurement(
8054                format!("{prefix}/source_role"),
8055                root_motion_token(trajectory.source_role.as_str())?,
8056            )?;
8057            let (suffix, present_field, availability_value, present) = match axis {
8058                crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => (
8059                    "translation_availability",
8060                    "measurement.root_translation_present",
8061                    trajectory.translation_availability,
8062                    trajectory.translation.is_some(),
8063                ),
8064                crate::RootMotionAxisV1::Yaw => (
8065                    "yaw_availability",
8066                    "measurement.root_yaw_present",
8067                    trajectory.yaw_availability,
8068                    trajectory.yaw.is_some(),
8069                ),
8070            };
8071            add_measurement(
8072                format!("{prefix}/{suffix}"),
8073                availability(availability_value)?,
8074            )?;
8075            refs.push(root_motion_project_reference(
8076                present_field,
8077                PredictionScalarV1::Boolean { value: present },
8078            )?);
8079        }
8080    }
8081    EnginePredictionBasisV4::new(refs)
8082}
8083
8084trait RootMotionFindingEvidence {
8085    fn root_motion_wire_value(&self) -> serde_json::Value;
8086}
8087
8088impl RootMotionFindingEvidence for crate::Finding {
8089    fn root_motion_wire_value(&self) -> serde_json::Value {
8090        serde_json::to_value(self).expect("Finding serializes")
8091    }
8092}
8093
8094impl RootMotionFindingEvidence for PredictionFindingInput {
8095    fn root_motion_wire_value(&self) -> serde_json::Value {
8096        serde_json::to_value(self).expect("finding input serializes")
8097    }
8098}
8099
8100fn root_motion_disposition(
8101    setting: &EngineSettingValueV2,
8102) -> Option<crate::RootMotionImporterDispositionV1> {
8103    match setting {
8104        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Bake) => {
8105            Some(crate::RootMotionImporterDispositionV1::BakedIntoPose)
8106        }
8107        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Extract) => {
8108            Some(crate::RootMotionImporterDispositionV1::StoredAsRootMotion)
8109        }
8110        _ => None,
8111    }
8112}
8113
8114fn root_motion_unavailable_reason(
8115    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
8116    resolved_root_bone_index: Option<u64>,
8117    root_name: Option<&str>,
8118    measurement: Option<&ClipMeasurements>,
8119    setting: Option<&EngineSettingValueV2>,
8120    axis: crate::RootMotionAxisV1,
8121) -> Result<Option<PredictionUnavailableReasonV2>, PredictionContractError> {
8122    let path_match = match path_resolution {
8123        Some(crate::RawTransformPathResolutionV1::Exact(path_match)) => path_match,
8124        Some(crate::RawTransformPathResolutionV1::NoMatch) | None => {
8125            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch));
8126        }
8127        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => {
8128            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous));
8129        }
8130        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8131            return Ok(Some(PredictionUnavailableReasonV2::RawSourceIncomplete));
8132        }
8133    };
8134    if resolved_root_bone_index.is_none()
8135        || path_match.projected_bone_index() != resolved_root_bone_index
8136    {
8137        return Ok(Some(PredictionUnavailableReasonV2::custom(
8138            "animsmith:root_motion_source_not_explicit_root",
8139        )?));
8140    }
8141    let Some(trajectory) = measurement
8142        .filter(|measurement| {
8143            measurement.root_trajectory_availability == MeasurementAvailability::Measured
8144        })
8145        .and_then(|measurement| measurement.root_trajectory.as_ref())
8146    else {
8147        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8148    };
8149    if trajectory.source_role != crate::measure::RootTrajectorySourceRole::Root
8150        || root_name != Some(trajectory.bone_name.as_str())
8151        || resolved_root_bone_index != Some(u64::from(trajectory.bone_index))
8152    {
8153        return Ok(Some(PredictionUnavailableReasonV2::custom(
8154            "animsmith:root_motion_source_not_explicit_root",
8155        )?));
8156    }
8157    let measured = match axis {
8158        crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => {
8159            trajectory.translation_availability == MeasurementAvailability::Measured
8160                && trajectory.translation.is_some()
8161        }
8162        crate::RootMotionAxisV1::Yaw => {
8163            trajectory.yaw_availability == MeasurementAvailability::Measured
8164                && trajectory.yaw.is_some()
8165        }
8166    };
8167    if !measured {
8168        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8169    }
8170    if setting.and_then(root_motion_disposition).is_none() {
8171        return Ok(Some(
8172            PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
8173        ));
8174    }
8175    Ok(None)
8176}
8177
8178fn track_scope(code: &'static str, subject: String) -> EvaluationScope {
8179    EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code)).subject(subject)
8180}
8181
8182fn track_subject_facet(
8183    scope: EvaluationScope,
8184    basis: EnginePredictionBasisV4,
8185    subject_kind: SourceImportSubjectKindV1,
8186    gate: Option<EngineSettingIdV2>,
8187) -> crate::EnginePredictionFacetV4 {
8188    match gate {
8189        None => track_unavailable(
8190            scope,
8191            basis,
8192            PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
8193        ),
8194        Some(controlling_gate) => crate::EnginePredictionFacetV4::available(
8195            scope,
8196            basis,
8197            EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
8198                subject_kind,
8199                disposition: SourceImportDispositionV1::Dropped,
8200                controlling_gate: Some(controlling_gate),
8201            }),
8202        )
8203        .expect("reconstructed track result is valid"),
8204    }
8205}
8206
8207fn track_unavailable(
8208    scope: EvaluationScope,
8209    basis: EnginePredictionBasisV4,
8210    reason: PredictionUnavailableReasonV2,
8211) -> crate::EnginePredictionFacetV4 {
8212    crate::EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
8213        .expect("reconstructed track unavailable facet is valid")
8214}
8215
8216fn track_gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
8217    let settings = provenance.base().settings();
8218    if matches!(
8219        settings
8220            .document_setting(EngineSettingIdV2::BevyAnimationFeature)
8221            .map(|row| row.value()),
8222        Some(EngineSettingValueV2::Boolean(false))
8223    ) {
8224        Some(EngineSettingIdV2::BevyAnimationFeature)
8225    } else if matches!(
8226        settings
8227            .document_setting(EngineSettingIdV2::LoadAnimations)
8228            .map(|row| row.value()),
8229        Some(EngineSettingValueV2::Boolean(false))
8230    ) {
8231        Some(EngineSettingIdV2::LoadAnimations)
8232    } else {
8233        None
8234    }
8235}
8236
8237fn track_static_basis() -> EnginePredictionBasisV4 {
8238    let v1 = |reference| PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference));
8239    EnginePredictionBasisV4::new(vec![
8240        v1(PredictionBasisReferenceV1::profile_fact("source_import_disposition").unwrap()),
8241        v1(PredictionBasisReferenceV1::primary_source("bevy-gltf-loader-0.19.0-c6f634ca").unwrap()),
8242        v1(
8243            PredictionBasisReferenceV1::primary_source("bevy-feature-manifest-0.19.0-c6f634ca")
8244                .unwrap(),
8245        ),
8246        v1(PredictionBasisReferenceV1::resolved_setting(
8247            ResolvedSettingLocationV1::Document,
8248            EngineSettingIdV2::BevyAnimationFeature.as_str(),
8249        )
8250        .unwrap()),
8251        v1(PredictionBasisReferenceV1::resolved_setting(
8252            ResolvedSettingLocationV1::Document,
8253            EngineSettingIdV2::LoadAnimations.as_str(),
8254        )
8255        .unwrap()),
8256    ])
8257    .unwrap()
8258}
8259
8260fn track_inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
8261    let mut references = track_static_basis().references().to_vec();
8262    references.push(PredictionBasisReferenceV4::v2(
8263        PredictionBasisReferenceV2::v1(
8264            PredictionBasisReferenceV1::project_field(
8265                "raw_animation_channel_inventory.animation_coverage",
8266                PredictionScalarV1::text(match inventory.animation_coverage().state() {
8267                    RawSourceSetCoverageStateV1::Complete => "complete",
8268                    RawSourceSetCoverageStateV1::Partial => "partial",
8269                    RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8270                })
8271                .unwrap(),
8272            )
8273            .unwrap(),
8274        ),
8275    ));
8276    references.push(PredictionBasisReferenceV4::v2(
8277        PredictionBasisReferenceV2::v1(
8278            PredictionBasisReferenceV1::project_field(
8279                "raw_animation_channel_inventory.source_coverage_complete",
8280                PredictionScalarV1::Boolean {
8281                    value: inventory.source_coverage_complete(),
8282                },
8283            )
8284            .unwrap(),
8285        ),
8286    ));
8287    if let Some(row) = inventory.rows().iter().find(|row| {
8288        row.channel_coverage()
8289            .is_some_and(|coverage| coverage.state() != RawSourceSetCoverageStateV1::Complete)
8290    }) {
8291        let coverage = row.channel_coverage().unwrap();
8292        let state = match coverage.state() {
8293            RawSourceSetCoverageStateV1::Complete => "complete",
8294            RawSourceSetCoverageStateV1::Partial => "partial",
8295            RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8296        };
8297        references.push(PredictionBasisReferenceV4::v2(
8298            PredictionBasisReferenceV2::v1(
8299                PredictionBasisReferenceV1::project_field(
8300                    "raw_animation_channel_inventory.incomplete_channel_animation_row",
8301                    PredictionScalarV1::UnsignedInteger {
8302                        value: row.source_animation_index(),
8303                    },
8304                )
8305                .unwrap(),
8306            ),
8307        ));
8308        references.push(PredictionBasisReferenceV4::v2(
8309            PredictionBasisReferenceV2::v1(
8310                PredictionBasisReferenceV1::project_field(
8311                    "raw_animation_channel_inventory.incomplete_channel_coverage",
8312                    PredictionScalarV1::text(state).unwrap(),
8313                )
8314                .unwrap(),
8315            ),
8316        ));
8317        if let Some(reason) = coverage.reason() {
8318            let reason = serde_json::to_value(reason).unwrap();
8319            references.push(PredictionBasisReferenceV4::v2(
8320                PredictionBasisReferenceV2::v1(
8321                    PredictionBasisReferenceV1::project_field(
8322                        "raw_animation_channel_inventory.incomplete_channel_reason",
8323                        PredictionScalarV1::text(reason.as_str().unwrap()).unwrap(),
8324                    )
8325                    .unwrap(),
8326                ),
8327            ));
8328        }
8329    }
8330    EnginePredictionBasisV4::new(references).unwrap()
8331}
8332
8333fn track_row_basis(
8334    inventory: &RawAnimationChannelInventoryV1,
8335    animation: u64,
8336    channel: Option<u64>,
8337) -> EnginePredictionBasisV4 {
8338    let mut references = track_inventory_basis(inventory).references().to_vec();
8339    references.push(PredictionBasisReferenceV4::v2(
8340        PredictionBasisReferenceV2::v1(
8341            PredictionBasisReferenceV1::project_field(
8342                "raw_animation_channel_inventory.animation_row",
8343                PredictionScalarV1::UnsignedInteger { value: animation },
8344            )
8345            .unwrap(),
8346        ),
8347    ));
8348    if let Some(channel) = channel {
8349        references.push(PredictionBasisReferenceV4::v2(
8350            PredictionBasisReferenceV2::v1(
8351                PredictionBasisReferenceV1::project_field(
8352                    "raw_animation_channel_inventory.channel_row",
8353                    PredictionScalarV1::UnsignedInteger { value: channel },
8354                )
8355                .unwrap(),
8356            ),
8357        ));
8358    }
8359    EnginePredictionBasisV4::new(references).unwrap()
8360}
8361
8362const ENGINE_UNIT_SCALE_CHECK_ID: &str = "engine-unit-scale";
8363const ENGINE_UNIT_SCALE_FILE_SCOPE: &str = "engine-unit-scale:file-unit";
8364const ENGINE_UNIT_SCALE_SCENE_SCOPE: &str = "engine-unit-scale:loader-scene-root";
8365const ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE: &str = "engine-unit-scale:scene-inventory";
8366const ENGINE_UNIT_SCALE_MESH_SCOPE: &str = "engine-unit-scale:loader-mesh-primitive";
8367const ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE: &str = "engine-unit-scale:mesh-inventory";
8368const ENGINE_UNIT_SCALE_SELECTED_SCOPE: &str = "engine-unit-scale:selected-source-node";
8369const ENGINE_UNIT_SCALE_BUDGET_SCOPE: &str = "engine-unit-scale:facet-budget";
8370const ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON: &str =
8371    "animsmith:selected_node_scene_reachability_unavailable";
8372const ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES: usize = 128;
8373
8374#[derive(Clone, PartialEq, Eq)]
8375struct ExpectedUnitScaleFacet {
8376    scope: EvaluationScope,
8377    result: Option<EngineMachineResultV1>,
8378    reasons: Vec<PredictionUnavailableReasonV2>,
8379}
8380
8381fn unit_scale_scope(code: &'static str, subject: Option<String>) -> EvaluationScope {
8382    let scope = EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code));
8383    subject.map_or(scope.clone(), |subject| scope.subject(subject))
8384}
8385
8386fn unit_scale_unavailable_reasons(
8387    reason: PredictionUnavailableReasonV2,
8388    dependency_complete: bool,
8389) -> Vec<PredictionUnavailableReasonV2> {
8390    let mut reasons = vec![reason];
8391    if !dependency_complete {
8392        reasons.push(PredictionUnavailableReasonV2::DependencyClosureIncomplete);
8393    }
8394    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
8395    reasons
8396}
8397
8398#[derive(Clone, Copy, PartialEq, Eq)]
8399enum UnitScaleSelectedReachability {
8400    Reachable(u64, u64),
8401    Unreachable,
8402    Unavailable,
8403    WorkBudgetExceeded,
8404}
8405
8406enum UnitScaleSelectedReachabilityPlan {
8407    Complete {
8408        node_index: u64,
8409        scene_witnesses: BTreeMap<u64, (u64, u64)>,
8410    },
8411    Refused {
8412        node_index: u64,
8413    },
8414}
8415
8416enum UnitScaleSelectedReachabilityPlans {
8417    Complete {
8418        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8419        selected_facet_count: usize,
8420    },
8421    Overflow {
8422        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8423    },
8424}
8425
8426impl UnitScaleSelectedReachabilityPlans {
8427    fn get(&self, selector: &str) -> Option<&UnitScaleSelectedReachabilityPlan> {
8428        match self {
8429            Self::Complete { plans, .. } | Self::Overflow { plans } => plans.get(selector),
8430        }
8431    }
8432
8433    const fn selected_facet_count(&self) -> usize {
8434        match self {
8435            Self::Complete {
8436                selected_facet_count,
8437                ..
8438            } => *selected_facet_count,
8439            Self::Overflow { .. } => PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE + 1,
8440        }
8441    }
8442}
8443
8444impl UnitScaleSelectedReachabilityPlan {
8445    const fn node_index(&self) -> u64 {
8446        match self {
8447            Self::Complete { node_index, .. } | Self::Refused { node_index } => *node_index,
8448        }
8449    }
8450}
8451
8452fn unit_scale_selected_reachability_budget_exceeded(work: &mut usize) -> bool {
8453    *work = work.saturating_add(1);
8454    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8455}
8456
8457fn unit_scale_selected_reachability(
8458    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8459    start: u64,
8460    roots: &[u64],
8461    work: &mut usize,
8462) -> UnitScaleSelectedReachability {
8463    let Ok(start) = usize::try_from(start) else {
8464        return UnitScaleSelectedReachability::Unavailable;
8465    };
8466    if *work >= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8467        return UnitScaleSelectedReachability::WorkBudgetExceeded;
8468    }
8469    let roots = roots
8470        .iter()
8471        .copied()
8472        .enumerate()
8473        .map(|(ordinal, node)| (node, ordinal as u64))
8474        .collect::<BTreeMap<_, _>>();
8475    let mut seen = BTreeSet::new();
8476    let mut current = Some(start);
8477    for _ in 0..ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES {
8478        let Some(index) = current else {
8479            return UnitScaleSelectedReachability::Unreachable;
8480        };
8481        if unit_scale_selected_reachability_budget_exceeded(work) {
8482            return UnitScaleSelectedReachability::WorkBudgetExceeded;
8483        }
8484        if let Some(ordinal) = roots.get(&(index as u64)) {
8485            return UnitScaleSelectedReachability::Reachable(*ordinal, index as u64);
8486        }
8487        if !seen.insert(index) {
8488            return UnitScaleSelectedReachability::Unavailable;
8489        }
8490        current = match nodes.get(&index) {
8491            Some(node) => node.parent_node_index,
8492            None => return UnitScaleSelectedReachability::Unavailable,
8493        };
8494    }
8495    match current {
8496        Some(_) => UnitScaleSelectedReachability::Unavailable,
8497        None => UnitScaleSelectedReachability::Unreachable,
8498    }
8499}
8500
8501fn unit_scale_selected_reachability_plans(
8502    provenance: &PredictionProvenanceV4,
8503    measurements: &MeasurementContract,
8504) -> UnitScaleSelectedReachabilityPlans {
8505    let assets = measurements.assets();
8506    let inventory = provenance
8507        .raw_scene_attachment()
8508        .inventory()
8509        .filter(|inventory| {
8510            assets.skeleton_source_coverage == SourceSkeletonCoverage::Complete
8511                && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8512                && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8513        });
8514    let nodes = assets
8515        .skeleton_nodes
8516        .iter()
8517        .map(|node| (node.node_index, node))
8518        .collect::<BTreeMap<_, _>>();
8519    let mut plans = BTreeMap::new();
8520    let mut selected_facet_count = 0usize;
8521    for selector in provenance.rule_inputs().runtime_node_selectors() {
8522        let Some(inventory) = inventory else {
8523            selected_facet_count = selected_facet_count.saturating_add(1);
8524            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8525                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8526            }
8527            continue;
8528        };
8529        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8530            node.name
8531                .as_deref()
8532                .is_some_and(|name| crate::config::glob_match(selector, name))
8533        });
8534        let Some(node) = matches.next() else {
8535            selected_facet_count = selected_facet_count.saturating_add(1);
8536            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8537                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8538            }
8539            continue;
8540        };
8541        if matches.next().is_some() {
8542            selected_facet_count = selected_facet_count.saturating_add(1);
8543            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8544                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8545            }
8546            continue;
8547        }
8548        let node_index = node.node_index as u64;
8549        let mut work = 0usize;
8550        let remaining_capacity =
8551            PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(selected_facet_count);
8552        let mut would_overflow = false;
8553        let mut plan = UnitScaleSelectedReachabilityPlan::Complete {
8554            node_index,
8555            scene_witnesses: BTreeMap::new(),
8556        };
8557        for scene in inventory.scenes().rows() {
8558            match unit_scale_selected_reachability(
8559                &nodes,
8560                node_index,
8561                scene.root_node_indices(),
8562                &mut work,
8563            ) {
8564                UnitScaleSelectedReachability::Reachable(root_ordinal, root_node_index) => {
8565                    let UnitScaleSelectedReachabilityPlan::Complete {
8566                        scene_witnesses, ..
8567                    } = &mut plan
8568                    else {
8569                        unreachable!("a reachable scene cannot follow a refusal");
8570                    };
8571                    if scene_witnesses.len() < remaining_capacity {
8572                        scene_witnesses
8573                            .insert(scene.source_scene_index(), (root_ordinal, root_node_index));
8574                    } else {
8575                        would_overflow = true;
8576                    }
8577                }
8578                UnitScaleSelectedReachability::Unreachable => {}
8579                UnitScaleSelectedReachability::Unavailable
8580                | UnitScaleSelectedReachability::WorkBudgetExceeded => {
8581                    plan = UnitScaleSelectedReachabilityPlan::Refused { node_index };
8582                    break;
8583                }
8584            }
8585        }
8586        if matches!(&plan, UnitScaleSelectedReachabilityPlan::Complete { .. }) && would_overflow {
8587            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8588        }
8589        let facets = match &plan {
8590            UnitScaleSelectedReachabilityPlan::Complete {
8591                scene_witnesses, ..
8592            } => scene_witnesses.len().max(1),
8593            UnitScaleSelectedReachabilityPlan::Refused { .. } => 1,
8594        };
8595        selected_facet_count = selected_facet_count.saturating_add(facets);
8596        if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8597            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8598        }
8599        plans.insert(selector.clone(), plan);
8600    }
8601    UnitScaleSelectedReachabilityPlans::Complete {
8602        plans,
8603        selected_facet_count,
8604    }
8605}
8606
8607fn unit_scale_selected_ancestry_reason(
8608    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8609    start: usize,
8610) -> Option<PredictionUnavailableReasonV2> {
8611    let mut current = start;
8612    let mut seen = BTreeSet::new();
8613    for _ in 0..128 {
8614        if !seen.insert(current) {
8615            return Some(
8616                PredictionUnavailableReasonV2::custom(
8617                    "animsmith:selected_node_ancestry_unavailable",
8618                )
8619                .expect("static reason is valid"),
8620            );
8621        }
8622        let Some(node) = nodes.get(&current) else {
8623            return Some(
8624                PredictionUnavailableReasonV2::custom(
8625                    "animsmith:selected_node_ancestry_unavailable",
8626                )
8627                .expect("static reason is valid"),
8628            );
8629        };
8630        match node.local_rest {
8631            SkeletonNodeLocalRestMeasurements::Matrix { .. } => {
8632                return Some(
8633                    PredictionUnavailableReasonV2::custom(
8634                        "animsmith:matrix_authored_selected_node_or_ancestry",
8635                    )
8636                    .expect("static reason is valid"),
8637                );
8638            }
8639            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {
8640                return Some(
8641                    PredictionUnavailableReasonV2::custom(
8642                        "animsmith:selected_node_ancestry_unavailable",
8643                    )
8644                    .expect("static reason is valid"),
8645                );
8646            }
8647            SkeletonNodeLocalRestMeasurements::Trs { .. } => {}
8648        }
8649        let parent = node.parent_node_index?;
8650        current = parent;
8651    }
8652    Some(
8653        PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
8654            .expect("static reason is valid"),
8655    )
8656}
8657
8658#[derive(Clone, Copy)]
8659struct CurrentUnitScaleMeshRow {
8660    source_scene_index: u64,
8661    source_root_ordinal: u64,
8662    root_node_index: u64,
8663    source_node_index: u64,
8664    source_mesh_index: u64,
8665    source_primitive_index: u64,
8666}
8667
8668enum CurrentUnitScaleMeshPlan {
8669    Detailed(Vec<CurrentUnitScaleMeshRow>),
8670    CompleteEmpty,
8671    Incomplete,
8672    JoinOverflow,
8673}
8674
8675fn unit_scale_join_budget_exceeded(work: &mut usize) -> bool {
8676    *work = work.saturating_add(1);
8677    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8678}
8679
8680fn unit_scale_reachable_root_indexed(
8681    start: u64,
8682    roots: &BTreeMap<u64, u64>,
8683    parents: &BTreeMap<u64, Option<u64>>,
8684    work: &mut usize,
8685) -> Result<Option<(u64, u64)>, ()> {
8686    let mut seen = BTreeSet::new();
8687    let mut current = Some(start);
8688    while let Some(index) = current {
8689        if unit_scale_join_budget_exceeded(work) {
8690            return Err(());
8691        }
8692        if let Some(ordinal) = roots.get(&index) {
8693            return Ok(Some((*ordinal, index)));
8694        }
8695        if !seen.insert(index) {
8696            return Ok(None);
8697        }
8698        current = match parents.get(&index) {
8699            Some(parent) => *parent,
8700            None => return Ok(None),
8701        };
8702    }
8703    Ok(None)
8704}
8705
8706fn current_unit_scale_mesh_plan(
8707    provenance: &PredictionProvenanceV4,
8708    measurements: &MeasurementContract,
8709) -> CurrentUnitScaleMeshPlan {
8710    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
8711        return CurrentUnitScaleMeshPlan::Incomplete;
8712    };
8713    if inventory.scenes().coverage() != RawSceneAttachmentCoverageV1::Complete
8714        || inventory.node_mesh_attachments().coverage() != RawSceneAttachmentCoverageV1::Complete
8715        || inventory.mesh_primitives().coverage() != RawSceneAttachmentCoverageV1::Complete
8716        || inventory.source_skeleton().coverage() != RawSceneAttachmentCoverageV1::Complete
8717        || measurements.assets().skeleton_source_coverage != SourceSkeletonCoverage::Complete
8718    {
8719        return CurrentUnitScaleMeshPlan::Incomplete;
8720    }
8721    let parents = measurements
8722        .assets()
8723        .skeleton_nodes
8724        .iter()
8725        .map(|node| {
8726            (
8727                node.node_index as u64,
8728                node.parent_node_index.map(|parent| parent as u64),
8729            )
8730        })
8731        .collect::<BTreeMap<_, _>>();
8732    let scene_roots = inventory
8733        .scenes()
8734        .rows()
8735        .iter()
8736        .map(|scene| {
8737            (
8738                scene.source_scene_index(),
8739                scene
8740                    .root_node_indices()
8741                    .iter()
8742                    .copied()
8743                    .enumerate()
8744                    .map(|(ordinal, node)| (node, ordinal as u64))
8745                    .collect::<BTreeMap<_, _>>(),
8746            )
8747        })
8748        .collect::<Vec<_>>();
8749    let primitives_by_mesh = inventory.mesh_primitives().rows().iter().fold(
8750        BTreeMap::<u64, Vec<u64>>::new(),
8751        |mut grouped, primitive| {
8752            grouped
8753                .entry(primitive.source_mesh_index())
8754                .or_default()
8755                .push(primitive.source_primitive_index());
8756            grouped
8757        },
8758    );
8759    let mut work = 0usize;
8760    let mut rows = Vec::new();
8761    for (source_scene_index, roots) in scene_roots {
8762        for attachment in inventory.node_mesh_attachments().rows() {
8763            if unit_scale_join_budget_exceeded(&mut work) {
8764                return CurrentUnitScaleMeshPlan::JoinOverflow;
8765            }
8766            let Ok(reachable) = unit_scale_reachable_root_indexed(
8767                attachment.source_node_index(),
8768                &roots,
8769                &parents,
8770                &mut work,
8771            ) else {
8772                return CurrentUnitScaleMeshPlan::JoinOverflow;
8773            };
8774            let Some((source_root_ordinal, root_node_index)) = reachable else {
8775                continue;
8776            };
8777            for &source_primitive_index in primitives_by_mesh
8778                .get(&attachment.source_mesh_index())
8779                .into_iter()
8780                .flatten()
8781            {
8782                if unit_scale_join_budget_exceeded(&mut work) {
8783                    return CurrentUnitScaleMeshPlan::JoinOverflow;
8784                }
8785                rows.push(CurrentUnitScaleMeshRow {
8786                    source_scene_index,
8787                    source_root_ordinal,
8788                    root_node_index,
8789                    source_node_index: attachment.source_node_index(),
8790                    source_mesh_index: attachment.source_mesh_index(),
8791                    source_primitive_index,
8792                });
8793            }
8794        }
8795    }
8796    if rows.is_empty() {
8797        CurrentUnitScaleMeshPlan::CompleteEmpty
8798    } else {
8799        CurrentUnitScaleMeshPlan::Detailed(rows)
8800    }
8801}
8802
8803fn current_unit_scale_selected_facet_count(
8804    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8805) -> usize {
8806    reachability_plans.selected_facet_count()
8807}
8808
8809fn expected_current_engine_unit_scale_facets(
8810    provenance: &PredictionProvenanceV4,
8811    measurements: &MeasurementContract,
8812    mesh_plan: &CurrentUnitScaleMeshPlan,
8813    candidate_capacity: usize,
8814    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8815) -> Option<Vec<ExpectedUnitScaleFacet>> {
8816    let dependency_complete = matches!(
8817        provenance.dependency_closure().coverage(),
8818        DependencyClosureCoverageV1::Complete
8819    );
8820    let available = |scope, result| ExpectedUnitScaleFacet {
8821        scope,
8822        result: Some(result),
8823        reasons: vec![],
8824    };
8825    let unavailable = |scope, reasons| ExpectedUnitScaleFacet {
8826        scope,
8827        result: None,
8828        reasons,
8829    };
8830    let mut expected = Vec::with_capacity(candidate_capacity);
8831    if expected.len() < candidate_capacity {
8832        expected.push(if dependency_complete {
8833            available(
8834                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8835                EngineMachineResultV1::UnitMapping(
8836                    UnitMappingResultV1::gltf_to_engine_world_length_unit(),
8837                ),
8838            )
8839        } else {
8840            unavailable(
8841                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8842                vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8843            )
8844        });
8845    }
8846
8847    let inventory = provenance.raw_scene_attachment().inventory();
8848    match inventory
8849        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
8850    {
8851        Some(inventory) => {
8852            for scene in inventory.scenes().rows() {
8853                if expected.len() >= candidate_capacity {
8854                    break;
8855                }
8856                let scope = unit_scale_scope(
8857                    ENGINE_UNIT_SCALE_SCENE_SCOPE,
8858                    Some(format!("source_scene:{}", scene.source_scene_index())),
8859                );
8860                expected.push(if dependency_complete {
8861                    available(
8862                        scope,
8863                        EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8864                            subject_kind: TransformScaleSubjectKindV1::LoaderSceneEntity,
8865                            creation: ImporterSubjectCreationV1::Created,
8866                            domain: TransformScaleDomainV1::Local,
8867                            classification: Some(LinearTransformClassification::UnitOrthonormal),
8868                        }),
8869                    )
8870                } else {
8871                    unavailable(
8872                        scope,
8873                        vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8874                    )
8875                });
8876            }
8877        }
8878        None if expected.len() < candidate_capacity => expected.push(unavailable(
8879            unit_scale_scope(ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE, None),
8880            unit_scale_unavailable_reasons(
8881                PredictionUnavailableReasonV2::RawSourceIncomplete,
8882                dependency_complete,
8883            ),
8884        )),
8885        None => {}
8886    }
8887
8888    let assets = measurements.assets();
8889    let nodes = assets
8890        .skeleton_nodes
8891        .iter()
8892        .map(|node| (node.node_index, node))
8893        .collect::<BTreeMap<_, _>>();
8894    if expected.len() < candidate_capacity {
8895        match mesh_plan {
8896            CurrentUnitScaleMeshPlan::Incomplete => expected.push(unavailable(
8897                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8898                unit_scale_unavailable_reasons(
8899                    PredictionUnavailableReasonV2::RawSourceIncomplete,
8900                    dependency_complete,
8901                ),
8902            )),
8903            CurrentUnitScaleMeshPlan::JoinOverflow => expected.push(unavailable(
8904                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8905                vec![
8906                    PredictionUnavailableReasonV2::custom(
8907                        "animsmith:mesh_join_work_budget_exceeded",
8908                    )
8909                    .expect("static reason is valid"),
8910                ],
8911            )),
8912            CurrentUnitScaleMeshPlan::CompleteEmpty => expected.push(if dependency_complete {
8913                available(
8914                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8915                    EngineMachineResultV1::InventoryCoverage(InventoryCoverageResultV1 {
8916                        domain: PredictionInventoryDomainV1::LoaderMeshPrimitiveSubjects,
8917                        coverage: PredictionInventoryCoverageStateV1::Complete,
8918                        retained_rows: 0,
8919                    }),
8920                )
8921            } else {
8922                unavailable(
8923                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8924                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8925                )
8926            }),
8927            CurrentUnitScaleMeshPlan::Detailed(rows) => {
8928                let load_meshes = matches!(
8929                    provenance
8930                        .settings()
8931                        .document_setting(EngineSettingIdV2::LoadMeshes)
8932                        .map(|setting| setting.value()),
8933                    Some(EngineSettingValueV2::Token(value)) if value == "nonempty"
8934                );
8935                for row in rows {
8936                    if expected.len() >= candidate_capacity {
8937                        break;
8938                    }
8939                    let scope = unit_scale_scope(
8940                        ENGINE_UNIT_SCALE_MESH_SCOPE,
8941                        Some(format!(
8942                            "source_scene:{}:source_node:{}:source_mesh:{}:source_primitive:{}",
8943                            row.source_scene_index,
8944                            row.source_node_index,
8945                            row.source_mesh_index,
8946                            row.source_primitive_index,
8947                        )),
8948                    );
8949                    expected.push(if dependency_complete {
8950                        available(
8951                            scope,
8952                            EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8953                                subject_kind:
8954                                    TransformScaleSubjectKindV1::LoaderMeshPrimitiveEntity,
8955                                creation: if load_meshes {
8956                                    ImporterSubjectCreationV1::Created
8957                                } else {
8958                                    ImporterSubjectCreationV1::SuppressedBySetting
8959                                },
8960                                domain: TransformScaleDomainV1::Local,
8961                                classification: load_meshes
8962                                    .then_some(LinearTransformClassification::UnitOrthonormal),
8963                            }),
8964                        )
8965                    } else {
8966                        unavailable(
8967                            scope,
8968                            vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8969                        )
8970                    });
8971                }
8972            }
8973        }
8974    }
8975
8976    for selector in provenance.rule_inputs().runtime_node_selectors() {
8977        if expected.len() >= candidate_capacity {
8978            break;
8979        }
8980        if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete
8981            || !inventory.is_some_and(|inventory| {
8982                inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8983                    && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8984            })
8985        {
8986            expected.push(unavailable(
8987                unit_scale_scope(
8988                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
8989                    Some(format!("selector:{selector}")),
8990                ),
8991                unit_scale_unavailable_reasons(
8992                    PredictionUnavailableReasonV2::RawSourceIncomplete,
8993                    dependency_complete,
8994                ),
8995            ));
8996            continue;
8997        }
8998        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8999            node.name
9000                .as_deref()
9001                .is_some_and(|name| crate::config::glob_match(selector, name))
9002        });
9003        let first = matches.next();
9004        let second = matches.next();
9005        if first.is_none() || second.is_some() {
9006            expected.push(unavailable(
9007                unit_scale_scope(
9008                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9009                    Some(format!("selector:{selector}")),
9010                ),
9011                unit_scale_unavailable_reasons(
9012                    if first.is_none() {
9013                        PredictionUnavailableReasonV2::SourceSelectorNoMatch
9014                    } else {
9015                        PredictionUnavailableReasonV2::SourceSelectorAmbiguous
9016                    },
9017                    dependency_complete,
9018                ),
9019            ));
9020            continue;
9021        }
9022        let node = first.expect("one selected source node was established");
9023        let inventory = inventory.expect("selected inventory was proven complete");
9024        let mut reachable_scenes = Vec::new();
9025        let plan = reachability_plans.get(selector)?;
9026        let reachability_unavailable =
9027            match plan {
9028                UnitScaleSelectedReachabilityPlan::Refused {
9029                    node_index: cached_node_index,
9030                } if *cached_node_index == node.node_index as u64 => true,
9031                UnitScaleSelectedReachabilityPlan::Complete {
9032                    node_index: cached_node_index,
9033                    scene_witnesses,
9034                } if *cached_node_index == node.node_index as u64 => {
9035                    reachable_scenes.extend(
9036                        inventory.scenes().rows().iter().filter(|scene| {
9037                            scene_witnesses.contains_key(&scene.source_scene_index())
9038                        }),
9039                    );
9040                    false
9041                }
9042                UnitScaleSelectedReachabilityPlan::Refused { .. }
9043                | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9044            };
9045        if reachability_unavailable {
9046            expected.push(unavailable(
9047                unit_scale_scope(
9048                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9049                    Some(format!("selector:{selector}")),
9050                ),
9051                unit_scale_unavailable_reasons(
9052                    PredictionUnavailableReasonV2::custom(
9053                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9054                    )
9055                    .expect("static reason is valid"),
9056                    dependency_complete,
9057                ),
9058            ));
9059            continue;
9060        }
9061        let reason = unit_scale_selected_ancestry_reason(&nodes, node.node_index).or_else(|| {
9062            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9063                || node.rest_world_matrix.is_none())
9064            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9065        });
9066        let mut reachable_found = false;
9067        for scene in reachable_scenes {
9068            reachable_found = true;
9069            if expected.len() >= candidate_capacity {
9070                break;
9071            }
9072            let scope = unit_scale_scope(
9073                ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9074                Some(format!(
9075                    "selector:{selector}:source_scene:{}:source_node:{}",
9076                    scene.source_scene_index(),
9077                    node.node_index,
9078                )),
9079            );
9080            expected.push(if let Some(reason) = reason.clone() {
9081                unavailable(
9082                    scope,
9083                    unit_scale_unavailable_reasons(reason, dependency_complete),
9084                )
9085            } else if dependency_complete {
9086                available(
9087                    scope,
9088                    EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9089                        subject_kind: TransformScaleSubjectKindV1::SelectedSourceNode,
9090                        creation: ImporterSubjectCreationV1::Created,
9091                        domain: TransformScaleDomainV1::LoaderRootToSubject,
9092                        classification: Some(node.rest_world_linear.classification),
9093                    }),
9094                )
9095            } else {
9096                unavailable(
9097                    scope,
9098                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9099                )
9100            });
9101        }
9102        if !reachable_found && expected.len() < candidate_capacity {
9103            expected.push(unavailable(
9104                unit_scale_scope(
9105                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9106                    Some(format!("selector:{selector}")),
9107                ),
9108                unit_scale_unavailable_reasons(
9109                    PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9110                        .expect("static reason is valid"),
9111                    dependency_complete,
9112                ),
9113            ));
9114        }
9115    }
9116    Some(expected)
9117}
9118
9119fn unit_scale_exact_raw_row_references(
9120    basis: &EnginePredictionBasisV4,
9121    expected: &[RawSceneAttachmentBasisReferenceV1],
9122) -> bool {
9123    let actual = basis
9124        .references()
9125        .iter()
9126        .filter_map(|reference| match reference {
9127            PredictionBasisReferenceV4::RawSceneAttachment(reference)
9128                if !matches!(
9129                    reference,
9130                    RawSceneAttachmentBasisReferenceV1::Coverage { .. }
9131                ) =>
9132            {
9133                Some(reference)
9134            }
9135            _ => None,
9136        })
9137        .collect::<Vec<_>>();
9138    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9139}
9140
9141fn unit_scale_mesh_scope_keys(subject: &str) -> Option<(u64, u64, u64, u64)> {
9142    let values = subject
9143        .strip_prefix("source_scene:")?
9144        .split(':')
9145        .collect::<Vec<_>>();
9146    if values.len() != 7
9147        || values[1] != "source_node"
9148        || values[3] != "source_mesh"
9149        || values[5] != "source_primitive"
9150    {
9151        return None;
9152    }
9153    Some((
9154        values[0].parse().ok()?,
9155        values[2].parse().ok()?,
9156        values[4].parse().ok()?,
9157        values[6].parse().ok()?,
9158    ))
9159}
9160
9161fn unit_scale_expected_mesh_raw_rows(
9162    subject: &str,
9163    mesh_plan: &CurrentUnitScaleMeshPlan,
9164) -> Option<Vec<RawSceneAttachmentBasisReferenceV1>> {
9165    let (source_scene_index, source_node_index, source_mesh_index, source_primitive_index) =
9166        unit_scale_mesh_scope_keys(subject)?;
9167    let CurrentUnitScaleMeshPlan::Detailed(rows) = mesh_plan else {
9168        return None;
9169    };
9170    let row = rows.iter().find(|row| {
9171        row.source_scene_index == source_scene_index
9172            && row.source_node_index == source_node_index
9173            && row.source_mesh_index == source_mesh_index
9174            && row.source_primitive_index == source_primitive_index
9175    })?;
9176    Some(vec![
9177        RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9178        RawSceneAttachmentBasisReferenceV1::SceneRoot {
9179            source_scene_index,
9180            source_root_ordinal: row.source_root_ordinal,
9181            source_node_index: row.root_node_index,
9182        },
9183        RawSceneAttachmentBasisReferenceV1::NodeMeshAttachmentRow {
9184            source_node_index,
9185            source_mesh_index,
9186        },
9187        RawSceneAttachmentBasisReferenceV1::MeshPrimitiveRow {
9188            source_mesh_index,
9189            source_primitive_index,
9190        },
9191    ])
9192}
9193
9194fn unit_scale_selected_scope_keys<'a>(
9195    subject: &str,
9196    selectors: &'a [String],
9197) -> Option<(&'a str, Option<(u64, u64)>)> {
9198    for selector in selectors {
9199        let prefix = format!("selector:{selector}");
9200        if subject == prefix {
9201            return Some((selector, None));
9202        }
9203        let Some(values) = subject
9204            .strip_prefix(&prefix)
9205            .and_then(|suffix| suffix.strip_prefix(":source_scene:"))
9206        else {
9207            continue;
9208        };
9209        let values = values.split(':').collect::<Vec<_>>();
9210        if values.len() == 3 && values[1] == "source_node" {
9211            return Some((
9212                selector,
9213                Some((values[0].parse().ok()?, values[2].parse().ok()?)),
9214            ));
9215        }
9216    }
9217    None
9218}
9219
9220fn unit_scale_classification_name(value: LinearTransformClassification) -> &'static str {
9221    match value {
9222        LinearTransformClassification::UnitOrthonormal => "unit_orthonormal",
9223        LinearTransformClassification::UniformScaled => "uniform_scaled",
9224        LinearTransformClassification::NonUniform => "non_uniform",
9225        LinearTransformClassification::Sheared => "sheared",
9226        LinearTransformClassification::Reflected => "reflected",
9227        LinearTransformClassification::Singular => "singular",
9228        LinearTransformClassification::NonFinite => "non_finite",
9229    }
9230}
9231
9232fn unit_scale_raw_source_node_reference(
9233    source_index: u64,
9234    field: &str,
9235    value: PredictionScalarV1,
9236) -> Option<RawSourceBasisReferenceV1> {
9237    RawSourceBasisReferenceV1::from_wire(
9238        RawSourceDomainV1::SourceNode,
9239        RawSourceKeyV1::SourceSkeleton {
9240            row_kind: SourceSkeletonRowKindV1::SourceNode,
9241            source_index,
9242        },
9243        RawSourceFieldIdV1::new(field).ok()?,
9244        value,
9245    )
9246    .ok()
9247}
9248
9249fn unit_scale_exact_raw_source_references(
9250    basis: &EnginePredictionBasisV4,
9251    expected: &[RawSourceBasisReferenceV1],
9252) -> bool {
9253    let actual = basis
9254        .references()
9255        .iter()
9256        .filter_map(|reference| match reference {
9257            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9258                PredictionBasisReferenceV1::RawSource { reference },
9259            )) => Some(reference),
9260            _ => None,
9261        })
9262        .collect::<Vec<_>>();
9263    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9264}
9265
9266fn unit_scale_selected_authored_kind(
9267    basis: &EnginePredictionBasisV4,
9268    source_index: u64,
9269) -> Option<&str> {
9270    let mut values = basis.references().iter().filter_map(|reference| {
9271        let PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9272            PredictionBasisReferenceV1::RawSource { reference },
9273        )) = reference
9274        else {
9275            return None;
9276        };
9277        if reference.domain() != RawSourceDomainV1::SourceNode
9278            || reference.key()
9279                != &(RawSourceKeyV1::SourceSkeleton {
9280                    row_kind: SourceSkeletonRowKindV1::SourceNode,
9281                    source_index,
9282                })
9283            || reference.field().as_str() != "local_rest.kind"
9284        {
9285            return None;
9286        }
9287        match reference.value() {
9288            PredictionScalarV1::Token { value } if matches!(value.as_str(), "trs" | "matrix") => {
9289                Some(value.as_str())
9290            }
9291            _ => None,
9292        }
9293    });
9294    let value = values.next()?;
9295    values.next().is_none().then_some(value)
9296}
9297
9298fn unit_scale_selected_expected_reasons(
9299    primary: Option<PredictionUnavailableReasonV2>,
9300    dependency_complete: bool,
9301) -> Vec<PredictionUnavailableReasonV2> {
9302    match primary {
9303        Some(reason) => unit_scale_unavailable_reasons(reason, dependency_complete),
9304        None if dependency_complete => Vec::new(),
9305        None => vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9306    }
9307}
9308
9309struct CurrentUnitScaleSelectedEvidence {
9310    raw_source_references: Vec<RawSourceBasisReferenceV1>,
9311    reasons: Vec<PredictionUnavailableReasonV2>,
9312}
9313
9314fn unit_scale_expected_selected_raw_source_references(
9315    selector: &str,
9316    keys: Option<(u64, u64)>,
9317    basis: &EnginePredictionBasisV4,
9318    provenance: &PredictionProvenanceV4,
9319    measurements: &MeasurementContract,
9320    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9321) -> Option<CurrentUnitScaleSelectedEvidence> {
9322    let assets = measurements.assets();
9323    let dependency_complete = matches!(
9324        provenance.dependency_closure().coverage(),
9325        DependencyClosureCoverageV1::Complete
9326    );
9327    let inventory = provenance.raw_scene_attachment().inventory();
9328    let inventory_complete = inventory.is_some_and(|inventory| {
9329        inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9330            && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9331    });
9332    if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete || !inventory_complete {
9333        return Some(CurrentUnitScaleSelectedEvidence {
9334            raw_source_references: Vec::new(),
9335            reasons: unit_scale_selected_expected_reasons(
9336                Some(PredictionUnavailableReasonV2::RawSourceIncomplete),
9337                dependency_complete,
9338            ),
9339        });
9340    }
9341    let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9342        node.name
9343            .as_deref()
9344            .is_some_and(|name| crate::config::glob_match(selector, name))
9345    });
9346    let first = matches.next();
9347    let second = matches.next();
9348    let name_reference = |node: &crate::measure::SkeletonNodeMeasurements| {
9349        unit_scale_raw_source_node_reference(
9350            node.node_index as u64,
9351            "name",
9352            node.name.as_ref().map_or(PredictionScalarV1::Null, |name| {
9353                PredictionScalarV1::text(name).expect("retained measurement text is bounded")
9354            }),
9355        )
9356    };
9357    if first.is_none() {
9358        return keys.is_none().then(|| CurrentUnitScaleSelectedEvidence {
9359            raw_source_references: Vec::new(),
9360            reasons: unit_scale_selected_expected_reasons(
9361                Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch),
9362                dependency_complete,
9363            ),
9364        });
9365    }
9366    if let Some(second) = second {
9367        if keys.is_some() {
9368            return None;
9369        }
9370        return Some(CurrentUnitScaleSelectedEvidence {
9371            raw_source_references: vec![name_reference(first?)?, name_reference(second)?],
9372            reasons: unit_scale_selected_expected_reasons(
9373                Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous),
9374                dependency_complete,
9375            ),
9376        });
9377    }
9378    let node = first?;
9379    if keys.is_some_and(|(_, source_node_index)| source_node_index != node.node_index as u64) {
9380        return None;
9381    }
9382    let mut expected = vec![name_reference(node)?];
9383    let nodes = assets
9384        .skeleton_nodes
9385        .iter()
9386        .map(|node| (node.node_index, node))
9387        .collect::<BTreeMap<_, _>>();
9388    let plan = reachability_plans.get(selector)?;
9389    let reachability_unavailable = match plan {
9390        UnitScaleSelectedReachabilityPlan::Refused {
9391            node_index: cached_node_index,
9392        } if *cached_node_index == node.node_index as u64 => true,
9393        UnitScaleSelectedReachabilityPlan::Complete {
9394            node_index: cached_node_index,
9395            ..
9396        } if *cached_node_index == node.node_index as u64 => false,
9397        UnitScaleSelectedReachabilityPlan::Refused { .. }
9398        | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9399    };
9400    if reachability_unavailable {
9401        if keys.is_some() {
9402            return None;
9403        }
9404        return Some(CurrentUnitScaleSelectedEvidence {
9405            raw_source_references: expected,
9406            reasons: unit_scale_selected_expected_reasons(
9407                Some(
9408                    PredictionUnavailableReasonV2::custom(
9409                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9410                    )
9411                    .expect("static reason is valid"),
9412                ),
9413                dependency_complete,
9414            ),
9415        });
9416    }
9417    let mut current = node.node_index;
9418    let mut seen = BTreeSet::new();
9419    let mut ancestry_reason = None;
9420    let mut ancestry_complete = false;
9421    for _ in 0..128 {
9422        if !seen.insert(current) {
9423            ancestry_reason = Some(
9424                PredictionUnavailableReasonV2::custom(
9425                    "animsmith:selected_node_ancestry_unavailable",
9426                )
9427                .expect("static reason is valid"),
9428            );
9429            break;
9430        }
9431        let Some(ancestry_node) = nodes.get(&current).copied() else {
9432            ancestry_reason = Some(
9433                PredictionUnavailableReasonV2::custom(
9434                    "animsmith:selected_node_ancestry_unavailable",
9435                )
9436                .expect("static reason is valid"),
9437            );
9438            break;
9439        };
9440        // The normalized measurement deliberately erases whether a non-finite
9441        // local rest was authored as TRS or as a matrix. The exact same-load
9442        // raw-source scalar in the basis retains that distinction per row.
9443        let retained_kind = unit_scale_selected_authored_kind(basis, current as u64)?;
9444        let local_kind = match ancestry_node.local_rest {
9445            SkeletonNodeLocalRestMeasurements::Trs { .. } if retained_kind == "trs" => "trs",
9446            SkeletonNodeLocalRestMeasurements::Matrix { .. } if retained_kind == "matrix" => {
9447                "matrix"
9448            }
9449            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => retained_kind,
9450            _ => return None,
9451        };
9452        expected.push(unit_scale_raw_source_node_reference(
9453            current as u64,
9454            "local_rest.kind",
9455            PredictionScalarV1::token(local_kind).ok()?,
9456        )?);
9457        expected.push(unit_scale_raw_source_node_reference(
9458            current as u64,
9459            "parent_source_node_index",
9460            ancestry_node
9461                .parent_node_index
9462                .map_or(PredictionScalarV1::Null, |parent| {
9463                    PredictionScalarV1::UnsignedInteger {
9464                        value: parent as u64,
9465                    }
9466                }),
9467        )?);
9468        if local_kind == "matrix" {
9469            ancestry_reason = Some(
9470                PredictionUnavailableReasonV2::custom(
9471                    "animsmith:matrix_authored_selected_node_or_ancestry",
9472                )
9473                .expect("static reason is valid"),
9474            );
9475            break;
9476        }
9477        let Some(parent) = ancestry_node.parent_node_index else {
9478            ancestry_complete = true;
9479            break;
9480        };
9481        current = parent;
9482    }
9483    if ancestry_reason.is_none() && !ancestry_complete {
9484        ancestry_reason = Some(
9485            PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
9486                .expect("static reason is valid"),
9487        );
9488    }
9489    let primary_reason = if keys.is_none() {
9490        Some(
9491            PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9492                .expect("static reason is valid"),
9493        )
9494    } else {
9495        ancestry_reason.or_else(|| {
9496            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9497                || node.rest_world_matrix.is_none())
9498            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9499        })
9500    };
9501    Some(CurrentUnitScaleSelectedEvidence {
9502        raw_source_references: expected,
9503        reasons: unit_scale_selected_expected_reasons(primary_reason, dependency_complete),
9504    })
9505}
9506
9507fn unit_scale_exact_selected_evidence(
9508    selector: &str,
9509    keys: Option<(u64, u64)>,
9510    reasons: &[PredictionUnavailableReasonV2],
9511    basis: &EnginePredictionBasisV4,
9512    provenance: &PredictionProvenanceV4,
9513    measurements: &MeasurementContract,
9514    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9515) -> bool {
9516    let Some(expected) = unit_scale_expected_selected_raw_source_references(
9517        selector,
9518        keys,
9519        basis,
9520        provenance,
9521        measurements,
9522        reachability_plans,
9523    ) else {
9524        return false;
9525    };
9526    if reasons != expected.reasons
9527        || !unit_scale_exact_raw_source_references(basis, &expected.raw_source_references)
9528    {
9529        return false;
9530    }
9531    let measurement_references = basis
9532        .references()
9533        .iter()
9534        .filter_map(|reference| match reference {
9535            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9536                PredictionBasisReferenceV1::Measurement {
9537                    schema,
9538                    pointer,
9539                    value,
9540                },
9541            )) => Some((*schema, pointer.as_str(), value)),
9542            _ => None,
9543        })
9544        .collect::<Vec<_>>();
9545    let Some((source_scene_index, source_node_index)) = keys else {
9546        return unit_scale_exact_raw_row_references(basis, &[])
9547            && measurement_references.is_empty();
9548    };
9549    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
9550        return false;
9551    };
9552    let Some(_scene) = inventory
9553        .scenes()
9554        .rows()
9555        .iter()
9556        .find(|scene| scene.source_scene_index() == source_scene_index)
9557    else {
9558        return false;
9559    };
9560    let witness = reachability_plans
9561        .get(selector)
9562        .filter(|plan| plan.node_index() == source_node_index)
9563        .and_then(|plan| match plan {
9564            UnitScaleSelectedReachabilityPlan::Complete {
9565                scene_witnesses, ..
9566            } => scene_witnesses.get(&source_scene_index).copied(),
9567            UnitScaleSelectedReachabilityPlan::Refused { .. } => None,
9568        });
9569    let Some((source_root_ordinal, source_node_index_at_root)) = witness else {
9570        return false;
9571    };
9572    if !unit_scale_exact_raw_row_references(
9573        basis,
9574        &[
9575            RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9576            RawSceneAttachmentBasisReferenceV1::SceneRoot {
9577                source_scene_index,
9578                source_root_ordinal,
9579                source_node_index: source_node_index_at_root,
9580            },
9581        ],
9582    ) {
9583        return false;
9584    }
9585    let Some((ordinal, node)) = measurements
9586        .assets()
9587        .skeleton_nodes
9588        .iter()
9589        .enumerate()
9590        .find(|(_, node)| node.node_index as u64 == source_node_index)
9591    else {
9592        return measurement_references.is_empty();
9593    };
9594    let pointer =
9595        format!("/measurements/skeleton_nodes/{ordinal}/rest_world_linear/classification");
9596    matches!(
9597        measurement_references.as_slice(),
9598        [(
9599            MEASUREMENTS_V16_SCHEMA_ID,
9600            actual_pointer,
9601            PredictionScalarV1::Token { value },
9602        )] if *actual_pointer == pointer
9603            && value == unit_scale_classification_name(node.rest_world_linear.classification)
9604    )
9605}
9606
9607fn validate_current_engine_unit_scale_basis(
9608    scope: &EvaluationScope,
9609    basis: &EnginePredictionBasisV4,
9610    reasons: &[PredictionUnavailableReasonV2],
9611    provenance: &PredictionProvenanceV4,
9612    measurements: &MeasurementContract,
9613    mesh_plan: &CurrentUnitScaleMeshPlan,
9614    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9615) -> bool {
9616    let v1 = |predicate: &dyn Fn(&PredictionBasisReferenceV1) -> bool| {
9617        basis.references().iter().any(|reference| {
9618            matches!(
9619                reference,
9620                PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(reference))
9621                    if predicate(reference)
9622            )
9623        })
9624    };
9625    let fact = |expected: &str| {
9626        v1(
9627            &|reference| matches!(reference, PredictionBasisReferenceV1::ProfileFact { fact_id } if fact_id == expected),
9628        )
9629    };
9630    let setting = |expected: &str| {
9631        v1(
9632            &|reference| matches!(reference, PredictionBasisReferenceV1::ResolvedSetting { setting_id, .. } if setting_id == expected),
9633        )
9634    };
9635    let source = |expected: &str| {
9636        v1(
9637            &|reference| matches!(reference, PredictionBasisReferenceV1::PrimarySource { source_id } if source_id == expected),
9638        )
9639    };
9640    let raw_coverage = |expected: RawSceneAttachmentBasisDomainV1| {
9641        basis.references().iter().any(|reference| {
9642            matches!(
9643                reference,
9644                PredictionBasisReferenceV4::RawSceneAttachment(
9645                    RawSceneAttachmentBasisReferenceV1::Coverage { domain }
9646                ) if *domain == expected
9647            )
9648        })
9649    };
9650    let common_transform = source("bevy-gltf-loader-0.19.0-c6f634ca")
9651        && source("bevy-gltf-coordinate-conversion-0.19.0-c6f634ca")
9652        && fact("resulting_transform_scale")
9653        && setting("extension_handler_environment");
9654    match scope.code.as_str() {
9655        ENGINE_UNIT_SCALE_FILE_SCOPE => {
9656            unit_scale_exact_raw_row_references(basis, &[])
9657                && [
9658                    "application_world_unit_policy",
9659                    "importer_scale_conversion",
9660                    "physical_dimensions_preserved",
9661                    "source_to_target_unit_mapping",
9662                    "target_linear_unit",
9663                ]
9664                .into_iter()
9665                .all(fact)
9666                && [
9667                    "bevy-gltf-loader-0.19.0-c6f634ca",
9668                    "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
9669                    "khronos-gltf-2.0-coordinate-units",
9670                ]
9671                .into_iter()
9672                .all(source)
9673        }
9674        ENGINE_UNIT_SCALE_SCENE_SCOPE | ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE => {
9675            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE {
9676                Some(Vec::new())
9677            } else {
9678                scope
9679                    .subject
9680                    .as_deref()
9681                    .and_then(|subject| subject.strip_prefix("source_scene:"))
9682                    .and_then(|index| index.parse::<u64>().ok())
9683                    .filter(|index| {
9684                        scope.subject.as_deref() == Some(&format!("source_scene:{index}"))
9685                    })
9686                    .map(|source_scene_index| {
9687                        vec![RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index }]
9688                    })
9689            };
9690            expected_rows.as_deref().is_some_and(|expected_rows| {
9691                unit_scale_exact_raw_row_references(basis, expected_rows)
9692            }) && common_transform
9693                && setting("rotate_scene_entity")
9694                && (provenance.raw_scene_attachment().inventory().is_none()
9695                    || raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes))
9696        }
9697        ENGINE_UNIT_SCALE_MESH_SCOPE | ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE => {
9698            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE {
9699                Some(Vec::new())
9700            } else {
9701                scope
9702                    .subject
9703                    .as_deref()
9704                    .and_then(|subject| unit_scale_expected_mesh_raw_rows(subject, mesh_plan))
9705            };
9706            expected_rows.as_deref().is_some_and(|expected_rows| {
9707                unit_scale_exact_raw_row_references(basis, expected_rows)
9708            }) && common_transform
9709                && source("bevy-render-asset-usages-0.19.0-c6f634ca")
9710                && setting("load_meshes")
9711                && setting("rotate_meshes")
9712                && (provenance.raw_scene_attachment().inventory().is_none()
9713                    || [
9714                        RawSceneAttachmentBasisDomainV1::SourceSkeleton,
9715                        RawSceneAttachmentBasisDomainV1::Scenes,
9716                        RawSceneAttachmentBasisDomainV1::NodeMeshAttachments,
9717                        RawSceneAttachmentBasisDomainV1::MeshPrimitives,
9718                    ]
9719                    .into_iter()
9720                    .all(raw_coverage))
9721        }
9722        ENGINE_UNIT_SCALE_SELECTED_SCOPE => {
9723            let selected = scope.subject.as_deref().and_then(|subject| {
9724                unit_scale_selected_scope_keys(
9725                    subject,
9726                    provenance.rule_inputs().runtime_node_selectors(),
9727                )
9728            });
9729            let selector = selected.map(|(selector, _)| selector);
9730            common_transform
9731                && setting("rotate_scene_entity")
9732                && selected.is_some_and(|(selector, keys)| {
9733                    unit_scale_exact_selected_evidence(
9734                        selector,
9735                        keys,
9736                        reasons,
9737                        basis,
9738                        provenance,
9739                        measurements,
9740                        reachability_plans,
9741                    )
9742                })
9743                && selector.is_some_and(|selector| {
9744                    v1(&|reference| {
9745                        matches!(
9746                            reference,
9747                            PredictionBasisReferenceV1::ProjectField {
9748                                field_id,
9749                                value: PredictionScalarV1::Text { value }
9750                            } if field_id == "runtime_nodes.selector" && value == selector
9751                        )
9752                    })
9753                })
9754                && (provenance.raw_scene_attachment().inventory().is_none()
9755                    || (raw_coverage(RawSceneAttachmentBasisDomainV1::SourceSkeleton)
9756                        && raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes)))
9757        }
9758        ENGINE_UNIT_SCALE_BUDGET_SCOPE => {
9759            unit_scale_exact_raw_row_references(basis, &[])
9760                && source("bevy-gltf-loader-0.19.0-c6f634ca")
9761        }
9762        _ => false,
9763    }
9764}
9765
9766fn validate_current_engine_unit_scale_prediction_v4(
9767    check_id: &str,
9768    selection: SelectionState,
9769    configuration: ConfigurationState,
9770    applicability: Applicability,
9771    prediction: Option<&EnginePredictionV4>,
9772    provenance: Option<&PredictionProvenanceV4>,
9773    measurements: &MeasurementContract,
9774) -> Result<(), PredictionContractError> {
9775    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9776        return Ok(());
9777    }
9778    let exact_profile = provenance.is_some_and(|provenance| {
9779        let selection = provenance.profile().selection();
9780        selection.family() == "bevy"
9781            && selection.profile_revision() == 2
9782            && selection.engine_version() == "0.19.0"
9783            && selection.importer() == "gltf-asset-loader"
9784            && provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9785            && provenance.profile().facts_identity().sha256()
9786                == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9787            && provenance.profile().facts_identity().bytes() == 4_783
9788            && matches!(
9789                provenance.source_format(),
9790                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9791            )
9792    });
9793    validate_current_engine_unit_scale_prediction_common(
9794        check_id,
9795        selection,
9796        configuration,
9797        applicability,
9798        prediction,
9799        provenance,
9800        measurements,
9801        exact_profile,
9802    )
9803}
9804
9805fn validate_current_engine_unit_scale_prediction_v5(
9806    check_id: &str,
9807    selection: SelectionState,
9808    configuration: ConfigurationState,
9809    applicability: Applicability,
9810    prediction: Option<&EnginePredictionV5>,
9811    provenance: Option<&PredictionProvenanceV5>,
9812    measurements: &MeasurementContract,
9813) -> Result<(), PredictionContractError> {
9814    let base = provenance.map(PredictionProvenanceV5::base);
9815    let exact_profile = base.is_some_and(|provenance| {
9816        let selection = provenance.profile().selection();
9817        let exact_identity = match selection.profile_revision() {
9818            2 => {
9819                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9820                    && provenance.profile().facts_identity().sha256()
9821                        == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9822                    && provenance.profile().facts_identity().bytes() == 4_783
9823            }
9824            3 => {
9825                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
9826                    && provenance.profile().facts_identity().sha256()
9827                        == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
9828                    && provenance.profile().facts_identity().bytes() == 4_849
9829            }
9830            _ => false,
9831        };
9832        selection.family() == "bevy"
9833            && selection.engine_version() == "0.19.0"
9834            && selection.importer() == "gltf-asset-loader"
9835            && exact_identity
9836            && matches!(
9837                provenance.source_format(),
9838                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9839            )
9840    });
9841    validate_current_engine_unit_scale_prediction_common(
9842        check_id,
9843        selection,
9844        configuration,
9845        applicability,
9846        prediction.map(EnginePredictionV5::base_prediction),
9847        base,
9848        measurements,
9849        exact_profile,
9850    )
9851}
9852
9853#[allow(clippy::too_many_arguments)]
9854fn validate_current_engine_unit_scale_prediction_common(
9855    check_id: &str,
9856    selection: SelectionState,
9857    configuration: ConfigurationState,
9858    applicability: Applicability,
9859    prediction: Option<&EnginePredictionV4>,
9860    provenance: Option<&PredictionProvenanceV4>,
9861    measurements: &MeasurementContract,
9862    exact_profile: bool,
9863) -> Result<(), PredictionContractError> {
9864    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9865        return Ok(());
9866    }
9867    if applicability
9868        != if exact_profile {
9869            Applicability::Applicable
9870        } else {
9871            Applicability::NotApplicable
9872        }
9873    {
9874        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9875    }
9876    if !exact_profile {
9877        return if prediction.is_none() {
9878            Ok(())
9879        } else {
9880            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9881        };
9882    }
9883    if selection != SelectionState::Selected || configuration != ConfigurationState::Enabled {
9884        return if prediction.is_none() {
9885            Ok(())
9886        } else {
9887            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9888        };
9889    }
9890    let provenance = provenance.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9891    let prediction = prediction.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9892    let mesh_plan = current_unit_scale_mesh_plan(provenance, measurements);
9893    let reachability_plans = unit_scale_selected_reachability_plans(provenance, measurements);
9894    if prediction.facets().iter().any(|facet| {
9895        !validate_current_engine_unit_scale_basis(
9896            facet.scope(),
9897            facet.basis(),
9898            facet.reasons(),
9899            provenance,
9900            measurements,
9901            &mesh_plan,
9902            &reachability_plans,
9903        )
9904    }) {
9905        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9906    }
9907    let has_summary = prediction.facets().iter().any(|facet| {
9908        facet.scope().code.as_str() == ENGINE_UNIT_SCALE_BUDGET_SCOPE
9909            && facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded]
9910    });
9911    let retained = prediction
9912        .facets()
9913        .iter()
9914        .filter(|facet| facet.scope().code.as_str() != ENGINE_UNIT_SCALE_BUDGET_SCOPE)
9915        .collect::<Vec<_>>();
9916    let scene_facets = provenance
9917        .raw_scene_attachment()
9918        .inventory()
9919        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
9920        .map_or(1, |inventory| inventory.scenes().rows().len());
9921    let mesh_facets = match &mesh_plan {
9922        CurrentUnitScaleMeshPlan::Detailed(rows) => rows.len(),
9923        CurrentUnitScaleMeshPlan::CompleteEmpty
9924        | CurrentUnitScaleMeshPlan::Incomplete
9925        | CurrentUnitScaleMeshPlan::JoinOverflow => 1,
9926    };
9927    let selected_facets = current_unit_scale_selected_facet_count(&reachability_plans);
9928    let expected_count = 1usize
9929        .checked_add(scene_facets)
9930        .and_then(|count| count.checked_add(mesh_facets))
9931        .and_then(|count| count.checked_add(selected_facets))
9932        .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9933    if (!has_summary && expected_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
9934        || (has_summary && retained.len() >= expected_count)
9935    {
9936        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9937    }
9938    let candidate_capacity = if has_summary {
9939        retained.len()
9940    } else {
9941        expected_count
9942    };
9943    let mut expected_retained = expected_current_engine_unit_scale_facets(
9944        provenance,
9945        measurements,
9946        &mesh_plan,
9947        candidate_capacity,
9948        &reachability_plans,
9949    )
9950    .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9951    if expected_retained.len() != candidate_capacity {
9952        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9953    }
9954    expected_retained.sort_by(|left, right| {
9955        left.scope
9956            .code
9957            .as_str()
9958            .cmp(right.scope.code.as_str())
9959            .then_with(|| left.scope.subject.cmp(&right.scope.subject))
9960    });
9961    if retained.len() != expected_retained.len()
9962        || retained
9963            .iter()
9964            .zip(&expected_retained)
9965            .any(|(actual, expected)| {
9966                actual.scope() != &expected.scope
9967                    || actual.result() != expected.result.as_ref()
9968                    || (actual.scope().code.as_str() != ENGINE_UNIT_SCALE_SELECTED_SCOPE
9969                        && actual.reasons() != expected.reasons)
9970            })
9971    {
9972        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9973    }
9974    Ok(())
9975}
9976
9977const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
9978const ENGINE_CLIP_BOUNDARY_SOURCE_ID: &str = "unreal-animation-sequences-5.8";
9979const ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY: &str = "unreal";
9980const ENGINE_CLIP_BOUNDARY_PROFILE_REVISION: u32 = 1;
9981const ENGINE_CLIP_BOUNDARY_ENGINE_VERSION: &str = "5.8";
9982const ENGINE_CLIP_BOUNDARY_IMPORTER: &str = "fbx-importer";
9983const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256: &str =
9984    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759";
9985const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES: u64 = 2_169;
9986
9987fn current_engine_clip_boundary_profile_matches_v3(provenance: &PredictionProvenanceV3) -> bool {
9988    let selection = provenance.profile().selection();
9989    provenance.source_format() == SourceFormatV1::Fbx
9990        && provenance.raw_source().source_format() == SourceFormatV1::Fbx
9991        && selection.family() == ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY
9992        && selection.profile_revision() == ENGINE_CLIP_BOUNDARY_PROFILE_REVISION
9993        && selection.engine_version() == ENGINE_CLIP_BOUNDARY_ENGINE_VERSION
9994        && selection.importer() == ENGINE_CLIP_BOUNDARY_IMPORTER
9995        && provenance.profile().facts_identity().sha256()
9996            == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
9997        && provenance.profile().facts_identity().bytes() == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
9998        && matches!(
9999            provenance
10000                .profile()
10001                .fact(EngineFactIdV1::WholeEndFrameRequired)
10002                .map(|fact| fact.state()),
10003            Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
10004        )
10005        && provenance
10006            .profile()
10007            .source(ENGINE_CLIP_BOUNDARY_SOURCE_ID)
10008            .is_some()
10009}
10010
10011fn validate_current_engine_clip_boundary_applicability_v3(
10012    check_id: &str,
10013    applicability: Applicability,
10014    provenance: Option<&PredictionProvenanceV3>,
10015) -> Result<(), PredictionContractError> {
10016    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10017        return Ok(());
10018    }
10019    let expected = match provenance {
10020        Some(provenance)
10021            if current_engine_clip_boundary_profile_matches_v3(provenance)
10022                && !(provenance.raw_source().clips_coverage().state()
10023                    == RawSourceSetCoverageStateV1::Complete
10024                    && provenance
10025                        .raw_source()
10026                        .exact_source_timing()
10027                        .is_some_and(|timing| timing.clips().is_empty())) =>
10028        {
10029            Applicability::Applicable
10030        }
10031        _ => Applicability::NotApplicable,
10032    };
10033    if applicability != expected {
10034        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10035    }
10036    Ok(())
10037}
10038
10039/// Re-derive the frozen output-v14 clip-boundary rule from embedded V3
10040/// provenance. This keeps readback and producer construction from accepting a
10041/// merely well-shaped facet whose scope, basis, availability, reason, or
10042/// finding disagrees with the retained exact source timing.
10043fn validate_current_engine_clip_boundary_prediction_v3(
10044    check_id: &str,
10045    prediction: &EnginePredictionV3,
10046    provenance: &PredictionProvenanceV3,
10047    evaluated_scopes: &[EvaluationScope],
10048    finding_scopes: &[&EvaluationScope],
10049) -> Result<(), PredictionContractError> {
10050    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10051        return Ok(());
10052    }
10053
10054    if !current_engine_clip_boundary_profile_matches_v3(provenance) {
10055        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10056    }
10057
10058    let expected_rows = provenance.settings().clips().len();
10059    let timing = provenance.raw_source().exact_source_timing();
10060    if timing.is_some_and(|timing| timing.clips().len() != expected_rows) {
10061        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10062    }
10063    let inventory_incomplete =
10064        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
10065    let has_budget_summary = prediction.has_facet_budget_summary();
10066    let mut seen_rows = vec![false; expected_rows];
10067    let mut row_facets = 0usize;
10068    let mut inventory_facets = 0usize;
10069    let mut available_scopes = Vec::new();
10070    let mut expected_finding_scopes = Vec::new();
10071
10072    for facet in prediction.facets() {
10073        if facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded] {
10074            if facet.basis() != &engine_clip_boundary_inventory_basis(timing)? {
10075                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10076            }
10077            continue;
10078        }
10079        if facet.scope().code.as_str() == "engine_clip_boundary" {
10080            let Some(source_clip_index) = facet
10081                .scope()
10082                .subject
10083                .as_deref()
10084                .and_then(|subject| subject.strip_prefix("source_stack:"))
10085                .and_then(|index| index.parse::<usize>().ok())
10086            else {
10087                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10088            };
10089            if source_clip_index >= expected_rows
10090                || std::mem::replace(&mut seen_rows[source_clip_index], true)
10091            {
10092                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10093            }
10094            row_facets += 1;
10095            let expected_basis = engine_clip_boundary_stack_basis(timing, source_clip_index)?;
10096            if facet.basis() != &expected_basis {
10097                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10098            }
10099
10100            let exact = timing.and_then(|timing| {
10101                let declared = matches!(
10102                    timing.declared_time_mode().state(),
10103                    ExactSourceTimingObservationStateWireV1::Observed(_)
10104                );
10105                let period = match timing.frame_period().state() {
10106                    ExactSourceTimingObservationStateWireV1::Observed(period) => {
10107                        Some(period.units_per_frame())
10108                    }
10109                    _ => None,
10110                };
10111                let end = match timing.clips()[source_clip_index]
10112                    .source_time_range()
10113                    .state()
10114                {
10115                    ExactSourceTimingObservationStateWireV1::Observed(range) => {
10116                        Some(range.end_units())
10117                    }
10118                    _ => None,
10119                };
10120                declared.then_some(())?;
10121                Some((period?, end?))
10122            });
10123            match exact {
10124                Some((period, end)) => {
10125                    if facet.state() != EnginePredictionFacetStateV1::Available
10126                        || !facet.reasons().is_empty()
10127                    {
10128                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10129                    }
10130                    available_scopes.push(facet.scope());
10131                    if end.rem_euclid(period) != 0 {
10132                        expected_finding_scopes.push(facet.scope());
10133                    }
10134                }
10135                None => {
10136                    let expected_reasons =
10137                        engine_clip_boundary_unavailable_reasons(timing, source_clip_index)?;
10138                    if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10139                        || facet.reasons() != expected_reasons
10140                    {
10141                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10142                    }
10143                }
10144            }
10145        } else if facet.scope().code.as_str() == "engine_clip_boundary_inventory"
10146            && facet.scope().subject.is_none()
10147        {
10148            inventory_facets += 1;
10149            if inventory_facets != 1
10150                || !inventory_incomplete
10151                || facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10152                || facet.reasons() != [PredictionUnavailableReasonV2::RawSourceIncomplete]
10153                || facet.basis() != &engine_clip_boundary_inventory_basis(timing)?
10154            {
10155                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10156            }
10157        } else {
10158            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10159        }
10160    }
10161
10162    if seen_rows[..row_facets].iter().any(|seen| !seen)
10163        || seen_rows[row_facets..].iter().any(|seen| *seen)
10164    {
10165        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10166    }
10167    let candidate_facets = row_facets + inventory_facets;
10168    let expected_demand = expected_rows + usize::from(inventory_incomplete);
10169    if has_budget_summary {
10170        if candidate_facets >= expected_demand
10171            || inventory_facets != usize::from(inventory_incomplete && candidate_facets != 0)
10172        {
10173            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10174        }
10175    } else if row_facets != expected_rows || inventory_facets != usize::from(inventory_incomplete) {
10176        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10177    }
10178    if evaluated_scopes.len() != available_scopes.len()
10179        || evaluated_scopes
10180            .iter()
10181            .any(|scope| !available_scopes.contains(&scope))
10182    {
10183        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10184    }
10185    if finding_scopes.len() != expected_finding_scopes.len()
10186        || expected_finding_scopes.iter().any(|expected| {
10187            finding_scopes
10188                .iter()
10189                .filter(|actual| **actual == *expected)
10190                .count()
10191                != 1
10192        })
10193    {
10194        return Err(PredictionContractError::EngineClipBoundaryFindingMismatch);
10195    }
10196    Ok(())
10197}
10198
10199fn engine_clip_boundary_common_basis()
10200-> Result<Vec<PredictionBasisReferenceV2>, PredictionContractError> {
10201    Ok(vec![
10202        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::profile_fact(
10203            "whole_end_frame_required",
10204        )?),
10205        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::primary_source(
10206            ENGINE_CLIP_BOUNDARY_SOURCE_ID,
10207        )?),
10208    ])
10209}
10210
10211fn engine_clip_boundary_exact_reference(
10212    binding: &ExactSourceTimingBindingV1,
10213    domain: ExactSourceTimingDomainV1,
10214    key: ExactSourceTimingKeyV1,
10215    field: &'static str,
10216) -> Result<PredictionBasisReferenceV2, PredictionContractError> {
10217    Ok(PredictionBasisReferenceV2::exact_source_timing(
10218        ExactSourceTimingBasisReferenceV1::from_binding(
10219            domain,
10220            key,
10221            RawSourceFieldIdV1::new(field)?,
10222            binding,
10223        )?,
10224    ))
10225}
10226
10227fn engine_clip_boundary_stack_basis(
10228    timing: Option<&ExactSourceTimingBindingV1>,
10229    source_clip_index: usize,
10230) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10231    let mut references = engine_clip_boundary_common_basis()?;
10232    let Some(timing) = timing else {
10233        return EnginePredictionBasisV2::new(references);
10234    };
10235    let stack_key = ExactSourceTimingKeyV1::Clip {
10236        source_clip_index: source_clip_index as u64,
10237    };
10238    for (domain, key, field) in [
10239        (
10240            ExactSourceTimingDomainV1::Document,
10241            ExactSourceTimingKeyV1::Document,
10242            "declared_time_mode.state",
10243        ),
10244        (
10245            ExactSourceTimingDomainV1::Document,
10246            ExactSourceTimingKeyV1::Document,
10247            "frame_period.state",
10248        ),
10249        (
10250            ExactSourceTimingDomainV1::Clip,
10251            stack_key.clone(),
10252            "source_time_range.state",
10253        ),
10254    ] {
10255        references.push(engine_clip_boundary_exact_reference(
10256            timing, domain, key, field,
10257        )?);
10258    }
10259    if matches!(
10260        timing.declared_time_mode().state(),
10261        ExactSourceTimingObservationStateWireV1::Observed(_)
10262    ) {
10263        references.push(engine_clip_boundary_exact_reference(
10264            timing,
10265            ExactSourceTimingDomainV1::Document,
10266            ExactSourceTimingKeyV1::Document,
10267            "declared_time_mode.value.time_mode",
10268        )?);
10269    }
10270    if matches!(
10271        timing.frame_period().state(),
10272        ExactSourceTimingObservationStateWireV1::Observed(_)
10273    ) {
10274        references.push(engine_clip_boundary_exact_reference(
10275            timing,
10276            ExactSourceTimingDomainV1::Document,
10277            ExactSourceTimingKeyV1::Document,
10278            "frame_period.value.units_per_frame",
10279        )?);
10280    }
10281    if matches!(
10282        timing.clips()[source_clip_index]
10283            .source_time_range()
10284            .state(),
10285        ExactSourceTimingObservationStateWireV1::Observed(_)
10286    ) {
10287        references.push(engine_clip_boundary_exact_reference(
10288            timing,
10289            ExactSourceTimingDomainV1::Clip,
10290            stack_key,
10291            "source_time_range.value.end_units",
10292        )?);
10293    }
10294    EnginePredictionBasisV2::new(references)
10295}
10296
10297fn engine_clip_boundary_inventory_basis(
10298    timing: Option<&ExactSourceTimingBindingV1>,
10299) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10300    let mut references = engine_clip_boundary_common_basis()?;
10301    if let Some(timing) = timing {
10302        for field in ["clip_coverage.state", "clip_coverage.reason"] {
10303            references.push(engine_clip_boundary_exact_reference(
10304                timing,
10305                ExactSourceTimingDomainV1::Document,
10306                ExactSourceTimingKeyV1::Document,
10307                field,
10308            )?);
10309        }
10310    }
10311    EnginePredictionBasisV2::new(references)
10312}
10313
10314fn engine_clip_boundary_unavailable_reasons(
10315    timing: Option<&ExactSourceTimingBindingV1>,
10316    source_clip_index: usize,
10317) -> Result<Vec<PredictionUnavailableReasonV2>, PredictionContractError> {
10318    let Some(timing) = timing else {
10319        return Ok(vec![PredictionUnavailableReasonV2::custom(
10320            "animsmith:exact_source_timing_unavailable",
10321        )?]);
10322    };
10323    let mut reasons = Vec::new();
10324    if !matches!(
10325        timing.declared_time_mode().state(),
10326        ExactSourceTimingObservationStateWireV1::Observed(_)
10327    ) {
10328        reasons.push(PredictionUnavailableReasonV2::custom(
10329            "animsmith:source_declared_time_mode_unavailable",
10330        )?);
10331    }
10332    if !matches!(
10333        timing.frame_period().state(),
10334        ExactSourceTimingObservationStateWireV1::Observed(_)
10335    ) {
10336        reasons.push(PredictionUnavailableReasonV2::custom(
10337            "animsmith:source_frame_period_unavailable",
10338        )?);
10339    }
10340    if !matches!(
10341        timing.clips()[source_clip_index]
10342            .source_time_range()
10343            .state(),
10344        ExactSourceTimingObservationStateWireV1::Observed(_)
10345    ) {
10346        reasons.push(PredictionUnavailableReasonV2::custom(
10347            "animsmith:source_clip_time_range_unavailable",
10348        )?);
10349    }
10350    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
10351    Ok(reasons)
10352}
10353
10354impl MeasurementReportInput {
10355    /// Read one report through the immutable output-v11 byte bound before
10356    /// UTF-8 or JSON parsing.
10357    ///
10358    /// The JSON parser receives at most [`OUTPUT_V11_MAX_REPORT_BYTES`] bytes
10359    /// and retains its recursion limit. This function never performs an
10360    /// unbounded `read_to_end` or constructs a generic JSON value.
10361    ///
10362    /// # Errors
10363    ///
10364    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic contract
10365    /// validation remains in [`Self::into_files`].
10366    pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
10367        Self::read_from_with_limit(reader, OUTPUT_V11_MAX_REPORT_BYTES)
10368    }
10369
10370    fn read_from_with_limit(
10371        reader: impl Read,
10372        limit: u64,
10373    ) -> Result<Self, MeasurementReportReadError> {
10374        let mut bounded = reader.take(limit + 1);
10375        let mut bytes = Vec::new();
10376        bounded
10377            .read_to_end(&mut bytes)
10378            .map_err(|source| MeasurementReportReadError::Io { source })?;
10379        if bytes.len() as u64 > limit {
10380            return Err(MeasurementReportReadError::ReportTooLarge { limit });
10381        }
10382        serde_json::from_slice(&bytes)
10383            .map_err(|source| MeasurementReportReadError::InvalidJson { source })
10384    }
10385
10386    /// Number of file records present before nested record validation.
10387    ///
10388    /// Returns `None` when the report omitted its file array. Consumers can
10389    /// retain this count while [`MeasurementReportInput::into_files`] performs
10390    /// full validation, then apply their own cardinality and error policy.
10391    pub fn file_count(&self) -> Option<usize> {
10392        self.files.as_ref().map(Vec::len)
10393    }
10394
10395    /// Validate current output/measurement identities and recover every file's
10396    /// complete measurement record from a `measure` or `lint` report.
10397    ///
10398    /// File order is preserved. Empty and multi-file reports are accepted so
10399    /// callers can apply their own cardinality policy.
10400    ///
10401    /// # Errors
10402    ///
10403    /// Returns a typed error for a missing or unsupported identity, command,
10404    /// file shape, nested measurement contract, or measurement payload.
10405    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
10406        #[derive(Clone, Copy, PartialEq, Eq)]
10407        enum ReaderRevision {
10408            V11,
10409            V12,
10410            V13,
10411            V14,
10412            V15,
10413            V16,
10414            V17,
10415            V18,
10416        }
10417
10418        let revision = match self.schema_version {
10419            Some(OUTPUT_V11_SCHEMA_VERSION) => ReaderRevision::V11,
10420            Some(OUTPUT_V12_SCHEMA_VERSION) => ReaderRevision::V12,
10421            Some(OUTPUT_V13_SCHEMA_VERSION) => ReaderRevision::V13,
10422            Some(OUTPUT_V14_SCHEMA_VERSION) => ReaderRevision::V14,
10423            Some(OUTPUT_V15_SCHEMA_VERSION) => ReaderRevision::V15,
10424            Some(OUTPUT_V16_SCHEMA_VERSION) => ReaderRevision::V16,
10425            Some(OUTPUT_V17_SCHEMA_VERSION) => ReaderRevision::V17,
10426            Some(OUTPUT_SCHEMA_VERSION) => ReaderRevision::V18,
10427            Some(found) => {
10428                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
10429            }
10430            None => return Err(MeasurementReportError::MissingOutputVersion),
10431        };
10432        let expected_schema = match revision {
10433            ReaderRevision::V11 => OUTPUT_V11_SCHEMA_ID,
10434            ReaderRevision::V12 => OUTPUT_V12_SCHEMA_ID,
10435            ReaderRevision::V13 => OUTPUT_V13_SCHEMA_ID,
10436            ReaderRevision::V14 => OUTPUT_V14_SCHEMA_ID,
10437            ReaderRevision::V15 => OUTPUT_V15_SCHEMA_ID,
10438            ReaderRevision::V16 => OUTPUT_V16_SCHEMA_ID,
10439            ReaderRevision::V17 => OUTPUT_V17_SCHEMA_ID,
10440            ReaderRevision::V18 => OUTPUT_SCHEMA_ID,
10441        };
10442        if self.schema.as_deref() != Some(expected_schema) {
10443            return Err(MeasurementReportError::WrongOutputIdentity);
10444        }
10445        let command = match self.command.as_deref() {
10446            Some(command @ ("measure" | "lint")) => command,
10447            Some(command) => {
10448                return Err(MeasurementReportError::UnsupportedCommand {
10449                    command: command.to_owned(),
10450                });
10451            }
10452            None => return Err(MeasurementReportError::MissingCommand),
10453        };
10454        if let Some(field) = self.extra.keys().next() {
10455            return Err(MeasurementReportError::UnknownOutputField {
10456                field: field.clone(),
10457            });
10458        }
10459        if self._tool.is_none() {
10460            return Err(MeasurementReportError::MissingTool);
10461        }
10462        // The V11 reader retains the same summary obligation as the released
10463        // V1 contract; only its prediction attachment identity differs.
10464        validate_prediction_summary_presence(command, self.summary.as_ref())?;
10465        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
10466        if files.len() > OUTPUT_V11_MAX_FILES {
10467            return Err(MeasurementReportError::TooManyFiles {
10468                found: files.len(),
10469                limit: OUTPUT_V11_MAX_FILES,
10470            });
10471        }
10472        let mut available = 0usize;
10473        let mut unavailable = 0usize;
10474        let mut decoded_files = Vec::with_capacity(files.len());
10475        for (file_index, raw) in files.into_iter().enumerate() {
10476            let file = if revision == ReaderRevision::V11 {
10477                let file = decode_legacy_v11_file(command, file_index, &raw)?;
10478                let (file_available, file_unavailable) =
10479                    validate_legacy_v11_prediction_phase_file(command, file_index, &file)?;
10480                available = available
10481                    .checked_add(file_available)
10482                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10483                unavailable = unavailable
10484                    .checked_add(file_unavailable)
10485                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10486                file
10487            } else if revision == ReaderRevision::V14 {
10488                let file = decode_prediction_phase_file_v14(command, file_index, &raw)?;
10489                let (file_available, file_unavailable) =
10490                    validate_prediction_phase_file_v14(command, file_index, &file)?;
10491                available = available
10492                    .checked_add(file_available)
10493                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10494                unavailable = unavailable
10495                    .checked_add(file_unavailable)
10496                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10497                file
10498            } else if revision == ReaderRevision::V15 {
10499                let file = decode_prediction_phase_file_v15(command, file_index, &raw)?;
10500                let (file_available, file_unavailable) =
10501                    validate_prediction_phase_file_v15(command, file_index, &file)?;
10502                available = available
10503                    .checked_add(file_available)
10504                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10505                unavailable = unavailable
10506                    .checked_add(file_unavailable)
10507                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10508                file
10509            } else if revision == ReaderRevision::V16 {
10510                let file = decode_prediction_phase_file_v16(command, file_index, &raw)?;
10511                let (file_available, file_unavailable) =
10512                    validate_prediction_phase_file_v16(command, file_index, &file)?;
10513                available = available
10514                    .checked_add(file_available)
10515                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10516                unavailable = unavailable
10517                    .checked_add(file_unavailable)
10518                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10519                file
10520            } else if matches!(revision, ReaderRevision::V17 | ReaderRevision::V18) {
10521                let file = decode_prediction_phase_file_v17(command, file_index, &raw)?;
10522                let (file_available, file_unavailable) =
10523                    validate_prediction_phase_file_v17(command, file_index, &file)?;
10524                available = available
10525                    .checked_add(file_available)
10526                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10527                unavailable = unavailable
10528                    .checked_add(file_unavailable)
10529                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10530                file
10531            } else {
10532                let expected_measurement_schema = if revision == ReaderRevision::V12 {
10533                    MEASUREMENTS_V15_SCHEMA_ID
10534                } else {
10535                    MEASUREMENTS_V16_SCHEMA_ID
10536                };
10537                let file = decode_prediction_phase_file(
10538                    command,
10539                    file_index,
10540                    &raw,
10541                    expected_measurement_schema,
10542                )?;
10543                let (file_available, file_unavailable) = validate_prediction_phase_file(
10544                    command,
10545                    file_index,
10546                    &file,
10547                    expected_measurement_schema,
10548                )?;
10549                available = available
10550                    .checked_add(file_available)
10551                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10552                unavailable = unavailable
10553                    .checked_add(file_unavailable)
10554                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10555                file
10556            };
10557            decoded_files.push(file);
10558        }
10559        validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
10560        let parsed = decoded_files
10561            .into_iter()
10562            .enumerate()
10563            .map(|(file_index, file)| {
10564                let path = file.path.ok_or_else(|| {
10565                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
10566                })?;
10567                let input = file.input.ok_or_else(|| {
10568                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
10569                })?;
10570                let sha256 = input.sha256.ok_or_else(|| {
10571                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
10572                })?;
10573                if sha256.len() != 64
10574                    || !sha256
10575                        .bytes()
10576                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
10577                {
10578                    return Err(MeasurementReportError::file(
10579                        file_index,
10580                        MeasurementFileError::InvalidSha256,
10581                    ));
10582                }
10583                let bytes = input.bytes.ok_or_else(|| {
10584                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
10585                })?;
10586                let measurements = file.measurements.ok_or_else(|| {
10587                    MeasurementReportError::file(
10588                        file_index,
10589                        MeasurementFileError::MissingMeasurements,
10590                    )
10591                })?;
10592                let measurements = decode_measurement_payload(
10593                    &measurements,
10594                    matches!(
10595                        revision,
10596                        ReaderRevision::V13
10597                            | ReaderRevision::V14
10598                            | ReaderRevision::V15
10599                            | ReaderRevision::V16
10600                            | ReaderRevision::V17
10601                            | ReaderRevision::V18
10602                    ),
10603                )
10604                .map_err(|source| {
10605                    MeasurementReportError::file(
10606                        file_index,
10607                        MeasurementFileError::InvalidMeasurementsShape {
10608                            reason: source.to_string(),
10609                        },
10610                    )
10611                })?;
10612                let (expected_measurement_version, expected_measurement_schema) = match revision {
10613                    ReaderRevision::V11 | ReaderRevision::V12 => {
10614                        (MEASUREMENTS_V15_SCHEMA_VERSION, MEASUREMENTS_V15_SCHEMA_ID)
10615                    }
10616                    ReaderRevision::V13
10617                    | ReaderRevision::V14
10618                    | ReaderRevision::V15
10619                    | ReaderRevision::V16
10620                    | ReaderRevision::V17 => {
10621                        (MEASUREMENTS_V16_SCHEMA_VERSION, MEASUREMENTS_V16_SCHEMA_ID)
10622                    }
10623                    ReaderRevision::V18 => (MEASUREMENTS_SCHEMA_VERSION, MEASUREMENTS_SCHEMA_ID),
10624                };
10625                match measurements.schema_version {
10626                    Some(found) if found == expected_measurement_version => {}
10627                    Some(found) => {
10628                        return Err(MeasurementReportError::file(
10629                            file_index,
10630                            MeasurementFileError::UnsupportedMeasurementVersion { found },
10631                        ));
10632                    }
10633                    None => {
10634                        return Err(MeasurementReportError::file(
10635                            file_index,
10636                            MeasurementFileError::MissingMeasurementVersion,
10637                        ));
10638                    }
10639                }
10640                if measurements.schema.as_deref() != Some(expected_measurement_schema) {
10641                    return Err(MeasurementReportError::file(
10642                        file_index,
10643                        MeasurementFileError::WrongMeasurementIdentity,
10644                    ));
10645                }
10646                let clips = measurements.clips.ok_or_else(|| {
10647                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
10648                })?;
10649                let material_resource_coverage =
10650                    measurements.material_resource_coverage.ok_or_else(|| {
10651                        MeasurementReportError::file(
10652                            file_index,
10653                            MeasurementFileError::MissingMaterialResourceCoverage,
10654                        )
10655                    })?;
10656                let material_definitions = measurements.material_definitions.ok_or_else(|| {
10657                    MeasurementReportError::file(
10658                        file_index,
10659                        MeasurementFileError::MissingMaterialDefinitions,
10660                    )
10661                })?;
10662                let textures = measurements.textures.ok_or_else(|| {
10663                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
10664                })?;
10665                let images = measurements.images.ok_or_else(|| {
10666                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
10667                })?;
10668                let skeleton_source_coverage =
10669                    measurements.skeleton_source_coverage.ok_or_else(|| {
10670                        MeasurementReportError::file(
10671                            file_index,
10672                            MeasurementFileError::MissingSkeletonSourceCoverage,
10673                        )
10674                    })?;
10675                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
10676                    MeasurementReportError::file(
10677                        file_index,
10678                        MeasurementFileError::MissingSkeletonNodes,
10679                    )
10680                })?;
10681                let skeleton_nodes = skeleton_nodes
10682                    .into_iter()
10683                    .enumerate()
10684                    .map(|(offset, node)| match node {
10685                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
10686                        SkeletonNodeMeasurementInput::Earlier { .. } => {
10687                            Err(MeasurementReportError::file(
10688                                file_index,
10689                                MeasurementFileError::InvalidMeasurements {
10690                                    source: MeasurementContractError::InvalidStructure {
10691                                        path: format!("skeleton_nodes[{offset}]"),
10692                                        reason: "uses a shape from an earlier measurement contract"
10693                                            .into(),
10694                                    },
10695                                },
10696                            ))
10697                        }
10698                    })
10699                    .collect::<Result<Vec<_>, _>>()?;
10700                let skins = measurements.skins.ok_or_else(|| {
10701                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
10702                })?;
10703                let skins = skins
10704                    .into_iter()
10705                    .enumerate()
10706                    .map(|(offset, skin)| match skin {
10707                        SkinMeasurementInput::Current(skin) => Ok(*skin),
10708                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
10709                            file_index,
10710                            MeasurementFileError::InvalidMeasurements {
10711                                source: MeasurementContractError::InvalidStructure {
10712                                    path: format!("skins[{offset}]"),
10713                                    reason: "uses a shape from an earlier measurement contract"
10714                                        .into(),
10715                                },
10716                            },
10717                        )),
10718                    })
10719                    .collect::<Result<Vec<_>, _>>()?;
10720                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
10721                    MeasurementReportError::file(
10722                        file_index,
10723                        MeasurementFileError::MissingMeshDefinitions,
10724                    )
10725                })?;
10726                let node_instances = measurements.node_instances.ok_or_else(|| {
10727                    MeasurementReportError::file(
10728                        file_index,
10729                        MeasurementFileError::MissingNodeInstances,
10730                    )
10731                })?;
10732                let scenes = measurements.scenes.ok_or_else(|| {
10733                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
10734                })?;
10735                let assets = AssetMeasurements {
10736                    material_resource_coverage,
10737                    material_definitions,
10738                    textures,
10739                    images,
10740                    skeleton_source_coverage,
10741                    skeleton_nodes,
10742                    skins,
10743                    mesh_definitions,
10744                    node_instances,
10745                    scenes,
10746                    default_scene_index: measurements.default_scene_index,
10747                };
10748                let measurements = match revision {
10749                    ReaderRevision::V11 | ReaderRevision::V12 => {
10750                        MeasurementContract::historical_v15(clips, assets)
10751                    }
10752                    ReaderRevision::V13
10753                    | ReaderRevision::V14
10754                    | ReaderRevision::V15
10755                    | ReaderRevision::V16
10756                    | ReaderRevision::V17 => MeasurementContract::historical_v16(clips, assets),
10757                    ReaderRevision::V18 => MeasurementContract::new(clips, assets),
10758                }
10759                .map_err(|source| {
10760                    MeasurementReportError::file(
10761                        file_index,
10762                        MeasurementFileError::InvalidMeasurements { source },
10763                    )
10764                })?;
10765                let prediction_measurements = if revision == ReaderRevision::V18 {
10766                    measurements.prediction_v16_projection().map_err(|source| {
10767                        MeasurementReportError::file(
10768                            file_index,
10769                            MeasurementFileError::InvalidMeasurements { source },
10770                        )
10771                    })?
10772                } else {
10773                    measurements.clone()
10774                };
10775                if revision == ReaderRevision::V15 {
10776                    let provenance = file.prediction_provenance_v4.as_present();
10777                    for (check_index, check) in file
10778                        .checks_v3
10779                        .as_deref()
10780                        .unwrap_or_default()
10781                        .iter()
10782                        .enumerate()
10783                    {
10784                        validate_current_engine_unit_scale_prediction_v4(
10785                            &check.check_id,
10786                            check.selection,
10787                            check.configuration,
10788                            check.applicability,
10789                            None,
10790                            None,
10791                            &prediction_measurements,
10792                        )
10793                        .map_err(|source| {
10794                            MeasurementReportError::file(
10795                                file_index,
10796                                MeasurementFileError::InvalidPrediction {
10797                                    check_index,
10798                                    source,
10799                                },
10800                            )
10801                        })?;
10802                    }
10803                    for (check_index, check) in file
10804                        .checks_v4
10805                        .as_deref()
10806                        .unwrap_or_default()
10807                        .iter()
10808                        .enumerate()
10809                    {
10810                        validate_current_engine_unit_scale_prediction_v4(
10811                            &check.check_id,
10812                            check.selection,
10813                            check.configuration,
10814                            check.applicability,
10815                            check.prediction.as_ref(),
10816                            provenance,
10817                            &prediction_measurements,
10818                        )
10819                        .map_err(|source| {
10820                            MeasurementReportError::file(
10821                                file_index,
10822                                MeasurementFileError::InvalidPrediction {
10823                                    check_index,
10824                                    source,
10825                                },
10826                            )
10827                        })?;
10828                    }
10829                }
10830                if matches!(
10831                    revision,
10832                    ReaderRevision::V16 | ReaderRevision::V17 | ReaderRevision::V18
10833                ) && !matches!(file.prediction_provenance_v5, RequiredNullable::Missing)
10834                {
10835                    let provenance = file.prediction_provenance_v5.as_present();
10836                    for (check_index, check) in file
10837                        .checks_v5
10838                        .as_deref()
10839                        .unwrap_or_default()
10840                        .iter()
10841                        .enumerate()
10842                    {
10843                        validate_current_engine_unit_scale_prediction_v5(
10844                            &check.check_id,
10845                            check.selection,
10846                            check.configuration,
10847                            check.applicability,
10848                            check.prediction.as_ref(),
10849                            provenance,
10850                            &prediction_measurements,
10851                        )
10852                        .map_err(|source| {
10853                            MeasurementReportError::file(
10854                                file_index,
10855                                MeasurementFileError::InvalidPrediction {
10856                                    check_index,
10857                                    source,
10858                                },
10859                            )
10860                        })?;
10861                    }
10862                }
10863                if matches!(revision, ReaderRevision::V17 | ReaderRevision::V18)
10864                    && !matches!(file.prediction_provenance_v6, RequiredNullable::Missing)
10865                {
10866                    let provenance = file.prediction_provenance_v6.as_present();
10867                    let rig = file.rig_v17.as_ref().ok_or_else(|| {
10868                        MeasurementReportError::file(
10869                            file_index,
10870                            MeasurementFileError::InvalidFileShape {
10871                                reason: "current role-policy rig evidence was not retained".into(),
10872                            },
10873                        )
10874                    })?;
10875                    for (check_index, check) in file
10876                        .checks_v6
10877                        .as_deref()
10878                        .unwrap_or_default()
10879                        .iter()
10880                        .enumerate()
10881                    {
10882                        validate_current_engine_root_motion_prediction_v6(
10883                            &check.check_id,
10884                            check.selection,
10885                            check.configuration,
10886                            check.applicability,
10887                            check.prediction.as_ref(),
10888                            provenance,
10889                            &check.findings,
10890                            rig,
10891                            &prediction_measurements,
10892                        )
10893                        .map_err(|source| {
10894                            MeasurementReportError::file(
10895                                file_index,
10896                                MeasurementFileError::InvalidPrediction {
10897                                    check_index,
10898                                    source,
10899                                },
10900                            )
10901                        })?;
10902                    }
10903                }
10904                Ok((
10905                    MeasurementReportFile {
10906                        path,
10907                        input: InputIdentity { sha256, bytes },
10908                        measurements,
10909                    },
10910                    (
10911                        file.checks.unwrap_or_default(),
10912                        file.legacy_checks.unwrap_or_default(),
10913                        file.checks_v3.unwrap_or_default(),
10914                        file.checks_v4.unwrap_or_default(),
10915                        file.checks_v5.unwrap_or_default(),
10916                        file.checks_v6.unwrap_or_default(),
10917                    ),
10918                ))
10919            })
10920            .collect::<Result<Vec<_>, _>>()?;
10921
10922        // Measurement-dependent basis pointers are deliberately resolved only
10923        // after every file's complete, version-routed measurements contract has passed.
10924        for (
10925            file_index,
10926            (file, (checks, legacy_checks, checks_v3, checks_v4, checks_v5, checks_v6)),
10927        ) in parsed.iter().enumerate()
10928        {
10929            let prediction_measurements = if revision == ReaderRevision::V18 {
10930                file.measurements
10931                    .prediction_v16_projection()
10932                    .map_err(|source| {
10933                        MeasurementReportError::file(
10934                            file_index,
10935                            MeasurementFileError::InvalidMeasurements { source },
10936                        )
10937                    })?
10938            } else {
10939                file.measurements.clone()
10940            };
10941            validate_measurement_references_batch_v4(
10942                &prediction_measurements,
10943                checks_v6
10944                    .iter()
10945                    .enumerate()
10946                    .filter_map(|(check_index, check)| {
10947                        check
10948                            .prediction
10949                            .as_ref()
10950                            .map(|prediction| (check_index, prediction.base_prediction()))
10951                    }),
10952            )
10953            .map_err(|error| {
10954                MeasurementReportError::file(
10955                    file_index,
10956                    MeasurementFileError::InvalidPrediction {
10957                        check_index: error.prediction_index,
10958                        source: error.source,
10959                    },
10960                )
10961            })?;
10962            validate_measurement_references_batch_v4(
10963                &prediction_measurements,
10964                checks_v5
10965                    .iter()
10966                    .enumerate()
10967                    .filter_map(|(check_index, check)| {
10968                        check
10969                            .prediction
10970                            .as_ref()
10971                            .map(|prediction| (check_index, prediction.base_prediction()))
10972                    }),
10973            )
10974            .map_err(|error| {
10975                MeasurementReportError::file(
10976                    file_index,
10977                    MeasurementFileError::InvalidPrediction {
10978                        check_index: error.prediction_index,
10979                        source: error.source,
10980                    },
10981                )
10982            })?;
10983            validate_measurement_references_batch_v4(
10984                &prediction_measurements,
10985                checks_v4
10986                    .iter()
10987                    .enumerate()
10988                    .filter_map(|(check_index, check)| {
10989                        check
10990                            .prediction
10991                            .as_ref()
10992                            .map(|prediction| (check_index, prediction))
10993                    }),
10994            )
10995            .map_err(|error| {
10996                MeasurementReportError::file(
10997                    file_index,
10998                    MeasurementFileError::InvalidPrediction {
10999                        check_index: error.prediction_index,
11000                        source: error.source,
11001                    },
11002                )
11003            })?;
11004            validate_measurement_references_batch_v3(
11005                &prediction_measurements,
11006                checks_v3
11007                    .iter()
11008                    .enumerate()
11009                    .filter_map(|(check_index, check)| {
11010                        check
11011                            .prediction
11012                            .as_ref()
11013                            .map(|prediction| (check_index, prediction))
11014                    }),
11015            )
11016            .map_err(|error| {
11017                MeasurementReportError::file(
11018                    file_index,
11019                    MeasurementFileError::InvalidPrediction {
11020                        check_index: error.prediction_index,
11021                        source: error.source,
11022                    },
11023                )
11024            })?;
11025            validate_measurement_references_batch_v2(
11026                &prediction_measurements,
11027                checks
11028                    .iter()
11029                    .enumerate()
11030                    .filter_map(|(check_index, check)| {
11031                        check
11032                            .prediction
11033                            .as_ref()
11034                            .map(|prediction| (check_index, prediction))
11035                    }),
11036            )
11037            .map_err(|error| {
11038                MeasurementReportError::file(
11039                    file_index,
11040                    MeasurementFileError::InvalidPrediction {
11041                        check_index: error.prediction_index,
11042                        source: error.source,
11043                    },
11044                )
11045            })?;
11046            validate_measurement_references_batch(
11047                &prediction_measurements,
11048                legacy_checks
11049                    .iter()
11050                    .enumerate()
11051                    .filter_map(|(check_index, check)| {
11052                        check
11053                            .prediction
11054                            .as_ref()
11055                            .map(|prediction| (check_index, prediction))
11056                    }),
11057            )
11058            .map_err(|error| {
11059                MeasurementReportError::file(
11060                    file_index,
11061                    MeasurementFileError::InvalidPrediction {
11062                        check_index: error.prediction_index,
11063                        source: error.source,
11064                    },
11065                )
11066            })?;
11067        }
11068        Ok(parsed.into_iter().map(|(file, _)| file).collect())
11069    }
11070}
11071
11072#[cfg(test)]
11073mod measurement_report_input_tests {
11074    use std::collections::BTreeMap;
11075
11076    use super::*;
11077    use crate::engine_contract::{
11078        EngineClipSettingsV1, EngineConversionControlV1, EngineCoordinateBasisV1, EngineFactIdV1,
11079        EngineFactStateV1, EngineFactValueV1, EngineForwardAxisV1, EngineHandednessV1,
11080        EngineLinearUnitV1, EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1,
11081        EngineUpAxisV1, ResolvedEngineProfileV1, ResolvedEngineSettingsCoverageV2,
11082        ResolvedEngineSettingsV1, ResolvedEngineSettingsV2, ResolvedEngineSettingsWorkV2,
11083    };
11084    use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
11085    use crate::measure::{
11086        AssetMeasurements, ImageMeasurements, MeshDefinitionMeasurements, PrimitiveMeasurements,
11087    };
11088    use crate::prediction::{
11089        EngineMachineResultV1, EnginePredictionBasisV1, EnginePredictionBasisV2,
11090        EnginePredictionBasisV4, EnginePredictionFacetV1, EnginePredictionFacetV2,
11091        EnginePredictionFacetV3, EnginePredictionFacetV4, EnginePredictionV1, EnginePredictionV2,
11092        EnginePredictionV3, EnginePredictionV4, PredictionBasisReferenceV1,
11093        PredictionBasisReferenceV2, PredictionBasisReferenceV4, PredictionProvenanceIdentityV4,
11094        PredictionScalarV1, PredictionUnavailableReasonV1, PredictionUnavailableReasonV2,
11095        RawSourceBindingV1, RawSourceBindingV2, UnitMappingResultV1,
11096    };
11097    use crate::source_facts::SourceFormatV1;
11098    use crate::{
11099        DependencyClosureV1, Document, Finding, ImageSourceKind, ImageUnavailableReason,
11100        MaterialResourceCoverage, ResolvedRoles,
11101    };
11102
11103    fn prediction_test_profile() -> ResolvedEngineProfileV1 {
11104        let all_fact_ids = [
11105            EngineFactIdV1::AcceptedInputs,
11106            EngineFactIdV1::AnimationAddressability,
11107            EngineFactIdV1::AnimationChannelHandling,
11108            EngineFactIdV1::AnimationTargetAddressability,
11109            EngineFactIdV1::AxisConversionControl,
11110            EngineFactIdV1::ConstructHandling,
11111            EngineFactIdV1::ExactAxisConversion,
11112            EngineFactIdV1::ExtensionHandling,
11113            EngineFactIdV1::ResultingHierarchyScale,
11114            EngineFactIdV1::RootMotionAddressability,
11115            EngineFactIdV1::TargetCoordinateBasis,
11116            EngineFactIdV1::TargetLinearUnit,
11117            EngineFactIdV1::UnitConversionControl,
11118            EngineFactIdV1::WholeEndFrameRequired,
11119        ];
11120        let facts = all_fact_ids
11121            .into_iter()
11122            .map(|id| {
11123                let state = if id == EngineFactIdV1::AcceptedInputs {
11124                    EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11125                        SourceFormatV1::Glb,
11126                    ]))
11127                } else {
11128                    EngineFactStateV1::Unknown
11129                };
11130                EngineProfileFactV1::new(id, state)
11131            })
11132            .collect();
11133        ResolvedEngineProfileV1::new(
11134            EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
11135            "urn:animsmith:engine-profile:test:1",
11136            facts,
11137            vec![],
11138            vec![
11139                EnginePrimarySourceV1::new(
11140                    "test-source",
11141                    "1",
11142                    "https://example.invalid/test",
11143                    "2026-08-20",
11144                    vec![EngineFactIdV1::AcceptedInputs],
11145                    vec![],
11146                )
11147                .unwrap(),
11148            ],
11149        )
11150        .unwrap()
11151    }
11152
11153    fn prediction_test_provenance_v2() -> PredictionProvenanceV2 {
11154        let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
11155            "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11156            "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11157            "source_format": "glb",
11158            "linear_unit": {
11159                "state": "observed", "value": 1.0, "disposition": "preserved",
11160                "provenance": {"kind": "format_defined"}
11161            },
11162            "coordinate_basis": {
11163                "state": "observed",
11164                "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11165                "disposition": "preserved", "provenance": {"kind": "format_defined"}
11166            },
11167            "frames_per_second": {
11168                "state": "observed", "value": 30.0, "disposition": "preserved",
11169                "provenance": {"kind": "format_defined"}
11170            },
11171            "clips_coverage": {"state": "complete"},
11172            "constructs_coverage": {"state": "complete"},
11173            "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11174            "source_skeleton_coverage": "unavailable",
11175            "work": {
11176                "inspected_rows": 0, "retained_rows": 0,
11177                "retained_text_bytes": 0, "max_traversal_depth": 0
11178            }
11179        }))
11180        .unwrap();
11181        let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
11182        let profile = prediction_test_profile();
11183        let settings = ResolvedEngineSettingsV2::new(
11184            &profile,
11185            vec![],
11186            vec![],
11187            ResolvedEngineSettingsCoverageV2::complete(),
11188            ResolvedEngineSettingsWorkV2::new(0, 0, 0),
11189        )
11190        .unwrap();
11191        PredictionProvenanceV2::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
11192    }
11193
11194    fn prediction_test_provenance() -> PredictionProvenanceV3 {
11195        let prior = prediction_test_provenance_v2();
11196        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11197            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11198            "source_facts": prior.raw_source(),
11199            "exact_source_timing": null
11200        }))
11201        .unwrap();
11202        PredictionProvenanceV3::new(
11203            prior.profile().clone(),
11204            prior.source_format(),
11205            prior.settings().clone(),
11206            raw,
11207            prior.dependency_closure().clone(),
11208        )
11209        .unwrap()
11210    }
11211
11212    fn partial_engine_provenance() -> PredictionProvenanceV3 {
11213        let complete = prediction_test_provenance();
11214        let mut raw_wire = serde_json::to_value(complete.raw_source().source_facts()).unwrap();
11215        raw_wire["clips_coverage"] = serde_json::json!({
11216            "state": "partial",
11217            "reason": "projection_budget_exceeded"
11218        });
11219        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11220            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11221            "source_facts": raw_wire,
11222            "exact_source_timing": null
11223        }))
11224        .unwrap();
11225        let clips = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
11226            .map(|index| EngineClipSettingsV1::new(format!("clip-{index:04}"), Vec::new()).unwrap())
11227            .collect();
11228        let settings = ResolvedEngineSettingsV2::new(
11229            complete.profile(),
11230            Vec::new(),
11231            clips,
11232            ResolvedEngineSettingsCoverageV2::actual_clip_rows_exceeded(),
11233            ResolvedEngineSettingsWorkV2::new(4_097, 4_096, 4_096),
11234        )
11235        .unwrap();
11236        PredictionProvenanceV3::new(
11237            complete.profile().clone(),
11238            complete.source_format(),
11239            settings,
11240            raw,
11241            complete.dependency_closure().clone(),
11242        )
11243        .unwrap()
11244    }
11245
11246    fn prediction_test_measurements() -> MeasurementContract {
11247        MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11248    }
11249
11250    fn prediction_test_measurements_v16() -> MeasurementContract {
11251        MeasurementContract::historical_v16(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11252    }
11253
11254    fn loop_projection_measurements(
11255        availability: &[MeasurementAvailability],
11256    ) -> MeasurementContract {
11257        let bones = availability
11258            .iter()
11259            .enumerate()
11260            .map(|(index, availability)| match availability {
11261                MeasurementAvailability::Measured => serde_json::json!({
11262                    "bone_index": index,
11263                    "bone_name": format!("bone-{index}"),
11264                    "availability": "measured",
11265                    "position_delta_m": index as f64,
11266                    "rotation_delta_deg": index as f64,
11267                    "seam_velocity_delta_mps": index as f64,
11268                    "seam_angular_velocity_delta_degps": index as f64,
11269                }),
11270                MeasurementAvailability::Unavailable => serde_json::json!({
11271                    "bone_index": index,
11272                    "bone_name": format!("bone-{index}"),
11273                    "availability": "unavailable",
11274                }),
11275                MeasurementAvailability::NotApplicable => {
11276                    panic!("loop-continuity rows are always applicable")
11277                }
11278            })
11279            .collect::<Vec<_>>();
11280        let clip: ClipMeasurements = serde_json::from_value(serde_json::json!({
11281            "duration_s": 1.0,
11282            "frame_count": 3,
11283            "animated_bones": [],
11284            "bone_channels": [],
11285            "bone_rotation_range_deg": {},
11286            "loop_continuity": { "bones": bones },
11287            "loop_continuity_availability": "measured",
11288            "loop_endpoint_mode_availability": "not_applicable",
11289            "frame_grid_availability": "not_applicable",
11290            "loop_seam_ratio_availability": "not_applicable",
11291            "gait_availability": "not_applicable",
11292            "root_trajectory_availability": "not_applicable",
11293            "speed_mps_availability": "not_applicable"
11294        }))
11295        .unwrap();
11296        MeasurementContract::new(
11297            BTreeMap::from([("loop".into(), clip)]),
11298            AssetMeasurements::default(),
11299        )
11300        .unwrap()
11301    }
11302
11303    fn measure_wire(measurements: MeasurementContract) -> serde_json::Value {
11304        let file = MeasureFileReport::new(
11305            "test.glb",
11306            InputIdentity::from_bytes(&[]),
11307            prediction_test_rig(),
11308            measurements,
11309        )
11310        .unwrap();
11311        let envelope =
11312            MeasureEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11313                .unwrap();
11314        let wire = serde_json::to_value(envelope).unwrap();
11315        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11316            .unwrap()
11317            .into_files()
11318            .expect("current measurement fixture reads back");
11319        wire
11320    }
11321
11322    fn primitive_measurement_contract() -> MeasurementContract {
11323        let mut assets = AssetMeasurements::default();
11324        assets.mesh_definitions.push(MeshDefinitionMeasurements {
11325            mesh_index: 0,
11326            name: "mesh".into(),
11327            primitives: Some(vec![
11328                PrimitiveMeasurements {
11329                    primitive_index: 1,
11330                    material_index: Some(7),
11331                    vertex_count: 2,
11332                    finite_vertex_count: 1,
11333                    geometry_aabb: Some(Aabb {
11334                        min: [-2.0, 1.0, 0.0],
11335                        max: [-2.0, 1.0, 0.0],
11336                    }),
11337                    geometry_centroid: Some([-2.0, 1.0, 0.0]),
11338                },
11339                PrimitiveMeasurements {
11340                    primitive_index: 3,
11341                    material_index: None,
11342                    vertex_count: 2,
11343                    finite_vertex_count: 2,
11344                    geometry_aabb: Some(Aabb {
11345                        min: [4.0, 3.0, 0.0],
11346                        max: [6.0, 3.0, 0.0],
11347                    }),
11348                    geometry_centroid: Some([5.0, 3.0, 0.0]),
11349                },
11350            ]),
11351            vertex_count: 4,
11352            geometry_aabb: Some(Aabb {
11353                min: [-2.0, 1.0, 0.0],
11354                max: [6.0, 3.0, 0.0],
11355            }),
11356            geometry_centroid: Some([8.0 / 3.0, 7.0 / 3.0, 0.0]),
11357            max_joints_per_vertex: 0,
11358            weight_sum_min: None,
11359            weight_sum_max: None,
11360            additional_influence_sets: Vec::new(),
11361        });
11362        MeasurementContract::new(BTreeMap::new(), assets).unwrap()
11363    }
11364
11365    fn prediction_test_rig() -> RigInfo {
11366        RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
11367    }
11368
11369    fn basis_v2(basis: EnginePredictionBasisV1) -> EnginePredictionBasisV2 {
11370        EnginePredictionBasisV2::new(
11371            basis
11372                .references()
11373                .iter()
11374                .cloned()
11375                .map(PredictionBasisReferenceV2::v1)
11376                .collect(),
11377        )
11378        .unwrap()
11379    }
11380
11381    fn unavailable_facet(
11382        subject: String,
11383        basis: EnginePredictionBasisV1,
11384    ) -> EnginePredictionFacetV3 {
11385        EnginePredictionFacetV3::required_unavailable(
11386            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
11387                .subject(subject),
11388            basis_v2(basis),
11389            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
11390        )
11391        .unwrap()
11392    }
11393
11394    fn unavailable_check(
11395        check_id: &'static str,
11396        provenance: &PredictionProvenanceV3,
11397        facets: Vec<EnginePredictionFacetV3>,
11398    ) -> CheckEvaluation {
11399        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11400        CheckEvaluation::evaluated(
11401            check_id,
11402            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11403                .with_engine_prediction_v3(prediction),
11404        )
11405        .unwrap()
11406    }
11407
11408    fn unavailable_check_v2(
11409        check_id: &'static str,
11410        provenance: &PredictionProvenanceV2,
11411        facets: Vec<EnginePredictionFacetV2>,
11412    ) -> CheckEvaluation {
11413        let prediction = EnginePredictionV2::new(provenance.identity().clone(), facets).unwrap();
11414        CheckEvaluation::evaluated(
11415            check_id,
11416            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11417                .with_engine_prediction_v2(prediction),
11418        )
11419        .unwrap()
11420    }
11421
11422    fn lint_file(
11423        provenance: &PredictionProvenanceV3,
11424        checks: Vec<CheckEvaluation>,
11425    ) -> Result<LintFileReport, OutputContractError> {
11426        LintFileReport::new(
11427            "limit.glb",
11428            provenance.raw_source().primary_input().clone(),
11429            prediction_test_rig(),
11430            Some(provenance.clone()),
11431            checks,
11432            prediction_test_measurements_v16(),
11433        )
11434    }
11435
11436    #[test]
11437    fn output_v15_rejects_mixed_v3_provenance_and_v4_predictions() {
11438        let provenance = prediction_test_provenance();
11439        let identity: PredictionProvenanceIdentityV4 =
11440            serde_json::from_value(serde_json::to_value(provenance.identity()).unwrap()).unwrap();
11441        let basis = EnginePredictionBasisV4::new(vec![PredictionBasisReferenceV4::v2(
11442            PredictionBasisReferenceV2::v1(
11443                PredictionBasisReferenceV1::profile_fact("accepted_inputs").unwrap(),
11444            ),
11445        )])
11446        .unwrap();
11447        let scope = EvaluationScope::new(EvaluationScopeCode::custom("acme:v4-mixed"));
11448        let facet = EnginePredictionFacetV4::available(
11449            scope.clone(),
11450            basis,
11451            EngineMachineResultV1::UnitMapping(
11452                UnitMappingResultV1::gltf_to_engine_world_length_unit(),
11453            ),
11454        )
11455        .unwrap();
11456        let prediction = EnginePredictionV4::new(identity, vec![facet]).unwrap();
11457        let check = CheckEvaluation::evaluated(
11458            "acme-v4-mixed",
11459            CheckOutput::from_coverage(vec![], vec![scope], vec![])
11460                .with_engine_prediction_v4(prediction),
11461        )
11462        .unwrap();
11463        assert!(matches!(
11464            lint_file(&provenance, vec![check]),
11465            Err(OutputContractError::PredictionRevisionMismatch)
11466        ));
11467    }
11468
11469    #[test]
11470    fn output_v15_preserves_prediction_without_provenance_error_precedence() {
11471        let provenance = prediction_test_provenance();
11472        let check = unavailable_check(
11473            "acme-v3-without-provenance",
11474            &provenance,
11475            vec![unavailable_facet(
11476                "row".to_owned(),
11477                EnginePredictionBasisV1::new(Vec::new()).unwrap(),
11478            )],
11479        );
11480        let error = LintFileReport::new(
11481            "without-provenance.glb",
11482            provenance.raw_source().primary_input().clone(),
11483            prediction_test_rig(),
11484            None,
11485            vec![check],
11486            prediction_test_measurements_v16(),
11487        )
11488        .unwrap_err();
11489        assert_eq!(error, OutputContractError::PredictionWithoutProvenance);
11490    }
11491
11492    #[test]
11493    fn output_v15_rejects_active_unit_scale_without_v4_provenance() {
11494        let check = CheckEvaluation::evaluated(
11495            ENGINE_UNIT_SCALE_CHECK_ID,
11496            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
11497        )
11498        .unwrap();
11499        let error = LintFileReport::new_v4(
11500            "unit-scale-without-provenance.glb",
11501            InputIdentity::from_bytes(&[]),
11502            prediction_test_rig(),
11503            None,
11504            vec![check],
11505            prediction_test_measurements_v16(),
11506        )
11507        .unwrap_err();
11508        assert_eq!(
11509            error,
11510            OutputContractError::InvalidPrediction(
11511                PredictionContractError::EngineUnitScaleFacetMismatch
11512            )
11513        );
11514    }
11515
11516    fn validated_lint_wire(
11517        provenance: &PredictionProvenanceV3,
11518        checks: Vec<CheckEvaluation>,
11519    ) -> serde_json::Value {
11520        let file = lint_file(provenance, checks).expect("producer accepts exact N");
11521        let envelope =
11522            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11523                .unwrap();
11524        let wire = serde_json::to_value(envelope).unwrap();
11525        let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
11526        read.into_files().expect("reader accepts exact N");
11527        wire
11528    }
11529
11530    fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
11531        let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
11532        read.into_files().expect_err("reader must reject N+1")
11533    }
11534
11535    fn clip_boundary_profile() -> ResolvedEngineProfileV1 {
11536        let all_fact_ids = [
11537            EngineFactIdV1::AcceptedInputs,
11538            EngineFactIdV1::AnimationAddressability,
11539            EngineFactIdV1::AnimationChannelHandling,
11540            EngineFactIdV1::AnimationTargetAddressability,
11541            EngineFactIdV1::AxisConversionControl,
11542            EngineFactIdV1::ConstructHandling,
11543            EngineFactIdV1::ExactAxisConversion,
11544            EngineFactIdV1::ExtensionHandling,
11545            EngineFactIdV1::ResultingHierarchyScale,
11546            EngineFactIdV1::RootMotionAddressability,
11547            EngineFactIdV1::TargetCoordinateBasis,
11548            EngineFactIdV1::TargetLinearUnit,
11549            EngineFactIdV1::UnitConversionControl,
11550            EngineFactIdV1::WholeEndFrameRequired,
11551        ];
11552        let facts = all_fact_ids
11553            .into_iter()
11554            .map(|id| {
11555                let state = match id {
11556                    EngineFactIdV1::AcceptedInputs => {
11557                        EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11558                            SourceFormatV1::Fbx,
11559                        ]))
11560                    }
11561                    EngineFactIdV1::TargetCoordinateBasis => EngineFactStateV1::Known(
11562                        EngineFactValueV1::CoordinateBasis(EngineCoordinateBasisV1 {
11563                            handedness: EngineHandednessV1::Left,
11564                            up_axis: EngineUpAxisV1::Z,
11565                            forward_axis: EngineForwardAxisV1::PositiveX,
11566                        }),
11567                    ),
11568                    EngineFactIdV1::TargetLinearUnit => EngineFactStateV1::Known(
11569                        EngineFactValueV1::LinearUnit(EngineLinearUnitV1::Centimetre),
11570                    ),
11571                    EngineFactIdV1::UnitConversionControl
11572                    | EngineFactIdV1::AxisConversionControl => {
11573                        EngineFactStateV1::Known(EngineFactValueV1::ConversionControl(
11574                            EngineConversionControlV1::ImporterOption,
11575                        ))
11576                    }
11577                    EngineFactIdV1::WholeEndFrameRequired => {
11578                        EngineFactStateV1::Known(EngineFactValueV1::Boolean(true))
11579                    }
11580                    _ => EngineFactStateV1::Unknown,
11581                };
11582                EngineProfileFactV1::new(id, state)
11583            })
11584            .collect();
11585        ResolvedEngineProfileV1::new(
11586            EngineProfileSelectionV1::new("unreal", 1, "5.8", "fbx-importer").unwrap(),
11587            "urn:animsmith:engine-profile:unreal:1",
11588            facts,
11589            vec![],
11590            vec![
11591                EnginePrimarySourceV1::new(
11592                    ENGINE_CLIP_BOUNDARY_SOURCE_ID,
11593                    "5.8",
11594                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
11595                    "2026-08-20",
11596                    vec![EngineFactIdV1::WholeEndFrameRequired],
11597                    vec![],
11598                )
11599                .unwrap(),
11600                EnginePrimarySourceV1::new(
11601                    "unreal-coordinate-system-5.8",
11602                    "5.8",
11603                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
11604                    "2026-08-20",
11605                    vec![EngineFactIdV1::TargetCoordinateBasis],
11606                    vec![],
11607                )
11608                .unwrap(),
11609                EnginePrimarySourceV1::new(
11610                    "unreal-fbx-import-options-5.8",
11611                    "5.8",
11612                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
11613                    "2026-08-20",
11614                    vec![
11615                        EngineFactIdV1::AcceptedInputs,
11616                        EngineFactIdV1::UnitConversionControl,
11617                        EngineFactIdV1::AxisConversionControl,
11618                    ],
11619                    vec![],
11620                )
11621                .unwrap(),
11622                EnginePrimarySourceV1::new(
11623                    "unreal-units-5.8",
11624                    "5.8",
11625                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
11626                    "2026-08-20",
11627                    vec![EngineFactIdV1::TargetLinearUnit],
11628                    vec![],
11629                )
11630                .unwrap(),
11631            ],
11632        )
11633        .unwrap()
11634    }
11635
11636    fn exact_observed(value: serde_json::Value, kind: &'static str) -> serde_json::Value {
11637        serde_json::json!({
11638            "state": {"kind": "observed", "value": value},
11639            "disposition": "preserved",
11640            "provenance": {"kind": kind}
11641        })
11642    }
11643
11644    fn clip_boundary_raw_wire(unavailable_last: bool) -> serde_json::Value {
11645        let last_range = if unavailable_last {
11646            serde_json::json!({
11647                "state": {"kind": "unavailable", "value": "malformed"},
11648                "disposition": "baked",
11649                "provenance": null
11650            })
11651        } else {
11652            exact_observed(
11653                serde_json::json!({
11654                    "selection": "primary", "begin_units": 0, "end_units": 9_408_000
11655                }),
11656                "parser_projected",
11657            )
11658        };
11659        serde_json::json!({
11660            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11661            "source_facts": {
11662                "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11663                "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11664                "source_format": "fbx",
11665                "linear_unit": {
11666                    "state": "observed", "value": 0.01, "disposition": "preserved",
11667                    "provenance": {"kind": "format_defined"}
11668                },
11669                "coordinate_basis": {
11670                    "state": "observed",
11671                    "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11672                    "disposition": "preserved", "provenance": {"kind": "format_defined"}
11673                },
11674                "frames_per_second": {
11675                    "state": "observed", "value": 30.0, "disposition": "preserved",
11676                    "provenance": {"kind": "format_defined"}
11677                },
11678                "clips_coverage": {"state": "complete"},
11679                "constructs_coverage": {"state": "complete"},
11680                "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11681                "source_skeleton_coverage": "unavailable",
11682                "work": {
11683                    "inspected_rows": 3, "retained_rows": 3,
11684                    "retained_text_bytes": 0, "max_traversal_depth": 0
11685                }
11686            },
11687            "exact_source_timing": {
11688                "schema": crate::EXACT_SOURCE_TIMING_V1_ID,
11689                "time_basis": exact_observed(
11690                    serde_json::json!({"units_per_second": 141_120_000}),
11691                    "format_defined"
11692                ),
11693                "declared_time_mode": exact_observed(
11694                    serde_json::json!("fps30"), "source_declared"
11695                ),
11696                "effective_time_mode": exact_observed(
11697                    serde_json::json!("fps30"), "parser_projected"
11698                ),
11699                "declared_custom_frame_rate": exact_observed(
11700                    serde_json::json!({"binary64_bits": 30.0_f64.to_bits()}),
11701                    "source_declared"
11702                ),
11703                "frame_period": exact_observed(
11704                    serde_json::json!({"units_per_frame": 4_704_000}),
11705                    "derived_from_source"
11706                ),
11707                "declared_time_protocol": exact_observed(
11708                    serde_json::json!("default"), "source_declared"
11709                ),
11710                "effective_time_protocol": exact_observed(
11711                    serde_json::json!("default"), "parser_projected"
11712                ),
11713                "clip_coverage": {"state": "complete"},
11714                "clips": [
11715                    {
11716                        "source_clip_index": 0,
11717                        "source_time_range": exact_observed(
11718                            serde_json::json!({
11719                                "selection": "primary", "begin_units": 0,
11720                                "end_units": 4_704_000
11721                            }),
11722                            "parser_projected"
11723                        )
11724                    },
11725                    {
11726                        "source_clip_index": 1,
11727                        "source_time_range": exact_observed(
11728                            serde_json::json!({
11729                                "selection": "primary", "begin_units": 0,
11730                                "end_units": 4_704_001
11731                            }),
11732                            "parser_projected"
11733                        )
11734                    },
11735                    {"source_clip_index": 2, "source_time_range": last_range}
11736                ]
11737            }
11738        })
11739    }
11740
11741    fn clip_boundary_provenance(unavailable_last: bool) -> PredictionProvenanceV3 {
11742        let raw: RawSourceBindingV2 =
11743            serde_json::from_value(clip_boundary_raw_wire(unavailable_last)).unwrap();
11744        let profile = clip_boundary_profile();
11745        let clips = (0..3)
11746            .map(|index| EngineClipSettingsV1::new(format!("stack-{index}"), vec![]).unwrap())
11747            .collect();
11748        let settings = ResolvedEngineSettingsV2::new(
11749            &profile,
11750            vec![],
11751            clips,
11752            ResolvedEngineSettingsCoverageV2::complete(),
11753            ResolvedEngineSettingsWorkV2::new(3, 3, 3),
11754        )
11755        .unwrap();
11756        PredictionProvenanceV3::new(
11757            profile,
11758            SourceFormatV1::Fbx,
11759            settings,
11760            raw.clone(),
11761            DependencyClosureV1::unavailable(raw.primary_input().clone()),
11762        )
11763        .unwrap()
11764    }
11765
11766    fn clip_boundary_provenance_with_settings(
11767        provenance: &PredictionProvenanceV3,
11768        profile: ResolvedEngineProfileV1,
11769        clips: Vec<EngineClipSettingsV1>,
11770    ) -> PredictionProvenanceV3 {
11771        let retained = clips.len();
11772        let settings = ResolvedEngineSettingsV2::new(
11773            &profile,
11774            vec![],
11775            clips,
11776            ResolvedEngineSettingsCoverageV2::complete(),
11777            ResolvedEngineSettingsWorkV2::new(retained, retained, retained),
11778        )
11779        .unwrap();
11780        PredictionProvenanceV3::new(
11781            profile,
11782            provenance.source_format(),
11783            settings,
11784            provenance.raw_source().clone(),
11785            provenance.dependency_closure().clone(),
11786        )
11787        .unwrap()
11788    }
11789
11790    fn altered_clip_boundary_source_profile(
11791        provenance: &PredictionProvenanceV3,
11792    ) -> ResolvedEngineProfileV1 {
11793        let sources = provenance
11794            .profile()
11795            .primary_sources()
11796            .iter()
11797            .map(|source| {
11798                let url = if source.id() == ENGINE_CLIP_BOUNDARY_SOURCE_ID {
11799                    format!("{}#altered", source.url())
11800                } else {
11801                    source.url().to_owned()
11802                };
11803                EnginePrimarySourceV1::new(
11804                    source.id(),
11805                    source.target_version(),
11806                    url,
11807                    source.verified_on(),
11808                    source.supported_fact_ids().to_vec(),
11809                    source.supported_setting_ids().to_vec(),
11810                )
11811                .unwrap()
11812            })
11813            .collect();
11814        ResolvedEngineProfileV1::new(
11815            provenance.profile().selection().clone(),
11816            provenance.profile().fact_bundle_urn(),
11817            provenance.profile().facts().to_vec(),
11818            provenance.profile().setting_descriptors().to_vec(),
11819            sources,
11820        )
11821        .unwrap()
11822    }
11823
11824    fn clip_boundary_scope(source_clip_index: usize) -> EvaluationScope {
11825        EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
11826            .subject(format!("source_stack:{source_clip_index}"))
11827    }
11828
11829    struct InapplicableClipBoundaryCheck;
11830
11831    impl crate::Check for InapplicableClipBoundaryCheck {
11832        fn id(&self) -> &'static str {
11833            ENGINE_CLIP_BOUNDARY_CHECK_ID
11834        }
11835
11836        fn applicability(&self, _ctx: &crate::CheckCtx<'_>) -> Applicability {
11837            Applicability::NotApplicable
11838        }
11839
11840        fn evaluate(&self, _ctx: &crate::CheckCtx<'_>) -> CheckOutput {
11841            panic!("an inapplicable check must not be evaluated")
11842        }
11843    }
11844
11845    fn inapplicable_clip_boundary_check() -> CheckEvaluation {
11846        let document = Document::default();
11847        let grids = crate::MetricGrids::new(&document);
11848        let roles = ResolvedRoles::default();
11849        let config = crate::Config::default();
11850        let context = crate::CheckCtx::new(&grids, &roles, &config);
11851        let checks: Vec<Box<dyn crate::Check>> = vec![Box::new(InapplicableClipBoundaryCheck)];
11852        crate::evaluate_checks(&context, &checks, crate::CheckSelection::All)
11853            .unwrap()
11854            .pop()
11855            .unwrap()
11856    }
11857
11858    fn clip_boundary_check(
11859        provenance: &PredictionProvenanceV3,
11860        unavailable_last: bool,
11861        first_basis: Option<EnginePredictionBasisV2>,
11862    ) -> CheckEvaluation {
11863        let timing = provenance.raw_source().exact_source_timing();
11864        let scopes = (0..3).map(clip_boundary_scope).collect::<Vec<_>>();
11865        let mut facets = Vec::new();
11866        for (index, scope) in scopes.iter().cloned().enumerate() {
11867            let basis = if index == 0 {
11868                first_basis
11869                    .clone()
11870                    .unwrap_or_else(|| engine_clip_boundary_stack_basis(timing, index).unwrap())
11871            } else {
11872                engine_clip_boundary_stack_basis(timing, index).unwrap()
11873            };
11874            if unavailable_last && index == 2 {
11875                facets.push(
11876                    EnginePredictionFacetV3::required_unavailable(
11877                        scope,
11878                        basis,
11879                        engine_clip_boundary_unavailable_reasons(timing, index).unwrap(),
11880                    )
11881                    .unwrap(),
11882                );
11883            } else {
11884                facets.push(EnginePredictionFacetV3::available(scope, basis).unwrap());
11885            }
11886        }
11887        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11888        let finding = Finding::new(
11889            ENGINE_CLIP_BOUNDARY_CHECK_ID,
11890            Severity::Warning,
11891            "fractional exact source clip end",
11892        )
11893        .prediction_scope(scopes[1].clone());
11894        let evaluated_scopes = if unavailable_last {
11895            scopes[..2].to_vec()
11896        } else {
11897            scopes
11898        };
11899        CheckEvaluation::evaluated(
11900            ENGINE_CLIP_BOUNDARY_CHECK_ID,
11901            CheckOutput::from_coverage(vec![finding], evaluated_scopes, vec![])
11902                .with_engine_prediction_v3(prediction),
11903        )
11904        .unwrap()
11905    }
11906
11907    fn clip_boundary_lint_wire(unavailable_last: bool) -> serde_json::Value {
11908        let provenance = clip_boundary_provenance(unavailable_last);
11909        let check = clip_boundary_check(&provenance, unavailable_last, None);
11910        let file = LintFileReport::new(
11911            "test.fbx",
11912            provenance.raw_source().primary_input().clone(),
11913            prediction_test_rig(),
11914            Some(provenance),
11915            vec![check],
11916            prediction_test_measurements_v16(),
11917        )
11918        .unwrap();
11919        let envelope =
11920            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11921                .unwrap();
11922        let wire = serde_json::to_value(envelope).unwrap();
11923        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11924            .unwrap()
11925            .into_files()
11926            .unwrap();
11927        wire
11928    }
11929
11930    fn assert_clip_boundary_read_error(wire: serde_json::Value, expected: PredictionContractError) {
11931        assert_eq!(
11932            lint_read_error(wire),
11933            MeasurementReportError::File {
11934                file_index: 0,
11935                source: MeasurementFileError::InvalidPrediction {
11936                    check_index: 0,
11937                    source: expected,
11938                },
11939            }
11940        );
11941    }
11942
11943    #[test]
11944    fn exact_source_raw_source_v2_observed_values_round_trip_and_reject_hostile_mutations() {
11945        let wire = clip_boundary_raw_wire(false);
11946        let binding: RawSourceBindingV2 = serde_json::from_value(wire.clone()).unwrap();
11947        assert_eq!(serde_json::to_value(binding).unwrap(), wire);
11948
11949        let mut invalid_value = wire.clone();
11950        invalid_value["exact_source_timing"]["frame_period"]["state"]["value"]["units_per_frame"] =
11951            serde_json::json!(0);
11952        assert_eq!(
11953            serde_json::from_value::<RawSourceBindingV2>(invalid_value)
11954                .unwrap_err()
11955                .to_string(),
11956            PredictionContractError::ExactSourceTimingValueMismatch.to_string()
11957        );
11958
11959        let mut invalid_coverage = wire.clone();
11960        invalid_coverage["exact_source_timing"]["clip_coverage"] = serde_json::json!({
11961            "state": "partial", "reason": "projection_budget_exceeded"
11962        });
11963        assert_eq!(
11964            serde_json::from_value::<RawSourceBindingV2>(invalid_coverage)
11965                .unwrap_err()
11966                .to_string(),
11967            PredictionContractError::ExactSourceTimingCoverageMismatch.to_string()
11968        );
11969
11970        let mut invalid_prefix = wire;
11971        invalid_prefix["exact_source_timing"]["clips"][1]["source_clip_index"] =
11972            serde_json::json!(2);
11973        assert_eq!(
11974            serde_json::from_value::<RawSourceBindingV2>(invalid_prefix)
11975                .unwrap_err()
11976                .to_string(),
11977            PredictionContractError::ExactSourceTimingClipPrefixMismatch.to_string()
11978        );
11979    }
11980
11981    #[test]
11982    fn clip_boundary_v3_readback_rejects_scope_basis_reason_and_finding_mutations() {
11983        let wire = clip_boundary_lint_wire(false);
11984
11985        let mut wrong_scope = wire.clone();
11986        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["subject"] =
11987            serde_json::json!("source_stack:9");
11988        wrong_scope["files"][0]["checks"][0]["evaluated_scopes"][0]["subject"] =
11989            serde_json::json!("source_stack:9");
11990        assert_clip_boundary_read_error(
11991            wrong_scope,
11992            PredictionContractError::EngineClipBoundaryFacetMismatch,
11993        );
11994
11995        let mut wrong_basis = wire.clone();
11996        wrong_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"] =
11997            serde_json::to_value(
11998                EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap(),
11999            )
12000            .unwrap();
12001        assert_clip_boundary_read_error(
12002            wrong_basis,
12003            PredictionContractError::EngineClipBoundaryFacetMismatch,
12004        );
12005
12006        let mut missing_finding = wire;
12007        missing_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([]);
12008        assert_clip_boundary_read_error(
12009            missing_finding,
12010            PredictionContractError::EngineClipBoundaryFindingMismatch,
12011        );
12012
12013        let mut wrong_reason = clip_boundary_lint_wire(true);
12014        wrong_reason["files"][0]["checks"][0]["prediction"]["facets"][2]["reasons"] =
12015            serde_json::json!(["animsmith:source_frame_period_unavailable"]);
12016        assert_clip_boundary_read_error(
12017            wrong_reason,
12018            PredictionContractError::EngineClipBoundaryFacetMismatch,
12019        );
12020    }
12021
12022    #[test]
12023    fn clip_boundary_v3_rederives_applicability_for_producer_and_readback() {
12024        let provenance = clip_boundary_provenance(false);
12025        assert!(matches!(
12026            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12027            Err(OutputContractError::InvalidPrediction(
12028                PredictionContractError::EngineClipBoundaryFacetMismatch
12029            ))
12030        ));
12031
12032        let mut wire = clip_boundary_lint_wire(false);
12033        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12034        check.insert(
12035            "applicability".to_owned(),
12036            serde_json::json!("not_applicable"),
12037        );
12038        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12039        check.insert("findings".to_owned(), serde_json::json!([]));
12040        check.remove("evaluated_scopes");
12041        check.remove("gaps");
12042        check.remove("prediction");
12043        assert_clip_boundary_read_error(
12044            wire,
12045            PredictionContractError::EngineClipBoundaryFacetMismatch,
12046        );
12047    }
12048
12049    #[test]
12050    fn clip_boundary_v3_applicability_uses_raw_exact_stack_inventory() {
12051        let original = clip_boundary_provenance(false);
12052        let provenance = clip_boundary_provenance_with_settings(
12053            &original,
12054            original.profile().clone(),
12055            Vec::new(),
12056        );
12057        assert_eq!(
12058            provenance
12059                .raw_source()
12060                .exact_source_timing()
12061                .unwrap()
12062                .clips()
12063                .len(),
12064            3
12065        );
12066        assert!(provenance.settings().clips().is_empty());
12067        assert!(matches!(
12068            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12069            Err(OutputContractError::InvalidPrediction(
12070                PredictionContractError::EngineClipBoundaryFacetMismatch
12071            ))
12072        ));
12073
12074        let mut wire = clip_boundary_lint_wire(false);
12075        wire["files"][0]["prediction_provenance"] = serde_json::to_value(provenance).unwrap();
12076        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12077        check.insert(
12078            "applicability".to_owned(),
12079            serde_json::json!("not_applicable"),
12080        );
12081        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12082        check.insert("findings".to_owned(), serde_json::json!([]));
12083        check.remove("evaluated_scopes");
12084        check.remove("gaps");
12085        check.remove("prediction");
12086        assert_clip_boundary_read_error(
12087            wire,
12088            PredictionContractError::EngineClipBoundaryFacetMismatch,
12089        );
12090    }
12091
12092    #[test]
12093    fn clip_boundary_v3_binds_the_frozen_unreal_profile_identity() {
12094        let original = clip_boundary_provenance(false);
12095        assert_eq!(
12096            original.profile().facts_identity().sha256(),
12097            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
12098        );
12099        assert_eq!(
12100            original.profile().facts_identity().bytes(),
12101            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
12102        );
12103        let altered_profile = altered_clip_boundary_source_profile(&original);
12104        assert_ne!(
12105            altered_profile.facts_identity(),
12106            original.profile().facts_identity()
12107        );
12108        let altered = clip_boundary_provenance_with_settings(
12109            &original,
12110            altered_profile,
12111            original.settings().clips().to_vec(),
12112        );
12113        let altered_check = clip_boundary_check(&altered, false, None);
12114        assert!(matches!(
12115            lint_file(&altered, vec![altered_check.clone()]),
12116            Err(OutputContractError::InvalidPrediction(
12117                PredictionContractError::EngineClipBoundaryFacetMismatch
12118            ))
12119        ));
12120
12121        let mut wire = clip_boundary_lint_wire(false);
12122        wire["files"][0]["prediction_provenance"] = serde_json::to_value(altered).unwrap();
12123        wire["files"][0]["checks"][0] = serde_json::to_value(altered_check).unwrap();
12124        assert_clip_boundary_read_error(
12125            wire,
12126            PredictionContractError::EngineClipBoundaryFacetMismatch,
12127        );
12128    }
12129
12130    #[test]
12131    fn clip_boundary_v3_producer_rejects_incomplete_exact_basis() {
12132        let provenance = clip_boundary_provenance(false);
12133        let incomplete_basis =
12134            EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap();
12135        let check = clip_boundary_check(&provenance, false, Some(incomplete_basis));
12136        assert!(matches!(
12137            lint_file(&provenance, vec![check]),
12138            Err(OutputContractError::InvalidPrediction(
12139                PredictionContractError::EngineClipBoundaryFacetMismatch
12140            ))
12141        ));
12142    }
12143
12144    fn prediction_with_retained_text(
12145        provenance: &PredictionProvenanceV3,
12146        retained_text: usize,
12147    ) -> EnginePredictionV3 {
12148        const FIELD_ID_BYTES: usize = 16;
12149        const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
12150        let fixed = "test:prediction-limit".len()
12151            + PredictionUnavailableReasonV2::ProjectIntentUnavailable
12152                .as_str()
12153                .len();
12154        let remaining = retained_text.checked_sub(fixed).unwrap();
12155        let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
12156        let full_rows = remaining / full_row;
12157        let remainder = remaining % full_row;
12158        let (full_rows, tail_lengths) = if remainder == 0 {
12159            (full_rows, Vec::new())
12160        } else if remainder >= FIELD_ID_BYTES {
12161            (full_rows, vec![remainder - FIELD_ID_BYTES])
12162        } else {
12163            (
12164                full_rows - 1,
12165                vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
12166            )
12167        };
12168        let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
12169        for index in 0..full_rows {
12170            references.push(
12171                PredictionBasisReferenceV1::project_field(
12172                    format!("f{index:015}"),
12173                    PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
12174                )
12175                .unwrap(),
12176            );
12177        }
12178        for length in tail_lengths {
12179            let index = references.len();
12180            references.push(
12181                PredictionBasisReferenceV1::project_field(
12182                    format!("f{index:015}"),
12183                    PredictionScalarV1::text("x".repeat(length)).unwrap(),
12184                )
12185                .unwrap(),
12186            );
12187        }
12188        let basis = EnginePredictionBasisV1::new(references).unwrap();
12189        let facet = EnginePredictionFacetV3::required_unavailable(
12190            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
12191            basis_v2(basis),
12192            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12193        )
12194        .unwrap();
12195        let prediction =
12196            EnginePredictionV3::new(provenance.identity().clone(), vec![facet]).unwrap();
12197        assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
12198        prediction
12199    }
12200
12201    #[test]
12202    fn report_reader_enforces_the_byte_cap_before_json_parsing() {
12203        let bytes = br#"{"schema_version":10,"tool":{}}"#;
12204        let report =
12205            MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
12206                .expect("exact N must parse");
12207        assert_eq!(report.schema_version, Some(10));
12208
12209        assert!(matches!(
12210            MeasurementReportInput::read_from_with_limit(
12211                bytes.as_slice(),
12212                bytes.len() as u64 - 1,
12213            ),
12214            Err(MeasurementReportReadError::ReportTooLarge { limit })
12215                if limit == bytes.len() as u64 - 1
12216        ));
12217    }
12218
12219    #[test]
12220    fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12221        let provenance = prediction_test_provenance();
12222        let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12223        let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
12224            .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
12225            .collect();
12226        let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
12227        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12228        let extra = unavailable_check(
12229            "test:facet-extra",
12230            &provenance,
12231            vec![unavailable_facet("facet-extra".into(), empty_basis)],
12232        );
12233
12234        assert_eq!(
12235            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12236            OutputContractError::TooManyPredictionFacets {
12237                found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12238                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12239            }
12240        );
12241
12242        wire["files"][0]["checks"]
12243            .as_array_mut()
12244            .unwrap()
12245            .push(serde_json::to_value(extra).unwrap());
12246        assert_eq!(
12247            lint_read_error(wire),
12248            MeasurementReportError::File {
12249                file_index: 0,
12250                source: MeasurementFileError::TooManyPredictionFacets {
12251                    found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12252                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12253                },
12254            }
12255        );
12256    }
12257
12258    #[test]
12259    fn v2_budget_summary_is_canonical_and_requires_an_exhausted_file_budget() {
12260        let provenance = prediction_test_provenance();
12261        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12262        let mut facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12263            .map(|index| unavailable_facet(format!("facet-{index:04}"), basis.clone()))
12264            .collect::<Vec<_>>();
12265        facets.push(
12266            EnginePredictionFacetV3::required_unavailable(
12267                EvaluationScope::new(EvaluationScopeCode::custom("test:budget:facet-budget")),
12268                basis_v2(basis),
12269                vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12270            )
12271            .unwrap(),
12272        );
12273        let check = unavailable_check("test:budget", &provenance, facets);
12274        let wire = validated_lint_wire(&provenance, vec![check]);
12275        let facets = wire["files"][0]["checks"][0]["prediction"]["facets"]
12276            .as_array()
12277            .unwrap();
12278        let summary_index = facets
12279            .iter()
12280            .position(|facet| facet["reasons"] == serde_json::json!(["facet_budget_exceeded"]))
12281            .unwrap();
12282
12283        let mut wrong_scope = wire.clone();
12284        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["code"] =
12285            serde_json::json!("test:wrong:facet-budget");
12286        assert!(matches!(
12287            lint_read_error(wrong_scope),
12288            MeasurementReportError::File {
12289                source: MeasurementFileError::InvalidPrediction {
12290                    source: PredictionContractError::InvalidFacetBudgetSummary,
12291                    ..
12292                },
12293                ..
12294            }
12295        ));
12296
12297        let mut subject = wire.clone();
12298        subject["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["subject"] =
12299            serde_json::json!("forged");
12300        assert!(matches!(
12301            lint_read_error(subject),
12302            MeasurementReportError::File {
12303                source: MeasurementFileError::InvalidPrediction {
12304                    source: PredictionContractError::InvalidFacetBudgetSummary,
12305                    ..
12306                },
12307                ..
12308            }
12309        ));
12310
12311        let mut available = wire.clone();
12312        available["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["state"] =
12313            serde_json::json!("available");
12314        let available_error = lint_read_error(available);
12315        assert!(
12316            matches!(
12317                available_error,
12318                MeasurementReportError::File {
12319                    source: MeasurementFileError::InvalidPrediction {
12320                        source: PredictionContractError::AvailableBasisEmpty,
12321                        ..
12322                    },
12323                    ..
12324                }
12325            ),
12326            "unexpected available mutation: {available_error:?}"
12327        );
12328
12329        let mut duplicate = wire.clone();
12330        let duplicate_summary =
12331            duplicate["files"][0]["checks"][0]["prediction"]["facets"][summary_index].clone();
12332        duplicate["files"][0]["checks"][0]["prediction"]["facets"]
12333            [if summary_index == 0 { 1 } else { 0 }] = duplicate_summary;
12334        assert!(matches!(
12335            lint_read_error(duplicate),
12336            MeasurementReportError::File {
12337                source: MeasurementFileError::InvalidPrediction {
12338                    source: PredictionContractError::DuplicateFacetScope,
12339                    ..
12340                },
12341                ..
12342            }
12343        ));
12344
12345        let mut under_full = wire;
12346        under_full["files"][0]["checks"][0]["prediction"]["facets"]
12347            .as_array_mut()
12348            .unwrap()
12349            .remove(if summary_index == 0 { 1 } else { 0 });
12350        under_full["summary"]["prediction_facets"]["required_prediction_unavailable"] =
12351            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE - 1);
12352        assert_eq!(
12353            lint_read_error(under_full),
12354            MeasurementReportError::File {
12355                file_index: 0,
12356                source: MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
12357                    found: PREDICTION_V1_MAX_FACETS_PER_FILE - 1,
12358                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12359                },
12360            }
12361        );
12362    }
12363
12364    #[test]
12365    fn partial_engine_inventory_can_be_replaced_by_its_budget_summary() {
12366        let provenance = partial_engine_provenance();
12367        let summary = EnginePredictionFacetV3::required_unavailable(
12368            EvaluationScope::new(EvaluationScopeCode::custom(
12369                "engine-addressability:facet-budget",
12370            )),
12371            basis_v2(EnginePredictionBasisV1::new(Vec::new()).unwrap()),
12372            vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12373        )
12374        .unwrap();
12375        let engine = unavailable_check("engine-addressability", &provenance, vec![summary]);
12376        let filler_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12377        let filler = unavailable_check(
12378            "test:filler",
12379            &provenance,
12380            (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12381                .map(|index| unavailable_facet(format!("filler-{index:04}"), filler_basis.clone()))
12382                .collect(),
12383        );
12384        // A capacity-zero incomplete inventory is represented by the one
12385        // canonical summary while other rules consume the retained slots.
12386        let wire = validated_lint_wire(&provenance, vec![engine, filler]);
12387        assert_eq!(
12388            wire["summary"]["prediction_facets"]["required_prediction_unavailable"],
12389            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE)
12390        );
12391    }
12392
12393    #[test]
12394    fn engine_addressability_inventory_reasons_follow_raw_and_settings_coverage() {
12395        let partial = partial_engine_provenance();
12396        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12397        let inventory = EnginePredictionFacetV3::required_unavailable(
12398            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12399            basis_v2(basis.clone()),
12400            vec![
12401                PredictionUnavailableReasonV2::RawSourceIncomplete,
12402                PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
12403            ],
12404        )
12405        .unwrap();
12406        let check = unavailable_check("engine-addressability", &partial, vec![inventory]);
12407        let mut wire = validated_lint_wire(&partial, vec![check]);
12408        wire["files"][0]["checks"][0]["prediction"]["facets"][0]["reasons"] =
12409            serde_json::json!(["raw_source_incomplete"]);
12410        assert!(matches!(
12411            lint_read_error(wire),
12412            MeasurementReportError::File {
12413                source: MeasurementFileError::InvalidPrediction {
12414                    source: PredictionContractError::EngineAddressabilityInventoryReasonsMismatch,
12415                    ..
12416                },
12417                ..
12418            }
12419        ));
12420
12421        let complete = prediction_test_provenance();
12422        let forged = EnginePredictionFacetV3::required_unavailable(
12423            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12424            basis_v2(basis),
12425            vec![PredictionUnavailableReasonV2::ResolvedSettingsOverflow],
12426        )
12427        .unwrap();
12428        assert!(matches!(
12429            lint_file(
12430                &complete,
12431                vec![unavailable_check(
12432                    "engine-addressability",
12433                    &complete,
12434                    vec![forged]
12435                )],
12436            ),
12437            Err(OutputContractError::InvalidPrediction(
12438                PredictionContractError::EngineAddressabilityInventoryReasonsMismatch
12439            ))
12440        ));
12441    }
12442
12443    #[test]
12444    fn engine_addressability_rejects_a_non_addressability_available_facet_prefix() {
12445        let mut wire = clip_boundary_lint_wire(false);
12446        let check = &mut wire["files"][0]["checks"][0];
12447        check["check_id"] = serde_json::json!("engine-addressability");
12448        for (index, facet) in check["prediction"]["facets"]
12449            .as_array_mut()
12450            .unwrap()
12451            .iter_mut()
12452            .enumerate()
12453        {
12454            facet["scope"]["code"] = serde_json::json!("animation_asset_label");
12455            facet["scope"]["subject"] = serde_json::json!(format!("Animation{index}"));
12456        }
12457        for (index, scope) in check["evaluated_scopes"]
12458            .as_array_mut()
12459            .unwrap()
12460            .iter_mut()
12461            .enumerate()
12462        {
12463            scope["code"] = serde_json::json!("animation_asset_label");
12464            scope["subject"] = serde_json::json!(format!("Animation{index}"));
12465        }
12466        check["findings"][0]["check_id"] = serde_json::json!("engine-addressability");
12467        check["findings"][0]["prediction_scope"]["code"] =
12468            serde_json::json!("animation_asset_label");
12469        check["findings"][0]["prediction_scope"]["subject"] = serde_json::json!("Animation1");
12470
12471        assert!(matches!(
12472            lint_read_error(wire),
12473            MeasurementReportError::File {
12474                source: MeasurementFileError::InvalidPrediction {
12475                    source: PredictionContractError::EngineAddressabilityFacetPrefixMismatch,
12476                    ..
12477                },
12478                ..
12479            }
12480        ));
12481    }
12482
12483    #[test]
12484    fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12485        let provenance = prediction_test_provenance();
12486        let basis = EnginePredictionBasisV1::new(
12487            (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
12488                .map(|index| {
12489                    PredictionBasisReferenceV1::project_field(
12490                        format!("project.field.{index:04}"),
12491                        PredictionScalarV1::Null,
12492                    )
12493                    .unwrap()
12494                })
12495                .collect(),
12496        )
12497        .unwrap();
12498        let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
12499            / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
12500        let facets = (0..facet_count)
12501            .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
12502            .collect();
12503        let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
12504        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12505        let extra_basis = EnginePredictionBasisV1::new(vec![
12506            PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
12507                .unwrap(),
12508        ])
12509        .unwrap();
12510        let extra = unavailable_check(
12511            "test:basis-extra",
12512            &provenance,
12513            vec![unavailable_facet("basis-extra".into(), extra_basis)],
12514        );
12515
12516        assert_eq!(
12517            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12518            OutputContractError::TooManyPredictionBasisReferences {
12519                found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
12520                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
12521            }
12522        );
12523
12524        wire["files"][0]["checks"]
12525            .as_array_mut()
12526            .unwrap()
12527            .push(serde_json::to_value(extra).unwrap());
12528        *wire["files"][0]["checks"]
12529            .as_array_mut()
12530            .unwrap()
12531            .last_mut()
12532            .unwrap()
12533            .get_mut("prediction")
12534            .unwrap()
12535            .get_mut("facets")
12536            .and_then(serde_json::Value::as_array_mut)
12537            .and_then(|facets| facets.first_mut())
12538            .and_then(|facet| facet.get_mut("basis"))
12539            .and_then(|basis| basis.get_mut("references"))
12540            .and_then(serde_json::Value::as_array_mut)
12541            .and_then(|references| references.first_mut())
12542            .unwrap() = serde_json::Value::Null;
12543        assert!(matches!(
12544            lint_read_error(wire),
12545            MeasurementReportError::File {
12546                file_index: 0,
12547                source: MeasurementFileError::TooManyPredictionBasisReferences { .. },
12548            }
12549        ));
12550    }
12551
12552    #[test]
12553    fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12554        let provenance = prediction_test_provenance();
12555        let provenance_text = provenance.retained_text_bytes().unwrap();
12556        let at_limit_prediction = prediction_with_retained_text(
12557            &provenance,
12558            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
12559        );
12560        let at_limit = CheckEvaluation::evaluated(
12561            "test:text-limit",
12562            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12563                .with_engine_prediction_v3(at_limit_prediction),
12564        )
12565        .unwrap();
12566        let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
12567
12568        let above_limit_prediction = prediction_with_retained_text(
12569            &provenance,
12570            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
12571        );
12572        let above_limit = CheckEvaluation::evaluated(
12573            "test:text-limit",
12574            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12575                .with_engine_prediction_v3(above_limit_prediction),
12576        )
12577        .unwrap();
12578        let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
12579        assert_eq!(
12580            lint_file(&provenance, vec![above_limit]).unwrap_err(),
12581            OutputContractError::TooMuchPredictionText {
12582                found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12583                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12584            }
12585        );
12586
12587        wire["files"][0]["checks"][0] = above_limit_wire;
12588        assert_eq!(
12589            lint_read_error(wire),
12590            MeasurementReportError::File {
12591                file_index: 0,
12592                source: MeasurementFileError::TooMuchPredictionText {
12593                    found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12594                    limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12595                },
12596            }
12597        );
12598    }
12599
12600    fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
12601        serde_json::from_value::<MeasurementReportInput>(wire)
12602            .expect("outer v11 shape remains valid")
12603            .into_files()
12604            .expect_err("mutated report must fail")
12605    }
12606
12607    fn empty_check(check_id: &'static str) -> CheckEvaluation {
12608        CheckEvaluation::evaluated(
12609            check_id,
12610            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
12611        )
12612        .unwrap()
12613    }
12614
12615    #[test]
12616    fn staged_reader_rejects_unknown_root_file_and_check_fields() {
12617        let provenance = prediction_test_provenance();
12618        let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12619
12620        let mut root = wire.clone();
12621        root["unknown_root"] = serde_json::json!(true);
12622        let bytes = serde_json::to_vec(&root).unwrap();
12623        assert_eq!(
12624            MeasurementReportInput::read_from(bytes.as_slice())
12625                .expect("unknown root fields are retained through the staged read")
12626                .into_files()
12627                .unwrap_err(),
12628            MeasurementReportError::UnknownOutputField {
12629                field: "unknown_root".into(),
12630            }
12631        );
12632
12633        let mut missing_tool = wire.clone();
12634        missing_tool.as_object_mut().unwrap().remove("tool");
12635        assert_eq!(
12636            reader_error(missing_tool),
12637            MeasurementReportError::MissingTool
12638        );
12639
12640        let bare = br#"{"walk":true}"#;
12641        assert_eq!(
12642            MeasurementReportInput::read_from(bare.as_slice())
12643                .expect("unknown root fields remain staged until header validation")
12644                .into_files()
12645                .unwrap_err(),
12646            MeasurementReportError::MissingOutputVersion,
12647        );
12648
12649        let unsupported = br#"{"schema_version":9,"walk":true}"#;
12650        assert_eq!(
12651            MeasurementReportInput::read_from(unsupported.as_slice())
12652                .expect("unknown root fields remain staged until header validation")
12653                .into_files()
12654                .unwrap_err(),
12655            MeasurementReportError::UnsupportedOutputVersion { found: 9 },
12656        );
12657
12658        let mut file = wire.clone();
12659        file["files"][0]["unknown_file"] = serde_json::json!(true);
12660        assert!(matches!(
12661            reader_error(file),
12662            MeasurementReportError::File {
12663                file_index: 0,
12664                source: MeasurementFileError::InvalidFileShape { reason },
12665            } if reason.contains("unknown field `unknown_file`")
12666        ));
12667
12668        let mut check = wire;
12669        check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
12670        assert!(matches!(
12671            reader_error(check),
12672            MeasurementReportError::File {
12673                file_index: 0,
12674                source: MeasurementFileError::InvalidPredictionShape {
12675                    check_index: 0,
12676                    reason,
12677                },
12678            } if reason.contains("unknown field `unknown_check`")
12679        ));
12680
12681        let provenance = prediction_test_provenance();
12682        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12683        summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
12684        let bytes = serde_json::to_vec(&summary).unwrap();
12685        assert!(matches!(
12686            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12687            MeasurementReportReadError::InvalidJson { source }
12688                if source.to_string().contains("unknown field `unknown_prediction_total`")
12689        ));
12690
12691        let provenance = prediction_test_provenance();
12692        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12693        summary["summary"]["unknown_summary"] = serde_json::json!(0);
12694        let bytes = serde_json::to_vec(&summary).unwrap();
12695        assert!(matches!(
12696            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12697            MeasurementReportReadError::InvalidJson { source }
12698                if source.to_string().contains("unknown field `unknown_summary`")
12699        ));
12700    }
12701
12702    #[test]
12703    fn staged_reader_preserves_typed_prediction_semantic_errors() {
12704        let provenance = prediction_test_provenance();
12705        let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
12706        provenance_wire["files"][0]["prediction_provenance"]["schema"] =
12707            serde_json::json!("urn:changed");
12708        assert!(matches!(
12709            reader_error(provenance_wire),
12710            MeasurementReportError::File {
12711                file_index: 0,
12712                source: MeasurementFileError::InvalidPredictionProvenance { .. },
12713            }
12714        ));
12715
12716        let basis = EnginePredictionBasisV1::new(vec![
12717            PredictionBasisReferenceV1::project_field(
12718                "test:project",
12719                PredictionScalarV1::Boolean { value: true },
12720            )
12721            .unwrap(),
12722        ])
12723        .unwrap();
12724        let facet = EnginePredictionFacetV3::required_unavailable(
12725            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12726            basis_v2(basis),
12727            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12728        )
12729        .unwrap();
12730        let prediction_wire = validated_lint_wire(
12731            &provenance,
12732            vec![unavailable_check("test:reader", &provenance, vec![facet])],
12733        );
12734
12735        let mut wrong_emitter = prediction_wire.clone();
12736        wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12737            serde_json::json!("member_existence");
12738        assert!(matches!(
12739            reader_error(wrong_emitter),
12740            MeasurementReportError::File {
12741                file_index: 0,
12742                source: MeasurementFileError::InvalidPredictionLifecycle {
12743                    check_index: 0,
12744                    reason: "prediction facet scope code is invalid for its parent check",
12745                },
12746            }
12747        ));
12748
12749        let mut empty_scope = prediction_wire.clone();
12750        empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12751            serde_json::json!("");
12752        assert!(matches!(
12753            reader_error(empty_scope),
12754            MeasurementReportError::File {
12755                file_index: 0,
12756                source: MeasurementFileError::InvalidPrediction { check_index: 0, .. },
12757            }
12758        ));
12759
12760        let mut prediction_wire = prediction_wire;
12761        prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
12762            ["bytes"] = serde_json::json!(0);
12763        assert!(matches!(
12764            reader_error(prediction_wire),
12765            MeasurementReportError::File {
12766                file_index: 0,
12767                source: MeasurementFileError::InvalidPrediction {
12768                    check_index: 0,
12769                    source: PredictionContractError::IdentityMismatch {
12770                        contract: "engine prediction basis v2",
12771                    },
12772                },
12773            }
12774        ));
12775    }
12776
12777    #[test]
12778    fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
12779        let provenance = prediction_test_provenance();
12780        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12781
12782        for (field, state) in [
12783            ("selection", "unselected"),
12784            ("configuration", "disabled"),
12785            ("applicability", "not_applicable"),
12786        ] {
12787            let mut inactive = base.clone();
12788            inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
12789            assert!(matches!(
12790                reader_error(inactive),
12791                MeasurementReportError::File {
12792                    file_index: 0,
12793                    source: MeasurementFileError::InvalidPredictionLifecycle {
12794                        check_index: 0,
12795                        reason: "evaluation does not match completed and missing prediction work",
12796                    },
12797                }
12798            ));
12799        }
12800
12801        let mut inactive = base.clone();
12802        inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
12803        inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12804        serde_json::from_value::<MeasurementReportInput>(inactive)
12805            .unwrap()
12806            .into_files()
12807            .expect("empty inactive record is valid");
12808
12809        let mut not_evaluated = base.clone();
12810        not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12811            "code": "test:missing",
12812            "message": "missing",
12813        }]);
12814        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12815        serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
12816            .unwrap()
12817            .into_files()
12818            .expect("missing-only active record derives not_evaluated");
12819        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
12820        assert!(matches!(
12821            reader_error(not_evaluated),
12822            MeasurementReportError::File {
12823                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12824                ..
12825            }
12826        ));
12827
12828        let mut partial = base.clone();
12829        partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12830            "code": "test:missing",
12831            "message": "missing",
12832        }]);
12833        partial["files"][0]["checks"][0]["evaluated_scopes"] =
12834            serde_json::json!([{ "code": "test:completed" }]);
12835        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12836        serde_json::from_value::<MeasurementReportInput>(partial.clone())
12837            .unwrap()
12838            .into_files()
12839            .expect("mixed active record derives partial");
12840        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
12841        assert!(matches!(
12842            reader_error(partial),
12843            MeasurementReportError::File {
12844                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12845                ..
12846            }
12847        ));
12848
12849        let mut wrong_complete = base;
12850        wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12851        assert!(matches!(
12852            reader_error(wrong_complete),
12853            MeasurementReportError::File {
12854                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12855                ..
12856            }
12857        ));
12858    }
12859
12860    #[test]
12861    fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
12862        let provenance = prediction_test_provenance();
12863        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12864
12865        let mut empty_scope = base.clone();
12866        empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
12867            serde_json::json!([{ "code": "" }]);
12868        assert!(matches!(
12869            reader_error(empty_scope),
12870            MeasurementReportError::File {
12871                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12872                ..
12873            }
12874        ));
12875
12876        let mut malformed_gap = base.clone();
12877        malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12878            "code": "",
12879            "message": "missing",
12880        }]);
12881        malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12882        assert!(matches!(
12883            reader_error(malformed_gap),
12884            MeasurementReportError::File {
12885                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12886                ..
12887            }
12888        ));
12889
12890        let mut incomplete_finding = base;
12891        incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
12892            "check_id": "test:reader",
12893        }]);
12894        assert!(matches!(
12895            reader_error(incomplete_finding),
12896            MeasurementReportError::File {
12897                source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
12898                ..
12899            }
12900        ));
12901    }
12902
12903    #[test]
12904    fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
12905        let provenance = prediction_test_provenance();
12906        let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12907        let mut later_file = wire["files"][0].clone();
12908        later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
12909        wire["files"].as_array_mut().unwrap().push(later_file);
12910        wire["summary"]["files"] = serde_json::json!(2);
12911        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12912
12913        assert!(matches!(
12914            reader_error(wire),
12915            MeasurementReportError::File {
12916                file_index: 0,
12917                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12918            }
12919        ));
12920    }
12921
12922    #[test]
12923    fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
12924        let provenance = prediction_test_provenance();
12925        let basis = EnginePredictionBasisV1::new(vec![
12926            PredictionBasisReferenceV1::project_field(
12927                "test:project",
12928                PredictionScalarV1::Boolean { value: true },
12929            )
12930            .unwrap(),
12931        ])
12932        .unwrap();
12933        let facet = EnginePredictionFacetV3::required_unavailable(
12934            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12935            basis_v2(basis),
12936            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12937        )
12938        .unwrap();
12939        let mut wire = validated_lint_wire(
12940            &provenance,
12941            vec![
12942                empty_check("test:first"),
12943                unavailable_check("test:second", &provenance, vec![facet]),
12944            ],
12945        );
12946        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12947        wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
12948
12949        assert!(matches!(
12950            reader_error(wire),
12951            MeasurementReportError::File {
12952                file_index: 0,
12953                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12954            }
12955        ));
12956    }
12957
12958    #[test]
12959    fn v11_nested_version_is_rejected_before_current_shape_decode() {
12960        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
12961            "schema_version": OUTPUT_SCHEMA_VERSION,
12962            "schema": OUTPUT_SCHEMA_ID,
12963            "tool": {},
12964            "command": "measure",
12965            "files": [{
12966                "path": "measurements-v11.json",
12967                "input": { "sha256": "0".repeat(64), "bytes": 0 },
12968                "rig": {},
12969                "measurements": {
12970                    "schema_version": 11,
12971                    "schema": "urn:animsmith:schema:measurements:11",
12972                    "skeleton_nodes": [{
12973                        "node_index": 0,
12974                        "scene_root_indices": [],
12975                        "local_rest": {
12976                            "kind": "trs",
12977                            "translation_m": [0.0, 0.0, 0.0],
12978                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
12979                            "scale": [1.0, 1.0, 1.0]
12980                        },
12981                        "rest_world_matrix": [
12982                            1.0, 0.0, 0.0, 0.0,
12983                            0.0, 1.0, 0.0, 0.0,
12984                            0.0, 0.0, 1.0, 0.0,
12985                            0.0, 0.0, 0.0, 1.0
12986                        ]
12987                    }],
12988                    "skins": [{ "skin_index": 0 }]
12989                }
12990            }]
12991        }))
12992        .expect("unsupported payload shapes remain decodable for version rejection");
12993
12994        assert!(matches!(
12995            report.into_files(),
12996            Err(MeasurementReportError::File {
12997                file_index: 0,
12998                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
12999            })
13000        ));
13001    }
13002
13003    #[test]
13004    fn current_v16_primitive_mutations_fail_closed_on_readback() {
13005        let wire = measure_wire(primitive_measurement_contract());
13006        let primitive = &wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0];
13007        assert_eq!(primitive["primitive_index"], serde_json::json!(1));
13008        assert_eq!(primitive["material_index"], serde_json::json!(7));
13009        assert_eq!(
13010            wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["material_index"],
13011            serde_json::Value::Null
13012        );
13013
13014        let mut missing_material = wire.clone();
13015        missing_material["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]
13016            .as_object_mut()
13017            .unwrap()
13018            .remove("material_index");
13019        assert!(matches!(
13020            reader_error(missing_material),
13021            MeasurementReportError::File {
13022                source: MeasurementFileError::InvalidMeasurementsShape { .. },
13023                ..
13024            }
13025        ));
13026
13027        let mut mutations = Vec::new();
13028        let mut missing = wire.clone();
13029        missing["files"][0]["measurements"]["mesh_definitions"][0]
13030            .as_object_mut()
13031            .unwrap()
13032            .remove("primitives");
13033        mutations.push(missing);
13034
13035        let mut duplicate_index = wire.clone();
13036        duplicate_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13037            serde_json::json!(1);
13038        mutations.push(duplicate_index);
13039
13040        let mut decreasing_index = wire.clone();
13041        decreasing_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13042            serde_json::json!(0);
13043        mutations.push(decreasing_index);
13044
13045        let mut finite_over = wire.clone();
13046        finite_over["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13047            serde_json::json!(3);
13048        mutations.push(finite_over);
13049
13050        let mut zero_with_facts = wire.clone();
13051        zero_with_facts["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13052            serde_json::json!(0);
13053        mutations.push(zero_with_facts);
13054
13055        let mut positive_without_centroid = wire.clone();
13056        positive_without_centroid["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13057            [0]
13058        .as_object_mut()
13059        .unwrap()
13060        .remove("geometry_centroid");
13061        mutations.push(positive_without_centroid);
13062
13063        let mut centroid_outside_aabb = wire.clone();
13064        centroid_outside_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13065            ["geometry_centroid"] = serde_json::json!([-3.0, 1.0, 0.0]);
13066        mutations.push(centroid_outside_aabb);
13067
13068        let mut missing_mesh_geometry = wire.clone();
13069        missing_mesh_geometry["files"][0]["measurements"]["mesh_definitions"][0]
13070            .as_object_mut()
13071            .unwrap()
13072            .remove("geometry_centroid");
13073        mutations.push(missing_mesh_geometry);
13074
13075        let mut wrong_mesh_aabb = wire.clone();
13076        wrong_mesh_aabb["files"][0]["measurements"]["mesh_definitions"][0]["geometry_aabb"]["max"]
13077            [0] = serde_json::json!(7.0);
13078        mutations.push(wrong_mesh_aabb);
13079
13080        let mut wrong_mesh_centroid = wire.clone();
13081        wrong_mesh_centroid["files"][0]["measurements"]["mesh_definitions"][0]["geometry_centroid"] =
13082            serde_json::json!([3.0, 7.0 / 3.0, 0.0]);
13083        mutations.push(wrong_mesh_centroid);
13084
13085        let mut wrong_sum = wire.clone();
13086        wrong_sum["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13087            serde_json::json!(3);
13088        mutations.push(wrong_sum);
13089
13090        let mut finite_sum_overflow = wire.clone();
13091        for primitive in
13092            finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13093                .as_array_mut()
13094                .unwrap()
13095        {
13096            primitive["vertex_count"] = serde_json::json!(u64::MAX);
13097            primitive["finite_vertex_count"] = serde_json::json!(u64::MAX);
13098        }
13099        finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13100            serde_json::json!(u64::MAX);
13101        mutations.push(finite_sum_overflow);
13102
13103        for mutation in mutations {
13104            assert!(matches!(
13105                reader_error(mutation),
13106                MeasurementReportError::File {
13107                    source: MeasurementFileError::InvalidMeasurements { .. },
13108                    ..
13109                }
13110            ));
13111        }
13112
13113        let mut unknown_root = wire.clone();
13114        unknown_root["files"][0]["measurements"]
13115            .as_object_mut()
13116            .unwrap()
13117            .insert("bogus".into(), serde_json::json!(true));
13118        let mut unknown_mesh = wire.clone();
13119        unknown_mesh["files"][0]["measurements"]["mesh_definitions"][0]
13120            .as_object_mut()
13121            .unwrap()
13122            .insert("bogus".into(), serde_json::json!(true));
13123        let mut unknown_primitive = wire.clone();
13124        unknown_primitive["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13125            .as_object_mut()
13126            .unwrap()
13127            .insert("bogus".into(), serde_json::json!(true));
13128        let mut unknown_aabb = wire;
13129        unknown_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13130            ["geometry_aabb"]
13131            .as_object_mut()
13132            .unwrap()
13133            .insert("bogus".into(), serde_json::json!(true));
13134        for mutation in [unknown_root, unknown_mesh, unknown_primitive, unknown_aabb] {
13135            assert!(matches!(
13136                reader_error(mutation),
13137                MeasurementReportError::File {
13138                    source: MeasurementFileError::InvalidMeasurementsShape { reason },
13139                    ..
13140                } if reason.contains("unknown field `bogus`")
13141            ));
13142        }
13143    }
13144
13145    #[test]
13146    fn current_v16_material_indices_follow_resource_coverage() {
13147        let unavailable = measure_wire(primitive_measurement_contract());
13148        serde_json::from_value::<MeasurementReportInput>(unavailable.clone())
13149            .unwrap()
13150            .into_files()
13151            .expect("unavailable inventory may retain a source material index");
13152
13153        let mut complete_out_of_range = unavailable.clone();
13154        complete_out_of_range["files"][0]["measurements"]["material_resource_coverage"] =
13155            serde_json::json!("complete");
13156        assert!(matches!(
13157            reader_error(complete_out_of_range),
13158            MeasurementReportError::File {
13159                source: MeasurementFileError::InvalidMeasurements { .. },
13160                ..
13161            }
13162        ));
13163
13164        let mut complete_in_range = unavailable;
13165        complete_in_range["files"][0]["measurements"]["material_resource_coverage"] =
13166            serde_json::json!("complete");
13167        complete_in_range["files"][0]["measurements"]["material_definitions"] = serde_json::json!([{
13168            "material_index": 0,
13169            "name": null,
13170            "texture_bindings": []
13171        }]);
13172        complete_in_range["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["material_index"] =
13173            serde_json::json!(0);
13174        serde_json::from_value::<MeasurementReportInput>(complete_in_range)
13175            .unwrap()
13176            .into_files()
13177            .expect("complete inventory accepts an in-range primitive material index");
13178    }
13179
13180    #[test]
13181    fn current_v16_leading_magic_is_bounded_reason_specific_hex() {
13182        let mut assets = AssetMeasurements {
13183            material_resource_coverage: MaterialResourceCoverage::Complete,
13184            ..AssetMeasurements::default()
13185        };
13186        assets.images.push(ImageMeasurements {
13187            image_index: 0,
13188            name: None,
13189            source_kind: ImageSourceKind::Embedded,
13190            declared_mime_type: None,
13191            detected_container: None,
13192            leading_magic_hex: Some("00ff10".into()),
13193            width: None,
13194            height: None,
13195            channel_count: None,
13196            decoded_color_type: None,
13197            unavailable_reason: Some(ImageUnavailableReason::UnsupportedContainer),
13198        });
13199        let wire = measure_wire(MeasurementContract::new(BTreeMap::new(), assets).unwrap());
13200
13201        let mut unknown_image = wire.clone();
13202        unknown_image["files"][0]["measurements"]["images"][0]
13203            .as_object_mut()
13204            .unwrap()
13205            .insert("bogus".into(), serde_json::json!(true));
13206        assert!(matches!(
13207            reader_error(unknown_image),
13208            MeasurementReportError::File {
13209                source: MeasurementFileError::InvalidMeasurementsShape { reason },
13210                ..
13211            } if reason.contains("unknown field `bogus`")
13212        ));
13213
13214        for magic in ["", "0", "0F", "00ff00112233445566778899aabbccdde"] {
13215            let mut mutation = wire.clone();
13216            mutation["files"][0]["measurements"]["images"][0]["leading_magic_hex"] =
13217                serde_json::json!(magic);
13218            assert!(matches!(
13219                reader_error(mutation),
13220                MeasurementReportError::File {
13221                    source: MeasurementFileError::InvalidMeasurements { .. },
13222                    ..
13223                }
13224            ));
13225        }
13226
13227        let mut wrong_reason = wire;
13228        wrong_reason["files"][0]["measurements"]["images"][0]["unavailable_reason"] =
13229            serde_json::json!("resource_limit");
13230        assert!(matches!(
13231            reader_error(wrong_reason),
13232            MeasurementReportError::File {
13233                source: MeasurementFileError::InvalidMeasurements { .. },
13234                ..
13235            }
13236        ));
13237    }
13238
13239    #[test]
13240    fn v12_v15_round_trips_without_inventing_primitive_rows() {
13241        let current_provenance = prediction_test_provenance_v2();
13242        let historical_provenance = current_provenance.clone().historical_v15_for_test();
13243        let basis =
13244            EnginePredictionBasisV1::new_v16(vec![PredictionBasisReferenceV1::measurement_v16(
13245                crate::MeasurementPointerV1::new("/measurements/schema_version").unwrap(),
13246                PredictionScalarV1::UnsignedInteger { value: 15 },
13247            )])
13248            .unwrap();
13249        let facet = EnginePredictionFacetV2::required_unavailable(
13250            EvaluationScope::new(EvaluationScopeCode::custom("test:v12")),
13251            basis,
13252            vec![PredictionUnavailableReasonV2::MeasurementUnavailable],
13253        )
13254        .unwrap();
13255        let current_check =
13256            unavailable_check_v2("test:v12", &current_provenance, vec![facet.clone()]);
13257        let prediction =
13258            EnginePredictionV2::new(current_provenance.identity().clone(), vec![facet])
13259                .unwrap()
13260                .historical_v15_for_test(historical_provenance.identity().clone());
13261        let mut check_wire = serde_json::to_value(current_check).unwrap();
13262        check_wire["prediction"] = serde_json::to_value(prediction).unwrap();
13263        let mut historical_assets = AssetMeasurements::default();
13264        historical_assets
13265            .mesh_definitions
13266            .push(MeshDefinitionMeasurements {
13267                mesh_index: 0,
13268                name: "legacy-mesh".into(),
13269                primitives: None,
13270                vertex_count: 0,
13271                geometry_aabb: None,
13272                geometry_centroid: None,
13273                max_joints_per_vertex: 0,
13274                weight_sum_min: None,
13275                weight_sum_max: None,
13276                additional_influence_sets: Vec::new(),
13277            });
13278        let historical_measurements =
13279            MeasurementContract::historical_v15(BTreeMap::new(), historical_assets).unwrap();
13280        let wire = serde_json::json!({
13281            "schema_version": OUTPUT_V12_SCHEMA_VERSION,
13282            "schema": OUTPUT_V12_SCHEMA_ID,
13283            "tool": {},
13284            "command": "lint",
13285            "summary": {"prediction_facets": {
13286                "available": 0,
13287                "required_prediction_unavailable": 1
13288            }},
13289            "files": [{
13290                "path": "historical-v12.glb",
13291                "input": {"sha256": "00".repeat(32), "bytes": 0},
13292                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13293                "measurements": serde_json::to_value(historical_measurements).unwrap(),
13294                "prediction_provenance": serde_json::to_value(historical_provenance).unwrap(),
13295                "checks": [check_wire]
13296            }]
13297        });
13298
13299        let mut historical_with_unknowns = wire.clone();
13300        historical_with_unknowns["files"][0]["measurements"]
13301            .as_object_mut()
13302            .unwrap()
13303            .insert("future_root_field".into(), serde_json::json!(true));
13304        historical_with_unknowns["files"][0]["measurements"]["mesh_definitions"][0]
13305            .as_object_mut()
13306            .unwrap()
13307            .insert("future_mesh_field".into(), serde_json::json!(true));
13308        serde_json::from_value::<MeasurementReportInput>(historical_with_unknowns)
13309            .unwrap()
13310            .into_files()
13311            .expect("historical measurements-v15 retains permissive unknown-field readback");
13312
13313        let mut smuggled_primitives = wire.clone();
13314        smuggled_primitives["files"][0]["measurements"]["mesh_definitions"][0]["primitives"] =
13315            serde_json::json!([]);
13316        assert!(matches!(
13317            serde_json::from_value::<MeasurementReportInput>(smuggled_primitives)
13318                .unwrap()
13319                .into_files(),
13320            Err(MeasurementReportError::File {
13321                source: MeasurementFileError::InvalidMeasurements { source },
13322                ..
13323            }) if source.to_string().contains("measurements-v15 cannot carry per-primitive evidence")
13324        ));
13325
13326        let mut smuggled_magic = wire.clone();
13327        smuggled_magic["files"][0]["measurements"]["material_resource_coverage"] =
13328            serde_json::json!("complete");
13329        smuggled_magic["files"][0]["measurements"]["images"] = serde_json::json!([{
13330            "image_index": 0,
13331            "name": null,
13332            "source_kind": "embedded",
13333            "declared_mime_type": null,
13334            "detected_container": null,
13335            "leading_magic_hex": "00",
13336            "width": null,
13337            "height": null,
13338            "channel_count": null,
13339            "decoded_color_type": null,
13340            "unavailable_reason": "unsupported_container"
13341        }]);
13342        assert!(matches!(
13343            serde_json::from_value::<MeasurementReportInput>(smuggled_magic)
13344                .unwrap()
13345                .into_files(),
13346            Err(MeasurementReportError::File {
13347                source: MeasurementFileError::InvalidMeasurements { source },
13348                ..
13349            }) if source.to_string().contains("measurements-v15 cannot carry leading-magic evidence")
13350        ));
13351
13352        let files = serde_json::from_value::<MeasurementReportInput>(wire.clone())
13353            .unwrap()
13354            .into_files()
13355            .unwrap();
13356        let readback = serde_json::to_value(files[0].measurements()).unwrap();
13357        assert_eq!(readback["schema_version"], serde_json::json!(15));
13358        assert_eq!(
13359            readback["schema"],
13360            serde_json::json!(MEASUREMENTS_V15_SCHEMA_ID)
13361        );
13362        assert_eq!(readback["mesh_definitions"].as_array().unwrap().len(), 1);
13363        assert!(readback["mesh_definitions"][0].get("primitives").is_none());
13364
13365        let mut historical_max = wire.clone();
13366        historical_max["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13367            serde_json::json!(u32::MAX);
13368        serde_json::from_value::<MeasurementReportInput>(historical_max)
13369            .unwrap()
13370            .into_files()
13371            .expect("measurements-v15 retains its historical inclusive u32 maximum");
13372
13373        let mut historical_overflow = wire.clone();
13374        historical_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13375            serde_json::json!(u64::from(u32::MAX) + 1);
13376        assert!(matches!(
13377            lint_read_error(historical_overflow),
13378            MeasurementReportError::File {
13379                source: MeasurementFileError::InvalidMeasurements { .. },
13380                ..
13381            }
13382        ));
13383
13384        let mut v16_basis = wire;
13385        v16_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["references"][0]["schema"] =
13386            serde_json::json!(MEASUREMENTS_SCHEMA_ID);
13387        assert!(matches!(
13388            lint_read_error(v16_basis),
13389            MeasurementReportError::File {
13390                source: MeasurementFileError::InvalidPrediction {
13391                    source: PredictionContractError::InvalidSchema {
13392                        field: "basis.measurement.schema",
13393                        expected: MEASUREMENTS_V15_SCHEMA_ID,
13394                        ..
13395                    },
13396                    ..
13397                },
13398                ..
13399            }
13400        ));
13401    }
13402
13403    #[test]
13404    fn adjacent_output_revisions_reject_each_others_nested_measurements() {
13405        let current = measure_wire(prediction_test_measurements());
13406        let mut v12_with_v17 = current.clone();
13407        v12_with_v17["schema_version"] = serde_json::json!(OUTPUT_V12_SCHEMA_VERSION);
13408        v12_with_v17["schema"] = serde_json::json!(OUTPUT_V12_SCHEMA_ID);
13409        assert!(matches!(
13410            reader_error(v12_with_v17),
13411            MeasurementReportError::File {
13412                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 17 },
13413                ..
13414            }
13415        ));
13416
13417        let historical =
13418            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13419                .unwrap();
13420        let mut v13_with_v15 = current;
13421        v13_with_v15["files"][0]["measurements"] = serde_json::to_value(historical).unwrap();
13422        assert!(matches!(
13423            reader_error(v13_with_v15),
13424            MeasurementReportError::File {
13425                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 15 },
13426                ..
13427            }
13428        ));
13429    }
13430
13431    #[test]
13432    fn historical_output_v17_writer_projects_current_measurements_to_v16() {
13433        let report = LintFileReportV17::new(
13434            "historical.glb",
13435            InputIdentity::from_bytes(&[]),
13436            prediction_test_rig(),
13437            None,
13438            Vec::new(),
13439            prediction_test_measurements(),
13440        )
13441        .unwrap();
13442        assert_eq!(
13443            report.measurements().schema_version,
13444            MEASUREMENTS_V16_SCHEMA_VERSION
13445        );
13446        assert_eq!(report.measurements().schema, MEASUREMENTS_V16_SCHEMA_ID);
13447    }
13448
13449    #[test]
13450    fn current_v18_writers_refuse_measurements_v16_at_file_and_envelope_boundaries() {
13451        let mismatch = OutputContractError::CurrentMeasurementContractMismatch {
13452            output_version: OUTPUT_SCHEMA_VERSION,
13453            found_version: MEASUREMENTS_V16_SCHEMA_VERSION,
13454            found_schema: MEASUREMENTS_V16_SCHEMA_ID.into(),
13455        };
13456        assert_eq!(
13457            MeasureFileReport::new(
13458                "historical.glb",
13459                InputIdentity::from_bytes(&[]),
13460                prediction_test_rig(),
13461                prediction_test_measurements_v16(),
13462            )
13463            .unwrap_err(),
13464            mismatch
13465        );
13466        assert_eq!(
13467            LintFileReportV18::new(
13468                "historical.glb",
13469                InputIdentity::from_bytes(&[]),
13470                prediction_test_rig(),
13471                None,
13472                Vec::new(),
13473                prediction_test_measurements_v16(),
13474            )
13475            .unwrap_err(),
13476            mismatch
13477        );
13478
13479        let malformed_measure = MeasureFileReport {
13480            evidence: FileEvidence::new(
13481                "historical.glb",
13482                InputIdentity::from_bytes(&[]),
13483                prediction_test_rig(),
13484                prediction_test_measurements_v16(),
13485            ),
13486        };
13487        assert_eq!(
13488            MeasureEnvelope::new(
13489                ToolInfo::animsmith(ToolSource::new(None, None)),
13490                vec![malformed_measure],
13491            )
13492            .unwrap_err(),
13493            mismatch
13494        );
13495        let malformed_lint = LintFileReportV18 {
13496            evidence: FileEvidence::new(
13497                "historical.glb",
13498                InputIdentity::from_bytes(&[]),
13499                prediction_test_rig(),
13500                prediction_test_measurements_v16(),
13501            ),
13502            prediction_provenance: None,
13503            checks: Vec::new(),
13504        };
13505        assert_eq!(
13506            LintEnvelopeV18::new(
13507                ToolInfo::animsmith(ToolSource::new(None, None)),
13508                vec![malformed_lint],
13509            )
13510            .unwrap_err(),
13511            mismatch
13512        );
13513    }
13514
13515    #[test]
13516    fn v17_to_v16_projection_is_total_and_used_by_prediction_and_historical_writers() {
13517        let fully_measured = loop_projection_measurements(&[
13518            MeasurementAvailability::Measured,
13519            MeasurementAvailability::Measured,
13520        ]);
13521        let projected = fully_measured.prediction_v16_projection().unwrap();
13522        assert_eq!(projected.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13523        let continuity = projected.clips()["loop"]
13524            .loop_continuity
13525            .as_ref()
13526            .expect("fully measured rows survive projection");
13527        assert_eq!(continuity.bones.len(), 2);
13528        let wire = serde_json::to_value(&projected).unwrap();
13529        assert!(
13530            wire["clips"]["loop"]["loop_continuity"]["bones"]
13531                .as_array()
13532                .unwrap()
13533                .iter()
13534                .all(|bone| bone.get("availability").is_none())
13535        );
13536
13537        let partial = loop_projection_measurements(&[
13538            MeasurementAvailability::Measured,
13539            MeasurementAvailability::Unavailable,
13540        ]);
13541        let all_unavailable = loop_projection_measurements(&[
13542            MeasurementAvailability::Unavailable,
13543            MeasurementAvailability::Unavailable,
13544        ]);
13545        for measurements in [&partial, &all_unavailable] {
13546            let projected = measurements.prediction_v16_projection().unwrap();
13547            assert_eq!(
13548                projected.clips()["loop"].loop_continuity_availability,
13549                MeasurementAvailability::Unavailable
13550            );
13551            assert!(projected.clips()["loop"].loop_continuity.is_none());
13552        }
13553
13554        let current_v3 = LintFileReportV18::new(
13555            "v3.glb",
13556            InputIdentity::from_bytes(&[]),
13557            prediction_test_rig(),
13558            None,
13559            Vec::new(),
13560            fully_measured,
13561        )
13562        .unwrap();
13563        let current_v5 = LintFileReportV18::new_v5(
13564            "v5.glb",
13565            InputIdentity::from_bytes(&[]),
13566            prediction_test_rig(),
13567            None,
13568            Vec::new(),
13569            partial.clone(),
13570        )
13571        .unwrap();
13572        let current_v6 = LintFileReportV18::new_v6(
13573            "v6.glb",
13574            InputIdentity::from_bytes(&[]),
13575            prediction_test_rig(),
13576            None,
13577            Vec::new(),
13578            all_unavailable.clone(),
13579        )
13580        .unwrap();
13581        assert!(
13582            current_v3.measurements().clips()["loop"]
13583                .loop_continuity
13584                .is_some()
13585        );
13586        assert!(
13587            current_v5.measurements().clips()["loop"]
13588                .loop_continuity
13589                .is_some()
13590        );
13591        assert!(
13592            current_v6.measurements().clips()["loop"]
13593                .loop_continuity
13594                .is_some()
13595        );
13596
13597        let historical_v15 = LintFileReport::new(
13598            "v15.glb",
13599            InputIdentity::from_bytes(&[]),
13600            prediction_test_rig(),
13601            None,
13602            Vec::new(),
13603            partial.clone(),
13604        )
13605        .unwrap();
13606        let historical_v16 = LintFileReportV16::new(
13607            "v16.glb",
13608            InputIdentity::from_bytes(&[]),
13609            prediction_test_rig(),
13610            None,
13611            Vec::new(),
13612            partial.clone(),
13613        )
13614        .unwrap();
13615        let historical_v17 = LintFileReportV17::new(
13616            "v17.glb",
13617            InputIdentity::from_bytes(&[]),
13618            prediction_test_rig(),
13619            None,
13620            Vec::new(),
13621            partial,
13622        )
13623        .unwrap();
13624        for measurements in [
13625            historical_v15.measurements(),
13626            historical_v16.measurements(),
13627            historical_v17.measurements(),
13628        ] {
13629            assert_eq!(measurements.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13630            assert_eq!(
13631                measurements.clips()["loop"].loop_continuity_availability,
13632                MeasurementAvailability::Unavailable
13633            );
13634            assert!(measurements.clips()["loop"].loop_continuity.is_none());
13635        }
13636    }
13637
13638    #[test]
13639    fn legacy_output_v11_reader_dispatch_preserves_measurement_recovery() {
13640        let provenance = prediction_test_provenance_v2();
13641        let legacy = PredictionProvenanceV1::new(
13642            provenance.profile().clone(),
13643            provenance.source_format(),
13644            ResolvedEngineSettingsV1::new(provenance.profile(), Vec::new(), Vec::new()).unwrap(),
13645            provenance.raw_source().clone(),
13646            provenance.dependency_closure().clone(),
13647        )
13648        .unwrap()
13649        .historical_v15_for_test();
13650        let legacy_prediction = EnginePredictionV1::new(
13651            legacy.identity().clone(),
13652            vec![
13653                EnginePredictionFacetV1::required_unavailable(
13654                    EvaluationScope::new(EvaluationScopeCode::custom("test:legacy-v11")),
13655                    EnginePredictionBasisV1::new(Vec::new()).unwrap(),
13656                    vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
13657                )
13658                .unwrap(),
13659            ],
13660        )
13661        .unwrap()
13662        .historical_v15_for_test(legacy.identity().clone());
13663        let legacy_measurements =
13664            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13665                .unwrap();
13666        // This is an actual V11/V1 wire shape, deliberately constructed
13667        // without producing a V12 envelope and swapping only its header.
13668        let wire = serde_json::json!({
13669            "schema_version": OUTPUT_V11_SCHEMA_VERSION,
13670            "schema": OUTPUT_V11_SCHEMA_ID,
13671            "tool": {},
13672            "command": "lint",
13673            "summary": {
13674                "prediction_facets": {
13675                    "available": 0,
13676                    "required_prediction_unavailable": 1,
13677                },
13678            },
13679            "files": [{
13680                "path": "legacy-v11.glb",
13681                "input": { "sha256": "00".repeat(32), "bytes": 0 },
13682                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13683                "measurements": serde_json::to_value(legacy_measurements).unwrap(),
13684                "prediction_provenance": serde_json::to_value(legacy.clone()).unwrap(),
13685                "checks": [{
13686                    "check_id": "test:legacy-v11",
13687                    "selection": "selected",
13688                    "configuration": "enabled",
13689                    "applicability": "applicable",
13690                    "evaluation": "not_evaluated",
13691                    "findings": [],
13692                    "evaluated_scopes": [],
13693                    "gaps": [],
13694                    "prediction": legacy_prediction,
13695                }],
13696            }],
13697        });
13698        let report: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
13699        assert_eq!(report.file_count(), Some(1));
13700        assert_eq!(report.into_files().unwrap().len(), 1);
13701
13702        let mut bad_provenance = wire.clone();
13703        bad_provenance["files"][0]["prediction_provenance"]["schema"] =
13704            serde_json::json!("urn:forged");
13705        assert!(matches!(
13706            lint_read_error(bad_provenance),
13707            MeasurementReportError::File {
13708                source: MeasurementFileError::InvalidPredictionProvenance { .. },
13709                ..
13710            }
13711        ));
13712
13713        let mut bad_prediction = wire.clone();
13714        bad_prediction["files"][0]["checks"] = serde_json::json!([{
13715            "check_id": "test:legacy-v11",
13716            "selection": "selected",
13717            "configuration": "enabled",
13718            "applicability": "applicable",
13719            "evaluation": "not_evaluated",
13720            "findings": [],
13721            "evaluated_scopes": [],
13722            "gaps": [],
13723            "prediction": { "schema": "urn:forged" }
13724        }]);
13725        assert!(matches!(
13726            lint_read_error(bad_prediction),
13727            MeasurementReportError::File {
13728                source: MeasurementFileError::InvalidPredictionShape { .. },
13729                ..
13730            }
13731        ));
13732
13733        let mut missing_provenance_before_malformed_prediction = wire.clone();
13734        missing_provenance_before_malformed_prediction["files"][0]["prediction_provenance"] =
13735            serde_json::Value::Null;
13736        missing_provenance_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13737            serde_json::json!({ "schema": "urn:forged" });
13738        assert!(matches!(
13739            lint_read_error(missing_provenance_before_malformed_prediction),
13740            MeasurementReportError::File {
13741                source: MeasurementFileError::PredictionWithoutProvenance { check_index: 0 },
13742                ..
13743            }
13744        ));
13745
13746        let mut inactive_before_malformed_prediction = wire.clone();
13747        inactive_before_malformed_prediction["files"][0]["checks"][0]["selection"] =
13748            serde_json::json!("unselected");
13749        inactive_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13750            serde_json::json!({ "schema": "urn:forged" });
13751        assert!(matches!(
13752            lint_read_error(inactive_before_malformed_prediction),
13753            MeasurementReportError::File {
13754                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13755                ..
13756            }
13757        ));
13758
13759        // A V11 aggregate overflow is terminal at the check that creates it;
13760        // a malformed later check must never change that first error.
13761        let mut overbudget_before_later_malformed = wire.clone();
13762        let facet =
13763            overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"][0]
13764                .clone();
13765        overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"] =
13766            serde_json::Value::Array(
13767                std::iter::repeat_n(facet, PREDICTION_V1_MAX_FACETS_PER_FILE + 1).collect(),
13768            );
13769        overbudget_before_later_malformed["files"][0]["checks"]
13770            .as_array_mut()
13771            .unwrap()
13772            .push(serde_json::json!({ "check_id": 7 }));
13773        let precedence_error = lint_read_error(overbudget_before_later_malformed);
13774        assert!(
13775            matches!(
13776                precedence_error,
13777                MeasurementReportError::File {
13778                    source: MeasurementFileError::InvalidPrediction {
13779                        source: PredictionContractError::TooManyFacets { .. },
13780                        ..
13781                    },
13782                    ..
13783                },
13784            ),
13785            "{precedence_error:?}"
13786        );
13787    }
13788}
13789
13790#[derive(Debug, Clone, Serialize)]
13791struct FileEvidence {
13792    path: String,
13793    input: InputIdentity,
13794    rig: RigInfo,
13795    measurements: MeasurementContract,
13796}
13797
13798impl FileEvidence {
13799    fn new(
13800        path: impl Into<String>,
13801        input: InputIdentity,
13802        rig: RigInfo,
13803        measurements: MeasurementContract,
13804    ) -> Self {
13805        Self {
13806            path: path.into(),
13807            input,
13808            rig,
13809            measurements,
13810        }
13811    }
13812
13813    fn historical_v16(
13814        path: impl Into<String>,
13815        input: InputIdentity,
13816        rig: RigInfo,
13817        measurements: MeasurementContract,
13818    ) -> Result<Self, OutputContractError> {
13819        Ok(Self::new(
13820            path,
13821            input,
13822            rig,
13823            measurements.prediction_v16_projection()?,
13824        ))
13825    }
13826}
13827
13828/// One source file and its measurement-command evidence.
13829#[derive(Debug, Clone, Serialize)]
13830pub struct MeasureFileReport {
13831    #[serde(flatten)]
13832    evidence: FileEvidence,
13833}
13834
13835impl MeasureFileReport {
13836    /// Construct a measurement-command file report.
13837    ///
13838    /// # Errors
13839    ///
13840    /// Returns [`OutputContractError::CurrentMeasurementContractMismatch`] if
13841    /// the nested evidence is not the exact current measurements-v17 contract.
13842    pub fn new(
13843        path: impl Into<String>,
13844        input: InputIdentity,
13845        rig: RigInfo,
13846        measurements: MeasurementContract,
13847    ) -> Result<Self, OutputContractError> {
13848        require_measurements_v17(OUTPUT_SCHEMA_VERSION, &measurements)?;
13849        Ok(Self {
13850            evidence: FileEvidence::new(path, input, rig, measurements),
13851        })
13852    }
13853
13854    /// Display path supplied by the producer.
13855    pub fn path(&self) -> &str {
13856        &self.evidence.path
13857    }
13858
13859    /// Immutable identity of the source bytes used to produce this record.
13860    pub fn input(&self) -> &InputIdentity {
13861        &self.evidence.input
13862    }
13863
13864    /// Nested measurement evidence.
13865    pub fn measurements(&self) -> &MeasurementContract {
13866        &self.evidence.measurements
13867    }
13868}
13869
13870/// A producer attempted to construct output outside the immutable v11 contract.
13871#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13872#[non_exhaustive]
13873pub enum OutputContractError {
13874    /// One envelope carried too many file records.
13875    #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
13876    TooManyFiles {
13877        /// Supplied file count.
13878        found: usize,
13879        /// Immutable v10 limit.
13880        limit: usize,
13881    },
13882    /// One lint file carried too many check records.
13883    #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
13884    TooManyChecks {
13885        /// Supplied check count.
13886        found: usize,
13887        /// Immutable v10 limit.
13888        limit: usize,
13889    },
13890    /// A check carried prediction evidence without its file-scoped authority.
13891    #[error("engine prediction requires non-null file prediction_provenance")]
13892    PredictionWithoutProvenance,
13893    /// A current output-v15 lint record attempted to attach historical prediction evidence.
13894    #[error("output-v15 lint cannot carry historical engine-prediction evidence")]
13895    HistoricalPredictionInV2Output,
13896    /// File provenance and check prediction used different contract revisions.
13897    #[error(
13898        "output-v15 prediction provenance and check attachments must use one correlated revision"
13899    )]
13900    PredictionRevisionMismatch,
13901    /// File and provenance primary-input identities differed.
13902    #[error("prediction provenance primary input does not match the lint file input")]
13903    PredictionPrimaryInputMismatch,
13904    /// Aggregate prediction facets exceeded the V1 file limit.
13905    #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
13906    TooManyPredictionFacets {
13907        /// Supplied facet count.
13908        found: usize,
13909        /// Immutable V1 limit.
13910        limit: usize,
13911    },
13912    /// A V2 rule emitted a budget summary although the file did not consume
13913    /// every one of its shared prediction-facet slots.
13914    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
13915    FacetBudgetSummaryWithoutExhaustedFileBudget {
13916        /// Aggregate facet count.
13917        found: usize,
13918        /// Immutable shared file limit.
13919        limit: usize,
13920    },
13921    /// Aggregate basis references exceeded the V1 file limit.
13922    #[error(
13923        "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
13924    )]
13925    TooManyPredictionBasisReferences {
13926        /// Supplied reference count.
13927        found: usize,
13928        /// Immutable V1 limit.
13929        limit: usize,
13930    },
13931    /// Aggregate retained provenance/prediction text exceeded the V1 limit.
13932    #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
13933    TooMuchPredictionText {
13934        /// Supplied UTF-8 byte count.
13935        found: usize,
13936        /// Immutable V1 limit.
13937        limit: usize,
13938    },
13939    /// Checked aggregate accounting overflowed.
13940    #[error("checked arithmetic overflow while validating output-v11 bounds")]
13941    ArithmeticOverflow,
13942    /// Nested prediction evidence violated its contract.
13943    #[error("invalid prediction evidence: {0}")]
13944    InvalidPrediction(#[from] PredictionContractError),
13945    /// Current measurements could not be projected into the immutable V16
13946    /// evidence view consumed by existing prediction contracts.
13947    #[error("invalid measurements-v16 prediction projection: {0}")]
13948    InvalidMeasurementProjection(#[from] MeasurementContractError),
13949    /// A historical output writer was paired with a different nested
13950    /// measurement contract.
13951    #[error(
13952        "output-v{output_version} requires measurements-v16, found version {found_version} with identity {found_schema}"
13953    )]
13954    HistoricalMeasurementContractMismatch {
13955        /// Historical outer output revision.
13956        output_version: u32,
13957        /// Supplied nested measurement revision.
13958        found_version: u32,
13959        /// Supplied nested measurement identity.
13960        found_schema: String,
13961    },
13962    /// A current output writer received a non-current nested measurement contract.
13963    #[error(
13964        "output-v{output_version} requires measurements-v17, found measurement schema_version {found_version} ({found_schema})"
13965    )]
13966    CurrentMeasurementContractMismatch {
13967        /// Current outer output revision.
13968        output_version: u32,
13969        /// Supplied nested measurement revision.
13970        found_version: u32,
13971        /// Supplied nested measurement identity.
13972        found_schema: String,
13973    },
13974}
13975
13976fn require_measurements_v17(
13977    output_version: u32,
13978    measurements: &MeasurementContract,
13979) -> Result<(), OutputContractError> {
13980    if measurements.schema_version == MEASUREMENTS_SCHEMA_VERSION
13981        && measurements.schema == MEASUREMENTS_SCHEMA_ID
13982    {
13983        Ok(())
13984    } else {
13985        Err(OutputContractError::CurrentMeasurementContractMismatch {
13986            output_version,
13987            found_version: measurements.schema_version,
13988            found_schema: measurements.schema.to_owned(),
13989        })
13990    }
13991}
13992
13993fn require_measurements_v16(
13994    output_version: u32,
13995    measurements: &MeasurementContract,
13996) -> Result<(), OutputContractError> {
13997    if measurements.schema_version == MEASUREMENTS_V16_SCHEMA_VERSION
13998        && measurements.schema == MEASUREMENTS_V16_SCHEMA_ID
13999    {
14000        Ok(())
14001    } else {
14002        Err(OutputContractError::HistoricalMeasurementContractMismatch {
14003            output_version,
14004            found_version: measurements.schema_version,
14005            found_schema: measurements.schema.to_owned(),
14006        })
14007    }
14008}
14009
14010#[derive(Debug, Clone, Serialize)]
14011struct EnvelopeHeader {
14012    schema_version: u32,
14013    schema: &'static str,
14014    tool: ToolInfo,
14015    command: &'static str,
14016}
14017
14018impl EnvelopeHeader {
14019    fn new(tool: ToolInfo, command: &'static str) -> Self {
14020        Self {
14021            schema_version: OUTPUT_SCHEMA_VERSION,
14022            schema: OUTPUT_SCHEMA_ID,
14023            tool,
14024            command,
14025        }
14026    }
14027}
14028
14029#[derive(Debug, Clone, Serialize)]
14030struct MeasureSummary {
14031    files: usize,
14032}
14033
14034#[derive(Debug, Clone, Default, Serialize)]
14035struct FindingSummary {
14036    error: usize,
14037    warning: usize,
14038    note: usize,
14039}
14040
14041impl FindingSummary {
14042    fn add(&mut self, severity: Severity) {
14043        match severity {
14044            Severity::Error => self.error += 1,
14045            Severity::Warning => self.warning += 1,
14046            Severity::Note => self.note += 1,
14047        }
14048    }
14049}
14050
14051#[derive(Debug, Clone, Default, Serialize)]
14052struct SelectionSummary {
14053    selected: usize,
14054    unselected: usize,
14055}
14056
14057#[derive(Debug, Clone, Default, Serialize)]
14058struct ConfigurationSummary {
14059    enabled: usize,
14060    disabled: usize,
14061}
14062
14063#[derive(Debug, Clone, Default, Serialize)]
14064struct ApplicabilitySummary {
14065    applicable: usize,
14066    not_applicable: usize,
14067}
14068
14069#[derive(Debug, Clone, Default, Serialize)]
14070struct EvaluationStateSummary {
14071    complete: usize,
14072    partial: usize,
14073    not_evaluated: usize,
14074}
14075
14076#[derive(Debug, Clone, Default, Serialize)]
14077struct CheckSummary {
14078    total: usize,
14079    selection: SelectionSummary,
14080    configuration: ConfigurationSummary,
14081    applicability: ApplicabilitySummary,
14082    evaluation: EvaluationStateSummary,
14083    gaps: usize,
14084}
14085
14086#[derive(Debug, Clone, Serialize)]
14087struct LintSummary {
14088    files: usize,
14089    findings: FindingSummary,
14090    checks: CheckSummary,
14091    prediction_facets: PredictionFacetSummary,
14092}
14093
14094#[derive(Debug, Clone, Default, Serialize)]
14095struct PredictionFacetSummary {
14096    available: usize,
14097    required_prediction_unavailable: usize,
14098}
14099
14100/// Current measure-command result envelope.
14101#[derive(Debug, Clone, Serialize)]
14102pub struct MeasureEnvelope {
14103    #[serde(flatten)]
14104    header: EnvelopeHeader,
14105    summary: MeasureSummary,
14106    files: Vec<MeasureFileReport>,
14107}
14108
14109impl MeasureEnvelope {
14110    /// Construct a schema-valid measurement envelope.
14111    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
14112        if files.len() > OUTPUT_V11_MAX_FILES {
14113            return Err(OutputContractError::TooManyFiles {
14114                found: files.len(),
14115                limit: OUTPUT_V11_MAX_FILES,
14116            });
14117        }
14118        for file in &files {
14119            require_measurements_v17(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
14120        }
14121        Ok(Self {
14122            header: EnvelopeHeader::new(tool, "measure"),
14123            summary: MeasureSummary { files: files.len() },
14124            files,
14125        })
14126    }
14127}
14128
14129#[derive(Debug, Clone, Serialize)]
14130#[serde(untagged)]
14131#[allow(
14132    clippy::large_enum_variant,
14133    reason = "the internal correlated revision enum preserves value ownership for both immutable wire types"
14134)]
14135enum CurrentPredictionProvenance {
14136    V3(PredictionProvenanceV3),
14137    V4(PredictionProvenanceV4),
14138}
14139
14140/// Current output-v15 lint file evidence with correlated V3 or V4 prediction provenance.
14141#[derive(Debug, Clone, Serialize)]
14142pub struct LintFileReport {
14143    #[serde(flatten)]
14144    evidence: FileEvidence,
14145    prediction_provenance: Option<CurrentPredictionProvenance>,
14146    checks: Vec<CheckEvaluation>,
14147}
14148
14149impl LintFileReport {
14150    /// Construct a legacy-V3 lint file report inside output-v15.
14151    pub fn new(
14152        path: impl Into<String>,
14153        input: InputIdentity,
14154        rig: RigInfo,
14155        prediction_provenance: Option<PredictionProvenanceV3>,
14156        checks: Vec<CheckEvaluation>,
14157        measurements: MeasurementContract,
14158    ) -> Result<Self, OutputContractError> {
14159        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14160            return Err(OutputContractError::TooManyChecks {
14161                found: checks.len(),
14162                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14163            });
14164        }
14165        let report = Self {
14166            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14167            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V3),
14168            checks,
14169        };
14170        report.validate()?;
14171        Ok(report)
14172    }
14173
14174    /// V3 prediction provenance, or `None` for engine-neutral lint.
14175    pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV3> {
14176        match self.prediction_provenance.as_ref() {
14177            Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14178            Some(CurrentPredictionProvenance::V4(_)) | None => None,
14179        }
14180    }
14181
14182    /// Construct a result-bearing V4 lint file report.
14183    pub fn new_v4(
14184        path: impl Into<String>,
14185        input: InputIdentity,
14186        rig: RigInfo,
14187        prediction_provenance: Option<PredictionProvenanceV4>,
14188        checks: Vec<CheckEvaluation>,
14189        measurements: MeasurementContract,
14190    ) -> Result<Self, OutputContractError> {
14191        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14192            return Err(OutputContractError::TooManyChecks {
14193                found: checks.len(),
14194                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14195            });
14196        }
14197        let report = Self {
14198            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14199            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V4),
14200            checks,
14201        };
14202        report.validate()?;
14203        Ok(report)
14204    }
14205
14206    /// V4 prediction provenance, when this file uses revision 4.
14207    pub const fn prediction_provenance_v4(&self) -> Option<&PredictionProvenanceV4> {
14208        match self.prediction_provenance.as_ref() {
14209            Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14210            Some(CurrentPredictionProvenance::V3(_)) | None => None,
14211        }
14212    }
14213
14214    /// Display path supplied by the producer.
14215    pub fn path(&self) -> &str {
14216        &self.evidence.path
14217    }
14218
14219    /// Immutable identity of the source bytes used to produce this record.
14220    pub fn input(&self) -> &InputIdentity {
14221        &self.evidence.input
14222    }
14223
14224    /// Nested measurement evidence.
14225    pub fn measurements(&self) -> &MeasurementContract {
14226        &self.evidence.measurements
14227    }
14228
14229    /// Catalog-ordered check records.
14230    pub fn checks(&self) -> &[CheckEvaluation] {
14231        &self.checks
14232    }
14233
14234    fn validate(&self) -> Result<(), OutputContractError> {
14235        require_measurements_v16(OUTPUT_V15_SCHEMA_VERSION, &self.evidence.measurements)?;
14236        if let Some(provenance) = &self.prediction_provenance {
14237            let primary_input = match provenance {
14238                CurrentPredictionProvenance::V3(provenance) => {
14239                    provenance.validate()?;
14240                    provenance.raw_source().primary_input()
14241                }
14242                CurrentPredictionProvenance::V4(provenance) => {
14243                    provenance.validate()?;
14244                    provenance.raw_source().primary_input()
14245                }
14246            };
14247            if primary_input != &self.evidence.input {
14248                return Err(OutputContractError::PredictionPrimaryInputMismatch);
14249            }
14250        }
14251        let mut facets = 0usize;
14252        let mut references = 0usize;
14253        let mut has_facet_budget_summary = false;
14254        let mut text = self
14255            .prediction_provenance
14256            .as_ref()
14257            .map(|provenance| match provenance {
14258                CurrentPredictionProvenance::V3(provenance) => provenance.retained_text_bytes(),
14259                CurrentPredictionProvenance::V4(provenance) => provenance.retained_text_bytes(),
14260            })
14261            .transpose()?
14262            .unwrap_or(0);
14263        for check in &self.checks {
14264            if check.engine_prediction().is_some() || check.engine_prediction_v2().is_some() {
14265                return Err(OutputContractError::HistoricalPredictionInV2Output);
14266            }
14267            let provenance_revision_matches = matches!(
14268                (
14269                    &self.prediction_provenance,
14270                    check.engine_prediction_v3(),
14271                    check.engine_prediction_v4()
14272                ),
14273                (Some(CurrentPredictionProvenance::V3(_)), Some(_), None)
14274                    | (Some(CurrentPredictionProvenance::V4(_)), None, Some(_))
14275                    | (_, None, None)
14276            );
14277            if !provenance_revision_matches {
14278                if self.prediction_provenance.is_none()
14279                    && (check.engine_prediction_v3().is_some()
14280                        || check.engine_prediction_v4().is_some())
14281                {
14282                    return Err(OutputContractError::PredictionWithoutProvenance);
14283                }
14284                return Err(OutputContractError::PredictionRevisionMismatch);
14285            }
14286            let legacy_v3_provenance = match self.prediction_provenance.as_ref() {
14287                Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14288                Some(CurrentPredictionProvenance::V4(_)) | None => None,
14289            };
14290            if !matches!(
14291                self.prediction_provenance.as_ref(),
14292                Some(CurrentPredictionProvenance::V4(_))
14293            ) {
14294                validate_current_engine_clip_boundary_applicability_v3(
14295                    check.check_id(),
14296                    check.applicability(),
14297                    legacy_v3_provenance,
14298                )?;
14299            }
14300            if check.check_id() == ENGINE_CLIP_BOUNDARY_CHECK_ID
14301                && check.selection() == SelectionState::Selected
14302                && check.configuration() == ConfigurationState::Enabled
14303                && check.applicability() == Applicability::Applicable
14304                && check.engine_prediction_v3().is_none()
14305            {
14306                return Err(OutputContractError::InvalidPrediction(
14307                    PredictionContractError::EngineClipBoundaryFacetMismatch,
14308                ));
14309            }
14310            let current_v4_provenance = match self.prediction_provenance.as_ref() {
14311                Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14312                Some(CurrentPredictionProvenance::V3(_)) | None => None,
14313            };
14314            validate_current_engine_unit_scale_prediction_v4(
14315                check.check_id(),
14316                check.selection(),
14317                check.configuration(),
14318                check.applicability(),
14319                check.engine_prediction_v4(),
14320                current_v4_provenance,
14321                &self.evidence.measurements,
14322            )?;
14323            if let Some(prediction) = check.engine_prediction_v3() {
14324                let Some(CurrentPredictionProvenance::V3(provenance)) =
14325                    self.prediction_provenance.as_ref()
14326                else {
14327                    return Err(OutputContractError::PredictionRevisionMismatch);
14328                };
14329                prediction.validate_against_provenance(provenance)?;
14330                prediction.validate_for_check(
14331                    check.check_id(),
14332                    check.evaluated_scopes(),
14333                    check.gaps(),
14334                    check.findings(),
14335                )?;
14336                validate_current_engine_addressability_prediction_v3(
14337                    check.check_id(),
14338                    prediction,
14339                    provenance,
14340                )?;
14341                let finding_scopes = check
14342                    .findings()
14343                    .iter()
14344                    .filter_map(|finding| finding.prediction_scope.as_ref())
14345                    .collect::<Vec<_>>();
14346                validate_current_engine_clip_boundary_prediction_v3(
14347                    check.check_id(),
14348                    prediction,
14349                    provenance,
14350                    check.evaluated_scopes(),
14351                    &finding_scopes,
14352                )?;
14353                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14354                facets = facets
14355                    .checked_add(prediction.facets().len())
14356                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14357                references = references
14358                    .checked_add(prediction.basis_reference_count())
14359                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14360                text = text
14361                    .checked_add(prediction.retained_text_bytes()?)
14362                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14363            }
14364            if let Some(prediction) = check.engine_prediction_v4() {
14365                let Some(CurrentPredictionProvenance::V4(provenance)) =
14366                    self.prediction_provenance.as_ref()
14367                else {
14368                    return Err(OutputContractError::PredictionRevisionMismatch);
14369                };
14370                prediction.validate_against_provenance(provenance)?;
14371                prediction.validate_for_check(
14372                    check.check_id(),
14373                    check.evaluated_scopes(),
14374                    check.gaps(),
14375                    check.findings(),
14376                )?;
14377                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14378                facets = facets
14379                    .checked_add(prediction.facets().len())
14380                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14381                references = references
14382                    .checked_add(prediction.basis_reference_count())
14383                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14384                text = text
14385                    .checked_add(prediction.retained_text_bytes()?)
14386                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14387            }
14388        }
14389        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14390            return Err(OutputContractError::TooManyPredictionFacets {
14391                found: facets,
14392                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14393            });
14394        }
14395        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14396            return Err(
14397                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14398                    found: facets,
14399                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14400                },
14401            );
14402        }
14403        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14404            return Err(OutputContractError::TooManyPredictionBasisReferences {
14405                found: references,
14406                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14407            });
14408        }
14409        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14410            return Err(OutputContractError::TooMuchPredictionText {
14411                found: text,
14412                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14413            });
14414        }
14415        validate_measurement_references_batch_v3(
14416            &self.evidence.measurements,
14417            self.checks
14418                .iter()
14419                .enumerate()
14420                .filter_map(|(check_index, check)| {
14421                    check
14422                        .engine_prediction_v3()
14423                        .map(|prediction| (check_index, prediction))
14424                }),
14425        )
14426        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14427        validate_measurement_references_batch_v4(
14428            &self.evidence.measurements,
14429            self.checks
14430                .iter()
14431                .enumerate()
14432                .filter_map(|(check_index, check)| {
14433                    check
14434                        .engine_prediction_v4()
14435                        .map(|prediction| (check_index, prediction))
14436                }),
14437        )
14438        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14439        Ok(())
14440    }
14441}
14442
14443#[derive(Debug, Clone, Serialize)]
14444struct EnvelopeHeaderV2 {
14445    schema_version: u32,
14446    schema: &'static str,
14447    tool: ToolInfo,
14448    command: &'static str,
14449}
14450
14451/// Immutable historical output-v15 lint envelope.
14452#[derive(Debug, Clone, Serialize)]
14453pub struct LintEnvelope {
14454    #[serde(flatten)]
14455    header: EnvelopeHeaderV2,
14456    summary: LintSummary,
14457    files: Vec<LintFileReport>,
14458}
14459
14460impl LintEnvelope {
14461    /// Construct a V2 lint envelope and derive its summaries.
14462    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
14463        if files.len() > OUTPUT_V11_MAX_FILES {
14464            return Err(OutputContractError::TooManyFiles {
14465                found: files.len(),
14466                limit: OUTPUT_V11_MAX_FILES,
14467            });
14468        }
14469        let mut findings = FindingSummary::default();
14470        let mut checks = CheckSummary::default();
14471        let mut prediction_facets = PredictionFacetSummary::default();
14472        for file in &files {
14473            file.validate()?;
14474            for check in file.checks() {
14475                checks.total += 1;
14476                for finding in check.findings() {
14477                    findings.add(finding.severity);
14478                }
14479                match check.selection() {
14480                    SelectionState::Selected => checks.selection.selected += 1,
14481                    SelectionState::Unselected => checks.selection.unselected += 1,
14482                }
14483                match check.configuration() {
14484                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14485                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14486                }
14487                match check.applicability() {
14488                    Applicability::Applicable => checks.applicability.applicable += 1,
14489                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14490                }
14491                match check.evaluation() {
14492                    EvaluationState::Complete => checks.evaluation.complete += 1,
14493                    EvaluationState::Partial => checks.evaluation.partial += 1,
14494                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14495                }
14496                checks.gaps += check.gaps().len();
14497                if let Some(prediction) = check.engine_prediction_v3() {
14498                    for facet in prediction.facets() {
14499                        match facet.state() {
14500                            EnginePredictionFacetStateV1::Available => {
14501                                prediction_facets.available += 1;
14502                            }
14503                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14504                                prediction_facets.required_prediction_unavailable += 1;
14505                            }
14506                        }
14507                    }
14508                }
14509                if let Some(prediction) = check.engine_prediction_v4() {
14510                    for facet in prediction.facets() {
14511                        match facet.state() {
14512                            EnginePredictionFacetStateV1::Available => {
14513                                prediction_facets.available += 1;
14514                            }
14515                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14516                                prediction_facets.required_prediction_unavailable += 1;
14517                            }
14518                        }
14519                    }
14520                }
14521            }
14522        }
14523        Ok(Self {
14524            header: EnvelopeHeaderV2 {
14525                schema_version: OUTPUT_V15_SCHEMA_VERSION,
14526                schema: OUTPUT_V15_SCHEMA_ID,
14527                tool,
14528                command: "lint",
14529            },
14530            summary: LintSummary {
14531                files: files.len(),
14532                findings,
14533                checks,
14534                prediction_facets,
14535            },
14536            files,
14537        })
14538    }
14539}
14540
14541#[derive(Debug, Clone, Serialize)]
14542#[serde(untagged)]
14543#[allow(clippy::large_enum_variant)]
14544enum CurrentPredictionProvenanceV16 {
14545    V3(PredictionProvenanceV3),
14546    V5(PredictionProvenanceV5),
14547}
14548
14549/// Current output-v16 lint file evidence with V3 or V5 prediction provenance.
14550#[derive(Debug, Clone, Serialize)]
14551pub struct LintFileReportV16 {
14552    #[serde(flatten)]
14553    evidence: FileEvidence,
14554    prediction_provenance: Option<CurrentPredictionProvenanceV16>,
14555    checks: Vec<CheckEvaluation>,
14556}
14557
14558impl LintFileReportV16 {
14559    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14560    pub fn new(
14561        path: impl Into<String>,
14562        input: InputIdentity,
14563        rig: RigInfo,
14564        prediction_provenance: Option<PredictionProvenanceV3>,
14565        checks: Vec<CheckEvaluation>,
14566        measurements: MeasurementContract,
14567    ) -> Result<Self, OutputContractError> {
14568        let report = Self {
14569            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14570            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V3),
14571            checks,
14572        };
14573        report.validate()?;
14574        Ok(report)
14575    }
14576
14577    /// Construct a revision-2-contract record using V5 provenance and checks.
14578    pub fn new_v5(
14579        path: impl Into<String>,
14580        input: InputIdentity,
14581        rig: RigInfo,
14582        prediction_provenance: Option<PredictionProvenanceV5>,
14583        checks: Vec<CheckEvaluation>,
14584        measurements: MeasurementContract,
14585    ) -> Result<Self, OutputContractError> {
14586        let report = Self {
14587            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14588            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V5),
14589            checks,
14590        };
14591        report.validate()?;
14592        Ok(report)
14593    }
14594
14595    /// Display path supplied by the producer.
14596    pub fn path(&self) -> &str {
14597        &self.evidence.path
14598    }
14599    /// Immutable source-byte identity.
14600    pub fn input(&self) -> &InputIdentity {
14601        &self.evidence.input
14602    }
14603    /// Nested measurement evidence.
14604    pub fn measurements(&self) -> &MeasurementContract {
14605        &self.evidence.measurements
14606    }
14607    /// Catalog-ordered check records.
14608    pub fn checks(&self) -> &[CheckEvaluation] {
14609        &self.checks
14610    }
14611    /// V3 provenance for a revision-1 profile.
14612    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14613        match self.prediction_provenance.as_ref() {
14614            Some(CurrentPredictionProvenanceV16::V3(provenance)) => Some(provenance),
14615            Some(CurrentPredictionProvenanceV16::V5(_)) | None => None,
14616        }
14617    }
14618    /// V5 provenance for a revision-2-contract profile.
14619    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14620        match self.prediction_provenance.as_ref() {
14621            Some(CurrentPredictionProvenanceV16::V5(provenance)) => Some(provenance),
14622            Some(CurrentPredictionProvenanceV16::V3(_)) | None => None,
14623        }
14624    }
14625
14626    fn validate(&self) -> Result<(), OutputContractError> {
14627        require_measurements_v16(OUTPUT_V16_SCHEMA_VERSION, &self.evidence.measurements)?;
14628        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14629            return Err(OutputContractError::TooManyChecks {
14630                found: self.checks.len(),
14631                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14632            });
14633        }
14634        if let Some(CurrentPredictionProvenanceV16::V3(provenance)) = &self.prediction_provenance {
14635            return LintFileReport {
14636                evidence: self.evidence.clone(),
14637                prediction_provenance: Some(CurrentPredictionProvenance::V3(provenance.clone())),
14638                checks: self.checks.clone(),
14639            }
14640            .validate();
14641        }
14642        let provenance = match &self.prediction_provenance {
14643            Some(CurrentPredictionProvenanceV16::V5(provenance)) => {
14644                provenance.validate()?;
14645                if provenance.base().raw_source().primary_input() != &self.evidence.input {
14646                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
14647                }
14648                Some(provenance)
14649            }
14650            Some(CurrentPredictionProvenanceV16::V3(_)) => unreachable!(),
14651            None => None,
14652        };
14653        let mut facets = 0usize;
14654        let mut references = 0usize;
14655        let mut has_facet_budget_summary = false;
14656        let mut text = provenance
14657            .map(PredictionProvenanceV5::retained_text_bytes)
14658            .transpose()?
14659            .unwrap_or(0);
14660        for check in &self.checks {
14661            if check.engine_prediction().is_some()
14662                || check.engine_prediction_v2().is_some()
14663                || check.engine_prediction_v3().is_some()
14664                || check.engine_prediction_v4().is_some()
14665            {
14666                return Err(OutputContractError::HistoricalPredictionInV2Output);
14667            }
14668            let prediction = check.engine_prediction_v5();
14669            if provenance.is_none() && prediction.is_some() {
14670                return Err(OutputContractError::PredictionWithoutProvenance);
14671            }
14672            if let Some(prediction) = prediction {
14673                let provenance =
14674                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
14675                prediction.validate_against_provenance(provenance)?;
14676                prediction.validate_for_check(
14677                    check.check_id(),
14678                    check.evaluated_scopes(),
14679                    check.gaps(),
14680                    check.findings(),
14681                )?;
14682                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
14683                facets = facets
14684                    .checked_add(prediction.facets().len())
14685                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14686                references = references
14687                    .checked_add(prediction.basis_reference_count())
14688                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14689                text = text
14690                    .checked_add(prediction.retained_text_bytes()?)
14691                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14692            }
14693            validate_current_engine_track_support_prediction_v5(
14694                check.check_id(),
14695                check.selection(),
14696                check.configuration(),
14697                check.applicability(),
14698                prediction,
14699                provenance,
14700                check.findings().is_empty(),
14701            )?;
14702            validate_current_engine_unit_scale_prediction_v5(
14703                check.check_id(),
14704                check.selection(),
14705                check.configuration(),
14706                check.applicability(),
14707                prediction,
14708                provenance,
14709                &self.evidence.measurements,
14710            )?;
14711        }
14712        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14713            return Err(OutputContractError::TooManyPredictionFacets {
14714                found: facets,
14715                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14716            });
14717        }
14718        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14719            return Err(
14720                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14721                    found: facets,
14722                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14723                },
14724            );
14725        }
14726        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14727            return Err(OutputContractError::TooManyPredictionBasisReferences {
14728                found: references,
14729                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14730            });
14731        }
14732        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14733            return Err(OutputContractError::TooMuchPredictionText {
14734                found: text,
14735                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14736            });
14737        }
14738        validate_measurement_references_batch_v4(
14739            &self.evidence.measurements,
14740            self.checks.iter().enumerate().filter_map(|(index, check)| {
14741                check
14742                    .engine_prediction_v5()
14743                    .map(|prediction| (index, prediction.base_prediction()))
14744            }),
14745        )
14746        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14747        Ok(())
14748    }
14749}
14750
14751/// Current output-v16 lint envelope.
14752#[derive(Debug, Clone, Serialize)]
14753pub struct LintEnvelopeV16 {
14754    #[serde(flatten)]
14755    header: EnvelopeHeaderV2,
14756    summary: LintSummary,
14757    files: Vec<LintFileReportV16>,
14758}
14759
14760impl LintEnvelopeV16 {
14761    /// Construct a schema-valid V16 lint envelope and derive summaries.
14762    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV16>) -> Result<Self, OutputContractError> {
14763        if files.len() > OUTPUT_V11_MAX_FILES {
14764            return Err(OutputContractError::TooManyFiles {
14765                found: files.len(),
14766                limit: OUTPUT_V11_MAX_FILES,
14767            });
14768        }
14769        let mut findings = FindingSummary::default();
14770        let mut checks = CheckSummary::default();
14771        let mut prediction_facets = PredictionFacetSummary::default();
14772        for file in &files {
14773            file.validate()?;
14774            for check in file.checks() {
14775                checks.total += 1;
14776                for finding in check.findings() {
14777                    findings.add(finding.severity);
14778                }
14779                match check.selection() {
14780                    SelectionState::Selected => checks.selection.selected += 1,
14781                    SelectionState::Unselected => checks.selection.unselected += 1,
14782                }
14783                match check.configuration() {
14784                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14785                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14786                }
14787                match check.applicability() {
14788                    Applicability::Applicable => checks.applicability.applicable += 1,
14789                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14790                }
14791                match check.evaluation() {
14792                    EvaluationState::Complete => checks.evaluation.complete += 1,
14793                    EvaluationState::Partial => checks.evaluation.partial += 1,
14794                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14795                }
14796                checks.gaps += check.gaps().len();
14797                for facet in check
14798                    .engine_prediction_v3()
14799                    .into_iter()
14800                    .flat_map(EnginePredictionV3::facets)
14801                {
14802                    match facet.state() {
14803                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
14804                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14805                            prediction_facets.required_prediction_unavailable += 1
14806                        }
14807                    }
14808                }
14809                for facet in check
14810                    .engine_prediction_v5()
14811                    .into_iter()
14812                    .flat_map(EnginePredictionV5::facets)
14813                {
14814                    match facet.state() {
14815                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
14816                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14817                            prediction_facets.required_prediction_unavailable += 1
14818                        }
14819                    }
14820                }
14821            }
14822        }
14823        Ok(Self {
14824            header: EnvelopeHeaderV2 {
14825                schema_version: OUTPUT_V16_SCHEMA_VERSION,
14826                schema: OUTPUT_V16_SCHEMA_ID,
14827                tool,
14828                command: "lint",
14829            },
14830            summary: LintSummary {
14831                files: files.len(),
14832                findings,
14833                checks,
14834                prediction_facets,
14835            },
14836            files,
14837        })
14838    }
14839}
14840
14841#[derive(Debug, Clone, Serialize)]
14842#[serde(untagged)]
14843#[allow(clippy::large_enum_variant)]
14844enum CurrentPredictionProvenanceV17 {
14845    V3(PredictionProvenanceV3),
14846    V5(PredictionProvenanceV5),
14847    V6(PredictionProvenanceV6),
14848}
14849
14850/// Historical output-v17 lint file evidence with immutable V3, V5, or V6 provenance.
14851#[derive(Debug, Clone, Serialize)]
14852pub struct LintFileReportV17 {
14853    #[serde(flatten)]
14854    evidence: FileEvidence,
14855    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
14856    checks: Vec<CheckEvaluation>,
14857}
14858
14859impl LintFileReportV17 {
14860    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14861    pub fn new(
14862        path: impl Into<String>,
14863        input: InputIdentity,
14864        rig: RigInfo,
14865        prediction_provenance: Option<PredictionProvenanceV3>,
14866        checks: Vec<CheckEvaluation>,
14867        measurements: MeasurementContract,
14868    ) -> Result<Self, OutputContractError> {
14869        let report = Self {
14870            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14871            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
14872            checks,
14873        };
14874        report.validate()?;
14875        Ok(report)
14876    }
14877
14878    /// Construct a V5 record whose output-v16 graph remains unchanged.
14879    pub fn new_v5(
14880        path: impl Into<String>,
14881        input: InputIdentity,
14882        rig: RigInfo,
14883        prediction_provenance: Option<PredictionProvenanceV5>,
14884        checks: Vec<CheckEvaluation>,
14885        measurements: MeasurementContract,
14886    ) -> Result<Self, OutputContractError> {
14887        let report = Self {
14888            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14889            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
14890            checks,
14891        };
14892        report.validate()?;
14893        Ok(report)
14894    }
14895
14896    /// Construct a transform-path-and-intent-bound V6 record.
14897    pub fn new_v6(
14898        path: impl Into<String>,
14899        input: InputIdentity,
14900        rig: RigInfo,
14901        prediction_provenance: Option<PredictionProvenanceV6>,
14902        checks: Vec<CheckEvaluation>,
14903        measurements: MeasurementContract,
14904    ) -> Result<Self, OutputContractError> {
14905        let report = Self {
14906            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14907            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
14908            checks,
14909        };
14910        report.validate()?;
14911        Ok(report)
14912    }
14913
14914    /// Display path supplied by the producer.
14915    pub fn path(&self) -> &str {
14916        &self.evidence.path
14917    }
14918    /// Immutable source-byte identity.
14919    pub fn input(&self) -> &InputIdentity {
14920        &self.evidence.input
14921    }
14922    /// Nested measurement evidence.
14923    pub fn measurements(&self) -> &MeasurementContract {
14924        &self.evidence.measurements
14925    }
14926    /// Catalog-ordered check records.
14927    pub fn checks(&self) -> &[CheckEvaluation] {
14928        &self.checks
14929    }
14930    /// V3 provenance for a revision-1 profile.
14931    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14932        match self.prediction_provenance.as_ref() {
14933            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
14934            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
14935            | None => None,
14936        }
14937    }
14938    /// Immutable V5 provenance.
14939    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14940        match self.prediction_provenance.as_ref() {
14941            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
14942            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
14943            | None => None,
14944        }
14945    }
14946    /// Current V6 provenance.
14947    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
14948        match self.prediction_provenance.as_ref() {
14949            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
14950            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
14951            | None => None,
14952        }
14953    }
14954
14955    fn validate(&self) -> Result<(), OutputContractError> {
14956        require_measurements_v16(OUTPUT_V17_SCHEMA_VERSION, &self.evidence.measurements)?;
14957        match &self.prediction_provenance {
14958            Some(CurrentPredictionProvenanceV17::V3(provenance)) => {
14959                return LintFileReportV16::new(
14960                    self.evidence.path.clone(),
14961                    self.evidence.input.clone(),
14962                    self.evidence.rig.clone(),
14963                    Some(provenance.clone()),
14964                    self.checks.clone(),
14965                    self.evidence.measurements.clone(),
14966                )
14967                .map(|_| ());
14968            }
14969            Some(CurrentPredictionProvenanceV17::V5(provenance)) => {
14970                return LintFileReportV16::new_v5(
14971                    self.evidence.path.clone(),
14972                    self.evidence.input.clone(),
14973                    self.evidence.rig.clone(),
14974                    Some(provenance.clone()),
14975                    self.checks.clone(),
14976                    self.evidence.measurements.clone(),
14977                )
14978                .map(|_| ());
14979            }
14980            Some(CurrentPredictionProvenanceV17::V6(_)) => {}
14981            None => {
14982                return LintFileReportV16::new(
14983                    self.evidence.path.clone(),
14984                    self.evidence.input.clone(),
14985                    self.evidence.rig.clone(),
14986                    None,
14987                    self.checks.clone(),
14988                    self.evidence.measurements.clone(),
14989                )
14990                .map(|_| ());
14991            }
14992        }
14993        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14994            return Err(OutputContractError::TooManyChecks {
14995                found: self.checks.len(),
14996                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14997            });
14998        }
14999        let provenance = match &self.prediction_provenance {
15000            Some(CurrentPredictionProvenanceV17::V6(provenance)) => {
15001                provenance.validate()?;
15002                if provenance.base().base().raw_source().primary_input() != &self.evidence.input {
15003                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
15004                }
15005                Some(provenance)
15006            }
15007            _ => unreachable!(),
15008        };
15009        let mut facets = 0usize;
15010        let mut references = 0usize;
15011        let mut has_facet_budget_summary = false;
15012        let mut text = provenance
15013            .map(PredictionProvenanceV6::retained_text_bytes)
15014            .transpose()?
15015            .unwrap_or(0);
15016        for check in &self.checks {
15017            if check.engine_prediction().is_some()
15018                || check.engine_prediction_v2().is_some()
15019                || check.engine_prediction_v3().is_some()
15020                || check.engine_prediction_v4().is_some()
15021                || check.engine_prediction_v5().is_some()
15022            {
15023                return Err(OutputContractError::HistoricalPredictionInV2Output);
15024            }
15025            let prediction = check.engine_prediction_v6();
15026            if let Some(prediction) = prediction {
15027                let provenance =
15028                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
15029                prediction.validate_against_provenance(provenance)?;
15030                prediction.validate_for_check(
15031                    check.check_id(),
15032                    check.evaluated_scopes(),
15033                    check.gaps(),
15034                    check.findings(),
15035                )?;
15036                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
15037                facets = facets
15038                    .checked_add(prediction.facets().len())
15039                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15040                references = references
15041                    .checked_add(prediction.basis_reference_count())
15042                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15043                text = text
15044                    .checked_add(prediction.retained_text_bytes()?)
15045                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15046            }
15047            validate_current_engine_root_motion_prediction_v6(
15048                check.check_id(),
15049                check.selection(),
15050                check.configuration(),
15051                check.applicability(),
15052                prediction,
15053                provenance,
15054                check.findings(),
15055                &self.evidence.rig,
15056                &self.evidence.measurements,
15057            )?;
15058        }
15059        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
15060            return Err(OutputContractError::TooManyPredictionFacets {
15061                found: facets,
15062                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15063            });
15064        }
15065        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
15066            return Err(
15067                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
15068                    found: facets,
15069                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15070                },
15071            );
15072        }
15073        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
15074            return Err(OutputContractError::TooManyPredictionBasisReferences {
15075                found: references,
15076                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
15077            });
15078        }
15079        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
15080            return Err(OutputContractError::TooMuchPredictionText {
15081                found: text,
15082                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
15083            });
15084        }
15085        validate_measurement_references_batch_v4(
15086            &self.evidence.measurements,
15087            self.checks.iter().enumerate().filter_map(|(index, check)| {
15088                check
15089                    .engine_prediction_v6()
15090                    .map(|prediction| (index, prediction.base_prediction()))
15091            }),
15092        )
15093        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
15094        Ok(())
15095    }
15096}
15097
15098/// Current output-v18 lint-file payload. Immutable prediction contracts are
15099/// checked against the deterministic measurements-v16 projection while the
15100/// report retains the complete measurements-v17 evidence.
15101#[derive(Debug, Clone, Serialize)]
15102pub struct LintFileReportV18 {
15103    #[serde(flatten)]
15104    evidence: FileEvidence,
15105    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15106    checks: Vec<CheckEvaluation>,
15107}
15108
15109impl LintFileReportV18 {
15110    /// Construct a revision-1-profile record whose V3 graph is unchanged.
15111    pub fn new(
15112        path: impl Into<String>,
15113        input: InputIdentity,
15114        rig: RigInfo,
15115        prediction_provenance: Option<PredictionProvenanceV3>,
15116        checks: Vec<CheckEvaluation>,
15117        measurements: MeasurementContract,
15118    ) -> Result<Self, OutputContractError> {
15119        Self::build(
15120            path,
15121            input,
15122            rig,
15123            prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
15124            checks,
15125            measurements,
15126        )
15127    }
15128
15129    /// Construct a V5 record whose immutable prediction graph consumes V16.
15130    pub fn new_v5(
15131        path: impl Into<String>,
15132        input: InputIdentity,
15133        rig: RigInfo,
15134        prediction_provenance: Option<PredictionProvenanceV5>,
15135        checks: Vec<CheckEvaluation>,
15136        measurements: MeasurementContract,
15137    ) -> Result<Self, OutputContractError> {
15138        Self::build(
15139            path,
15140            input,
15141            rig,
15142            prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
15143            checks,
15144            measurements,
15145        )
15146    }
15147
15148    /// Construct a V6 record whose immutable prediction graph consumes V16.
15149    pub fn new_v6(
15150        path: impl Into<String>,
15151        input: InputIdentity,
15152        rig: RigInfo,
15153        prediction_provenance: Option<PredictionProvenanceV6>,
15154        checks: Vec<CheckEvaluation>,
15155        measurements: MeasurementContract,
15156    ) -> Result<Self, OutputContractError> {
15157        Self::build(
15158            path,
15159            input,
15160            rig,
15161            prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
15162            checks,
15163            measurements,
15164        )
15165    }
15166
15167    fn build(
15168        path: impl Into<String>,
15169        input: InputIdentity,
15170        rig: RigInfo,
15171        prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15172        checks: Vec<CheckEvaluation>,
15173        measurements: MeasurementContract,
15174    ) -> Result<Self, OutputContractError> {
15175        require_measurements_v17(OUTPUT_SCHEMA_VERSION, &measurements)?;
15176        let report = Self {
15177            evidence: FileEvidence::new(path, input, rig, measurements),
15178            prediction_provenance,
15179            checks,
15180        };
15181        report.historical_prediction_view()?;
15182        Ok(report)
15183    }
15184
15185    /// Display path supplied by the producer.
15186    pub fn path(&self) -> &str {
15187        &self.evidence.path
15188    }
15189
15190    /// Immutable source-byte identity.
15191    pub fn input(&self) -> &InputIdentity {
15192        &self.evidence.input
15193    }
15194
15195    /// Nested current measurement evidence.
15196    pub fn measurements(&self) -> &MeasurementContract {
15197        &self.evidence.measurements
15198    }
15199
15200    /// Catalog-ordered check records.
15201    pub fn checks(&self) -> &[CheckEvaluation] {
15202        &self.checks
15203    }
15204
15205    /// V3 provenance for a revision-1 profile.
15206    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
15207        match self.prediction_provenance.as_ref() {
15208            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
15209            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
15210            | None => None,
15211        }
15212    }
15213
15214    /// Immutable V5 provenance.
15215    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
15216        match self.prediction_provenance.as_ref() {
15217            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
15218            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
15219            | None => None,
15220        }
15221    }
15222
15223    /// Immutable V6 provenance.
15224    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
15225        match self.prediction_provenance.as_ref() {
15226            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
15227            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
15228            | None => None,
15229        }
15230    }
15231
15232    fn historical_prediction_view(&self) -> Result<LintFileReportV17, OutputContractError> {
15233        let measurements = self.evidence.measurements.prediction_v16_projection()?;
15234        let report = LintFileReportV17 {
15235            evidence: FileEvidence::new(
15236                self.evidence.path.clone(),
15237                self.evidence.input.clone(),
15238                self.evidence.rig.clone(),
15239                measurements,
15240            ),
15241            prediction_provenance: self.prediction_provenance.clone(),
15242            checks: self.checks.clone(),
15243        };
15244        report.validate()?;
15245        Ok(report)
15246    }
15247}
15248
15249/// Historical output-v17 lint envelope retained for immutable writer tests and
15250/// the output-v18 prediction projection.
15251#[derive(Debug, Clone, Serialize)]
15252pub struct LintEnvelopeV17 {
15253    #[serde(flatten)]
15254    header: EnvelopeHeaderV2,
15255    summary: LintSummary,
15256    files: Vec<LintFileReportV17>,
15257}
15258
15259impl LintEnvelopeV17 {
15260    /// Construct a schema-valid V17 lint envelope and derive summaries.
15261    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV17>) -> Result<Self, OutputContractError> {
15262        if files.len() > OUTPUT_V11_MAX_FILES {
15263            return Err(OutputContractError::TooManyFiles {
15264                found: files.len(),
15265                limit: OUTPUT_V11_MAX_FILES,
15266            });
15267        }
15268        let mut findings = FindingSummary::default();
15269        let mut checks = CheckSummary::default();
15270        let mut prediction_facets = PredictionFacetSummary::default();
15271        for file in &files {
15272            file.validate()?;
15273            for check in file.checks() {
15274                checks.total += 1;
15275                for finding in check.findings() {
15276                    findings.add(finding.severity);
15277                }
15278                match check.selection() {
15279                    SelectionState::Selected => checks.selection.selected += 1,
15280                    SelectionState::Unselected => checks.selection.unselected += 1,
15281                }
15282                match check.configuration() {
15283                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
15284                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
15285                }
15286                match check.applicability() {
15287                    Applicability::Applicable => checks.applicability.applicable += 1,
15288                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
15289                }
15290                match check.evaluation() {
15291                    EvaluationState::Complete => checks.evaluation.complete += 1,
15292                    EvaluationState::Partial => checks.evaluation.partial += 1,
15293                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
15294                }
15295                checks.gaps += check.gaps().len();
15296                let mut add_facet = |state| match state {
15297                    EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15298                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15299                        prediction_facets.required_prediction_unavailable += 1
15300                    }
15301                };
15302                for facet in check
15303                    .engine_prediction_v3()
15304                    .into_iter()
15305                    .flat_map(EnginePredictionV3::facets)
15306                {
15307                    add_facet(facet.state());
15308                }
15309                for facet in check
15310                    .engine_prediction_v5()
15311                    .into_iter()
15312                    .flat_map(EnginePredictionV5::facets)
15313                {
15314                    add_facet(facet.state());
15315                }
15316                for facet in check
15317                    .engine_prediction_v6()
15318                    .into_iter()
15319                    .flat_map(EnginePredictionV6::facets)
15320                {
15321                    add_facet(facet.state());
15322                }
15323            }
15324        }
15325        Ok(Self {
15326            header: EnvelopeHeaderV2 {
15327                schema_version: OUTPUT_V17_SCHEMA_VERSION,
15328                schema: OUTPUT_V17_SCHEMA_ID,
15329                tool,
15330                command: "lint",
15331            },
15332            summary: LintSummary {
15333                files: files.len(),
15334                findings,
15335                checks,
15336                prediction_facets,
15337            },
15338            files,
15339        })
15340    }
15341}
15342
15343/// Current output-v18 lint envelope.
15344#[derive(Debug, Clone, Serialize)]
15345pub struct LintEnvelopeV18 {
15346    #[serde(flatten)]
15347    header: EnvelopeHeaderV2,
15348    summary: LintSummary,
15349    files: Vec<LintFileReportV18>,
15350}
15351
15352impl LintEnvelopeV18 {
15353    /// Construct a schema-valid V18 lint envelope and derive summaries.
15354    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV18>) -> Result<Self, OutputContractError> {
15355        if files.len() > OUTPUT_V11_MAX_FILES {
15356            return Err(OutputContractError::TooManyFiles {
15357                found: files.len(),
15358                limit: OUTPUT_V11_MAX_FILES,
15359            });
15360        }
15361        for file in &files {
15362            require_measurements_v17(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
15363        }
15364        let historical_files = files
15365            .iter()
15366            .map(LintFileReportV18::historical_prediction_view)
15367            .collect::<Result<Vec<_>, _>>()?;
15368        let historical = LintEnvelopeV17::new(tool.clone(), historical_files)?;
15369        Ok(Self {
15370            header: EnvelopeHeaderV2 {
15371                schema_version: OUTPUT_SCHEMA_VERSION,
15372                schema: OUTPUT_SCHEMA_ID,
15373                tool,
15374                command: "lint",
15375            },
15376            summary: historical.summary,
15377            files,
15378        })
15379    }
15380}
15381
15382#[derive(Debug, Clone, Serialize)]
15383struct DiffInputs {
15384    before: String,
15385    after: String,
15386}
15387
15388#[derive(Debug, Clone, Serialize)]
15389struct DiffSummary {
15390    deltas: usize,
15391}
15392
15393/// Current diff-command result envelope.
15394#[derive(Debug, Serialize)]
15395pub struct DiffEnvelope {
15396    #[serde(flatten)]
15397    header: EnvelopeHeader,
15398    inputs: DiffInputs,
15399    summary: DiffSummary,
15400    deltas: Vec<MetricDelta>,
15401}
15402
15403impl DiffEnvelope {
15404    /// Construct a schema-valid diff envelope.
15405    pub fn new(
15406        tool: ToolInfo,
15407        before: impl Into<String>,
15408        after: impl Into<String>,
15409        deltas: Vec<MetricDelta>,
15410    ) -> Self {
15411        Self {
15412            header: EnvelopeHeader::new(tool, "diff"),
15413            inputs: DiffInputs {
15414                before: before.into(),
15415                after: after.into(),
15416            },
15417            summary: DiffSummary {
15418                deltas: deltas.len(),
15419            },
15420            deltas,
15421        }
15422    }
15423}