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, decode_engine_prediction_v2,
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, decode_prediction_provenance_v2_with_measurement_schema,
58    decode_prediction_provenance_v3, decode_prediction_provenance_v4,
59    validate_measurement_references_batch, validate_measurement_references_batch_v2,
60    validate_measurement_references_batch_v3, 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 = 17;
78/// Immutable identity of the current outer result envelope.
79pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:17";
80/// Immutable output-v16 identity retained for historical V5 prediction evidence.
81pub const OUTPUT_V16_SCHEMA_ID: &str = "urn:animsmith:schema:output:16";
82/// Schema version of output-v16.
83pub const OUTPUT_V16_SCHEMA_VERSION: u32 = 16;
84/// Immutable output-v15 identity retained for historical V4 prediction evidence.
85pub const OUTPUT_V15_SCHEMA_ID: &str = "urn:animsmith:schema:output:15";
86/// Schema version of output-v15.
87pub const OUTPUT_V15_SCHEMA_VERSION: u32 = 15;
88/// Immutable output-v10 identity retained by V1 dependent contracts.
89pub const OUTPUT_V10_SCHEMA_ID: &str = "urn:animsmith:schema:output:10";
90/// Immutable output-v11 identity retained as historical schema evidence.
91pub const OUTPUT_V11_SCHEMA_ID: &str = "urn:animsmith:schema:output:11";
92/// Immutable output-v11 version retained as historical schema evidence.
93pub const OUTPUT_V11_SCHEMA_VERSION: u32 = 11;
94/// Immutable identity of the bounded-overflow outer result envelope.
95pub const OUTPUT_V12_SCHEMA_ID: &str = "urn:animsmith:schema:output:12";
96/// Schema version of output-v12.
97pub const OUTPUT_V12_SCHEMA_VERSION: u32 = 12;
98/// Immutable output-v13 identity retained as historical V2 prediction evidence.
99pub const OUTPUT_V13_SCHEMA_ID: &str = "urn:animsmith:schema:output:13";
100/// Schema version of output-v13.
101pub const OUTPUT_V13_SCHEMA_VERSION: u32 = 13;
102/// Immutable output-v14 identity retained for historical V3 prediction evidence.
103pub const OUTPUT_V14_SCHEMA_ID: &str = "urn:animsmith:schema:output:14";
104/// Schema version of output-v14.
105pub const OUTPUT_V14_SCHEMA_VERSION: u32 = 14;
106/// Maximum serialized bytes accepted by the output-v11 report reader.
107pub const OUTPUT_V11_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
108/// Maximum file records carried by one output-v11 envelope.
109pub const OUTPUT_V11_MAX_FILES: usize = 4_096;
110/// Maximum check records carried by one output-v11 lint file.
111pub const OUTPUT_V11_MAX_CHECKS_PER_FILE: usize = 4_096;
112/// Current nested measurement-contract version.
113pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 16;
114/// Immutable identity of the current nested measurement contract.
115pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:16";
116/// Immutable measurements-v15 identity retained for output-v11 and output-v12 readers.
117pub const MEASUREMENTS_V15_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:15";
118/// Immutable measurements-v15 version retained for historical report readers.
119pub const MEASUREMENTS_V15_SCHEMA_VERSION: u32 = 15;
120
121/// Source checkout identity for the producing animsmith build.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct ToolSource {
124    revision: Option<String>,
125    dirty: Option<bool>,
126}
127
128impl ToolSource {
129    /// Construct source identity from a full Git revision and dirty bit.
130    ///
131    /// Packaged or otherwise provenance-free builds use `None` for fields they
132    /// cannot establish rather than claiming a clean checkout. Revisions that
133    /// are not full 40-character hexadecimal Git object ids are dropped so an
134    /// envelope constructed through this API remains within output v11.
135    pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
136        let revision = revision.filter(|revision| {
137            revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
138        });
139        Self { revision, dirty }
140    }
141}
142
143/// Identity of the animsmith producer that emitted an envelope.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct ToolInfo {
146    name: &'static str,
147    version: &'static str,
148    source: ToolSource,
149}
150
151impl ToolInfo {
152    /// Construct animsmith producer identity from this package's version and
153    /// optional source-checkout metadata.
154    pub fn animsmith(source: ToolSource) -> Self {
155        Self {
156            name: "animsmith",
157            version: env!("CARGO_PKG_VERSION"),
158            source,
159        }
160    }
161}
162
163/// Immutable identity of the bytes used to produce one file report.
164///
165/// The digest is lowercase hexadecimal SHA-256 so consumers can compare
166/// identities without retaining the source bytes themselves.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168pub struct InputIdentity {
169    sha256: String,
170    bytes: u64,
171}
172
173/// Lowercase hexadecimal SHA-256 digest of exactly these bytes.
174///
175/// The digest type carries no `LowerHex` impl, so every producer of a
176/// contract `sha256` field goes through this one formatter rather than
177/// re-deriving the encoding.
178#[must_use]
179pub fn sha256_hex(bytes: &[u8]) -> String {
180    sha256_digest_hex(Sha256::digest(bytes).into())
181}
182
183fn sha256_digest_hex(digest: [u8; 32]) -> String {
184    let mut hex = String::with_capacity(64);
185    for byte in digest {
186        let _ = write!(hex, "{byte:02x}");
187    }
188    hex
189}
190
191impl InputIdentity {
192    /// Calculate the identity for source bytes.
193    pub fn from_bytes(bytes: &[u8]) -> Self {
194        Self {
195            sha256: sha256_hex(bytes),
196            bytes: bytes.len() as u64,
197        }
198    }
199
200    /// Construct an identity from an already-computed SHA-256 digest and exact byte count.
201    ///
202    /// This keeps streaming and bounded readers on the same lowercase digest
203    /// authority without exposing an unchecked string constructor.
204    pub fn from_sha256_digest(digest: [u8; 32], bytes: u64) -> Self {
205        Self {
206            sha256: sha256_digest_hex(digest),
207            bytes,
208        }
209    }
210
211    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
212    pub fn sha256(&self) -> &str {
213        &self.sha256
214    }
215
216    /// Number of source bytes represented by this identity.
217    pub fn bytes(&self) -> u64 {
218        self.bytes
219    }
220}
221
222/// Rig profile and resolved semantic-role bindings for one input file.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct RigInfo {
226    profile: String,
227    resolution_outcome: String,
228    resolved_roles: BTreeMap<String, String>,
229    resolved_role_policies: BTreeMap<String, String>,
230}
231
232/// Resolved-role evidence did not belong to the supplied document.
233#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
234#[non_exhaustive]
235pub enum RigInfoError {
236    /// A resolved role referenced a bone outside the document's skeleton.
237    #[error(
238        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
239    )]
240    InvalidBoneId {
241        /// Stable semantic role name.
242        role: &'static str,
243        /// Invalid bone index carried by the resolution.
244        bone: usize,
245        /// Number of bones available in the supplied document.
246        bone_count: usize,
247    },
248    /// A valid bone index now names a different bone than the resolution did.
249    #[error(
250        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
251    )]
252    BoneNameMismatch {
253        /// Stable semantic role name.
254        role: &'static str,
255        /// Bone index carried by the resolution.
256        bone: usize,
257        /// Bone name captured when the role was resolved.
258        expected: String,
259        /// Bone name at that index in the supplied document.
260        found: String,
261    },
262}
263
264impl RigInfo {
265    /// Project resolved roles into their stable role names and source bone
266    /// names for the result contract.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`RigInfoError`] when `roles` references a bone outside the
271    /// supplied document, such as a resolution produced from another
272    /// skeleton.
273    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
274        let resolved = roles
275            .iter_with_details()
276            .map(|(role, bone, expected_name, policy)| {
277                let name = doc
278                    .skeleton
279                    .bones
280                    .get(bone)
281                    .ok_or(RigInfoError::InvalidBoneId {
282                        role: role.as_str(),
283                        bone,
284                        bone_count: doc.skeleton.bones.len(),
285                    })?;
286                if name.name != expected_name {
287                    return Err(RigInfoError::BoneNameMismatch {
288                        role: role.as_str(),
289                        bone,
290                        expected: expected_name.to_owned(),
291                        found: name.name.clone(),
292                    });
293                }
294                Ok((role.as_str(), (name.name.clone(), policy.as_str())))
295            })
296            .collect::<Result<BTreeMap<_, _>, _>>()?;
297        Ok(Self {
298            profile: roles.profile.clone(),
299            resolution_outcome: roles.outcome().as_str().to_owned(),
300            resolved_roles: resolved
301                .iter()
302                .map(|(&role, (name, _))| (role.to_owned(), name.clone()))
303                .collect(),
304            resolved_role_policies: resolved
305                .into_iter()
306                .map(|(role, (_, policy))| (role.to_owned(), policy.to_owned()))
307                .collect(),
308        })
309    }
310}
311
312/// Independently versioned measurement payload nested in measure and lint
313/// file records.
314#[derive(Debug, Clone, Serialize)]
315pub struct MeasurementContract {
316    schema_version: u32,
317    schema: &'static str,
318    clips: BTreeMap<String, ClipMeasurements>,
319    #[serde(flatten)]
320    assets: AssetMeasurements,
321}
322
323/// Measurement evidence could not satisfy the current measurement contract.
324#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
325#[non_exhaustive]
326pub enum MeasurementContractError {
327    /// A required or present numeric value was non-finite.
328    #[error("measurement value {path} must be finite")]
329    NonFiniteValue {
330        /// Human-readable location within the measurement contract.
331        path: String,
332    },
333    /// Related measurement fields were structurally inconsistent.
334    #[error("measurement structure {path} is invalid: {reason}")]
335    InvalidStructure {
336        /// Human-readable location within the measurement contract.
337        path: String,
338        /// Stable explanation of the violated relationship.
339        reason: String,
340    },
341}
342
343impl MeasurementContract {
344    /// Construct the current measurement contract.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`MeasurementContractError`] when required or present numeric
349    /// evidence is non-finite or structurally inconsistent.
350    pub fn new(
351        clips: BTreeMap<String, ClipMeasurements>,
352        assets: AssetMeasurements,
353    ) -> Result<Self, MeasurementContractError> {
354        validate_measurements(&clips, &assets, MeasurementRevision::V16)?;
355        Ok(Self {
356            schema_version: MEASUREMENTS_SCHEMA_VERSION,
357            schema: MEASUREMENTS_SCHEMA_ID,
358            clips,
359            assets,
360        })
361    }
362
363    fn historical_v15(
364        clips: BTreeMap<String, ClipMeasurements>,
365        assets: AssetMeasurements,
366    ) -> Result<Self, MeasurementContractError> {
367        validate_measurements(&clips, &assets, MeasurementRevision::V15)?;
368        Ok(Self {
369            schema_version: MEASUREMENTS_V15_SCHEMA_VERSION,
370            schema: MEASUREMENTS_V15_SCHEMA_ID,
371            clips,
372            assets,
373        })
374    }
375
376    /// Per-clip measurements keyed by clip name.
377    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
378        &self.clips
379    }
380
381    /// Static source-geometry, node-instance, and declared-scene evidence.
382    pub fn assets(&self) -> &AssetMeasurements {
383        &self.assets
384    }
385
386    /// Consume the contract and return its clip and static asset measurements.
387    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
388        (self.clips, self.assets)
389    }
390}
391
392#[derive(Clone, Copy, PartialEq, Eq)]
393enum MeasurementRevision {
394    V15,
395    V16,
396}
397
398fn validate_measurements(
399    clips: &BTreeMap<String, ClipMeasurements>,
400    assets: &AssetMeasurements,
401    revision: MeasurementRevision,
402) -> Result<(), MeasurementContractError> {
403    let finite = |value: f64, path: String| {
404        value
405            .is_finite()
406            .then_some(())
407            .ok_or(MeasurementContractError::NonFiniteValue { path })
408    };
409    let permits_roundoff = |observed: f64, lower_bound: f64| {
410        let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
411        observed + tolerance >= lower_bound
412    };
413    let check_availability = |value_present: bool,
414                              availability: MeasurementAvailability,
415                              path: String| {
416        match (value_present, availability) {
417            (true, MeasurementAvailability::Measured) => Ok(()),
418            (
419                false,
420                MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
421            ) => Ok(()),
422            _ => Err(MeasurementContractError::InvalidStructure {
423                path,
424                reason: "value presence must match availability status".into(),
425            }),
426        }
427    };
428    for (clip_name, clip) in clips {
429        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
430        let mut previous_bone_index = None;
431        let mut covered_bone_names = BTreeSet::new();
432        for (offset, bone) in clip.bone_channels.iter().enumerate() {
433            let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
434            if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
435                return Err(MeasurementContractError::InvalidStructure {
436                    path: format!("{path}.bone_index"),
437                    reason: "bone channel entries must use strictly increasing unique bone indices"
438                        .into(),
439                });
440            }
441            previous_bone_index = Some(bone.bone_index);
442            if bone.properties.is_empty() {
443                return Err(MeasurementContractError::InvalidStructure {
444                    path: format!("{path}.properties"),
445                    reason: "bone channel coverage must contain at least one property".into(),
446                });
447            }
448            if bone
449                .properties
450                .windows(2)
451                .any(|properties| properties[0] >= properties[1])
452            {
453                return Err(MeasurementContractError::InvalidStructure {
454                    path: format!("{path}.properties"),
455                    reason:
456                        "channel properties must be unique and ordered translation, rotation, scale"
457                            .into(),
458                });
459            }
460            covered_bone_names.insert(bone.bone_name.clone());
461        }
462        let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
463        if clip.animated_bones != expected_animated_bones {
464            return Err(MeasurementContractError::InvalidStructure {
465                path: format!("clips[{clip_name:?}].animated_bones"),
466                reason: "animated_bones must equal the sorted unique bone names in bone_channels"
467                    .into(),
468            });
469        }
470        for (bone, value) in &clip.bone_rotation_range_deg {
471            if clip.animated_bones.binary_search(bone).is_err() {
472                return Err(MeasurementContractError::InvalidStructure {
473                    path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
474                    reason: "rotation-range bones must be present in animated_bones".into(),
475                });
476            }
477            finite(
478                *value,
479                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
480            )?;
481        }
482        check_availability(
483            clip.loop_continuity.is_some(),
484            clip.loop_continuity_availability,
485            format!("clips[{clip_name:?}].loop_continuity"),
486        )?;
487        check_availability(
488            clip.loop_endpoint_mode.is_some(),
489            clip.loop_endpoint_mode_availability,
490            format!("clips[{clip_name:?}].loop_endpoint_mode"),
491        )?;
492        check_availability(
493            clip.frame_grid.is_some(),
494            clip.frame_grid_availability,
495            format!("clips[{clip_name:?}].frame_grid"),
496        )?;
497        check_availability(
498            clip.loop_seam_ratio.is_some(),
499            clip.loop_seam_ratio_availability,
500            format!("clips[{clip_name:?}].loop_seam_ratio"),
501        )?;
502        check_availability(
503            clip.gait.is_some(),
504            clip.gait_availability,
505            format!("clips[{clip_name:?}].gait"),
506        )?;
507        check_availability(
508            clip.root_trajectory.is_some(),
509            clip.root_trajectory_availability,
510            format!("clips[{clip_name:?}].root_trajectory"),
511        )?;
512        check_availability(
513            clip.speed_mps.is_some(),
514            clip.speed_mps_availability,
515            format!("clips[{clip_name:?}].speed_mps"),
516        )?;
517        if let Some(gait) = &clip.gait {
518            check_availability(
519                gait.phase.is_some(),
520                gait.phase_availability,
521                format!("clips[{clip_name:?}].gait.phase"),
522            )?;
523        }
524        if let Some(trajectory) = &clip.root_trajectory {
525            let path = format!("clips[{clip_name:?}].root_trajectory");
526            check_availability(
527                trajectory.translation.is_some(),
528                trajectory.translation_availability,
529                format!("{path}.translation"),
530            )?;
531            check_availability(
532                trajectory.yaw.is_some(),
533                trajectory.yaw_availability,
534                format!("{path}.yaw"),
535            )?;
536            if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
537                return Err(MeasurementContractError::InvalidStructure {
538                    path: format!("{path}.translation_availability"),
539                    reason:
540                        "translation remains applicable when a root-trajectory bone is selected"
541                            .into(),
542                });
543            }
544            if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
545                return Err(MeasurementContractError::InvalidStructure {
546                    path: format!("{path}.yaw_availability"),
547                    reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
548                });
549            }
550            if let Some(translation) = trajectory.translation {
551                for (field, value) in [
552                    (
553                        "horizontal_displacement_x_m",
554                        translation.horizontal_displacement_x_m,
555                    ),
556                    (
557                        "horizontal_displacement_z_m",
558                        translation.horizontal_displacement_z_m,
559                    ),
560                    ("horizontal_travel_m", translation.horizontal_travel_m),
561                    (
562                        "vertical_displacement_m",
563                        translation.vertical_displacement_m,
564                    ),
565                    (
566                        "vertical_min_displacement_m",
567                        translation.vertical_min_displacement_m,
568                    ),
569                    (
570                        "vertical_max_displacement_m",
571                        translation.vertical_max_displacement_m,
572                    ),
573                ] {
574                    finite(value, format!("{path}.translation.{field}"))?;
575                }
576                if translation.horizontal_travel_m < 0.0 {
577                    return Err(MeasurementContractError::InvalidStructure {
578                        path: format!("{path}.translation.horizontal_travel_m"),
579                        reason: "sampled horizontal travel must be non-negative".into(),
580                    });
581                }
582                let horizontal_displacement_m = translation
583                    .horizontal_displacement_x_m
584                    .hypot(translation.horizontal_displacement_z_m);
585                if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
586                    return Err(MeasurementContractError::InvalidStructure {
587                        path: format!("{path}.translation.horizontal_travel_m"),
588                        reason: "sampled horizontal travel must contain endpoint displacement"
589                            .into(),
590                    });
591                }
592                if translation.vertical_min_displacement_m > 0.0
593                    || translation.vertical_max_displacement_m < 0.0
594                    || translation.vertical_displacement_m < translation.vertical_min_displacement_m
595                    || translation.vertical_displacement_m > translation.vertical_max_displacement_m
596                {
597                    return Err(MeasurementContractError::InvalidStructure {
598                        path: format!("{path}.translation"),
599                        reason: "vertical extrema must include zero and the endpoint displacement"
600                            .into(),
601                    });
602                }
603            }
604            if let Some(yaw) = trajectory.yaw {
605                finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
606                finite(
607                    yaw.unwrapped_yaw_deg,
608                    format!("{path}.yaw.unwrapped_yaw_deg"),
609                )?;
610                finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
611                if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
612                    return Err(MeasurementContractError::InvalidStructure {
613                        path: format!("{path}.yaw.net_yaw_deg"),
614                        reason: "net yaw must be in the inclusive range [-180, 180]".into(),
615                    });
616                }
617                if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
618                    return Err(MeasurementContractError::InvalidStructure {
619                        path: format!("{path}.yaw.net_yaw_deg"),
620                        reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
621                            .into(),
622                    });
623                }
624                if yaw.yaw_travel_deg < 0.0 {
625                    return Err(MeasurementContractError::InvalidStructure {
626                        path: format!("{path}.yaw.yaw_travel_deg"),
627                        reason: "sampled yaw travel must be non-negative".into(),
628                    });
629                }
630                if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
631                    return Err(MeasurementContractError::InvalidStructure {
632                        path: format!("{path}.yaw.yaw_travel_deg"),
633                        reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
634                    });
635                }
636            }
637        }
638        if let Some(loop_continuity) = &clip.loop_continuity {
639            if loop_continuity.bones.is_empty() {
640                return Err(MeasurementContractError::InvalidStructure {
641                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
642                    reason: "present loop-continuity evidence must contain at least one bone"
643                        .into(),
644                });
645            }
646            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
647                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
648                if usize::try_from(bone.bone_index) != Ok(expected_index) {
649                    return Err(MeasurementContractError::InvalidStructure {
650                        path: format!("{path}.bone_index"),
651                        reason: format!(
652                            "expected skeleton-order index {expected_index}, found {}",
653                            bone.bone_index
654                        ),
655                    });
656                }
657                for (field, value) in [
658                    ("position_delta_m", bone.position_delta_m),
659                    ("rotation_delta_deg", bone.rotation_delta_deg),
660                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
661                    (
662                        "seam_angular_velocity_delta_degps",
663                        bone.seam_angular_velocity_delta_degps,
664                    ),
665                ] {
666                    finite(value, format!("{path}.{field}"))?;
667                    if value < 0.0 {
668                        return Err(MeasurementContractError::InvalidStructure {
669                            path: format!("{path}.{field}"),
670                            reason: "loop-continuity deltas must be non-negative".into(),
671                        });
672                    }
673                }
674            }
675        }
676        if let Some(frame_grid) = &clip.frame_grid {
677            let path = format!("clips[{clip_name:?}].frame_grid");
678            finite(frame_grid.fps, format!("{path}.fps"))?;
679            if frame_grid.fps <= 0.0 {
680                return Err(MeasurementContractError::InvalidStructure {
681                    path: format!("{path}.fps"),
682                    reason: "declared frame-grid FPS must be positive".into(),
683                });
684            }
685            if frame_grid.frame_intervals == 0 {
686                return Err(MeasurementContractError::InvalidStructure {
687                    path: format!("{path}.frame_intervals"),
688                    reason: "declared frame-grid evidence must contain at least one interval"
689                        .into(),
690                });
691            }
692        }
693        if let Some(value) = clip.loop_seam_ratio {
694            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
695        }
696        if let Some(gait) = &clip.gait {
697            if let Some(value) = gait.phase {
698                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
699            }
700            finite(
701                gait.lr_amplitude_m,
702                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
703            )?;
704        }
705        if let Some(value) = clip.speed_mps {
706            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
707        }
708    }
709    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
710        path,
711        reason: reason.to_owned(),
712    };
713    let finite_aabb = |aabb: &Aabb, path: &str| {
714        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
715            for (axis, value) in values.into_iter().enumerate() {
716                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
717            }
718        }
719        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
720            if min > max {
721                return Err(invalid(
722                    format!("{path}.min[{axis}]"),
723                    "AABB minimum cannot exceed maximum",
724                ));
725            }
726        }
727        Ok(())
728    };
729
730    let mut mesh_indices = BTreeSet::new();
731    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
732        if !mesh_indices.insert(mesh.mesh_index) {
733            return Err(invalid(
734                format!("mesh_definitions[{index}].mesh_index"),
735                "mesh_index must be unique",
736            ));
737        }
738        if let Some(aabb) = &mesh.geometry_aabb {
739            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
740        }
741        if let Some(centroid) = mesh.geometry_centroid {
742            for (axis, value) in centroid.into_iter().enumerate() {
743                finite(
744                    f64::from(value),
745                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
746                )?;
747            }
748        }
749        if let Some(value) = mesh.weight_sum_min {
750            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
751        }
752        if let Some(value) = mesh.weight_sum_max {
753            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
754        }
755        if revision == MeasurementRevision::V15 && mesh.vertex_count > u64::from(u32::MAX) {
756            return Err(invalid(
757                format!("mesh_definitions[{index}].vertex_count"),
758                "measurements-v15 vertex_count cannot exceed its historical u32 maximum",
759            ));
760        }
761        match (&mesh.primitives, revision) {
762            (None, MeasurementRevision::V16) => {
763                return Err(invalid(
764                    format!("mesh_definitions[{index}].primitives"),
765                    "measurements-v16 requires per-primitive evidence",
766                ));
767            }
768            (Some(_), MeasurementRevision::V15) => {
769                return Err(invalid(
770                    format!("mesh_definitions[{index}].primitives"),
771                    "measurements-v15 cannot carry per-primitive evidence",
772                ));
773            }
774            (Some(primitives), MeasurementRevision::V16) => {
775                let mut summed_vertex_count = 0u64;
776                let mut summed_finite_vertex_count = 0u64;
777                let mut aggregate_min = [f32::INFINITY; 3];
778                let mut aggregate_max = [f32::NEG_INFINITY; 3];
779                let mut weighted_centroid_sum = [0.0f64; 3];
780                let mut previous_primitive_index = None;
781                for (primitive_offset, primitive) in primitives.iter().enumerate() {
782                    let path = format!("mesh_definitions[{index}].primitives[{primitive_offset}]");
783                    if previous_primitive_index
784                        .is_some_and(|previous| previous >= primitive.primitive_index)
785                    {
786                        return Err(invalid(
787                            format!("{path}.primitive_index"),
788                            "primitive_index must be unique and strictly increasing in source order",
789                        ));
790                    }
791                    previous_primitive_index = Some(primitive.primitive_index);
792                    if primitive.finite_vertex_count > primitive.vertex_count {
793                        return Err(invalid(
794                            format!("{path}.finite_vertex_count"),
795                            "finite_vertex_count cannot exceed vertex_count",
796                        ));
797                    }
798                    match (
799                        primitive.finite_vertex_count,
800                        primitive.geometry_aabb.as_ref(),
801                        primitive.geometry_centroid,
802                    ) {
803                        (0, None, None) => {}
804                        (1.., Some(aabb), Some(centroid)) => {
805                            finite_aabb(aabb, &format!("{path}.geometry_aabb"))?;
806                            for (axis, value) in centroid.into_iter().enumerate() {
807                                finite(
808                                    f64::from(value),
809                                    format!("{path}.geometry_centroid[{axis}]"),
810                                )?;
811                                if value < aabb.min[axis] || value > aabb.max[axis] {
812                                    return Err(invalid(
813                                        format!("{path}.geometry_centroid[{axis}]"),
814                                        "primitive centroid must lie inside its geometry AABB",
815                                    ));
816                                }
817                                aggregate_min[axis] = aggregate_min[axis].min(aabb.min[axis]);
818                                aggregate_max[axis] = aggregate_max[axis].max(aabb.max[axis]);
819                                weighted_centroid_sum[axis] +=
820                                    f64::from(value) * primitive.finite_vertex_count as f64;
821                            }
822                        }
823                        (0, _, _) => {
824                            return Err(invalid(
825                                path,
826                                "a primitive with no finite vertices cannot carry geometry facts",
827                            ));
828                        }
829                        (1.., _, _) => {
830                            return Err(invalid(
831                                path,
832                                "a primitive with finite vertices requires both geometry facts",
833                            ));
834                        }
835                    }
836                    if assets.material_resource_coverage == MaterialResourceCoverage::Complete
837                        && primitive.material_index.is_some_and(|material_index| {
838                            material_index >= assets.material_definitions.len()
839                        })
840                    {
841                        return Err(invalid(
842                            format!("{path}.material_index"),
843                            "material_index must reference a source material when material resource coverage is complete",
844                        ));
845                    }
846                    summed_vertex_count = summed_vertex_count
847                        .checked_add(primitive.vertex_count)
848                        .ok_or_else(|| {
849                        invalid(
850                            format!("mesh_definitions[{index}].vertex_count"),
851                            "primitive vertex-count sum overflows u64",
852                        )
853                    })?;
854                    summed_finite_vertex_count = summed_finite_vertex_count
855                        .checked_add(primitive.finite_vertex_count)
856                        .ok_or_else(|| {
857                            invalid(
858                                format!("mesh_definitions[{index}].primitives"),
859                                "primitive finite-vertex-count sum overflows u64",
860                            )
861                        })?;
862                }
863                if summed_vertex_count != mesh.vertex_count {
864                    return Err(invalid(
865                        format!("mesh_definitions[{index}].vertex_count"),
866                        "vertex_count must equal the checked sum of primitive vertex counts",
867                    ));
868                }
869                let expected_aabb = (summed_finite_vertex_count != 0).then_some(Aabb {
870                    min: aggregate_min,
871                    max: aggregate_max,
872                });
873                let expected_centroid = (summed_finite_vertex_count != 0).then(|| {
874                    let count = summed_finite_vertex_count as f64;
875                    weighted_centroid_sum.map(|sum| (sum / count) as f32)
876                });
877                match (
878                    summed_finite_vertex_count,
879                    mesh.geometry_aabb.as_ref(),
880                    mesh.geometry_centroid,
881                ) {
882                    (0, None, None) | (1.., Some(_), Some(_)) => {}
883                    (0, _, _) => {
884                        return Err(invalid(
885                            format!("mesh_definitions[{index}]"),
886                            "a mesh with no finite primitive vertices cannot carry geometry facts",
887                        ));
888                    }
889                    (1.., _, _) => {
890                        return Err(invalid(
891                            format!("mesh_definitions[{index}]"),
892                            "a mesh with finite primitive vertices requires both geometry facts",
893                        ));
894                    }
895                }
896                if mesh.geometry_aabb != expected_aabb {
897                    return Err(invalid(
898                        format!("mesh_definitions[{index}].geometry_aabb"),
899                        "mesh AABB must equal the exact union of primitive AABBs",
900                    ));
901                }
902                if mesh.geometry_centroid != expected_centroid {
903                    return Err(invalid(
904                        format!("mesh_definitions[{index}].geometry_centroid"),
905                        "mesh centroid must equal the finite-count-weighted primitive centroids",
906                    ));
907                }
908            }
909            (None, MeasurementRevision::V15) => {}
910        }
911        let mut previous_set_index = None;
912        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
913            let path = format!(
914                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
915            );
916            if set.set_index == 0 {
917                return Err(invalid(path, "set_index must be at least 1"));
918            }
919            if !set.joints_present && !set.weights_present {
920                return Err(invalid(
921                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
922                    "an additional influence set must declare joints, weights, or both",
923                ));
924            }
925            if set.joints_without_weights_present && !set.joints_present {
926                return Err(invalid(
927                    format!(
928                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
929                    ),
930                    "joints_without_weights_present requires joints_present",
931                ));
932            }
933            if set.weights_without_joints_present && !set.weights_present {
934                return Err(invalid(
935                    format!(
936                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
937                    ),
938                    "weights_without_joints_present requires weights_present",
939                ));
940            }
941            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
942                return Err(invalid(
943                    format!(
944                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
945                    ),
946                    "joints_without_weights_present is required when weights_present is false",
947                ));
948            }
949            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
950                return Err(invalid(
951                    format!(
952                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
953                    ),
954                    "weights_without_joints_present is required when joints_present is false",
955                ));
956            }
957            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
958                return Err(invalid(
959                    path,
960                    "set_index values must be strictly increasing and unique",
961                ));
962            }
963            previous_set_index = Some(set.set_index);
964        }
965    }
966
967    let mut node_indices = BTreeSet::new();
968    for (index, instance) in assets.node_instances.iter().enumerate() {
969        if !node_indices.insert(instance.node_index) {
970            return Err(invalid(
971                format!("node_instances[{index}].node_index"),
972                "node_index must be unique",
973            ));
974        }
975        if !mesh_indices.contains(&instance.mesh_index) {
976            return Err(invalid(
977                format!("node_instances[{index}].mesh_index"),
978                "mesh_index must reference a mesh definition",
979            ));
980        }
981        match (
982            instance.static_node_world_aabb.as_ref(),
983            instance.static_node_world_aabb_unavailable_reason,
984        ) {
985            (Some(aabb), None) => finite_aabb(
986                aabb,
987                &format!("node_instances[{index}].static_node_world_aabb"),
988            )?,
989            (None, Some(_)) => {}
990            (Some(_), Some(_)) => {
991                return Err(invalid(
992                    format!("node_instances[{index}]"),
993                    "an available static node AABB cannot have an unavailable reason",
994                ));
995            }
996            (None, None) => {
997                return Err(invalid(
998                    format!("node_instances[{index}]"),
999                    "a missing static node AABB requires an unavailable reason",
1000                ));
1001            }
1002        }
1003    }
1004
1005    let mut scene_indices = BTreeSet::new();
1006    for (index, scene) in assets.scenes.iter().enumerate() {
1007        if !scene_indices.insert(scene.scene_index) {
1008            return Err(invalid(
1009                format!("scenes[{index}].scene_index"),
1010                "scene_index must be unique",
1011            ));
1012        }
1013        if scene.excluded_instance_count > scene.instance_count {
1014            return Err(invalid(
1015                format!("scenes[{index}].excluded_instance_count"),
1016                "excluded_instance_count cannot exceed instance_count",
1017            ));
1018        }
1019        let available = scene.instance_count - scene.excluded_instance_count;
1020        match (&scene.static_scene_world_aabb, available) {
1021            (Some(aabb), 1..) => {
1022                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
1023            }
1024            (None, 0) => {}
1025            (Some(_), 0) => {
1026                return Err(invalid(
1027                    format!("scenes[{index}].static_scene_world_aabb"),
1028                    "a scene with no available instances cannot have an AABB",
1029                ));
1030            }
1031            (None, _) => {
1032                return Err(invalid(
1033                    format!("scenes[{index}].static_scene_world_aabb"),
1034                    "a scene with available instances requires an AABB",
1035                ));
1036            }
1037        }
1038    }
1039    if let Some(default_scene_index) = assets.default_scene_index
1040        && !scene_indices.contains(&default_scene_index)
1041    {
1042        return Err(invalid(
1043            "default_scene_index".into(),
1044            "default_scene_index must reference a declared scene",
1045        ));
1046    }
1047    validate_skeleton_measurements(assets, &invalid)?;
1048    validate_material_resources(assets, revision, &invalid)?;
1049    Ok(())
1050}
1051
1052fn validate_linear_transform_fields(
1053    linear: &LinearTransformMeasurements,
1054    path: &str,
1055    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1056) -> Result<(), MeasurementContractError> {
1057    let numeric_fields_present = linear.axis_lengths.is_some()
1058        && linear.determinant.is_some()
1059        && linear.orientation.is_some();
1060    if linear.classification == LinearTransformClassification::NonFinite {
1061        if linear.axis_lengths.is_some()
1062            || linear.determinant.is_some()
1063            || linear.orientation.is_some()
1064            || linear.uniform_scale.is_some()
1065        {
1066            return Err(invalid(
1067                path.into(),
1068                "a non_finite classification cannot carry numeric linear-transform facts",
1069            ));
1070        }
1071        return Ok(());
1072    }
1073    if !numeric_fields_present {
1074        return Err(invalid(
1075            path.into(),
1076            "a finite classification requires axis_lengths, determinant, and orientation",
1077        ));
1078    }
1079    for (axis, value) in linear
1080        .axis_lengths
1081        .expect("presence checked")
1082        .into_iter()
1083        .enumerate()
1084    {
1085        if !value.is_finite() {
1086            return Err(MeasurementContractError::NonFiniteValue {
1087                path: format!("{path}.axis_lengths[{axis}]"),
1088            });
1089        }
1090        if value < 0.0 {
1091            return Err(invalid(
1092                format!("{path}.axis_lengths[{axis}]"),
1093                "axis lengths must be non-negative",
1094            ));
1095        }
1096    }
1097    if !linear.determinant.expect("presence checked").is_finite() {
1098        return Err(MeasurementContractError::NonFiniteValue {
1099            path: format!("{path}.determinant"),
1100        });
1101    }
1102    if let Some(scale) = linear.uniform_scale {
1103        if !scale.is_finite() {
1104            return Err(MeasurementContractError::NonFiniteValue {
1105                path: format!("{path}.uniform_scale"),
1106            });
1107        }
1108        if scale < 0.0 {
1109            return Err(invalid(
1110                format!("{path}.uniform_scale"),
1111                "uniform scale must be non-negative",
1112            ));
1113        }
1114    }
1115    Ok(())
1116}
1117
1118fn validate_skeleton_measurements(
1119    assets: &AssetMeasurements,
1120    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1121) -> Result<(), MeasurementContractError> {
1122    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
1123        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
1124            return Err(invalid(
1125                "skeleton_source_coverage".into(),
1126                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
1127            ));
1128        }
1129        return Ok(());
1130    }
1131
1132    let finite_matrix = |matrix: &[f32; 16], path: &str| {
1133        for (component, value) in matrix.iter().enumerate() {
1134            if !value.is_finite() {
1135                return Err(MeasurementContractError::NonFiniteValue {
1136                    path: format!("{path}[{component}]"),
1137                });
1138            }
1139        }
1140        Ok(())
1141    };
1142    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1143        if node.node_index != offset {
1144            return Err(invalid(
1145                format!("skeleton_nodes[{offset}].node_index"),
1146                "node_index must be contiguous and match source order",
1147            ));
1148        }
1149        match &node.local_rest {
1150            SkeletonNodeLocalRestMeasurements::Trs {
1151                translation_parent_space_m,
1152                rotation_xyzw,
1153                scale,
1154            } => {
1155                for (field, values) in [
1156                    (
1157                        "translation_parent_space_m",
1158                        translation_parent_space_m.as_slice(),
1159                    ),
1160                    ("rotation_xyzw", rotation_xyzw.as_slice()),
1161                    ("scale", scale.as_slice()),
1162                ] {
1163                    for (component, value) in values.iter().enumerate() {
1164                        if !value.is_finite() {
1165                            return Err(MeasurementContractError::NonFiniteValue {
1166                                path: format!(
1167                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
1168                                ),
1169                            });
1170                        }
1171                    }
1172                }
1173            }
1174            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
1175                matrix,
1176                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
1177            )?,
1178            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
1179        }
1180        let node_path = format!("skeleton_nodes[{offset}]");
1181        validate_linear_transform_fields(
1182            &node.rest_world_linear,
1183            &format!("{node_path}.rest_world_linear"),
1184            invalid,
1185        )?;
1186        match (
1187            node.rest_world_matrix.as_ref(),
1188            node.rest_world_translation_m.as_ref(),
1189            node.rest_world_matrix_unavailable_reason,
1190        ) {
1191            (Some(matrix), Some(translation), None) => {
1192                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
1193                for (component, value) in translation.iter().enumerate() {
1194                    if !value.is_finite() {
1195                        return Err(MeasurementContractError::NonFiniteValue {
1196                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
1197                        });
1198                    }
1199                }
1200                let expected_translation = [matrix[12], matrix[13], matrix[14]];
1201                if *translation != expected_translation {
1202                    return Err(invalid(
1203                        format!("{node_path}.rest_world_translation_m"),
1204                        "rest_world_translation_m must equal the rest-world matrix translation column",
1205                    ));
1206                }
1207                let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
1208                if node.rest_world_linear != expected_linear {
1209                    return Err(invalid(
1210                        format!("{node_path}.rest_world_linear"),
1211                        "rest_world_linear must be derived from rest_world_matrix",
1212                    ));
1213                }
1214            }
1215            (None, None, Some(_)) => {
1216                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
1217                {
1218                    return Err(invalid(
1219                        format!("{node_path}.rest_world_linear"),
1220                        "an unavailable rest-world matrix requires a non_finite linear classification",
1221                    ));
1222                }
1223            }
1224            (Some(_), Some(_), Some(_)) => {
1225                return Err(invalid(
1226                    node_path,
1227                    "an available rest_world_matrix cannot have an unavailable reason",
1228                ));
1229            }
1230            _ => {
1231                return Err(invalid(
1232                    node_path,
1233                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
1234                ));
1235            }
1236        }
1237    }
1238    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1239        if let Some(parent) = node.parent_node_index
1240            && parent >= assets.skeleton_nodes.len()
1241        {
1242            return Err(invalid(
1243                format!("skeleton_nodes[{offset}].parent_node_index"),
1244                "parent_node_index must reference a skeleton node",
1245            ));
1246        }
1247        let mut previous_scene = None;
1248        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1249            if !assets
1250                .scenes
1251                .iter()
1252                .any(|scene| scene.scene_index == *scene_index)
1253            {
1254                return Err(invalid(
1255                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1256                    "scene_root_indices values must reference declared scenes",
1257                ));
1258            }
1259            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1260                return Err(invalid(
1261                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1262                    "scene_root_indices values must be strictly increasing and unique",
1263                ));
1264            }
1265            previous_scene = Some(*scene_index);
1266        }
1267    }
1268    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1269    for start in 0..assets.skeleton_nodes.len() {
1270        if visits.get(start) != Some(&ParentVisit::Unvisited) {
1271            continue;
1272        }
1273        let mut path = Vec::new();
1274        let mut current = start;
1275        loop {
1276            match visits.get(current).copied().ok_or_else(|| {
1277                invalid(
1278                    format!("skeleton_nodes[{current}].parent_node_index"),
1279                    "parent_node_index must reference a skeleton node",
1280                )
1281            })? {
1282                ParentVisit::Done => break,
1283                ParentVisit::Visiting => {
1284                    return Err(invalid(
1285                        format!("skeleton_nodes[{current}].parent_node_index"),
1286                        "source node parent graph must be acyclic",
1287                    ));
1288                }
1289                ParentVisit::Unvisited => {
1290                    *visits.get_mut(current).ok_or_else(|| {
1291                        invalid(
1292                            format!("skeleton_nodes[{current}].parent_node_index"),
1293                            "parent_node_index must reference a skeleton node",
1294                        )
1295                    })? = ParentVisit::Visiting;
1296                    path.push(current);
1297                    match assets
1298                        .skeleton_nodes
1299                        .get(current)
1300                        .ok_or_else(|| {
1301                            invalid(
1302                                format!("skeleton_nodes[{current}].parent_node_index"),
1303                                "parent_node_index must reference a skeleton node",
1304                            )
1305                        })?
1306                        .parent_node_index
1307                    {
1308                        Some(parent) => current = parent,
1309                        None => break,
1310                    }
1311                }
1312            }
1313        }
1314        for node_index in path {
1315            *visits.get_mut(node_index).ok_or_else(|| {
1316                invalid(
1317                    format!("skeleton_nodes[{node_index}].parent_node_index"),
1318                    "parent_node_index must reference a skeleton node",
1319                )
1320            })? = ParentVisit::Done;
1321        }
1322    }
1323
1324    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1325        let local_rest_available = !matches!(
1326            node.local_rest,
1327            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1328        );
1329        let path = format!("skeleton_nodes[{offset}]");
1330        if !local_rest_available {
1331            if node.rest_world_matrix.is_some()
1332                || node.rest_world_matrix_unavailable_reason
1333                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1334            {
1335                return Err(invalid(
1336                    path,
1337                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1338                ));
1339            }
1340            continue;
1341        }
1342
1343        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1344            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1345                invalid(
1346                    format!("skeleton_nodes[{offset}].parent_node_index"),
1347                    "parent_node_index must reference a skeleton node",
1348                )
1349            })?;
1350            if parent.rest_world_matrix.is_none() {
1351                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1352            } else {
1353                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1354            }
1355        } else {
1356            None
1357        };
1358        match (
1359            node.rest_world_matrix.is_some(),
1360            expected_unavailable_reason,
1361        ) {
1362            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1363            }
1364            (false, Some(expected))
1365                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1366            _ => {
1367                return Err(invalid(
1368                    path,
1369                    "rest-world availability must agree with local rest and parent rest-world evidence",
1370                ));
1371            }
1372        }
1373    }
1374
1375    for (offset, skin) in assets.skins.iter().enumerate() {
1376        if skin.skin_index != offset {
1377            return Err(invalid(
1378                format!("skins[{offset}].skin_index"),
1379                "skin_index must be contiguous and match source order",
1380            ));
1381        }
1382        if let Some(root) = skin.skeleton_root_node_index
1383            && root >= assets.skeleton_nodes.len()
1384        {
1385            return Err(invalid(
1386                format!("skins[{offset}].skeleton_root_node_index"),
1387                "skeleton_root_node_index must reference a skeleton node",
1388            ));
1389        }
1390        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1391            if joint.joint_index != joint_offset {
1392                return Err(invalid(
1393                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1394                    "joint_index must be contiguous and match declared skin order",
1395                ));
1396            }
1397            if joint.node_index >= assets.skeleton_nodes.len() {
1398                return Err(invalid(
1399                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
1400                    "joint node_index must reference a skeleton node",
1401                ));
1402            }
1403        }
1404        match skin.inverse_bind_accessor.status {
1405            SourceInverseBindAccessorStatus::Absent => {
1406                if skin.inverse_bind_accessor.declared_count.is_some()
1407                    || !skin.inverse_bind_accessor.matrices.is_empty()
1408                {
1409                    return Err(invalid(
1410                        format!("skins[{offset}].inverse_bind_accessor"),
1411                        "an absent inverse-bind declaration has no declared count or matrices",
1412                    ));
1413                }
1414            }
1415            SourceInverseBindAccessorStatus::EmptyAccessor => {
1416                if skin.inverse_bind_accessor.declared_count != Some(0)
1417                    || !skin.inverse_bind_accessor.matrices.is_empty()
1418                {
1419                    return Err(invalid(
1420                        format!("skins[{offset}].inverse_bind_accessor"),
1421                        "an empty inverse-bind declaration has declared_count 0 and no matrices",
1422                    ));
1423                }
1424            }
1425            SourceInverseBindAccessorStatus::Available => {
1426                if skin.inverse_bind_accessor.declared_count
1427                    != Some(skin.inverse_bind_accessor.matrices.len())
1428                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1429                {
1430                    return Err(invalid(
1431                        format!("skins[{offset}].inverse_bind_accessor"),
1432                        "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1433                    ));
1434                }
1435            }
1436            SourceInverseBindAccessorStatus::CountMismatch => {
1437                if skin.inverse_bind_accessor.declared_count
1438                    != Some(skin.inverse_bind_accessor.matrices.len())
1439                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1440                {
1441                    return Err(invalid(
1442                        format!("skins[{offset}].inverse_bind_accessor"),
1443                        "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1444                    ));
1445                }
1446            }
1447            SourceInverseBindAccessorStatus::Unreadable => {
1448                if skin.inverse_bind_accessor.declared_count.is_none()
1449                    || !skin.inverse_bind_accessor.matrices.is_empty()
1450                {
1451                    return Err(invalid(
1452                        format!("skins[{offset}].inverse_bind_accessor"),
1453                        "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1454                    ));
1455                }
1456            }
1457        }
1458        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1459            finite_matrix(
1460                matrix,
1461                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1462            )?;
1463        }
1464        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1465            let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1466            let joint_bind_path =
1467                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1468            validate_derived_matrix(
1469                &joint.joint_bind_to_mesh,
1470                &joint_bind_path,
1471                &finite_matrix,
1472                invalid,
1473            )?;
1474            validate_derived_reason_compatibility(
1475                &joint.joint_bind_to_mesh,
1476                skin.inverse_bind_accessor.status,
1477                skin.inverse_bind_accessor.matrices.len(),
1478                joint_offset,
1479                &joint_bind_path,
1480                DerivedMatrixDomain::JointBindToMesh,
1481                invalid,
1482            )?;
1483            validate_derived_source(
1484                &joint.joint_bind_to_mesh,
1485                expected_source,
1486                None,
1487                &joint_bind_path,
1488                DerivedMatrixDomain::JointBindToMesh,
1489                invalid,
1490            )?;
1491
1492            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1493            validate_derived_matrix(
1494                &joint.mesh_bind_world,
1495                &mesh_bind_path,
1496                &finite_matrix,
1497                invalid,
1498            )?;
1499            validate_derived_reason_compatibility(
1500                &joint.mesh_bind_world,
1501                skin.inverse_bind_accessor.status,
1502                skin.inverse_bind_accessor.matrices.len(),
1503                joint_offset,
1504                &mesh_bind_path,
1505                DerivedMatrixDomain::MeshBindWorld,
1506                invalid,
1507            )?;
1508            let joint_rest_world_available = assets
1509                .skeleton_nodes
1510                .get(joint.node_index)
1511                .ok_or_else(|| {
1512                    invalid(
1513                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
1514                        "joint node_index must reference a skeleton node",
1515                    )
1516                })?
1517                .rest_world_matrix
1518                .is_some();
1519            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1520                .rest_world_matrix
1521                .as_ref();
1522            validate_mesh_bind_world_reason_compatibility(
1523                &joint.mesh_bind_world,
1524                joint_rest_world_available,
1525                &mesh_bind_path,
1526                invalid,
1527            )?;
1528            validate_derived_source(
1529                &joint.mesh_bind_world,
1530                expected_source,
1531                joint_rest_world,
1532                &mesh_bind_path,
1533                DerivedMatrixDomain::MeshBindWorld,
1534                invalid,
1535            )?;
1536        }
1537        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1538            && !scale.is_finite()
1539        {
1540            return Err(MeasurementContractError::NonFiniteValue {
1541                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1542            });
1543        }
1544        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1545        if skin.joint_bind_linear_summary != expected_summary {
1546            return Err(invalid(
1547                format!("skins[{offset}].joint_bind_linear_summary"),
1548                "joint-bind linear summary must match the skin joint observations",
1549            ));
1550        }
1551        let mut previous_attachment_node = None;
1552        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1553            if attachment.node_index >= assets.skeleton_nodes.len() {
1554                return Err(invalid(
1555                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1556                    "attachment node_index must reference a skeleton node",
1557                ));
1558            }
1559            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1560                return Err(invalid(
1561                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1562                    "attachment node_index values must be strictly increasing and unique",
1563                ));
1564            }
1565            previous_attachment_node = Some(attachment.node_index);
1566        }
1567    }
1568    Ok(())
1569}
1570
1571fn validate_derived_reason_compatibility(
1572    matrix: &SkinDerivedMatrixMeasurements,
1573    status: SourceInverseBindAccessorStatus,
1574    readable_matrix_count: usize,
1575    joint_index: usize,
1576    path: &str,
1577    domain: DerivedMatrixDomain,
1578    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1579) -> Result<(), MeasurementContractError> {
1580    let requires_accessor_reason = match status {
1581        SourceInverseBindAccessorStatus::Absent => {
1582            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1583        }
1584        SourceInverseBindAccessorStatus::EmptyAccessor => {
1585            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1586        }
1587        SourceInverseBindAccessorStatus::Unreadable => {
1588            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1589        }
1590        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1591            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1592        }
1593        SourceInverseBindAccessorStatus::Available
1594        | SourceInverseBindAccessorStatus::CountMismatch => None,
1595    };
1596    if let Some(expected) = requires_accessor_reason {
1597        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1598            return Err(invalid(
1599                path.into(),
1600                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1601            ));
1602        }
1603    } else {
1604        match (domain, matrix.unavailable_reason) {
1605            (
1606                _,
1607                Some(
1608                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1609                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1610                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1611                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1612                ),
1613            ) => {
1614                return Err(invalid(
1615                    format!("{path}.unavailable_reason"),
1616                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1617                ));
1618            }
1619            (
1620                DerivedMatrixDomain::JointBindToMesh,
1621                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1622            ) => {
1623                return Err(invalid(
1624                    format!("{path}.unavailable_reason"),
1625                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1626                ));
1627            }
1628            (
1629                DerivedMatrixDomain::MeshBindWorld,
1630                Some(
1631                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1632                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1633                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1634                ),
1635            ) => {
1636                return Err(invalid(
1637                    format!("{path}.unavailable_reason"),
1638                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1639                ));
1640            }
1641            _ => {}
1642        }
1643    }
1644    Ok(())
1645}
1646
1647fn validate_mesh_bind_world_reason_compatibility(
1648    matrix: &SkinDerivedMatrixMeasurements,
1649    joint_rest_world_available: bool,
1650    path: &str,
1651    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1652) -> Result<(), MeasurementContractError> {
1653    match matrix.unavailable_reason {
1654        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1655            if joint_rest_world_available =>
1656        {
1657            Err(invalid(
1658                format!("{path}.unavailable_reason"),
1659                "an available joint rest-world matrix cannot be reported as unavailable",
1660            ))
1661        }
1662        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1663            if !joint_rest_world_available =>
1664        {
1665            Err(invalid(
1666                format!("{path}.unavailable_reason"),
1667                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1668            ))
1669        }
1670        _ => Ok(()),
1671    }
1672}
1673
1674#[derive(Clone, Copy, PartialEq, Eq)]
1675enum ParentVisit {
1676    Unvisited,
1677    Visiting,
1678    Done,
1679}
1680
1681#[derive(Clone, Copy)]
1682enum DerivedMatrixDomain {
1683    JointBindToMesh,
1684    MeshBindWorld,
1685}
1686
1687fn validate_derived_source(
1688    measurements: &SkinDerivedMatrixMeasurements,
1689    expected_source: Option<&[f32; 16]>,
1690    joint_rest_world: Option<&[f32; 16]>,
1691    path: &str,
1692    domain: DerivedMatrixDomain,
1693    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1694) -> Result<(), MeasurementContractError> {
1695    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1696        return Err(invalid(
1697            format!("{path}.source_inverse_bind_matrix"),
1698            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1699        ));
1700    }
1701    let Some(source) = expected_source else {
1702        if measurements.inversion_quality.is_some() {
1703            return Err(invalid(
1704                format!("{path}.inversion_quality"),
1705                "inversion quality requires a readable source inverse-bind matrix",
1706            ));
1707        }
1708        return Ok(());
1709    };
1710    let raw = Mat4::from_cols_array(source);
1711    match domain {
1712        DerivedMatrixDomain::JointBindToMesh => {
1713            let assessment = assess_inverse_bind(raw);
1714            if measurements.inversion_quality != assessment.quality {
1715                return Err(invalid(
1716                    format!("{path}.inversion_quality"),
1717                    "inversion quality must be derived from the source linear 3x3",
1718                ));
1719            }
1720            match assessment.inverse {
1721                Ok(inverse) => {
1722                    if measurements.matrix != Some(inverse.to_cols_array())
1723                        || measurements.unavailable_reason.is_some()
1724                    {
1725                        return Err(invalid(
1726                            path.into(),
1727                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1728                        ));
1729                    }
1730                }
1731                Err(reason) => {
1732                    if measurements.matrix.is_some()
1733                        || measurements.unavailable_reason != Some(reason)
1734                    {
1735                        return Err(invalid(
1736                            path.into(),
1737                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1738                        ));
1739                    }
1740                }
1741            }
1742        }
1743        DerivedMatrixDomain::MeshBindWorld => {
1744            if measurements.inversion_quality.is_some() {
1745                return Err(invalid(
1746                    format!("{path}.inversion_quality"),
1747                    "mesh_bind_world does not invert its source matrix",
1748                ));
1749            }
1750            if let Some(world) = joint_rest_world {
1751                let expected = Mat4::from_cols_array(world) * raw;
1752                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1753                    if measurements.matrix != Some(expected.to_cols_array())
1754                        || measurements.unavailable_reason.is_some()
1755                    {
1756                        return Err(invalid(
1757                            path.into(),
1758                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1759                        ));
1760                    }
1761                } else if measurements.unavailable_reason
1762                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1763                {
1764                    return Err(invalid(
1765                        format!("{path}.unavailable_reason"),
1766                        "a non-finite mesh-bind product requires its typed unavailable reason",
1767                    ));
1768                }
1769            }
1770        }
1771    }
1772    Ok(())
1773}
1774
1775fn validate_derived_matrix(
1776    matrix: &SkinDerivedMatrixMeasurements,
1777    path: &str,
1778    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1779    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1780) -> Result<(), MeasurementContractError> {
1781    if let Some(source) = &matrix.source_inverse_bind_matrix {
1782        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1783    }
1784    if let Some(quality) = matrix.inversion_quality {
1785        let value = quality.reciprocal_condition_number_inf;
1786        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1787            return Err(invalid(
1788                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1789                "reciprocal condition number must be finite and between zero and one",
1790            ));
1791        }
1792    }
1793    match (
1794        &matrix.matrix,
1795        matrix.linear.as_ref(),
1796        matrix.unavailable_reason,
1797    ) {
1798        (Some(matrix), Some(linear), None) => {
1799            finite_matrix(matrix, &format!("{path}.matrix"))?;
1800            validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1801            if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1802                return Err(invalid(
1803                    format!("{path}.linear"),
1804                    "linear facts must be derived from the available matrix",
1805                ));
1806            }
1807        }
1808        (None, None, Some(_)) => {}
1809        (Some(_), Some(_), Some(_)) => {
1810            return Err(invalid(
1811                path.into(),
1812                "an available derived matrix cannot have an unavailable reason",
1813            ));
1814        }
1815        _ => {
1816            return Err(invalid(
1817                path.into(),
1818                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1819            ));
1820        }
1821    }
1822    Ok(())
1823}
1824
1825fn validate_material_resources(
1826    assets: &AssetMeasurements,
1827    revision: MeasurementRevision,
1828    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1829) -> Result<(), MeasurementContractError> {
1830    let absent = assets.material_definitions.is_empty()
1831        && assets.textures.is_empty()
1832        && assets.images.is_empty();
1833    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1834        return Err(invalid(
1835            "material_resource_coverage".into(),
1836            "unavailable resource coverage requires empty material, texture, and image arrays",
1837        ));
1838    }
1839
1840    for (offset, material) in assets.material_definitions.iter().enumerate() {
1841        if material.material_index != offset {
1842            return Err(invalid(
1843                format!("material_definitions[{offset}].material_index"),
1844                "material_index must be contiguous and match source order",
1845            ));
1846        }
1847        let mut previous_slot = None;
1848        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1849            if binding.texture_index >= assets.textures.len() {
1850                return Err(invalid(
1851                    format!(
1852                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1853                    ),
1854                    "texture_index must reference a source texture",
1855                ));
1856            }
1857            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1858                return Err(invalid(
1859                    format!(
1860                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1861                    ),
1862                    "texture bindings must be strictly ordered by slot and unique",
1863                ));
1864            }
1865            previous_slot = Some(binding.slot);
1866        }
1867    }
1868    for (offset, texture) in assets.textures.iter().enumerate() {
1869        if texture.texture_index != offset {
1870            return Err(invalid(
1871                format!("textures[{offset}].texture_index"),
1872                "texture_index must be contiguous and match source order",
1873            ));
1874        }
1875        if texture.image_index >= assets.images.len() {
1876            return Err(invalid(
1877                format!("textures[{offset}].image_index"),
1878                "image_index must reference a source image",
1879            ));
1880        }
1881    }
1882    for (offset, image) in assets.images.iter().enumerate() {
1883        validate_image_measurement(image, offset, revision, invalid)?;
1884    }
1885    Ok(())
1886}
1887
1888fn validate_image_measurement(
1889    image: &ImageMeasurements,
1890    offset: usize,
1891    revision: MeasurementRevision,
1892    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1893) -> Result<(), MeasurementContractError> {
1894    if image.image_index != offset {
1895        return Err(invalid(
1896            format!("images[{offset}].image_index"),
1897            "image_index must be contiguous and match source order",
1898        ));
1899    }
1900    let available = [
1901        image.width.is_some(),
1902        image.height.is_some(),
1903        image.channel_count.is_some(),
1904        image.decoded_color_type.is_some(),
1905    ];
1906    match (
1907        available.into_iter().all(|value| value),
1908        image.unavailable_reason,
1909    ) {
1910        (true, None) => {
1911            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1912                image.width,
1913                image.height,
1914                image.channel_count,
1915                image.decoded_color_type,
1916            ) else {
1917                return Err(invalid(
1918                    format!("images[{offset}]"),
1919                    "available image metadata must include width, height, channel_count, and decoded_color_type",
1920                ));
1921            };
1922            if width == 0 || height == 0 {
1923                return Err(invalid(
1924                    format!("images[{offset}]"),
1925                    "available image dimensions must be greater than zero",
1926                ));
1927            }
1928            if channel_count != color_type_channel_count(decoded_color_type) {
1929                return Err(invalid(
1930                    format!("images[{offset}].channel_count"),
1931                    "channel_count must match decoded_color_type",
1932                ));
1933            }
1934            if image.detected_container.is_none() {
1935                return Err(invalid(
1936                    format!("images[{offset}].detected_container"),
1937                    "available image metadata requires a detected_container",
1938                ));
1939            }
1940        }
1941        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1942        (true, Some(_)) => {
1943            return Err(invalid(
1944                format!("images[{offset}]"),
1945                "available image metadata cannot have an unavailable_reason",
1946            ));
1947        }
1948        (false, None) if available.into_iter().all(|value| !value) => {
1949            return Err(invalid(
1950                format!("images[{offset}]"),
1951                "missing image metadata requires an unavailable_reason",
1952            ));
1953        }
1954        (false, _) => {
1955            return Err(invalid(
1956                format!("images[{offset}]"),
1957                "available image metadata must include width, height, channel_count, and decoded_color_type",
1958            ));
1959        }
1960    }
1961    match image.unavailable_reason {
1962        Some(crate::model::ImageUnavailableReason::DecodeFailed)
1963            if image.detected_container.is_none() =>
1964        {
1965            return Err(invalid(
1966                format!("images[{offset}].detected_container"),
1967                "decode_failed requires a detected_container",
1968            ));
1969        }
1970        Some(
1971            crate::model::ImageUnavailableReason::SourceUnavailable
1972            | crate::model::ImageUnavailableReason::InvalidDataUri
1973            | crate::model::ImageUnavailableReason::UnsupportedContainer,
1974        ) if image.detected_container.is_some() => {
1975            return Err(invalid(
1976                format!("images[{offset}].detected_container"),
1977                "this unavailable_reason cannot have a detected_container",
1978            ));
1979        }
1980        _ => {}
1981    }
1982    match (revision, image.unavailable_reason, &image.leading_magic_hex) {
1983        (MeasurementRevision::V15, _, Some(_)) => {
1984            return Err(invalid(
1985                format!("images[{offset}].leading_magic_hex"),
1986                "measurements-v15 cannot carry leading-magic evidence",
1987            ));
1988        }
1989        (
1990            MeasurementRevision::V16,
1991            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
1992            Some(magic),
1993        ) => {
1994            if magic.is_empty()
1995                || magic.len() > 32
1996                || !magic.len().is_multiple_of(2)
1997                || !magic
1998                    .bytes()
1999                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2000            {
2001                return Err(invalid(
2002                    format!("images[{offset}].leading_magic_hex"),
2003                    "leading_magic_hex must be nonempty lowercase even-length hex for at most 16 bytes",
2004                ));
2005            }
2006        }
2007        (
2008            MeasurementRevision::V16,
2009            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2010            None,
2011        )
2012        | (MeasurementRevision::V15, _, None) => {}
2013        (MeasurementRevision::V16, _, Some(_)) => {
2014            return Err(invalid(
2015                format!("images[{offset}].leading_magic_hex"),
2016                "leading_magic_hex is permitted only for unsupported_container",
2017            ));
2018        }
2019        (MeasurementRevision::V16, _, None) => {}
2020    }
2021    Ok(())
2022}
2023
2024fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
2025    match color_type {
2026        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
2027        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
2028        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
2029        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
2030    }
2031}
2032
2033/// Typed read-side subset accepted when a consumer needs measurements from a
2034/// current `measure` or `lint` report.
2035///
2036/// This intentionally models only the fields needed to recover the nested
2037/// measurement contract while retaining every legitimate output-v11 root
2038/// field. The frozen schema is closed, while all protocol identities and
2039/// command constraints are validated by [`MeasurementReportInput::into_files`].
2040#[derive(Debug)]
2041pub struct MeasurementReportInput {
2042    schema_version: Option<u32>,
2043    schema: Option<String>,
2044    _tool: Option<Box<RawValue>>,
2045    command: Option<String>,
2046    summary: Option<MeasurementReportSummaryInput>,
2047    files: Option<Vec<Box<RawValue>>>,
2048    _inputs: Option<Box<RawValue>>,
2049    _deltas: Option<Box<RawValue>>,
2050    extra: BTreeMap<String, Box<RawValue>>,
2051}
2052
2053impl<'de> Deserialize<'de> for MeasurementReportInput {
2054    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2055    where
2056        D: Deserializer<'de>,
2057    {
2058        struct MeasurementReportInputVisitor;
2059
2060        impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
2061            type Value = MeasurementReportInput;
2062
2063            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2064                formatter.write_str("an output report object")
2065            }
2066
2067            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
2068            where
2069                A: MapAccess<'de>,
2070            {
2071                let mut schema_version = None;
2072                let mut schema = None;
2073                let mut tool = None;
2074                let mut command = None;
2075                let mut summary = None;
2076                let mut files = None;
2077                let mut inputs = None;
2078                let mut deltas = None;
2079                let mut extra = BTreeMap::new();
2080                while let Some(field) = map.next_key::<String>()? {
2081                    match field.as_str() {
2082                        "schema_version" => {
2083                            if schema_version.is_some() {
2084                                return Err(serde::de::Error::duplicate_field("schema_version"));
2085                            }
2086                            schema_version = Some(map.next_value()?);
2087                        }
2088                        "schema" => {
2089                            if schema.is_some() {
2090                                return Err(serde::de::Error::duplicate_field("schema"));
2091                            }
2092                            schema = Some(map.next_value()?);
2093                        }
2094                        "tool" => {
2095                            if tool.is_some() {
2096                                return Err(serde::de::Error::duplicate_field("tool"));
2097                            }
2098                            tool = Some(map.next_value()?);
2099                        }
2100                        "command" => {
2101                            if command.is_some() {
2102                                return Err(serde::de::Error::duplicate_field("command"));
2103                            }
2104                            command = Some(map.next_value()?);
2105                        }
2106                        "summary" => {
2107                            if summary.is_some() {
2108                                return Err(serde::de::Error::duplicate_field("summary"));
2109                            }
2110                            summary = Some(map.next_value()?);
2111                        }
2112                        "files" => {
2113                            if files.is_some() {
2114                                return Err(serde::de::Error::duplicate_field("files"));
2115                            }
2116                            files = Some(map.next_value()?);
2117                        }
2118                        "inputs" => {
2119                            if inputs.is_some() {
2120                                return Err(serde::de::Error::duplicate_field("inputs"));
2121                            }
2122                            inputs = Some(map.next_value()?);
2123                        }
2124                        "deltas" => {
2125                            if deltas.is_some() {
2126                                return Err(serde::de::Error::duplicate_field("deltas"));
2127                            }
2128                            deltas = Some(map.next_value()?);
2129                        }
2130                        _ => {
2131                            extra.insert(field, map.next_value()?);
2132                        }
2133                    }
2134                }
2135                Ok(MeasurementReportInput {
2136                    schema_version: schema_version.unwrap_or_default(),
2137                    schema: schema.unwrap_or_default(),
2138                    _tool: tool,
2139                    command: command.unwrap_or_default(),
2140                    summary: summary.unwrap_or_default(),
2141                    files: files.unwrap_or_default(),
2142                    _inputs: inputs.unwrap_or_default(),
2143                    _deltas: deltas.unwrap_or_default(),
2144                    extra,
2145                })
2146            }
2147        }
2148
2149        deserializer.deserialize_map(MeasurementReportInputVisitor)
2150    }
2151}
2152
2153#[derive(Debug, Deserialize)]
2154#[serde(deny_unknown_fields)]
2155struct MeasurementFileWireInput {
2156    path: Option<String>,
2157    input: Option<InputIdentityInput>,
2158    rig: Box<RawValue>,
2159    measurements: Option<Box<RawValue>>,
2160    #[serde(default, deserialize_with = "deserialize_required_nullable")]
2161    prediction_provenance: RequiredNullable<Box<RawValue>>,
2162    checks: Option<Vec<Box<RawValue>>>,
2163}
2164
2165#[derive(Debug)]
2166struct MeasurementFileInput {
2167    path: Option<String>,
2168    input: Option<InputIdentityInput>,
2169    /// V17 alone retains the strictly decoded rig needed by the shared
2170    /// root-motion reconstruction hook. Historical readers keep treating rig
2171    /// as opaque evidence, preserving their released acceptance behavior.
2172    rig_v17: Option<RigInfo>,
2173    measurements: Option<Box<RawValue>>,
2174    prediction_provenance: RequiredNullable<PredictionProvenanceV2>,
2175    checks: Option<Vec<PredictionCheckInput>>,
2176    legacy_prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
2177    legacy_checks: Option<Vec<LegacyPredictionCheckInput>>,
2178    prediction_provenance_v3: RequiredNullable<PredictionProvenanceV3>,
2179    checks_v3: Option<Vec<PredictionCheckInputV3>>,
2180    prediction_provenance_v4: RequiredNullable<PredictionProvenanceV4>,
2181    checks_v4: Option<Vec<PredictionCheckInputV4>>,
2182    prediction_provenance_v5: RequiredNullable<PredictionProvenanceV5>,
2183    checks_v5: Option<Vec<PredictionCheckInputV5>>,
2184    prediction_provenance_v6: RequiredNullable<PredictionProvenanceV6>,
2185    checks_v6: Option<Vec<PredictionCheckInputV6>>,
2186}
2187
2188#[derive(Debug, Deserialize)]
2189#[serde(deny_unknown_fields)]
2190struct LegacyPredictionCheckWireV11 {
2191    check_id: String,
2192    selection: SelectionState,
2193    configuration: ConfigurationState,
2194    applicability: Applicability,
2195    evaluation: EvaluationState,
2196    findings: Vec<PredictionFindingInput>,
2197    #[serde(default)]
2198    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2199    #[serde(default)]
2200    gaps: Vec<PredictionGapInput>,
2201    prediction: Option<Box<RawValue>>,
2202}
2203
2204/// The immutable V11 check attachment.  This deliberately remains a separate
2205/// internal shape: V11 evidence is validated with its V1 identity and staged
2206/// decoding rules, never converted into a V2 attachment.
2207#[derive(Debug)]
2208struct LegacyPredictionCheckInput {
2209    check_id: String,
2210    selection: SelectionState,
2211    configuration: ConfigurationState,
2212    applicability: Applicability,
2213    evaluation: EvaluationState,
2214    findings: Vec<PredictionFindingInput>,
2215    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2216    gaps: Vec<PredictionGapInput>,
2217    prediction: Option<EnginePredictionV1>,
2218}
2219
2220#[derive(Debug, Default)]
2221enum RequiredNullable<T> {
2222    #[default]
2223    Missing,
2224    Present(Option<T>),
2225}
2226
2227impl<T> RequiredNullable<T> {
2228    fn as_present(&self) -> Option<&T> {
2229        match self {
2230            Self::Missing | Self::Present(None) => None,
2231            Self::Present(Some(value)) => Some(value),
2232        }
2233    }
2234}
2235
2236fn deserialize_required_nullable<'de, D, T>(
2237    deserializer: D,
2238) -> Result<RequiredNullable<T>, D::Error>
2239where
2240    D: Deserializer<'de>,
2241    T: Deserialize<'de>,
2242{
2243    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
2244}
2245
2246#[derive(Debug, Deserialize)]
2247#[serde(deny_unknown_fields)]
2248struct MeasurementReportSummaryInput {
2249    #[serde(rename = "files")]
2250    _files: Option<Box<RawValue>>,
2251    #[serde(rename = "findings")]
2252    _findings: Option<Box<RawValue>>,
2253    #[serde(rename = "checks")]
2254    _checks: Option<Box<RawValue>>,
2255    #[serde(rename = "deltas")]
2256    _deltas: Option<Box<RawValue>>,
2257    prediction_facets: Option<PredictionFacetSummaryInput>,
2258}
2259
2260#[derive(Debug, Deserialize)]
2261#[serde(deny_unknown_fields)]
2262struct PredictionFacetSummaryInput {
2263    available: usize,
2264    required_prediction_unavailable: usize,
2265}
2266
2267#[derive(Debug, Deserialize)]
2268#[serde(deny_unknown_fields)]
2269struct PredictionCheckWireInput {
2270    check_id: String,
2271    selection: SelectionState,
2272    configuration: ConfigurationState,
2273    applicability: Applicability,
2274    evaluation: EvaluationState,
2275    findings: Vec<PredictionFindingInput>,
2276    #[serde(default)]
2277    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2278    #[serde(default)]
2279    gaps: Vec<PredictionGapInput>,
2280    prediction: Option<Box<RawValue>>,
2281}
2282
2283#[derive(Debug)]
2284struct PredictionCheckInput {
2285    check_id: String,
2286    selection: SelectionState,
2287    configuration: ConfigurationState,
2288    applicability: Applicability,
2289    evaluation: EvaluationState,
2290    findings: Vec<PredictionFindingInput>,
2291    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2292    gaps: Vec<PredictionGapInput>,
2293    prediction: Option<EnginePredictionV2>,
2294}
2295
2296#[derive(Debug)]
2297struct PredictionCheckInputV3 {
2298    check_id: String,
2299    selection: SelectionState,
2300    configuration: ConfigurationState,
2301    applicability: Applicability,
2302    evaluation: EvaluationState,
2303    findings: Vec<PredictionFindingInput>,
2304    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2305    gaps: Vec<PredictionGapInput>,
2306    prediction: Option<EnginePredictionV3>,
2307}
2308
2309#[derive(Debug)]
2310struct PredictionCheckInputV4 {
2311    check_id: String,
2312    selection: SelectionState,
2313    configuration: ConfigurationState,
2314    applicability: Applicability,
2315    evaluation: EvaluationState,
2316    findings: Vec<PredictionFindingInput>,
2317    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2318    gaps: Vec<PredictionGapInput>,
2319    prediction: Option<EnginePredictionV4>,
2320}
2321
2322#[derive(Debug)]
2323struct PredictionCheckInputV5 {
2324    check_id: String,
2325    selection: SelectionState,
2326    configuration: ConfigurationState,
2327    applicability: Applicability,
2328    evaluation: EvaluationState,
2329    findings: Vec<PredictionFindingInput>,
2330    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2331    gaps: Vec<PredictionGapInput>,
2332    prediction: Option<EnginePredictionV5>,
2333}
2334
2335#[derive(Debug)]
2336struct PredictionCheckInputV6 {
2337    check_id: String,
2338    selection: SelectionState,
2339    configuration: ConfigurationState,
2340    applicability: Applicability,
2341    evaluation: EvaluationState,
2342    findings: Vec<PredictionFindingInput>,
2343    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2344    gaps: Vec<PredictionGapInput>,
2345    prediction: Option<EnginePredictionV6>,
2346}
2347
2348#[derive(Debug, Serialize, Deserialize)]
2349#[serde(deny_unknown_fields)]
2350struct PredictionFindingInput {
2351    check_id: String,
2352    #[serde(rename = "severity")]
2353    _severity: PredictionSeverityInput,
2354    #[serde(rename = "clip", skip_serializing_if = "Option::is_none")]
2355    _clip: Option<String>,
2356    #[serde(rename = "bone", skip_serializing_if = "Option::is_none")]
2357    _bone: Option<String>,
2358    #[serde(rename = "node", skip_serializing_if = "Option::is_none")]
2359    _node: Option<String>,
2360    #[serde(skip_serializing_if = "Option::is_none")]
2361    prediction_scope: Option<crate::evaluation::EvaluationScope>,
2362    #[serde(rename = "time_s", skip_serializing_if = "Option::is_none")]
2363    _time_s: Option<f32>,
2364    #[serde(rename = "measured", skip_serializing_if = "Option::is_none")]
2365    _measured: Option<Box<RawValue>>,
2366    #[serde(rename = "expected", skip_serializing_if = "Option::is_none")]
2367    _expected: Option<Box<RawValue>>,
2368    #[serde(rename = "members", skip_serializing_if = "Option::is_none")]
2369    _members: Option<Box<RawValue>>,
2370    #[serde(rename = "message")]
2371    _message: String,
2372}
2373
2374#[derive(Debug, Deserialize)]
2375#[serde(deny_unknown_fields)]
2376struct PredictionGapInput {
2377    code: String,
2378    #[serde(rename = "message")]
2379    _message: String,
2380    scope: Option<crate::evaluation::EvaluationScope>,
2381}
2382
2383#[derive(Debug, Serialize, Deserialize)]
2384#[serde(rename_all = "snake_case")]
2385enum PredictionSeverityInput {
2386    Error,
2387    Warning,
2388    Note,
2389}
2390
2391#[derive(Debug, Deserialize)]
2392#[serde(deny_unknown_fields)]
2393struct InputIdentityInput {
2394    sha256: Option<String>,
2395    bytes: Option<u64>,
2396}
2397
2398/// Recursively preserves the historical JSON-f64-to-Rust-f32 narrowing path
2399/// without materializing an unbounded generic JSON value.
2400///
2401/// `serde_json` rejects a finite JSON number that exceeds `f32::MAX` when it
2402/// directly services `deserialize_f32`. Output-v9 readback first retained the
2403/// number as `f64`, then narrowed it to `f32`; semantic measurement validation
2404/// consequently reported the resulting infinity as a typed non-finite value.
2405/// This adapter retains that contract while streaming directly into the bounded
2406/// typed measurement DTO.
2407struct MeasurementF32NarrowingDeserializer<D>(D);
2408
2409macro_rules! delegate_measurement_deserializer {
2410    ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2411        fn $method<V>(
2412            self,
2413            $($argument: $argument_type,)*
2414            visitor: V,
2415        ) -> Result<V::Value, Self::Error>
2416        where
2417            V: Visitor<'de>,
2418        {
2419            self.0.$method(
2420                $($argument,)*
2421                MeasurementF32NarrowingVisitor(visitor),
2422            )
2423        }
2424    };
2425}
2426
2427impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2428where
2429    D: Deserializer<'de>,
2430{
2431    type Error = D::Error;
2432
2433    delegate_measurement_deserializer!(deserialize_any);
2434    delegate_measurement_deserializer!(deserialize_bool);
2435    delegate_measurement_deserializer!(deserialize_i8);
2436    delegate_measurement_deserializer!(deserialize_i16);
2437    delegate_measurement_deserializer!(deserialize_i32);
2438    delegate_measurement_deserializer!(deserialize_i64);
2439    delegate_measurement_deserializer!(deserialize_i128);
2440    delegate_measurement_deserializer!(deserialize_u8);
2441    delegate_measurement_deserializer!(deserialize_u16);
2442    delegate_measurement_deserializer!(deserialize_u32);
2443    delegate_measurement_deserializer!(deserialize_u64);
2444    delegate_measurement_deserializer!(deserialize_u128);
2445
2446    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2447    where
2448        V: Visitor<'de>,
2449    {
2450        self.0
2451            .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2452    }
2453
2454    delegate_measurement_deserializer!(deserialize_f64);
2455    delegate_measurement_deserializer!(deserialize_char);
2456    delegate_measurement_deserializer!(deserialize_str);
2457    delegate_measurement_deserializer!(deserialize_string);
2458    delegate_measurement_deserializer!(deserialize_bytes);
2459    delegate_measurement_deserializer!(deserialize_byte_buf);
2460    delegate_measurement_deserializer!(deserialize_option);
2461    delegate_measurement_deserializer!(deserialize_unit);
2462    delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2463    delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2464    delegate_measurement_deserializer!(deserialize_seq);
2465    delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2466    delegate_measurement_deserializer!(
2467        deserialize_tuple_struct,
2468        name: &'static str,
2469        len: usize
2470    );
2471    delegate_measurement_deserializer!(deserialize_map);
2472    delegate_measurement_deserializer!(
2473        deserialize_struct,
2474        name: &'static str,
2475        fields: &'static [&'static str]
2476    );
2477    delegate_measurement_deserializer!(
2478        deserialize_enum,
2479        name: &'static str,
2480        variants: &'static [&'static str]
2481    );
2482    delegate_measurement_deserializer!(deserialize_identifier);
2483    delegate_measurement_deserializer!(deserialize_ignored_any);
2484
2485    fn is_human_readable(&self) -> bool {
2486        self.0.is_human_readable()
2487    }
2488}
2489
2490struct MeasurementF32NarrowingNumberVisitor<V>(V);
2491
2492impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2493where
2494    V: Visitor<'de>,
2495{
2496    type Value = V::Value;
2497
2498    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2499        self.0.expecting(formatter)
2500    }
2501
2502    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2503    where
2504        E: serde::de::Error,
2505    {
2506        self.0.visit_f32(value)
2507    }
2508
2509    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2510    where
2511        E: serde::de::Error,
2512    {
2513        self.0.visit_f32(value as f32)
2514    }
2515
2516    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2517    where
2518        E: serde::de::Error,
2519    {
2520        self.0.visit_f32(value as f32)
2521    }
2522
2523    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2524    where
2525        E: serde::de::Error,
2526    {
2527        self.0.visit_f32(value as f32)
2528    }
2529
2530    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2531    where
2532        E: serde::de::Error,
2533    {
2534        self.0.visit_f32(value as f32)
2535    }
2536
2537    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2538    where
2539        E: serde::de::Error,
2540    {
2541        self.0.visit_f32(value as f32)
2542    }
2543}
2544
2545struct MeasurementF32NarrowingVisitor<V>(V);
2546
2547macro_rules! delegate_measurement_visitor {
2548    ($method:ident, $value_type:ty) => {
2549        fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2550        where
2551            E: serde::de::Error,
2552        {
2553            self.0.$method(value)
2554        }
2555    };
2556}
2557
2558impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2559where
2560    V: Visitor<'de>,
2561{
2562    type Value = V::Value;
2563
2564    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2565        self.0.expecting(formatter)
2566    }
2567
2568    delegate_measurement_visitor!(visit_bool, bool);
2569    delegate_measurement_visitor!(visit_i8, i8);
2570    delegate_measurement_visitor!(visit_i16, i16);
2571    delegate_measurement_visitor!(visit_i32, i32);
2572    delegate_measurement_visitor!(visit_i64, i64);
2573    delegate_measurement_visitor!(visit_i128, i128);
2574    delegate_measurement_visitor!(visit_u8, u8);
2575    delegate_measurement_visitor!(visit_u16, u16);
2576    delegate_measurement_visitor!(visit_u32, u32);
2577    delegate_measurement_visitor!(visit_u64, u64);
2578    delegate_measurement_visitor!(visit_u128, u128);
2579    delegate_measurement_visitor!(visit_f32, f32);
2580    delegate_measurement_visitor!(visit_f64, f64);
2581    delegate_measurement_visitor!(visit_char, char);
2582
2583    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2584    where
2585        E: serde::de::Error,
2586    {
2587        self.0.visit_str(value)
2588    }
2589
2590    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2591    where
2592        E: serde::de::Error,
2593    {
2594        self.0.visit_borrowed_str(value)
2595    }
2596
2597    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2598    where
2599        E: serde::de::Error,
2600    {
2601        self.0.visit_string(value)
2602    }
2603
2604    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2605    where
2606        E: serde::de::Error,
2607    {
2608        self.0.visit_bytes(value)
2609    }
2610
2611    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2612    where
2613        E: serde::de::Error,
2614    {
2615        self.0.visit_borrowed_bytes(value)
2616    }
2617
2618    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2619    where
2620        E: serde::de::Error,
2621    {
2622        self.0.visit_byte_buf(value)
2623    }
2624
2625    fn visit_none<E>(self) -> Result<Self::Value, E>
2626    where
2627        E: serde::de::Error,
2628    {
2629        self.0.visit_none()
2630    }
2631
2632    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2633    where
2634        D: Deserializer<'de>,
2635    {
2636        self.0
2637            .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2638    }
2639
2640    fn visit_unit<E>(self) -> Result<Self::Value, E>
2641    where
2642        E: serde::de::Error,
2643    {
2644        self.0.visit_unit()
2645    }
2646
2647    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2648    where
2649        D: Deserializer<'de>,
2650    {
2651        self.0
2652            .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2653    }
2654
2655    fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2656    where
2657        A: SeqAccess<'de>,
2658    {
2659        self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2660    }
2661
2662    fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2663    where
2664        A: MapAccess<'de>,
2665    {
2666        self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2667    }
2668
2669    fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2670    where
2671        A: EnumAccess<'de>,
2672    {
2673        self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2674    }
2675}
2676
2677struct MeasurementF32NarrowingSeed<S>(S);
2678
2679impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2680where
2681    S: DeserializeSeed<'de>,
2682{
2683    type Value = S::Value;
2684
2685    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2686    where
2687        D: Deserializer<'de>,
2688    {
2689        self.0
2690            .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2691    }
2692}
2693
2694struct MeasurementF32NarrowingSeqAccess<A>(A);
2695
2696impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2697where
2698    A: SeqAccess<'de>,
2699{
2700    type Error = A::Error;
2701
2702    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2703    where
2704        T: DeserializeSeed<'de>,
2705    {
2706        self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2707    }
2708
2709    fn size_hint(&self) -> Option<usize> {
2710        self.0.size_hint()
2711    }
2712}
2713
2714struct MeasurementF32NarrowingMapAccess<A>(A);
2715
2716impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2717where
2718    A: MapAccess<'de>,
2719{
2720    type Error = A::Error;
2721
2722    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2723    where
2724        K: DeserializeSeed<'de>,
2725    {
2726        self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2727    }
2728
2729    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2730    where
2731        V: DeserializeSeed<'de>,
2732    {
2733        self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2734    }
2735
2736    fn size_hint(&self) -> Option<usize> {
2737        self.0.size_hint()
2738    }
2739}
2740
2741struct MeasurementF32NarrowingEnumAccess<A>(A);
2742
2743impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2744where
2745    A: EnumAccess<'de>,
2746{
2747    type Error = A::Error;
2748    type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2749
2750    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2751    where
2752        V: DeserializeSeed<'de>,
2753    {
2754        let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2755        Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2756    }
2757}
2758
2759struct MeasurementF32NarrowingVariantAccess<A>(A);
2760
2761impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2762where
2763    A: VariantAccess<'de>,
2764{
2765    type Error = A::Error;
2766
2767    fn unit_variant(self) -> Result<(), Self::Error> {
2768        self.0.unit_variant()
2769    }
2770
2771    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2772    where
2773        T: DeserializeSeed<'de>,
2774    {
2775        self.0
2776            .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2777    }
2778
2779    fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2780    where
2781        V: Visitor<'de>,
2782    {
2783        self.0
2784            .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2785    }
2786
2787    fn struct_variant<V>(
2788        self,
2789        fields: &'static [&'static str],
2790        visitor: V,
2791    ) -> Result<V::Value, Self::Error>
2792    where
2793        V: Visitor<'de>,
2794    {
2795        self.0
2796            .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
2797    }
2798}
2799
2800#[derive(Debug, Deserialize)]
2801#[serde(untagged)]
2802enum SkeletonNodeMeasurementInput {
2803    Current(Box<crate::measure::SkeletonNodeMeasurements>),
2804    Earlier {
2805        #[serde(rename = "node_index")]
2806        _node_index: usize,
2807    },
2808}
2809
2810#[derive(Debug, Deserialize)]
2811#[serde(untagged)]
2812enum SkinMeasurementInput {
2813    Current(Box<crate::measure::SkinMeasurements>),
2814    Earlier {
2815        #[serde(rename = "skin_index")]
2816        _skin_index: usize,
2817    },
2818}
2819
2820#[derive(Debug, Deserialize)]
2821struct MeasurementPayloadInput {
2822    schema_version: Option<u32>,
2823    schema: Option<String>,
2824    clips: Option<BTreeMap<String, ClipMeasurements>>,
2825    material_resource_coverage: Option<MaterialResourceCoverage>,
2826    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2827    textures: Option<Vec<TextureMeasurements>>,
2828    images: Option<Vec<ImageMeasurements>>,
2829    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2830    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2831    skins: Option<Vec<SkinMeasurementInput>>,
2832    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
2833    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
2834    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
2835    default_scene_index: Option<usize>,
2836}
2837
2838/// Current measurement payload readback is closed at the root and at every
2839/// domain introduced or extended by measurements-v16. Historical readers use
2840/// [`MeasurementPayloadInput`] directly so their accepted JSON shape does not
2841/// change retroactively.
2842#[derive(Debug, Deserialize)]
2843#[serde(deny_unknown_fields)]
2844struct MeasurementPayloadV16Input {
2845    schema_version: Option<u32>,
2846    schema: Option<String>,
2847    clips: Option<BTreeMap<String, ClipMeasurements>>,
2848    material_resource_coverage: Option<MaterialResourceCoverage>,
2849    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
2850    textures: Option<Vec<TextureMeasurements>>,
2851    images: Option<Vec<ImageMeasurementsV16Input>>,
2852    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
2853    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
2854    skins: Option<Vec<SkinMeasurementInput>>,
2855    mesh_definitions: Option<Vec<MeshDefinitionMeasurementsV16Input>>,
2856    node_instances: Option<Vec<NodeInstanceMeasurementsV16Input>>,
2857    scenes: Option<Vec<SceneMeasurementsV16Input>>,
2858    default_scene_index: Option<usize>,
2859}
2860
2861#[derive(Debug, Deserialize)]
2862#[serde(deny_unknown_fields)]
2863struct AabbV16Input {
2864    min: [f32; 3],
2865    max: [f32; 3],
2866}
2867
2868impl From<AabbV16Input> for Aabb {
2869    fn from(value: AabbV16Input) -> Self {
2870        Self {
2871            min: value.min,
2872            max: value.max,
2873        }
2874    }
2875}
2876
2877#[derive(Debug, Deserialize)]
2878#[serde(deny_unknown_fields)]
2879struct PrimitiveMeasurementsV16Input {
2880    primitive_index: usize,
2881    #[serde(deserialize_with = "deserialize_required_optional_usize")]
2882    material_index: Option<usize>,
2883    vertex_count: u64,
2884    finite_vertex_count: u64,
2885    geometry_aabb: Option<AabbV16Input>,
2886    geometry_centroid: Option<[f32; 3]>,
2887}
2888
2889fn deserialize_required_optional_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
2890where
2891    D: Deserializer<'de>,
2892{
2893    Option::<usize>::deserialize(deserializer)
2894}
2895
2896impl From<PrimitiveMeasurementsV16Input> for PrimitiveMeasurements {
2897    fn from(value: PrimitiveMeasurementsV16Input) -> Self {
2898        Self {
2899            primitive_index: value.primitive_index,
2900            material_index: value.material_index,
2901            vertex_count: value.vertex_count,
2902            finite_vertex_count: value.finite_vertex_count,
2903            geometry_aabb: value.geometry_aabb.map(Into::into),
2904            geometry_centroid: value.geometry_centroid,
2905        }
2906    }
2907}
2908
2909#[derive(Debug, Deserialize)]
2910#[serde(deny_unknown_fields)]
2911struct MeshDefinitionMeasurementsV16Input {
2912    mesh_index: usize,
2913    name: String,
2914    primitives: Option<Vec<PrimitiveMeasurementsV16Input>>,
2915    vertex_count: u64,
2916    geometry_aabb: Option<AabbV16Input>,
2917    geometry_centroid: Option<[f32; 3]>,
2918    max_joints_per_vertex: u32,
2919    weight_sum_min: Option<f64>,
2920    weight_sum_max: Option<f64>,
2921    additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
2922}
2923
2924impl From<MeshDefinitionMeasurementsV16Input> for MeshDefinitionMeasurements {
2925    fn from(value: MeshDefinitionMeasurementsV16Input) -> Self {
2926        Self {
2927            mesh_index: value.mesh_index,
2928            name: value.name,
2929            primitives: value
2930                .primitives
2931                .map(|primitives| primitives.into_iter().map(Into::into).collect()),
2932            vertex_count: value.vertex_count,
2933            geometry_aabb: value.geometry_aabb.map(Into::into),
2934            geometry_centroid: value.geometry_centroid,
2935            max_joints_per_vertex: value.max_joints_per_vertex,
2936            weight_sum_min: value.weight_sum_min,
2937            weight_sum_max: value.weight_sum_max,
2938            additional_influence_sets: value.additional_influence_sets,
2939        }
2940    }
2941}
2942
2943#[derive(Debug, Deserialize)]
2944#[serde(deny_unknown_fields)]
2945struct ImageMeasurementsV16Input {
2946    image_index: usize,
2947    name: Option<String>,
2948    source_kind: crate::model::ImageSourceKind,
2949    declared_mime_type: Option<String>,
2950    detected_container: Option<crate::model::ImageContainerFormat>,
2951    leading_magic_hex: Option<String>,
2952    width: Option<u32>,
2953    height: Option<u32>,
2954    channel_count: Option<u8>,
2955    decoded_color_type: Option<DecodedImageColorType>,
2956    unavailable_reason: Option<crate::model::ImageUnavailableReason>,
2957}
2958
2959impl From<ImageMeasurementsV16Input> for ImageMeasurements {
2960    fn from(value: ImageMeasurementsV16Input) -> Self {
2961        Self {
2962            image_index: value.image_index,
2963            name: value.name,
2964            source_kind: value.source_kind,
2965            declared_mime_type: value.declared_mime_type,
2966            detected_container: value.detected_container,
2967            leading_magic_hex: value.leading_magic_hex,
2968            width: value.width,
2969            height: value.height,
2970            channel_count: value.channel_count,
2971            decoded_color_type: value.decoded_color_type,
2972            unavailable_reason: value.unavailable_reason,
2973        }
2974    }
2975}
2976
2977#[derive(Debug, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979struct NodeInstanceMeasurementsV16Input {
2980    node_index: usize,
2981    node_name: String,
2982    mesh_index: usize,
2983    static_node_world_aabb: Option<AabbV16Input>,
2984    static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
2985}
2986
2987impl From<NodeInstanceMeasurementsV16Input> for NodeInstanceMeasurements {
2988    fn from(value: NodeInstanceMeasurementsV16Input) -> Self {
2989        Self {
2990            node_index: value.node_index,
2991            node_name: value.node_name,
2992            mesh_index: value.mesh_index,
2993            static_node_world_aabb: value.static_node_world_aabb.map(Into::into),
2994            static_node_world_aabb_unavailable_reason: value
2995                .static_node_world_aabb_unavailable_reason,
2996        }
2997    }
2998}
2999
3000#[derive(Debug, Deserialize)]
3001#[serde(deny_unknown_fields)]
3002struct SceneMeasurementsV16Input {
3003    scene_index: usize,
3004    name: Option<String>,
3005    instance_count: usize,
3006    static_scene_world_aabb: Option<AabbV16Input>,
3007    excluded_instance_count: usize,
3008}
3009
3010impl From<SceneMeasurementsV16Input> for SceneMeasurements {
3011    fn from(value: SceneMeasurementsV16Input) -> Self {
3012        Self {
3013            scene_index: value.scene_index,
3014            name: value.name,
3015            instance_count: value.instance_count,
3016            static_scene_world_aabb: value.static_scene_world_aabb.map(Into::into),
3017            excluded_instance_count: value.excluded_instance_count,
3018        }
3019    }
3020}
3021
3022impl From<MeasurementPayloadV16Input> for MeasurementPayloadInput {
3023    fn from(value: MeasurementPayloadV16Input) -> Self {
3024        Self {
3025            schema_version: value.schema_version,
3026            schema: value.schema,
3027            clips: value.clips,
3028            material_resource_coverage: value.material_resource_coverage,
3029            material_definitions: value.material_definitions,
3030            textures: value.textures,
3031            images: value
3032                .images
3033                .map(|images| images.into_iter().map(Into::into).collect()),
3034            skeleton_source_coverage: value.skeleton_source_coverage,
3035            skeleton_nodes: value.skeleton_nodes,
3036            skins: value.skins,
3037            mesh_definitions: value
3038                .mesh_definitions
3039                .map(|meshes| meshes.into_iter().map(Into::into).collect()),
3040            node_instances: value
3041                .node_instances
3042                .map(|instances| instances.into_iter().map(Into::into).collect()),
3043            scenes: value
3044                .scenes
3045                .map(|scenes| scenes.into_iter().map(Into::into).collect()),
3046            default_scene_index: value.default_scene_index,
3047        }
3048    }
3049}
3050
3051fn decode_measurement_payload(
3052    raw: &RawValue,
3053    strict_v16: bool,
3054) -> Result<MeasurementPayloadInput, serde_json::Error> {
3055    let mut deserializer = serde_json::Deserializer::from_str(raw.get());
3056    let payload = if strict_v16 {
3057        MeasurementPayloadV16Input::deserialize(MeasurementF32NarrowingDeserializer(
3058            &mut deserializer,
3059        ))?
3060        .into()
3061    } else {
3062        MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
3063            &mut deserializer,
3064        ))?
3065    };
3066    deserializer.end()?;
3067    Ok(payload)
3068}
3069
3070/// One validated file record recovered from a measurement report.
3071///
3072/// The record retains its source path and full nested measurement contract so
3073/// consumers can choose the clip, mesh, and cardinality policies appropriate
3074/// to their workflow.
3075#[derive(Debug, Clone)]
3076pub struct MeasurementReportFile {
3077    path: String,
3078    input: InputIdentity,
3079    measurements: MeasurementContract,
3080}
3081
3082impl MeasurementReportFile {
3083    /// Source path recorded by the producing report.
3084    pub fn path(&self) -> &str {
3085        &self.path
3086    }
3087
3088    /// Immutable identity of the source bytes used to produce this record.
3089    pub fn input(&self) -> &InputIdentity {
3090        &self.input
3091    }
3092
3093    /// Validated nested measurement contract.
3094    pub fn measurements(&self) -> &MeasurementContract {
3095        &self.measurements
3096    }
3097
3098    /// Consume this record and return its validated measurement contract.
3099    pub fn into_measurements(self) -> MeasurementContract {
3100        self.measurements
3101    }
3102}
3103
3104/// A typed measurement-report subset failed current-contract validation.
3105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3106#[non_exhaustive]
3107pub enum MeasurementReportError {
3108    /// The outer envelope omitted its version.
3109    #[error("report envelope has no `schema_version`")]
3110    MissingOutputVersion,
3111    /// The outer envelope uses an unsupported version.
3112    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
3113    UnsupportedOutputVersion {
3114        /// Version found in the input.
3115        found: u32,
3116    },
3117    /// The outer envelope does not carry the immutable current identity.
3118    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
3119    WrongOutputIdentity,
3120    /// The outer envelope omitted its command.
3121    #[error("report envelope has no `command`")]
3122    MissingCommand,
3123    /// The outer envelope belongs to a command without file measurements.
3124    #[error("report command {command:?} does not carry measurement file records")]
3125    UnsupportedCommand {
3126        /// Command found in the input.
3127        command: String,
3128    },
3129    /// A current output-v11 envelope carried a field outside its closed schema.
3130    #[error("report envelope has unknown field `{field}`")]
3131    UnknownOutputField {
3132        /// Lexically first unknown root field.
3133        field: String,
3134    },
3135    /// A current output-v11 envelope omitted its producer metadata.
3136    #[error("report envelope has no `tool` object")]
3137    MissingTool,
3138    /// The outer envelope omitted its file array.
3139    #[error("report envelope has no `files` array")]
3140    MissingFiles,
3141    /// The outer envelope exceeds the immutable file-record bound.
3142    #[error("report contains {found} files, exceeding the output-v11 limit of {limit}")]
3143    TooManyFiles {
3144        /// Supplied file count.
3145        found: usize,
3146        /// Immutable output-v11 limit.
3147        limit: usize,
3148    },
3149    /// A lint report omitted the derived prediction-facet summary.
3150    #[error("lint report summary has no `prediction_facets` object")]
3151    MissingPredictionFacetSummary,
3152    /// A measure report carried a lint-only prediction-facet summary.
3153    #[error("measure report summary must not carry `prediction_facets`")]
3154    UnexpectedPredictionFacetSummary,
3155    /// Derived prediction-facet totals did not match the lint summary.
3156    #[error("lint report prediction-facet summary does not match its check records")]
3157    PredictionFacetSummaryMismatch,
3158    /// One file record failed validation.
3159    #[error("files[{file_index}] {source}")]
3160    File {
3161        /// Zero-based index of the invalid file record.
3162        file_index: usize,
3163        /// Typed record-validation failure.
3164        #[source]
3165        source: MeasurementFileError,
3166    },
3167}
3168
3169/// A serialized output-v11 report could not be read within the public bound.
3170#[derive(Debug, thiserror::Error)]
3171#[non_exhaustive]
3172pub enum MeasurementReportReadError {
3173    /// Reading the bounded input failed.
3174    #[error("cannot read report: {source}")]
3175    Io {
3176        /// Underlying bounded-reader failure.
3177        #[source]
3178        source: std::io::Error,
3179    },
3180    /// The serialized report exceeded the immutable output-v11 byte limit.
3181    #[error("report exceeds the output-v11 limit of {limit} bytes")]
3182    ReportTooLarge {
3183        /// Immutable maximum accepted byte count.
3184        limit: u64,
3185    },
3186    /// The bounded bytes were not valid JSON for the output-v11 read shape.
3187    #[error("invalid report JSON: {source}")]
3188    InvalidJson {
3189        /// JSON syntax or typed-shape failure.
3190        #[source]
3191        source: serde_json::Error,
3192    },
3193}
3194
3195/// One measurement-report file record failed validation.
3196#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3197#[non_exhaustive]
3198pub enum MeasurementFileError {
3199    /// The bounded file record could not be decoded after the outer v11
3200    /// identity was accepted.
3201    #[error("has invalid output-v11 file shape: {reason}")]
3202    InvalidFileShape {
3203        /// Stable serde diagnostic for the malformed nested record.
3204        reason: String,
3205    },
3206    /// The file record omitted its source path.
3207    #[error("has no `path`")]
3208    MissingPath,
3209    /// The file record omitted its source-byte identity.
3210    #[error("has no `input`")]
3211    MissingInput,
3212    /// The source-byte identity omitted its SHA-256 digest.
3213    #[error("input has no `sha256`")]
3214    MissingSha256,
3215    /// The source-byte identity uses a malformed SHA-256 digest.
3216    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
3217    InvalidSha256,
3218    /// The source-byte identity omitted its byte count.
3219    #[error("input has no `bytes`")]
3220    MissingBytes,
3221    /// The file record omitted its nested measurement contract.
3222    #[error("has no measurements")]
3223    MissingMeasurements,
3224    /// A lint file omitted its required nullable provenance field.
3225    #[error("has no required `prediction_provenance` field")]
3226    MissingPredictionProvenance,
3227    /// A measure file carried lint-only prediction provenance.
3228    #[error("measure file must not carry `prediction_provenance`")]
3229    UnexpectedPredictionProvenance,
3230    /// A lint file omitted its check array.
3231    #[error("lint file has no `checks` array")]
3232    MissingChecks,
3233    /// A measure file carried lint-only check records.
3234    #[error("measure file must not carry `checks`")]
3235    UnexpectedChecks,
3236    /// A lint file exceeded the immutable per-file check bound.
3237    #[error("contains {found} checks, exceeding the output-v11 limit of {limit}")]
3238    TooManyChecks {
3239        /// Supplied check count.
3240        found: usize,
3241        /// Immutable output-v11 limit.
3242        limit: usize,
3243    },
3244    /// File and prediction-provenance primary identities differ.
3245    #[error("prediction provenance primary input does not match file input")]
3246    PredictionPrimaryInputMismatch,
3247    /// File-scoped prediction provenance violated its immutable contract.
3248    #[error("has invalid prediction provenance: {source}")]
3249    InvalidPredictionProvenance {
3250        /// Typed nested provenance failure.
3251        #[source]
3252        source: PredictionContractError,
3253    },
3254    /// The serialized provenance object could not be decoded as the strict V1 wire.
3255    #[error("has invalid prediction provenance shape: {reason}")]
3256    InvalidPredictionProvenanceShape {
3257        /// Stable serde diagnostic for the malformed nested object.
3258        reason: String,
3259    },
3260    /// One check carried a prediction without file provenance.
3261    #[error("checks[{check_index}] has prediction without non-null file provenance")]
3262    PredictionWithoutProvenance {
3263        /// Zero-based check index.
3264        check_index: usize,
3265    },
3266    /// One check's prediction evidence violated its immutable contract.
3267    #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
3268    InvalidPrediction {
3269        /// Zero-based check index.
3270        check_index: usize,
3271        /// Typed nested prediction failure.
3272        #[source]
3273        source: PredictionContractError,
3274    },
3275    /// One serialized check or prediction object could not be decoded as the strict V1 wire.
3276    #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
3277    InvalidPredictionShape {
3278        /// Zero-based check index.
3279        check_index: usize,
3280        /// Stable serde diagnostic for the malformed nested object.
3281        reason: String,
3282    },
3283    /// One check's prediction attachment contradicts the sole check lifecycle.
3284    #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
3285    InvalidPredictionLifecycle {
3286        /// Zero-based check index.
3287        check_index: usize,
3288        /// Stable relationship failure.
3289        reason: &'static str,
3290    },
3291    /// Aggregate prediction facets exceeded the per-file V1 bound.
3292    #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
3293    TooManyPredictionFacets {
3294        /// Supplied facet count.
3295        found: usize,
3296        /// Immutable V1 limit.
3297        limit: usize,
3298    },
3299    /// A decoded V2 budget summary did not coincide with an exhausted shared
3300    /// file facet budget.
3301    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
3302    FacetBudgetSummaryWithoutExhaustedFileBudget {
3303        /// Aggregate facet count.
3304        found: usize,
3305        /// Immutable shared file limit.
3306        limit: usize,
3307    },
3308    /// Aggregate prediction basis rows exceeded the per-file V1 bound.
3309    #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
3310    TooManyPredictionBasisReferences {
3311        /// Supplied basis-row count.
3312        found: usize,
3313        /// Immutable V1 limit.
3314        limit: usize,
3315    },
3316    /// Aggregate prediction/provenance retained text exceeded the per-file bound.
3317    #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
3318    TooMuchPredictionText {
3319        /// Supplied UTF-8 byte count.
3320        found: usize,
3321        /// Immutable V1 limit.
3322        limit: usize,
3323    },
3324    /// Checked prediction accounting overflowed.
3325    #[error("prediction bound accounting overflowed")]
3326    PredictionAccountingOverflow,
3327    /// The nested measurement contract omitted its version.
3328    #[error("has no versioned measurement contract")]
3329    MissingMeasurementVersion,
3330    /// The nested measurement contract uses an unsupported version.
3331    #[error(
3332        "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
3333    )]
3334    UnsupportedMeasurementVersion {
3335        /// Version found in the nested contract.
3336        found: u32,
3337    },
3338    /// The nested contract does not carry the immutable measurement identity.
3339    #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
3340    WrongMeasurementIdentity,
3341    /// The nested contract omitted its clip-measurement map.
3342    #[error("measurement contract has no `clips` map")]
3343    MissingClips,
3344    /// The nested contract omitted material resource coverage.
3345    #[error("measurement contract has no `material_resource_coverage`")]
3346    MissingMaterialResourceCoverage,
3347    /// The nested contract omitted its material definition array.
3348    #[error("measurement contract has no `material_definitions` array")]
3349    MissingMaterialDefinitions,
3350    /// The nested contract omitted its texture array.
3351    #[error("measurement contract has no `textures` array")]
3352    MissingTextures,
3353    /// The nested contract omitted its image array.
3354    #[error("measurement contract has no `images` array")]
3355    MissingImages,
3356    /// The nested contract omitted skeleton source coverage.
3357    #[error("measurement contract has no `skeleton_source_coverage`")]
3358    MissingSkeletonSourceCoverage,
3359    /// The nested contract omitted its source skeleton-node array.
3360    #[error("measurement contract has no `skeleton_nodes` array")]
3361    MissingSkeletonNodes,
3362    /// The nested contract omitted its source skin array.
3363    #[error("measurement contract has no `skins` array")]
3364    MissingSkins,
3365    /// The nested contract omitted its mesh-definition array.
3366    #[error("measurement contract has no `mesh_definitions` array")]
3367    MissingMeshDefinitions,
3368    /// The nested contract omitted its node-instance array.
3369    #[error("measurement contract has no `node_instances` array")]
3370    MissingNodeInstances,
3371    /// The nested contract omitted its scene array.
3372    #[error("measurement contract has no `scenes` array")]
3373    MissingScenes,
3374    /// The nested measurements object could not be decoded after prediction-independent
3375    /// validation completed.
3376    #[error("has invalid measurements shape: {reason}")]
3377    InvalidMeasurementsShape {
3378        /// Stable serde diagnostic for the malformed nested measurements.
3379        reason: String,
3380    },
3381    /// The nested measurement values do not satisfy the current contract.
3382    #[error("has invalid measurements: {source}")]
3383    InvalidMeasurements {
3384        /// Measurement validation failure.
3385        #[source]
3386        source: MeasurementContractError,
3387    },
3388}
3389
3390impl MeasurementReportError {
3391    /// Zero-based file index for an error in one report record.
3392    ///
3393    /// Envelope-level errors return `None`.
3394    pub fn file_index(&self) -> Option<usize> {
3395        match self {
3396            Self::File { file_index, .. } => Some(*file_index),
3397            _ => None,
3398        }
3399    }
3400
3401    fn file(file_index: usize, source: MeasurementFileError) -> Self {
3402        Self::File { file_index, source }
3403    }
3404}
3405
3406fn prediction_file_error(
3407    file_index: usize,
3408    source: MeasurementFileError,
3409) -> MeasurementReportError {
3410    MeasurementReportError::file(file_index, source)
3411}
3412
3413fn decode_prediction_phase_file(
3414    command: &str,
3415    file_index: usize,
3416    raw: &RawValue,
3417    expected_measurement_schema: &'static str,
3418) -> Result<MeasurementFileInput, MeasurementReportError> {
3419    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3420        prediction_file_error(
3421            file_index,
3422            MeasurementFileError::InvalidFileShape {
3423                reason: source.to_string(),
3424            },
3425        )
3426    })?;
3427
3428    if command == "measure" {
3429        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3430            return Err(prediction_file_error(
3431                file_index,
3432                MeasurementFileError::UnexpectedPredictionProvenance,
3433            ));
3434        }
3435        if wire.checks.is_some() {
3436            return Err(prediction_file_error(
3437                file_index,
3438                MeasurementFileError::UnexpectedChecks,
3439            ));
3440        }
3441        return Ok(MeasurementFileInput {
3442            path: wire.path,
3443            input: wire.input,
3444            rig_v17: None,
3445            measurements: wire.measurements,
3446            prediction_provenance: RequiredNullable::Missing,
3447            checks: None,
3448            legacy_prediction_provenance: RequiredNullable::Missing,
3449            legacy_checks: None,
3450            prediction_provenance_v3: RequiredNullable::Missing,
3451            checks_v3: None,
3452            prediction_provenance_v4: RequiredNullable::Missing,
3453            checks_v4: None,
3454            prediction_provenance_v5: RequiredNullable::Missing,
3455            checks_v5: None,
3456            prediction_provenance_v6: RequiredNullable::Missing,
3457            checks_v6: None,
3458        });
3459    }
3460
3461    if wire
3462        .checks
3463        .as_ref()
3464        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3465    {
3466        return Err(prediction_file_error(
3467            file_index,
3468            MeasurementFileError::TooManyChecks {
3469                found: wire.checks.as_ref().map_or(0, Vec::len),
3470                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3471            },
3472        ));
3473    }
3474
3475    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3476        return Err(prediction_file_error(
3477            file_index,
3478            MeasurementFileError::MissingPredictionProvenance,
3479        ));
3480    }
3481
3482    let prediction_provenance = match wire.prediction_provenance {
3483        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3484        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3485        RequiredNullable::Present(Some(raw)) => {
3486            let provenance = if expected_measurement_schema == MEASUREMENTS_SCHEMA_ID {
3487                decode_prediction_provenance_v2(raw.get())
3488            } else {
3489                decode_prediction_provenance_v2_with_measurement_schema(
3490                    raw.get(),
3491                    expected_measurement_schema,
3492                )
3493            }
3494            .map_err(|error| {
3495                let source = match error {
3496                    PredictionDecodeError::Shape(source) => {
3497                        MeasurementFileError::InvalidPredictionProvenanceShape {
3498                            reason: source.to_string(),
3499                        }
3500                    }
3501                    PredictionDecodeError::Semantic(source) => {
3502                        MeasurementFileError::InvalidPredictionProvenance { source }
3503                    }
3504                    PredictionDecodeError::TooManyFileFacets
3505                    | PredictionDecodeError::TooManyFileBasisReferences => {
3506                        unreachable!("provenance never consumes prediction budgets")
3507                    }
3508                };
3509                prediction_file_error(file_index, source)
3510            })?;
3511            RequiredNullable::Present(Some(provenance))
3512        }
3513    };
3514    let mut decoded_facets = 0usize;
3515    let mut decoded_references = 0usize;
3516    let mut has_facet_budget_summary = false;
3517    let mut decoded_text = match &prediction_provenance {
3518        RequiredNullable::Present(Some(provenance)) => {
3519            provenance.retained_text_bytes().map_err(|source| {
3520                prediction_file_error(
3521                    file_index,
3522                    MeasurementFileError::InvalidPredictionProvenance { source },
3523                )
3524            })?
3525        }
3526        RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
3527    };
3528    let provenance_for_checks = match &prediction_provenance {
3529        RequiredNullable::Present(provenance) => provenance.as_ref(),
3530        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3531    };
3532    let checks = wire
3533        .checks
3534        .map(|raw_checks| {
3535            let mut checks = Vec::with_capacity(raw_checks.len());
3536            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3537                let wire: PredictionCheckWireInput =
3538                    serde_json::from_str(raw.get()).map_err(|source| {
3539                        prediction_file_error(
3540                            file_index,
3541                            MeasurementFileError::InvalidPredictionShape {
3542                                check_index,
3543                                reason: source.to_string(),
3544                            },
3545                        )
3546                    })?;
3547                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3548                    return Err(prediction_file_error(
3549                        file_index,
3550                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3551                    ));
3552                }
3553                if (wire.selection == SelectionState::Unselected
3554                    || wire.configuration == ConfigurationState::Disabled
3555                    || wire.applicability == Applicability::NotApplicable)
3556                    && wire.prediction.is_some()
3557                {
3558                    return Err(prediction_file_error(
3559                        file_index,
3560                        MeasurementFileError::InvalidPredictionLifecycle {
3561                            check_index,
3562                            reason: "inactive check must have empty output",
3563                        },
3564                    ));
3565                }
3566                // Allocate the remaining aggregate facet/reference budgets
3567                // before parsing this attachment.  This prevents an over-limit
3568                // later prediction from retaining a prefix whose findings can
3569                // no longer be represented by the file contract.
3570                let prediction = wire
3571                    .prediction
3572                    .map(|raw| {
3573                        let facet_limit =
3574                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets);
3575                        let reference_limit = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3576                            .saturating_sub(decoded_references);
3577                        if expected_measurement_schema == MEASUREMENTS_SCHEMA_ID {
3578                            decode_engine_prediction_v2(raw.get(), facet_limit, reference_limit)
3579                        } else {
3580                            decode_engine_prediction_v2_with_measurement_schema(
3581                                raw.get(),
3582                                facet_limit,
3583                                reference_limit,
3584                                expected_measurement_schema,
3585                            )
3586                        }
3587                        .map_err(|error| {
3588                            let source = match error {
3589                                PredictionDecodeError::Shape(source) => {
3590                                    MeasurementFileError::InvalidPredictionShape {
3591                                        check_index,
3592                                        reason: source.to_string(),
3593                                    }
3594                                }
3595                                PredictionDecodeError::Semantic(source) => {
3596                                    MeasurementFileError::InvalidPrediction {
3597                                        check_index,
3598                                        source,
3599                                    }
3600                                }
3601                                PredictionDecodeError::TooManyFileFacets => {
3602                                    MeasurementFileError::TooManyPredictionFacets {
3603                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3604                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3605                                    }
3606                                }
3607                                PredictionDecodeError::TooManyFileBasisReferences => {
3608                                    MeasurementFileError::TooManyPredictionBasisReferences {
3609                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3610                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3611                                    }
3612                                }
3613                            };
3614                            prediction_file_error(file_index, source)
3615                        })
3616                    })
3617                    .transpose()?;
3618                let check = PredictionCheckInput {
3619                    check_id: wire.check_id,
3620                    selection: wire.selection,
3621                    configuration: wire.configuration,
3622                    applicability: wire.applicability,
3623                    evaluation: wire.evaluation,
3624                    findings: wire.findings,
3625                    evaluated_scopes: wire.evaluated_scopes,
3626                    gaps: wire.gaps,
3627                    prediction,
3628                };
3629                check
3630                    .validate(
3631                        check_index,
3632                        provenance_for_checks,
3633                        expected_measurement_schema,
3634                    )
3635                    .map_err(|source| prediction_file_error(file_index, source))?;
3636                if let Some(prediction) = &check.prediction {
3637                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
3638                    decoded_facets = decoded_facets
3639                        .checked_add(prediction.facets().len())
3640                        .ok_or_else(|| {
3641                            prediction_file_error(
3642                                file_index,
3643                                MeasurementFileError::PredictionAccountingOverflow,
3644                            )
3645                        })?;
3646                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3647                        return Err(prediction_file_error(
3648                            file_index,
3649                            MeasurementFileError::TooManyPredictionFacets {
3650                                found: decoded_facets,
3651                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3652                            },
3653                        ));
3654                    }
3655                    decoded_references = decoded_references
3656                        .checked_add(prediction.basis_reference_count())
3657                        .ok_or_else(|| {
3658                            prediction_file_error(
3659                                file_index,
3660                                MeasurementFileError::PredictionAccountingOverflow,
3661                            )
3662                        })?;
3663                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3664                        return Err(prediction_file_error(
3665                            file_index,
3666                            MeasurementFileError::TooManyPredictionBasisReferences {
3667                                found: decoded_references,
3668                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3669                            },
3670                        ));
3671                    }
3672                    decoded_text = decoded_text
3673                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3674                            prediction_file_error(
3675                                file_index,
3676                                MeasurementFileError::InvalidPrediction {
3677                                    check_index,
3678                                    source,
3679                                },
3680                            )
3681                        })?)
3682                        .ok_or_else(|| {
3683                            prediction_file_error(
3684                                file_index,
3685                                MeasurementFileError::PredictionAccountingOverflow,
3686                            )
3687                        })?;
3688                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3689                        return Err(prediction_file_error(
3690                            file_index,
3691                            MeasurementFileError::TooMuchPredictionText {
3692                                found: decoded_text,
3693                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3694                            },
3695                        ));
3696                    }
3697                }
3698                checks.push(check);
3699            }
3700            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
3701                return Err(prediction_file_error(
3702                    file_index,
3703                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
3704                        found: decoded_facets,
3705                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3706                    },
3707                ));
3708            }
3709            Ok(checks)
3710        })
3711        .transpose()?;
3712    Ok(MeasurementFileInput {
3713        path: wire.path,
3714        input: wire.input,
3715        rig_v17: None,
3716        measurements: wire.measurements,
3717        prediction_provenance,
3718        checks,
3719        legacy_prediction_provenance: RequiredNullable::Missing,
3720        legacy_checks: None,
3721        prediction_provenance_v3: RequiredNullable::Missing,
3722        checks_v3: None,
3723        prediction_provenance_v4: RequiredNullable::Missing,
3724        checks_v4: None,
3725        prediction_provenance_v5: RequiredNullable::Missing,
3726        checks_v5: None,
3727        prediction_provenance_v6: RequiredNullable::Missing,
3728        checks_v6: None,
3729    })
3730}
3731
3732fn decode_prediction_phase_file_v14(
3733    command: &str,
3734    file_index: usize,
3735    raw: &RawValue,
3736) -> Result<MeasurementFileInput, MeasurementReportError> {
3737    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3738        prediction_file_error(
3739            file_index,
3740            MeasurementFileError::InvalidFileShape {
3741                reason: source.to_string(),
3742            },
3743        )
3744    })?;
3745    if command == "measure" {
3746        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3747            return Err(prediction_file_error(
3748                file_index,
3749                MeasurementFileError::UnexpectedPredictionProvenance,
3750            ));
3751        }
3752        if wire.checks.is_some() {
3753            return Err(prediction_file_error(
3754                file_index,
3755                MeasurementFileError::UnexpectedChecks,
3756            ));
3757        }
3758        return Ok(MeasurementFileInput {
3759            path: wire.path,
3760            input: wire.input,
3761            rig_v17: None,
3762            measurements: wire.measurements,
3763            prediction_provenance: RequiredNullable::Missing,
3764            checks: None,
3765            legacy_prediction_provenance: RequiredNullable::Missing,
3766            legacy_checks: None,
3767            prediction_provenance_v3: RequiredNullable::Missing,
3768            checks_v3: None,
3769            prediction_provenance_v4: RequiredNullable::Missing,
3770            checks_v4: None,
3771            prediction_provenance_v5: RequiredNullable::Missing,
3772            checks_v5: None,
3773            prediction_provenance_v6: RequiredNullable::Missing,
3774            checks_v6: None,
3775        });
3776    }
3777    if wire
3778        .checks
3779        .as_ref()
3780        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3781    {
3782        return Err(prediction_file_error(
3783            file_index,
3784            MeasurementFileError::TooManyChecks {
3785                found: wire.checks.as_ref().map_or(0, Vec::len),
3786                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3787            },
3788        ));
3789    }
3790    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3791        return Err(prediction_file_error(
3792            file_index,
3793            MeasurementFileError::MissingPredictionProvenance,
3794        ));
3795    }
3796    let prediction_provenance_v3 = match wire.prediction_provenance {
3797        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3798        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3799        RequiredNullable::Present(Some(raw)) => {
3800            let provenance = decode_prediction_provenance_v3(raw.get()).map_err(|error| {
3801                let source = match error {
3802                    PredictionDecodeError::Shape(source) => {
3803                        MeasurementFileError::InvalidPredictionProvenanceShape {
3804                            reason: source.to_string(),
3805                        }
3806                    }
3807                    PredictionDecodeError::Semantic(source) => {
3808                        MeasurementFileError::InvalidPredictionProvenance { source }
3809                    }
3810                    PredictionDecodeError::TooManyFileFacets
3811                    | PredictionDecodeError::TooManyFileBasisReferences => {
3812                        unreachable!("provenance never consumes prediction budgets")
3813                    }
3814                };
3815                prediction_file_error(file_index, source)
3816            })?;
3817            RequiredNullable::Present(Some(provenance))
3818        }
3819    };
3820    let provenance_for_checks = match &prediction_provenance_v3 {
3821        RequiredNullable::Present(provenance) => provenance.as_ref(),
3822        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3823    };
3824    let mut decoded_facets = 0usize;
3825    let mut decoded_references = 0usize;
3826    let mut has_facet_budget_summary = false;
3827    let mut decoded_text = provenance_for_checks
3828        .map(PredictionProvenanceV3::retained_text_bytes)
3829        .transpose()
3830        .map_err(|source| {
3831            prediction_file_error(
3832                file_index,
3833                MeasurementFileError::InvalidPredictionProvenance { source },
3834            )
3835        })?
3836        .unwrap_or(0);
3837    let checks_v3 = wire
3838        .checks
3839        .map(|raw_checks| {
3840            let mut checks = Vec::with_capacity(raw_checks.len());
3841            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3842                let wire: PredictionCheckWireInput =
3843                    serde_json::from_str(raw.get()).map_err(|source| {
3844                        prediction_file_error(
3845                            file_index,
3846                            MeasurementFileError::InvalidPredictionShape {
3847                                check_index,
3848                                reason: source.to_string(),
3849                            },
3850                        )
3851                    })?;
3852                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3853                    return Err(prediction_file_error(
3854                        file_index,
3855                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3856                    ));
3857                }
3858                if (wire.selection == SelectionState::Unselected
3859                    || wire.configuration == ConfigurationState::Disabled
3860                    || wire.applicability == Applicability::NotApplicable)
3861                    && wire.prediction.is_some()
3862                {
3863                    return Err(prediction_file_error(
3864                        file_index,
3865                        MeasurementFileError::InvalidPredictionLifecycle {
3866                            check_index,
3867                            reason: "inactive check must have empty output",
3868                        },
3869                    ));
3870                }
3871                let prediction = wire
3872                    .prediction
3873                    .map(|raw| {
3874                        decode_engine_prediction_v3(
3875                            raw.get(),
3876                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
3877                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3878                                .saturating_sub(decoded_references),
3879                        )
3880                        .map_err(|error| {
3881                            let source = match error {
3882                                PredictionDecodeError::Shape(source) => {
3883                                    MeasurementFileError::InvalidPredictionShape {
3884                                        check_index,
3885                                        reason: source.to_string(),
3886                                    }
3887                                }
3888                                PredictionDecodeError::Semantic(source) => {
3889                                    MeasurementFileError::InvalidPrediction {
3890                                        check_index,
3891                                        source,
3892                                    }
3893                                }
3894                                PredictionDecodeError::TooManyFileFacets => {
3895                                    MeasurementFileError::TooManyPredictionFacets {
3896                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3897                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3898                                    }
3899                                }
3900                                PredictionDecodeError::TooManyFileBasisReferences => {
3901                                    MeasurementFileError::TooManyPredictionBasisReferences {
3902                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3903                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3904                                    }
3905                                }
3906                            };
3907                            prediction_file_error(file_index, source)
3908                        })
3909                    })
3910                    .transpose()?;
3911                let check = PredictionCheckInputV3 {
3912                    check_id: wire.check_id,
3913                    selection: wire.selection,
3914                    configuration: wire.configuration,
3915                    applicability: wire.applicability,
3916                    evaluation: wire.evaluation,
3917                    findings: wire.findings,
3918                    evaluated_scopes: wire.evaluated_scopes,
3919                    gaps: wire.gaps,
3920                    prediction,
3921                };
3922                check
3923                    .validate(check_index, provenance_for_checks)
3924                    .map_err(|source| prediction_file_error(file_index, source))?;
3925                if let Some(prediction) = &check.prediction {
3926                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
3927                    decoded_facets = decoded_facets
3928                        .checked_add(prediction.facets().len())
3929                        .ok_or_else(|| {
3930                            prediction_file_error(
3931                                file_index,
3932                                MeasurementFileError::PredictionAccountingOverflow,
3933                            )
3934                        })?;
3935                    decoded_references = decoded_references
3936                        .checked_add(prediction.basis_reference_count())
3937                        .ok_or_else(|| {
3938                            prediction_file_error(
3939                                file_index,
3940                                MeasurementFileError::PredictionAccountingOverflow,
3941                            )
3942                        })?;
3943                    decoded_text = decoded_text
3944                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3945                            prediction_file_error(
3946                                file_index,
3947                                MeasurementFileError::InvalidPrediction {
3948                                    check_index,
3949                                    source,
3950                                },
3951                            )
3952                        })?)
3953                        .ok_or_else(|| {
3954                            prediction_file_error(
3955                                file_index,
3956                                MeasurementFileError::PredictionAccountingOverflow,
3957                            )
3958                        })?;
3959                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3960                        return Err(prediction_file_error(
3961                            file_index,
3962                            MeasurementFileError::TooMuchPredictionText {
3963                                found: decoded_text,
3964                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3965                            },
3966                        ));
3967                    }
3968                }
3969                checks.push(check);
3970            }
3971            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
3972                return Err(prediction_file_error(
3973                    file_index,
3974                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
3975                        found: decoded_facets,
3976                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3977                    },
3978                ));
3979            }
3980            Ok(checks)
3981        })
3982        .transpose()?;
3983    Ok(MeasurementFileInput {
3984        path: wire.path,
3985        input: wire.input,
3986        rig_v17: None,
3987        measurements: wire.measurements,
3988        prediction_provenance: RequiredNullable::Missing,
3989        checks: None,
3990        legacy_prediction_provenance: RequiredNullable::Missing,
3991        legacy_checks: None,
3992        prediction_provenance_v3,
3993        checks_v3,
3994        prediction_provenance_v4: RequiredNullable::Missing,
3995        checks_v4: None,
3996        prediction_provenance_v5: RequiredNullable::Missing,
3997        checks_v5: None,
3998        prediction_provenance_v6: RequiredNullable::Missing,
3999        checks_v6: None,
4000    })
4001}
4002
4003fn decode_prediction_phase_file_v15(
4004    command: &str,
4005    file_index: usize,
4006    raw: &RawValue,
4007) -> Result<MeasurementFileInput, MeasurementReportError> {
4008    #[derive(Deserialize)]
4009    struct SchemaProbe {
4010        schema: String,
4011    }
4012
4013    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4014        prediction_file_error(
4015            file_index,
4016            MeasurementFileError::InvalidFileShape {
4017                reason: source.to_string(),
4018            },
4019        )
4020    })?;
4021    if command == "measure"
4022        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4023    {
4024        return decode_prediction_phase_file_v14(command, file_index, raw);
4025    }
4026    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4027        return decode_prediction_phase_file_v14(command, file_index, raw);
4028    };
4029    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4030        prediction_file_error(
4031            file_index,
4032            MeasurementFileError::InvalidPredictionProvenanceShape {
4033                reason: source.to_string(),
4034            },
4035        )
4036    })?;
4037    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4038        return decode_prediction_phase_file_v14(command, file_index, raw);
4039    }
4040    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V4_ID {
4041        return Err(prediction_file_error(
4042            file_index,
4043            MeasurementFileError::InvalidPredictionProvenance {
4044                source: PredictionContractError::InvalidSchema {
4045                    field: "prediction provenance.schema",
4046                    expected: crate::prediction::PREDICTION_PROVENANCE_V4_ID,
4047                    found: schema.schema,
4048                },
4049            },
4050        ));
4051    }
4052
4053    let MeasurementFileWireInput {
4054        path,
4055        input,
4056        measurements,
4057        prediction_provenance,
4058        checks,
4059        ..
4060    } = probe;
4061    if checks
4062        .as_ref()
4063        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4064    {
4065        return Err(prediction_file_error(
4066            file_index,
4067            MeasurementFileError::TooManyChecks {
4068                found: checks.as_ref().map_or(0, Vec::len),
4069                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4070            },
4071        ));
4072    }
4073    let RequiredNullable::Present(Some(provenance_raw)) = prediction_provenance else {
4074        unreachable!("V4 provenance was probed above")
4075    };
4076    let provenance = decode_prediction_provenance_v4(provenance_raw.get()).map_err(|error| {
4077        let source = match error {
4078            PredictionDecodeError::Shape(source) => {
4079                MeasurementFileError::InvalidPredictionProvenanceShape {
4080                    reason: source.to_string(),
4081                }
4082            }
4083            PredictionDecodeError::Semantic(source) => {
4084                MeasurementFileError::InvalidPredictionProvenance { source }
4085            }
4086            PredictionDecodeError::TooManyFileFacets
4087            | PredictionDecodeError::TooManyFileBasisReferences => unreachable!(),
4088        };
4089        prediction_file_error(file_index, source)
4090    })?;
4091    let mut decoded_facets = 0usize;
4092    let mut decoded_references = 0usize;
4093    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4094        prediction_file_error(
4095            file_index,
4096            MeasurementFileError::InvalidPredictionProvenance { source },
4097        )
4098    })?;
4099    let checks_v4 = checks
4100        .map(|raw_checks| {
4101            let mut decoded = Vec::with_capacity(raw_checks.len());
4102            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4103                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4104                    .map_err(|source| {
4105                        prediction_file_error(
4106                            file_index,
4107                            MeasurementFileError::InvalidPredictionShape {
4108                                check_index,
4109                                reason: source.to_string(),
4110                            },
4111                        )
4112                    })?;
4113                if (wire.selection == SelectionState::Unselected
4114                    || wire.configuration == ConfigurationState::Disabled
4115                    || wire.applicability == Applicability::NotApplicable)
4116                    && wire.prediction.is_some()
4117                {
4118                    return Err(prediction_file_error(
4119                        file_index,
4120                        MeasurementFileError::InvalidPredictionLifecycle {
4121                            check_index,
4122                            reason: "inactive check must have empty output",
4123                        },
4124                    ));
4125                }
4126                let prediction = wire
4127                    .prediction
4128                    .map(|prediction_raw| {
4129                        decode_engine_prediction_v4(
4130                            prediction_raw.get(),
4131                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4132                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4133                                .saturating_sub(decoded_references),
4134                        )
4135                        .map_err(|error| {
4136                            let source = match error {
4137                                PredictionDecodeError::Shape(source) => {
4138                                    MeasurementFileError::InvalidPredictionShape {
4139                                        check_index,
4140                                        reason: source.to_string(),
4141                                    }
4142                                }
4143                                PredictionDecodeError::Semantic(source) => {
4144                                    MeasurementFileError::InvalidPrediction {
4145                                        check_index,
4146                                        source,
4147                                    }
4148                                }
4149                                PredictionDecodeError::TooManyFileFacets => {
4150                                    MeasurementFileError::TooManyPredictionFacets {
4151                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4152                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4153                                    }
4154                                }
4155                                PredictionDecodeError::TooManyFileBasisReferences => {
4156                                    MeasurementFileError::TooManyPredictionBasisReferences {
4157                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4158                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4159                                    }
4160                                }
4161                            };
4162                            prediction_file_error(file_index, source)
4163                        })
4164                    })
4165                    .transpose()?;
4166                let check = PredictionCheckInputV4 {
4167                    check_id: wire.check_id,
4168                    selection: wire.selection,
4169                    configuration: wire.configuration,
4170                    applicability: wire.applicability,
4171                    evaluation: wire.evaluation,
4172                    findings: wire.findings,
4173                    evaluated_scopes: wire.evaluated_scopes,
4174                    gaps: wire.gaps,
4175                    prediction,
4176                };
4177                check
4178                    .validate(check_index, Some(&provenance))
4179                    .map_err(|source| prediction_file_error(file_index, source))?;
4180                if let Some(prediction) = &check.prediction {
4181                    decoded_facets = decoded_facets
4182                        .checked_add(prediction.facets().len())
4183                        .ok_or_else(|| {
4184                            prediction_file_error(
4185                                file_index,
4186                                MeasurementFileError::PredictionAccountingOverflow,
4187                            )
4188                        })?;
4189                    decoded_references = decoded_references
4190                        .checked_add(prediction.basis_reference_count())
4191                        .ok_or_else(|| {
4192                            prediction_file_error(
4193                                file_index,
4194                                MeasurementFileError::PredictionAccountingOverflow,
4195                            )
4196                        })?;
4197                    decoded_text = decoded_text
4198                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4199                            prediction_file_error(
4200                                file_index,
4201                                MeasurementFileError::InvalidPrediction {
4202                                    check_index,
4203                                    source,
4204                                },
4205                            )
4206                        })?)
4207                        .ok_or_else(|| {
4208                            prediction_file_error(
4209                                file_index,
4210                                MeasurementFileError::PredictionAccountingOverflow,
4211                            )
4212                        })?;
4213                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4214                        return Err(prediction_file_error(
4215                            file_index,
4216                            MeasurementFileError::TooMuchPredictionText {
4217                                found: decoded_text,
4218                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4219                            },
4220                        ));
4221                    }
4222                }
4223                decoded.push(check);
4224            }
4225            Ok(decoded)
4226        })
4227        .transpose()?;
4228    Ok(MeasurementFileInput {
4229        path,
4230        input,
4231        rig_v17: None,
4232        measurements,
4233        prediction_provenance: RequiredNullable::Missing,
4234        checks: None,
4235        legacy_prediction_provenance: RequiredNullable::Missing,
4236        legacy_checks: None,
4237        prediction_provenance_v3: RequiredNullable::Missing,
4238        checks_v3: None,
4239        prediction_provenance_v4: RequiredNullable::Present(Some(provenance)),
4240        checks_v4,
4241        prediction_provenance_v5: RequiredNullable::Missing,
4242        checks_v5: None,
4243        prediction_provenance_v6: RequiredNullable::Missing,
4244        checks_v6: None,
4245    })
4246}
4247
4248fn decode_prediction_phase_file_v16(
4249    command: &str,
4250    file_index: usize,
4251    raw: &RawValue,
4252) -> Result<MeasurementFileInput, MeasurementReportError> {
4253    #[derive(Deserialize)]
4254    struct SchemaProbe {
4255        schema: String,
4256    }
4257
4258    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4259        prediction_file_error(
4260            file_index,
4261            MeasurementFileError::InvalidFileShape {
4262                reason: source.to_string(),
4263            },
4264        )
4265    })?;
4266    if command == "measure"
4267        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4268    {
4269        return decode_prediction_phase_file_v15(command, file_index, raw);
4270    }
4271    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4272        return decode_prediction_phase_file_v15(command, file_index, raw);
4273    };
4274    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4275        prediction_file_error(
4276            file_index,
4277            MeasurementFileError::InvalidPredictionProvenanceShape {
4278                reason: source.to_string(),
4279            },
4280        )
4281    })?;
4282    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4283        return decode_prediction_phase_file_v15(command, file_index, raw);
4284    }
4285    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V5_ID {
4286        return Err(prediction_file_error(
4287            file_index,
4288            MeasurementFileError::InvalidPredictionProvenance {
4289                source: PredictionContractError::InvalidSchema {
4290                    field: "prediction provenance.schema",
4291                    expected: crate::prediction::PREDICTION_PROVENANCE_V5_ID,
4292                    found: schema.schema,
4293                },
4294            },
4295        ));
4296    }
4297    let MeasurementFileWireInput {
4298        path,
4299        input,
4300        measurements,
4301        prediction_provenance,
4302        checks,
4303        ..
4304    } = probe;
4305    if checks
4306        .as_ref()
4307        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4308    {
4309        return Err(prediction_file_error(
4310            file_index,
4311            MeasurementFileError::TooManyChecks {
4312                found: checks.as_ref().map_or(0, Vec::len),
4313                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4314            },
4315        ));
4316    }
4317    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4318        unreachable!()
4319    };
4320    let provenance: PredictionProvenanceV5 =
4321        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4322            prediction_file_error(
4323                file_index,
4324                MeasurementFileError::InvalidPredictionProvenanceShape {
4325                    reason: source.to_string(),
4326                },
4327            )
4328        })?;
4329    provenance.validate().map_err(|source| {
4330        prediction_file_error(
4331            file_index,
4332            MeasurementFileError::InvalidPredictionProvenance { source },
4333        )
4334    })?;
4335    let mut decoded_facets = 0usize;
4336    let mut decoded_references = 0usize;
4337    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4338        prediction_file_error(
4339            file_index,
4340            MeasurementFileError::InvalidPredictionProvenance { source },
4341        )
4342    })?;
4343    let checks_v5 = checks
4344        .map(|raw_checks| {
4345            let mut decoded = Vec::with_capacity(raw_checks.len());
4346            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4347                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4348                    .map_err(|source| {
4349                        prediction_file_error(
4350                            file_index,
4351                            MeasurementFileError::InvalidPredictionShape {
4352                                check_index,
4353                                reason: source.to_string(),
4354                            },
4355                        )
4356                    })?;
4357                if (wire.selection == SelectionState::Unselected
4358                    || wire.configuration == ConfigurationState::Disabled
4359                    || wire.applicability == Applicability::NotApplicable)
4360                    && wire.prediction.is_some()
4361                {
4362                    return Err(prediction_file_error(
4363                        file_index,
4364                        MeasurementFileError::InvalidPredictionLifecycle {
4365                            check_index,
4366                            reason: "inactive check must have empty output",
4367                        },
4368                    ));
4369                }
4370                let prediction =
4371                    wire.prediction
4372                        .map(|raw_prediction| {
4373                            serde_json::from_str::<EnginePredictionV5>(raw_prediction.get())
4374                                .map_err(|source| {
4375                                    prediction_file_error(
4376                                        file_index,
4377                                        MeasurementFileError::InvalidPredictionShape {
4378                                            check_index,
4379                                            reason: source.to_string(),
4380                                        },
4381                                    )
4382                                })
4383                        })
4384                        .transpose()?;
4385                let check = PredictionCheckInputV5 {
4386                    check_id: wire.check_id,
4387                    selection: wire.selection,
4388                    configuration: wire.configuration,
4389                    applicability: wire.applicability,
4390                    evaluation: wire.evaluation,
4391                    findings: wire.findings,
4392                    evaluated_scopes: wire.evaluated_scopes,
4393                    gaps: wire.gaps,
4394                    prediction,
4395                };
4396                check
4397                    .validate(check_index, Some(&provenance))
4398                    .map_err(|source| prediction_file_error(file_index, source))?;
4399                if let Some(prediction) = &check.prediction {
4400                    decoded_facets = decoded_facets
4401                        .checked_add(prediction.facets().len())
4402                        .ok_or_else(|| {
4403                            prediction_file_error(
4404                                file_index,
4405                                MeasurementFileError::PredictionAccountingOverflow,
4406                            )
4407                        })?;
4408                    decoded_references = decoded_references
4409                        .checked_add(prediction.basis_reference_count())
4410                        .ok_or_else(|| {
4411                            prediction_file_error(
4412                                file_index,
4413                                MeasurementFileError::PredictionAccountingOverflow,
4414                            )
4415                        })?;
4416                    decoded_text = decoded_text
4417                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4418                            prediction_file_error(
4419                                file_index,
4420                                MeasurementFileError::InvalidPrediction {
4421                                    check_index,
4422                                    source,
4423                                },
4424                            )
4425                        })?)
4426                        .ok_or_else(|| {
4427                            prediction_file_error(
4428                                file_index,
4429                                MeasurementFileError::PredictionAccountingOverflow,
4430                            )
4431                        })?;
4432                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4433                        return Err(prediction_file_error(
4434                            file_index,
4435                            MeasurementFileError::TooManyPredictionFacets {
4436                                found: decoded_facets,
4437                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4438                            },
4439                        ));
4440                    }
4441                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4442                        return Err(prediction_file_error(
4443                            file_index,
4444                            MeasurementFileError::TooManyPredictionBasisReferences {
4445                                found: decoded_references,
4446                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4447                            },
4448                        ));
4449                    }
4450                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4451                        return Err(prediction_file_error(
4452                            file_index,
4453                            MeasurementFileError::TooMuchPredictionText {
4454                                found: decoded_text,
4455                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4456                            },
4457                        ));
4458                    }
4459                }
4460                decoded.push(check);
4461            }
4462            Ok(decoded)
4463        })
4464        .transpose()?;
4465    Ok(MeasurementFileInput {
4466        path,
4467        input,
4468        rig_v17: None,
4469        measurements,
4470        prediction_provenance: RequiredNullable::Missing,
4471        checks: None,
4472        legacy_prediction_provenance: RequiredNullable::Missing,
4473        legacy_checks: None,
4474        prediction_provenance_v3: RequiredNullable::Missing,
4475        checks_v3: None,
4476        prediction_provenance_v4: RequiredNullable::Missing,
4477        checks_v4: None,
4478        prediction_provenance_v5: RequiredNullable::Present(Some(provenance)),
4479        checks_v5,
4480        prediction_provenance_v6: RequiredNullable::Missing,
4481        checks_v6: None,
4482    })
4483}
4484
4485fn decode_prediction_phase_file_v17(
4486    command: &str,
4487    file_index: usize,
4488    raw: &RawValue,
4489) -> Result<MeasurementFileInput, MeasurementReportError> {
4490    #[derive(Deserialize)]
4491    struct SchemaProbe {
4492        schema: String,
4493    }
4494    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4495        prediction_file_error(
4496            file_index,
4497            MeasurementFileError::InvalidFileShape {
4498                reason: source.to_string(),
4499            },
4500        )
4501    })?;
4502    if command == "measure"
4503        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4504    {
4505        return decode_prediction_phase_file_v16(command, file_index, raw);
4506    }
4507    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4508        return decode_prediction_phase_file_v16(command, file_index, raw);
4509    };
4510    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4511        prediction_file_error(
4512            file_index,
4513            MeasurementFileError::InvalidPredictionProvenanceShape {
4514                reason: source.to_string(),
4515            },
4516        )
4517    })?;
4518    if matches!(
4519        schema.schema.as_str(),
4520        crate::prediction::PREDICTION_PROVENANCE_V3_ID
4521            | crate::prediction::PREDICTION_PROVENANCE_V5_ID
4522    ) {
4523        return decode_prediction_phase_file_v16(command, file_index, raw);
4524    }
4525    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V6_ID {
4526        return Err(prediction_file_error(
4527            file_index,
4528            MeasurementFileError::InvalidPredictionProvenance {
4529                source: PredictionContractError::InvalidSchema {
4530                    field: "prediction provenance.schema",
4531                    expected: crate::prediction::PREDICTION_PROVENANCE_V6_ID,
4532                    found: schema.schema,
4533                },
4534            },
4535        ));
4536    }
4537    let MeasurementFileWireInput {
4538        path,
4539        input,
4540        rig,
4541        measurements,
4542        prediction_provenance,
4543        checks,
4544    } = probe;
4545    let rig_v17 = serde_json::from_str::<RigInfo>(rig.get()).map_err(|source| {
4546        prediction_file_error(
4547            file_index,
4548            MeasurementFileError::InvalidFileShape {
4549                reason: format!("invalid output-v17 rig evidence: {source}"),
4550            },
4551        )
4552    })?;
4553    if checks
4554        .as_ref()
4555        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4556    {
4557        return Err(prediction_file_error(
4558            file_index,
4559            MeasurementFileError::TooManyChecks {
4560                found: checks.as_ref().map_or(0, Vec::len),
4561                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4562            },
4563        ));
4564    }
4565    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4566        unreachable!()
4567    };
4568    let provenance: PredictionProvenanceV6 =
4569        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4570            prediction_file_error(
4571                file_index,
4572                MeasurementFileError::InvalidPredictionProvenanceShape {
4573                    reason: source.to_string(),
4574                },
4575            )
4576        })?;
4577    provenance.validate().map_err(|source| {
4578        prediction_file_error(
4579            file_index,
4580            MeasurementFileError::InvalidPredictionProvenance { source },
4581        )
4582    })?;
4583    let mut decoded_facets = 0usize;
4584    let mut decoded_references = 0usize;
4585    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4586        prediction_file_error(
4587            file_index,
4588            MeasurementFileError::InvalidPredictionProvenance { source },
4589        )
4590    })?;
4591    let checks_v6 = checks
4592        .map(|raw_checks| {
4593            let mut decoded = Vec::with_capacity(raw_checks.len());
4594            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4595                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4596                    .map_err(|source| {
4597                        prediction_file_error(
4598                            file_index,
4599                            MeasurementFileError::InvalidPredictionShape {
4600                                check_index,
4601                                reason: source.to_string(),
4602                            },
4603                        )
4604                    })?;
4605                if (wire.selection == SelectionState::Unselected
4606                    || wire.configuration == ConfigurationState::Disabled
4607                    || wire.applicability == Applicability::NotApplicable)
4608                    && wire.prediction.is_some()
4609                {
4610                    return Err(prediction_file_error(
4611                        file_index,
4612                        MeasurementFileError::InvalidPredictionLifecycle {
4613                            check_index,
4614                            reason: "inactive check must have empty output",
4615                        },
4616                    ));
4617                }
4618                let prediction =
4619                    wire.prediction
4620                        .map(|raw_prediction| {
4621                            serde_json::from_str::<EnginePredictionV6>(raw_prediction.get())
4622                                .map_err(|source| {
4623                                    prediction_file_error(
4624                                        file_index,
4625                                        MeasurementFileError::InvalidPredictionShape {
4626                                            check_index,
4627                                            reason: source.to_string(),
4628                                        },
4629                                    )
4630                                })
4631                        })
4632                        .transpose()?;
4633                let check = PredictionCheckInputV6 {
4634                    check_id: wire.check_id,
4635                    selection: wire.selection,
4636                    configuration: wire.configuration,
4637                    applicability: wire.applicability,
4638                    evaluation: wire.evaluation,
4639                    findings: wire.findings,
4640                    evaluated_scopes: wire.evaluated_scopes,
4641                    gaps: wire.gaps,
4642                    prediction,
4643                };
4644                check
4645                    .validate(check_index, Some(&provenance))
4646                    .map_err(|source| prediction_file_error(file_index, source))?;
4647                if let Some(prediction) = &check.prediction {
4648                    decoded_facets = decoded_facets
4649                        .checked_add(prediction.facets().len())
4650                        .ok_or_else(|| {
4651                            prediction_file_error(
4652                                file_index,
4653                                MeasurementFileError::PredictionAccountingOverflow,
4654                            )
4655                        })?;
4656                    decoded_references = decoded_references
4657                        .checked_add(prediction.basis_reference_count())
4658                        .ok_or_else(|| {
4659                            prediction_file_error(
4660                                file_index,
4661                                MeasurementFileError::PredictionAccountingOverflow,
4662                            )
4663                        })?;
4664                    decoded_text = decoded_text
4665                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4666                            prediction_file_error(
4667                                file_index,
4668                                MeasurementFileError::InvalidPrediction {
4669                                    check_index,
4670                                    source,
4671                                },
4672                            )
4673                        })?)
4674                        .ok_or_else(|| {
4675                            prediction_file_error(
4676                                file_index,
4677                                MeasurementFileError::PredictionAccountingOverflow,
4678                            )
4679                        })?;
4680                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4681                        return Err(prediction_file_error(
4682                            file_index,
4683                            MeasurementFileError::TooManyPredictionFacets {
4684                                found: decoded_facets,
4685                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4686                            },
4687                        ));
4688                    }
4689                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4690                        return Err(prediction_file_error(
4691                            file_index,
4692                            MeasurementFileError::TooManyPredictionBasisReferences {
4693                                found: decoded_references,
4694                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4695                            },
4696                        ));
4697                    }
4698                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4699                        return Err(prediction_file_error(
4700                            file_index,
4701                            MeasurementFileError::TooMuchPredictionText {
4702                                found: decoded_text,
4703                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4704                            },
4705                        ));
4706                    }
4707                }
4708                decoded.push(check);
4709            }
4710            Ok(decoded)
4711        })
4712        .transpose()?;
4713    Ok(MeasurementFileInput {
4714        path,
4715        input,
4716        rig_v17: Some(rig_v17),
4717        measurements,
4718        prediction_provenance: RequiredNullable::Missing,
4719        checks: None,
4720        legacy_prediction_provenance: RequiredNullable::Missing,
4721        legacy_checks: None,
4722        prediction_provenance_v3: RequiredNullable::Missing,
4723        checks_v3: None,
4724        prediction_provenance_v4: RequiredNullable::Missing,
4725        checks_v4: None,
4726        prediction_provenance_v5: RequiredNullable::Missing,
4727        checks_v5: None,
4728        prediction_provenance_v6: RequiredNullable::Present(Some(provenance)),
4729        checks_v6,
4730    })
4731}
4732
4733/// Decode the immutable output-v11 file envelope with its original V1 staged
4734/// reader.  Historical evidence remains V1 all the way through validation;
4735/// accepting it must be neither weaker nor a reinterpretation as V2.
4736fn decode_legacy_v11_file(
4737    command: &str,
4738    file_index: usize,
4739    raw: &RawValue,
4740) -> Result<MeasurementFileInput, MeasurementReportError> {
4741    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4742        prediction_file_error(
4743            file_index,
4744            MeasurementFileError::InvalidFileShape {
4745                reason: source.to_string(),
4746            },
4747        )
4748    })?;
4749    if command == "measure" {
4750        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4751            return Err(prediction_file_error(
4752                file_index,
4753                MeasurementFileError::UnexpectedPredictionProvenance,
4754            ));
4755        }
4756        if wire.checks.is_some() {
4757            return Err(prediction_file_error(
4758                file_index,
4759                MeasurementFileError::UnexpectedChecks,
4760            ));
4761        }
4762        return Ok(MeasurementFileInput {
4763            path: wire.path,
4764            input: wire.input,
4765            rig_v17: None,
4766            measurements: wire.measurements,
4767            prediction_provenance: RequiredNullable::Missing,
4768            checks: None,
4769            legacy_prediction_provenance: RequiredNullable::Missing,
4770            legacy_checks: None,
4771            prediction_provenance_v3: RequiredNullable::Missing,
4772            checks_v3: None,
4773            prediction_provenance_v4: RequiredNullable::Missing,
4774            checks_v4: None,
4775            prediction_provenance_v5: RequiredNullable::Missing,
4776            checks_v5: None,
4777            prediction_provenance_v6: RequiredNullable::Missing,
4778            checks_v6: None,
4779        });
4780    }
4781
4782    if wire
4783        .checks
4784        .as_ref()
4785        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4786    {
4787        return Err(prediction_file_error(
4788            file_index,
4789            MeasurementFileError::TooManyChecks {
4790                found: wire.checks.as_ref().map_or(0, Vec::len),
4791                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4792            },
4793        ));
4794    }
4795    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4796        return Err(prediction_file_error(
4797            file_index,
4798            MeasurementFileError::MissingPredictionProvenance,
4799        ));
4800    }
4801    let legacy_prediction_provenance = match wire.prediction_provenance {
4802        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4803        RequiredNullable::Present(None) => RequiredNullable::Present(None),
4804        RequiredNullable::Present(Some(raw)) => RequiredNullable::Present(Some(
4805            decode_prediction_provenance_v1_with_measurement_schema(
4806                raw.get(),
4807                MEASUREMENTS_V15_SCHEMA_ID,
4808            )
4809            .map_err(|error| {
4810                prediction_file_error(
4811                    file_index,
4812                    match error {
4813                        PredictionDecodeError::Shape(source) => {
4814                            MeasurementFileError::InvalidPredictionProvenanceShape {
4815                                reason: source.to_string(),
4816                            }
4817                        }
4818                        PredictionDecodeError::Semantic(source) => {
4819                            MeasurementFileError::InvalidPredictionProvenance { source }
4820                        }
4821                        PredictionDecodeError::TooManyFileFacets
4822                        | PredictionDecodeError::TooManyFileBasisReferences => {
4823                            unreachable!("provenance decoding cannot consume prediction budgets")
4824                        }
4825                    },
4826                )
4827            })?,
4828        )),
4829    };
4830    let mut decoded_facets = 0usize;
4831    let mut decoded_references = 0usize;
4832    let mut decoded_text = legacy_prediction_provenance
4833        .as_present()
4834        .map(PredictionProvenanceV1::retained_text_bytes)
4835        .transpose()
4836        .map_err(|source| {
4837            prediction_file_error(
4838                file_index,
4839                MeasurementFileError::InvalidPredictionProvenance { source },
4840            )
4841        })?
4842        .unwrap_or(0);
4843    let provenance_for_checks = legacy_prediction_provenance.as_present();
4844    let legacy_checks = wire
4845        .checks
4846        .map(|raw_checks| {
4847            let mut checks = Vec::with_capacity(raw_checks.len());
4848            for (check_index, raw) in raw_checks.into_iter().enumerate() {
4849                let wire: LegacyPredictionCheckWireV11 =
4850                    serde_json::from_str(raw.get()).map_err(|source| {
4851                        prediction_file_error(
4852                            file_index,
4853                            MeasurementFileError::InvalidPredictionShape {
4854                                check_index,
4855                                reason: source.to_string(),
4856                            },
4857                        )
4858                    })?;
4859                // Preserve the released V11 reader's precedence: these
4860                // lifecycle violations are decided from the raw attachment
4861                // presence before a malformed prediction can be decoded.
4862                if provenance_for_checks.is_none() && wire.prediction.is_some() {
4863                    return Err(prediction_file_error(
4864                        file_index,
4865                        MeasurementFileError::PredictionWithoutProvenance { check_index },
4866                    ));
4867                }
4868                if (wire.selection == SelectionState::Unselected
4869                    || wire.configuration == ConfigurationState::Disabled
4870                    || wire.applicability == Applicability::NotApplicable)
4871                    && wire.prediction.is_some()
4872                {
4873                    return Err(prediction_file_error(
4874                        file_index,
4875                        MeasurementFileError::InvalidPredictionLifecycle {
4876                            check_index,
4877                            reason: "inactive check must have empty output",
4878                        },
4879                    ));
4880                }
4881                let prediction = wire
4882                    .prediction
4883                    .map(|raw| {
4884                        decode_engine_prediction_v1_with_measurement_schema(
4885                            raw.get(),
4886                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4887                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4888                                .saturating_sub(decoded_references),
4889                            MEASUREMENTS_V15_SCHEMA_ID,
4890                        )
4891                        .map_err(|error| {
4892                            prediction_file_error(
4893                                file_index,
4894                                match error {
4895                                    PredictionDecodeError::Shape(source) => {
4896                                        MeasurementFileError::InvalidPredictionShape {
4897                                            check_index,
4898                                            reason: source.to_string(),
4899                                        }
4900                                    }
4901                                    PredictionDecodeError::Semantic(source) => {
4902                                        MeasurementFileError::InvalidPrediction {
4903                                            check_index,
4904                                            source,
4905                                        }
4906                                    }
4907                                    PredictionDecodeError::TooManyFileFacets => {
4908                                        MeasurementFileError::TooManyPredictionFacets {
4909                                            found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4910                                            limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4911                                        }
4912                                    }
4913                                    PredictionDecodeError::TooManyFileBasisReferences => {
4914                                        MeasurementFileError::TooManyPredictionBasisReferences {
4915                                            found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4916                                            limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4917                                        }
4918                                    }
4919                                },
4920                            )
4921                        })
4922                    })
4923                    .transpose()?;
4924                let check = LegacyPredictionCheckInput {
4925                    check_id: wire.check_id,
4926                    selection: wire.selection,
4927                    configuration: wire.configuration,
4928                    applicability: wire.applicability,
4929                    evaluation: wire.evaluation,
4930                    findings: wire.findings,
4931                    evaluated_scopes: wire.evaluated_scopes,
4932                    gaps: wire.gaps,
4933                    prediction,
4934                };
4935                check
4936                    .validate(
4937                        check_index,
4938                        provenance_for_checks,
4939                        MEASUREMENTS_V15_SCHEMA_ID,
4940                    )
4941                    .map_err(|source| prediction_file_error(file_index, source))?;
4942                if let Some(prediction) = &check.prediction {
4943                    decoded_facets = decoded_facets
4944                        .checked_add(prediction.facets().len())
4945                        .ok_or_else(|| {
4946                            prediction_file_error(
4947                                file_index,
4948                                MeasurementFileError::PredictionAccountingOverflow,
4949                            )
4950                        })?;
4951                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4952                        return Err(prediction_file_error(
4953                            file_index,
4954                            MeasurementFileError::TooManyPredictionFacets {
4955                                found: decoded_facets,
4956                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4957                            },
4958                        ));
4959                    }
4960                    decoded_references = decoded_references
4961                        .checked_add(prediction.basis_reference_count())
4962                        .ok_or_else(|| {
4963                            prediction_file_error(
4964                                file_index,
4965                                MeasurementFileError::PredictionAccountingOverflow,
4966                            )
4967                        })?;
4968                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4969                        return Err(prediction_file_error(
4970                            file_index,
4971                            MeasurementFileError::TooManyPredictionBasisReferences {
4972                                found: decoded_references,
4973                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4974                            },
4975                        ));
4976                    }
4977                    decoded_text = decoded_text
4978                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4979                            prediction_file_error(
4980                                file_index,
4981                                MeasurementFileError::InvalidPrediction {
4982                                    check_index,
4983                                    source,
4984                                },
4985                            )
4986                        })?)
4987                        .ok_or_else(|| {
4988                            prediction_file_error(
4989                                file_index,
4990                                MeasurementFileError::PredictionAccountingOverflow,
4991                            )
4992                        })?;
4993                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4994                        return Err(prediction_file_error(
4995                            file_index,
4996                            MeasurementFileError::TooMuchPredictionText {
4997                                found: decoded_text,
4998                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4999                            },
5000                        ));
5001                    }
5002                }
5003                checks.push(check);
5004            }
5005            Ok(checks)
5006        })
5007        .transpose()?;
5008    Ok(MeasurementFileInput {
5009        path: wire.path,
5010        input: wire.input,
5011        rig_v17: None,
5012        measurements: wire.measurements,
5013        prediction_provenance: RequiredNullable::Missing,
5014        checks: None,
5015        legacy_prediction_provenance,
5016        legacy_checks,
5017        prediction_provenance_v3: RequiredNullable::Missing,
5018        checks_v3: None,
5019        prediction_provenance_v4: RequiredNullable::Missing,
5020        checks_v4: None,
5021        prediction_provenance_v5: RequiredNullable::Missing,
5022        checks_v5: None,
5023        prediction_provenance_v6: RequiredNullable::Missing,
5024        checks_v6: None,
5025    })
5026}
5027
5028fn validate_legacy_v11_prediction_phase_file(
5029    command: &str,
5030    file_index: usize,
5031    file: &MeasurementFileInput,
5032) -> Result<(usize, usize), MeasurementReportError> {
5033    match command {
5034        "measure" => return Ok((0, 0)),
5035        "lint" => {}
5036        _ => unreachable!("command was validated before prediction phase"),
5037    }
5038    let provenance = match &file.legacy_prediction_provenance {
5039        RequiredNullable::Missing => {
5040            return Err(prediction_file_error(
5041                file_index,
5042                MeasurementFileError::MissingPredictionProvenance,
5043            ));
5044        }
5045        RequiredNullable::Present(provenance) => provenance.as_ref(),
5046    };
5047    let checks = file
5048        .legacy_checks
5049        .as_ref()
5050        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5051    if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5052        return Err(prediction_file_error(
5053            file_index,
5054            MeasurementFileError::TooManyChecks {
5055                found: checks.len(),
5056                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5057            },
5058        ));
5059    }
5060    if let Some(provenance) = provenance {
5061        provenance
5062            .validate_with_measurement_schema(MEASUREMENTS_V15_SCHEMA_ID)
5063            .map_err(|source| {
5064                prediction_file_error(
5065                    file_index,
5066                    MeasurementFileError::InvalidPredictionProvenance { source },
5067                )
5068            })?;
5069        let input = file
5070            .input
5071            .as_ref()
5072            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5073        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5074            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5075        {
5076            return Err(prediction_file_error(
5077                file_index,
5078                MeasurementFileError::PredictionPrimaryInputMismatch,
5079            ));
5080        }
5081    }
5082    let mut facets = 0usize;
5083    let mut references = 0usize;
5084    let mut text = provenance
5085        .map(PredictionProvenanceV1::retained_text_bytes)
5086        .transpose()
5087        .map_err(|source| {
5088            prediction_file_error(
5089                file_index,
5090                MeasurementFileError::InvalidPredictionProvenance { source },
5091            )
5092        })?
5093        .unwrap_or(0);
5094    let mut available = 0usize;
5095    let mut unavailable = 0usize;
5096    for (check_index, check) in checks.iter().enumerate() {
5097        if let Some(prediction) = &check.prediction {
5098            facets = facets
5099                .checked_add(prediction.facets().len())
5100                .ok_or_else(|| {
5101                    prediction_file_error(
5102                        file_index,
5103                        MeasurementFileError::PredictionAccountingOverflow,
5104                    )
5105                })?;
5106            references = references
5107                .checked_add(prediction.basis_reference_count())
5108                .ok_or_else(|| {
5109                    prediction_file_error(
5110                        file_index,
5111                        MeasurementFileError::PredictionAccountingOverflow,
5112                    )
5113                })?;
5114            text = text
5115                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5116                    prediction_file_error(
5117                        file_index,
5118                        MeasurementFileError::InvalidPrediction {
5119                            check_index,
5120                            source,
5121                        },
5122                    )
5123                })?)
5124                .ok_or_else(|| {
5125                    prediction_file_error(
5126                        file_index,
5127                        MeasurementFileError::PredictionAccountingOverflow,
5128                    )
5129                })?;
5130            for facet in prediction.facets() {
5131                match facet.state() {
5132                    EnginePredictionFacetStateV1::Available => {
5133                        available = available
5134                            .checked_add(1)
5135                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5136                    }
5137                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5138                        unavailable = unavailable
5139                            .checked_add(1)
5140                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5141                    }
5142                }
5143            }
5144        }
5145    }
5146    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5147        return Err(prediction_file_error(
5148            file_index,
5149            MeasurementFileError::TooManyPredictionFacets {
5150                found: facets,
5151                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5152            },
5153        ));
5154    }
5155    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5156        return Err(prediction_file_error(
5157            file_index,
5158            MeasurementFileError::TooManyPredictionBasisReferences {
5159                found: references,
5160                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5161            },
5162        ));
5163    }
5164    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5165        return Err(prediction_file_error(
5166            file_index,
5167            MeasurementFileError::TooMuchPredictionText {
5168                found: text,
5169                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5170            },
5171        ));
5172    }
5173    Ok((available, unavailable))
5174}
5175
5176impl LegacyPredictionCheckInput {
5177    fn validate(
5178        &self,
5179        check_index: usize,
5180        provenance: Option<&PredictionProvenanceV1>,
5181        expected_measurement_schema: &'static str,
5182    ) -> Result<(), MeasurementFileError> {
5183        let gap_refs = self
5184            .gaps
5185            .iter()
5186            .map(|gap| CheckEvaluationGapRef {
5187                code: &gap.code,
5188                scope: gap.scope.as_ref(),
5189            })
5190            .collect::<Vec<_>>();
5191        let finding_check_ids = self
5192            .findings
5193            .iter()
5194            .map(|finding| finding.check_id.as_str())
5195            .collect::<Vec<_>>();
5196        let prediction_scopes = self
5197            .prediction
5198            .as_ref()
5199            .into_iter()
5200            .flat_map(EnginePredictionV1::facets)
5201            .map(|facet| facet.scope())
5202            .collect::<Vec<_>>();
5203        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
5204            check_id: &self.check_id,
5205            selection: self.selection,
5206            configuration: self.configuration,
5207            applicability: self.applicability,
5208            finding_check_ids: &finding_check_ids,
5209            evaluated_scopes: &self.evaluated_scopes,
5210            gaps: &gap_refs,
5211            prediction_scopes: &prediction_scopes,
5212            has_prediction: self.prediction.is_some(),
5213            prediction_has_required_unavailable: self
5214                .prediction
5215                .as_ref()
5216                .is_some_and(EnginePredictionV1::has_required_unavailable),
5217        })
5218        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
5219            check_index,
5220            reason: error.reason(),
5221        })?;
5222        if self.evaluation != derived {
5223            return Err(MeasurementFileError::InvalidPredictionLifecycle {
5224                check_index,
5225                reason: "evaluation does not match completed and missing prediction work",
5226            });
5227        }
5228        let Some(prediction) = &self.prediction else {
5229            if self
5230                .findings
5231                .iter()
5232                .any(|finding| finding.prediction_scope.is_some())
5233            {
5234                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5235                    check_index,
5236                    reason: "finding has prediction_scope without prediction",
5237                });
5238            }
5239            return Ok(());
5240        };
5241        let provenance =
5242            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
5243        prediction
5244            .validate_against_provenance_with_measurement_schema(
5245                provenance,
5246                expected_measurement_schema,
5247            )
5248            .map_err(|source| MeasurementFileError::InvalidPrediction {
5249                check_index,
5250                source,
5251            })?;
5252        for facet in prediction.facets() {
5253            let evaluated = self
5254                .evaluated_scopes
5255                .iter()
5256                .filter(|scope| *scope == facet.scope())
5257                .count();
5258            let duplicated_gap = self
5259                .gaps
5260                .iter()
5261                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
5262            match facet.state() {
5263                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
5264                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5265                        check_index,
5266                        reason: "available facet scope must occur exactly once in evaluated_scopes",
5267                    });
5268                }
5269                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
5270                    if evaluated != 0 || duplicated_gap =>
5271                {
5272                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5273                        check_index,
5274                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
5275                    });
5276                }
5277                _ => {}
5278            }
5279        }
5280        for finding in &self.findings {
5281            let Some(scope) = &finding.prediction_scope else {
5282                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5283                    check_index,
5284                    reason: "prediction-backed finding must carry prediction_scope",
5285                });
5286            };
5287            if prediction
5288                .facets()
5289                .iter()
5290                .filter(|facet| {
5291                    facet.scope() == scope
5292                        && facet.state() == EnginePredictionFacetStateV1::Available
5293                })
5294                .count()
5295                != 1
5296            {
5297                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5298                    check_index,
5299                    reason: "finding prediction_scope must name one available facet",
5300                });
5301            }
5302        }
5303        Ok(())
5304    }
5305}
5306
5307fn validate_prediction_phase_file(
5308    command: &str,
5309    file_index: usize,
5310    file: &MeasurementFileInput,
5311    expected_measurement_schema: &'static str,
5312) -> Result<(usize, usize), MeasurementReportError> {
5313    let mut available = 0usize;
5314    let mut unavailable = 0usize;
5315    match command {
5316        "measure" => {
5317            if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
5318                return Err(prediction_file_error(
5319                    file_index,
5320                    MeasurementFileError::UnexpectedPredictionProvenance,
5321                ));
5322            }
5323            if file.checks.is_some() {
5324                return Err(prediction_file_error(
5325                    file_index,
5326                    MeasurementFileError::UnexpectedChecks,
5327                ));
5328            }
5329        }
5330        "lint" => {
5331            let provenance = match &file.prediction_provenance {
5332                RequiredNullable::Missing => {
5333                    return Err(prediction_file_error(
5334                        file_index,
5335                        MeasurementFileError::MissingPredictionProvenance,
5336                    ));
5337                }
5338                RequiredNullable::Present(provenance) => provenance.as_ref(),
5339            };
5340            let checks = file.checks.as_ref().ok_or_else(|| {
5341                prediction_file_error(file_index, MeasurementFileError::MissingChecks)
5342            })?;
5343            if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5344                return Err(prediction_file_error(
5345                    file_index,
5346                    MeasurementFileError::TooManyChecks {
5347                        found: checks.len(),
5348                        limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5349                    },
5350                ));
5351            }
5352            if let Some(provenance) = provenance {
5353                provenance
5354                    .validate_with_measurement_schema(expected_measurement_schema)
5355                    .map_err(|source| {
5356                        prediction_file_error(
5357                            file_index,
5358                            MeasurementFileError::InvalidPredictionProvenance { source },
5359                        )
5360                    })?;
5361                let input = file.input.as_ref().ok_or_else(|| {
5362                    prediction_file_error(file_index, MeasurementFileError::MissingInput)
5363                })?;
5364                if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5365                    || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5366                {
5367                    return Err(prediction_file_error(
5368                        file_index,
5369                        MeasurementFileError::PredictionPrimaryInputMismatch,
5370                    ));
5371                }
5372            }
5373
5374            let mut facets = 0usize;
5375            let mut references = 0usize;
5376            let mut text = provenance
5377                .map(PredictionProvenanceV2::retained_text_bytes)
5378                .transpose()
5379                .map_err(|source| {
5380                    prediction_file_error(
5381                        file_index,
5382                        MeasurementFileError::InvalidPredictionProvenance { source },
5383                    )
5384                })?
5385                .unwrap_or(0);
5386            for (check_index, check) in checks.iter().enumerate() {
5387                if let Some(prediction) = &check.prediction {
5388                    facets = facets
5389                        .checked_add(prediction.facets().len())
5390                        .ok_or_else(|| {
5391                            prediction_file_error(
5392                                file_index,
5393                                MeasurementFileError::PredictionAccountingOverflow,
5394                            )
5395                        })?;
5396                    references = references
5397                        .checked_add(prediction.basis_reference_count())
5398                        .ok_or_else(|| {
5399                            prediction_file_error(
5400                                file_index,
5401                                MeasurementFileError::PredictionAccountingOverflow,
5402                            )
5403                        })?;
5404                    text = text
5405                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5406                            prediction_file_error(
5407                                file_index,
5408                                MeasurementFileError::InvalidPrediction {
5409                                    check_index,
5410                                    source,
5411                                },
5412                            )
5413                        })?)
5414                        .ok_or_else(|| {
5415                            prediction_file_error(
5416                                file_index,
5417                                MeasurementFileError::PredictionAccountingOverflow,
5418                            )
5419                        })?;
5420                    for facet in prediction.facets() {
5421                        match facet.state() {
5422                            EnginePredictionFacetStateV1::Available => {
5423                                available = available.checked_add(1).ok_or(
5424                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5425                                )?;
5426                            }
5427                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5428                                unavailable = unavailable.checked_add(1).ok_or(
5429                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5430                                )?;
5431                            }
5432                        }
5433                    }
5434                }
5435            }
5436            if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5437                return Err(prediction_file_error(
5438                    file_index,
5439                    MeasurementFileError::TooManyPredictionFacets {
5440                        found: facets,
5441                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5442                    },
5443                ));
5444            }
5445            if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5446                return Err(prediction_file_error(
5447                    file_index,
5448                    MeasurementFileError::TooManyPredictionBasisReferences {
5449                        found: references,
5450                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5451                    },
5452                ));
5453            }
5454            if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5455                return Err(prediction_file_error(
5456                    file_index,
5457                    MeasurementFileError::TooMuchPredictionText {
5458                        found: text,
5459                        limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5460                    },
5461                ));
5462            }
5463        }
5464        _ => unreachable!("command was validated before prediction phase"),
5465    }
5466    Ok((available, unavailable))
5467}
5468
5469fn validate_prediction_phase_file_v14(
5470    command: &str,
5471    file_index: usize,
5472    file: &MeasurementFileInput,
5473) -> Result<(usize, usize), MeasurementReportError> {
5474    if command == "measure" {
5475        if !matches!(file.prediction_provenance_v3, RequiredNullable::Missing) {
5476            return Err(prediction_file_error(
5477                file_index,
5478                MeasurementFileError::UnexpectedPredictionProvenance,
5479            ));
5480        }
5481        if file.checks_v3.is_some() {
5482            return Err(prediction_file_error(
5483                file_index,
5484                MeasurementFileError::UnexpectedChecks,
5485            ));
5486        }
5487        return Ok((0, 0));
5488    }
5489    let provenance = match &file.prediction_provenance_v3 {
5490        RequiredNullable::Missing => {
5491            return Err(prediction_file_error(
5492                file_index,
5493                MeasurementFileError::MissingPredictionProvenance,
5494            ));
5495        }
5496        RequiredNullable::Present(provenance) => provenance.as_ref(),
5497    };
5498    let checks = file
5499        .checks_v3
5500        .as_ref()
5501        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5502    if let Some(provenance) = provenance {
5503        provenance.validate().map_err(|source| {
5504            prediction_file_error(
5505                file_index,
5506                MeasurementFileError::InvalidPredictionProvenance { source },
5507            )
5508        })?;
5509        let input = file
5510            .input
5511            .as_ref()
5512            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5513        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5514            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5515        {
5516            return Err(prediction_file_error(
5517                file_index,
5518                MeasurementFileError::PredictionPrimaryInputMismatch,
5519            ));
5520        }
5521    }
5522    let mut available = 0usize;
5523    let mut unavailable = 0usize;
5524    let mut facets = 0usize;
5525    let mut references = 0usize;
5526    let mut text = provenance
5527        .map(PredictionProvenanceV3::retained_text_bytes)
5528        .transpose()
5529        .map_err(|source| {
5530            prediction_file_error(
5531                file_index,
5532                MeasurementFileError::InvalidPredictionProvenance { source },
5533            )
5534        })?
5535        .unwrap_or(0);
5536    for (check_index, check) in checks.iter().enumerate() {
5537        check
5538            .validate(check_index, provenance)
5539            .map_err(|source| prediction_file_error(file_index, source))?;
5540        if let Some(prediction) = &check.prediction {
5541            facets = facets
5542                .checked_add(prediction.facets().len())
5543                .ok_or_else(|| {
5544                    prediction_file_error(
5545                        file_index,
5546                        MeasurementFileError::PredictionAccountingOverflow,
5547                    )
5548                })?;
5549            references = references
5550                .checked_add(prediction.basis_reference_count())
5551                .ok_or_else(|| {
5552                    prediction_file_error(
5553                        file_index,
5554                        MeasurementFileError::PredictionAccountingOverflow,
5555                    )
5556                })?;
5557            text = text
5558                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5559                    prediction_file_error(
5560                        file_index,
5561                        MeasurementFileError::InvalidPrediction {
5562                            check_index,
5563                            source,
5564                        },
5565                    )
5566                })?)
5567                .ok_or_else(|| {
5568                    prediction_file_error(
5569                        file_index,
5570                        MeasurementFileError::PredictionAccountingOverflow,
5571                    )
5572                })?;
5573            for facet in prediction.facets() {
5574                match facet.state() {
5575                    EnginePredictionFacetStateV1::Available => available += 1,
5576                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5577                }
5578            }
5579        }
5580    }
5581    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5582        return Err(prediction_file_error(
5583            file_index,
5584            MeasurementFileError::TooManyPredictionFacets {
5585                found: facets,
5586                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5587            },
5588        ));
5589    }
5590    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5591        return Err(prediction_file_error(
5592            file_index,
5593            MeasurementFileError::TooManyPredictionBasisReferences {
5594                found: references,
5595                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5596            },
5597        ));
5598    }
5599    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5600        return Err(prediction_file_error(
5601            file_index,
5602            MeasurementFileError::TooMuchPredictionText {
5603                found: text,
5604                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5605            },
5606        ));
5607    }
5608    Ok((available, unavailable))
5609}
5610
5611fn validate_prediction_phase_file_v15(
5612    command: &str,
5613    file_index: usize,
5614    file: &MeasurementFileInput,
5615) -> Result<(usize, usize), MeasurementReportError> {
5616    if matches!(file.prediction_provenance_v4, RequiredNullable::Missing) {
5617        return validate_prediction_phase_file_v14(command, file_index, file);
5618    }
5619    if command == "measure" {
5620        return Err(prediction_file_error(
5621            file_index,
5622            MeasurementFileError::UnexpectedPredictionProvenance,
5623        ));
5624    }
5625    let provenance = match &file.prediction_provenance_v4 {
5626        RequiredNullable::Present(provenance) => provenance.as_ref(),
5627        RequiredNullable::Missing => unreachable!(),
5628    };
5629    let checks = file
5630        .checks_v4
5631        .as_ref()
5632        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5633    if let Some(provenance) = provenance {
5634        provenance.validate().map_err(|source| {
5635            prediction_file_error(
5636                file_index,
5637                MeasurementFileError::InvalidPredictionProvenance { source },
5638            )
5639        })?;
5640        let input = file
5641            .input
5642            .as_ref()
5643            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5644        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5645            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5646        {
5647            return Err(prediction_file_error(
5648                file_index,
5649                MeasurementFileError::PredictionPrimaryInputMismatch,
5650            ));
5651        }
5652    }
5653    let mut available = 0usize;
5654    let mut unavailable = 0usize;
5655    let mut has_facet_budget_summary = false;
5656    let mut facets = 0usize;
5657    let mut references = 0usize;
5658    let mut text = provenance
5659        .map(PredictionProvenanceV4::retained_text_bytes)
5660        .transpose()
5661        .map_err(|source| {
5662            prediction_file_error(
5663                file_index,
5664                MeasurementFileError::InvalidPredictionProvenance { source },
5665            )
5666        })?
5667        .unwrap_or(0);
5668    for (check_index, check) in checks.iter().enumerate() {
5669        check
5670            .validate(check_index, provenance)
5671            .map_err(|source| prediction_file_error(file_index, source))?;
5672        if let Some(prediction) = &check.prediction {
5673            has_facet_budget_summary |= prediction.has_facet_budget_summary();
5674            facets = facets
5675                .checked_add(prediction.facets().len())
5676                .ok_or_else(|| {
5677                    prediction_file_error(
5678                        file_index,
5679                        MeasurementFileError::PredictionAccountingOverflow,
5680                    )
5681                })?;
5682            references = references
5683                .checked_add(prediction.basis_reference_count())
5684                .ok_or_else(|| {
5685                    prediction_file_error(
5686                        file_index,
5687                        MeasurementFileError::PredictionAccountingOverflow,
5688                    )
5689                })?;
5690            text = text
5691                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5692                    prediction_file_error(
5693                        file_index,
5694                        MeasurementFileError::InvalidPrediction {
5695                            check_index,
5696                            source,
5697                        },
5698                    )
5699                })?)
5700                .ok_or_else(|| {
5701                    prediction_file_error(
5702                        file_index,
5703                        MeasurementFileError::PredictionAccountingOverflow,
5704                    )
5705                })?;
5706            for facet in prediction.facets() {
5707                match facet.state() {
5708                    EnginePredictionFacetStateV1::Available => available += 1,
5709                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5710                }
5711            }
5712        }
5713    }
5714    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5715        return Err(prediction_file_error(
5716            file_index,
5717            MeasurementFileError::TooManyPredictionFacets {
5718                found: facets,
5719                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5720            },
5721        ));
5722    }
5723    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5724        return Err(prediction_file_error(
5725            file_index,
5726            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5727                found: facets,
5728                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5729            },
5730        ));
5731    }
5732    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5733        return Err(prediction_file_error(
5734            file_index,
5735            MeasurementFileError::TooManyPredictionBasisReferences {
5736                found: references,
5737                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5738            },
5739        ));
5740    }
5741    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5742        return Err(prediction_file_error(
5743            file_index,
5744            MeasurementFileError::TooMuchPredictionText {
5745                found: text,
5746                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5747            },
5748        ));
5749    }
5750    Ok((available, unavailable))
5751}
5752
5753fn validate_prediction_phase_file_v16(
5754    command: &str,
5755    file_index: usize,
5756    file: &MeasurementFileInput,
5757) -> Result<(usize, usize), MeasurementReportError> {
5758    if matches!(file.prediction_provenance_v5, RequiredNullable::Missing) {
5759        return validate_prediction_phase_file_v15(command, file_index, file);
5760    }
5761    if command == "measure" {
5762        return Err(prediction_file_error(
5763            file_index,
5764            MeasurementFileError::UnexpectedPredictionProvenance,
5765        ));
5766    }
5767    let provenance = match &file.prediction_provenance_v5 {
5768        RequiredNullable::Present(provenance) => provenance.as_ref(),
5769        RequiredNullable::Missing => unreachable!(),
5770    };
5771    let checks = file
5772        .checks_v5
5773        .as_ref()
5774        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5775    if let Some(provenance) = provenance {
5776        provenance.validate().map_err(|source| {
5777            prediction_file_error(
5778                file_index,
5779                MeasurementFileError::InvalidPredictionProvenance { source },
5780            )
5781        })?;
5782        let input = file
5783            .input
5784            .as_ref()
5785            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5786        if input.sha256.as_deref()
5787            != Some(provenance.raw_animation_channels().primary_input().sha256())
5788            || input.bytes != Some(provenance.raw_animation_channels().primary_input().bytes())
5789        {
5790            return Err(prediction_file_error(
5791                file_index,
5792                MeasurementFileError::PredictionPrimaryInputMismatch,
5793            ));
5794        }
5795    }
5796    let mut available = 0usize;
5797    let mut unavailable = 0usize;
5798    let mut facets = 0usize;
5799    let mut references = 0usize;
5800    let mut has_facet_budget_summary = false;
5801    let mut text = provenance
5802        .map(PredictionProvenanceV5::retained_text_bytes)
5803        .transpose()
5804        .map_err(|source| {
5805            prediction_file_error(
5806                file_index,
5807                MeasurementFileError::InvalidPredictionProvenance { source },
5808            )
5809        })?
5810        .unwrap_or(0);
5811    for (check_index, check) in checks.iter().enumerate() {
5812        check
5813            .validate(check_index, provenance)
5814            .map_err(|source| prediction_file_error(file_index, source))?;
5815        validate_current_engine_track_support_prediction_v5(
5816            &check.check_id,
5817            check.selection,
5818            check.configuration,
5819            check.applicability,
5820            check.prediction.as_ref(),
5821            provenance,
5822            check.findings.is_empty(),
5823        )
5824        .map_err(|source| {
5825            prediction_file_error(
5826                file_index,
5827                MeasurementFileError::InvalidPrediction {
5828                    check_index,
5829                    source,
5830                },
5831            )
5832        })?;
5833        if let Some(prediction) = &check.prediction {
5834            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
5835            facets = facets
5836                .checked_add(prediction.facets().len())
5837                .ok_or_else(|| {
5838                    prediction_file_error(
5839                        file_index,
5840                        MeasurementFileError::PredictionAccountingOverflow,
5841                    )
5842                })?;
5843            references = references
5844                .checked_add(prediction.basis_reference_count())
5845                .ok_or_else(|| {
5846                    prediction_file_error(
5847                        file_index,
5848                        MeasurementFileError::PredictionAccountingOverflow,
5849                    )
5850                })?;
5851            text = text
5852                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5853                    prediction_file_error(
5854                        file_index,
5855                        MeasurementFileError::InvalidPrediction {
5856                            check_index,
5857                            source,
5858                        },
5859                    )
5860                })?)
5861                .ok_or_else(|| {
5862                    prediction_file_error(
5863                        file_index,
5864                        MeasurementFileError::PredictionAccountingOverflow,
5865                    )
5866                })?;
5867            for facet in prediction.facets() {
5868                match facet.state() {
5869                    EnginePredictionFacetStateV1::Available => available += 1,
5870                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5871                }
5872            }
5873        }
5874    }
5875    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5876        return Err(prediction_file_error(
5877            file_index,
5878            MeasurementFileError::TooManyPredictionFacets {
5879                found: facets,
5880                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5881            },
5882        ));
5883    }
5884    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5885        return Err(prediction_file_error(
5886            file_index,
5887            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5888                found: facets,
5889                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5890            },
5891        ));
5892    }
5893    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5894        return Err(prediction_file_error(
5895            file_index,
5896            MeasurementFileError::TooManyPredictionBasisReferences {
5897                found: references,
5898                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5899            },
5900        ));
5901    }
5902    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5903        return Err(prediction_file_error(
5904            file_index,
5905            MeasurementFileError::TooMuchPredictionText {
5906                found: text,
5907                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5908            },
5909        ));
5910    }
5911    Ok((available, unavailable))
5912}
5913
5914fn validate_prediction_phase_file_v17(
5915    command: &str,
5916    file_index: usize,
5917    file: &MeasurementFileInput,
5918) -> Result<(usize, usize), MeasurementReportError> {
5919    if matches!(file.prediction_provenance_v6, RequiredNullable::Missing) {
5920        return validate_prediction_phase_file_v16(command, file_index, file);
5921    }
5922    if command == "measure" {
5923        return Err(prediction_file_error(
5924            file_index,
5925            MeasurementFileError::UnexpectedPredictionProvenance,
5926        ));
5927    }
5928    let provenance = match &file.prediction_provenance_v6 {
5929        RequiredNullable::Present(provenance) => provenance.as_ref(),
5930        RequiredNullable::Missing => unreachable!(),
5931    };
5932    let checks = file
5933        .checks_v6
5934        .as_ref()
5935        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5936    if let Some(provenance) = provenance {
5937        provenance.validate().map_err(|source| {
5938            prediction_file_error(
5939                file_index,
5940                MeasurementFileError::InvalidPredictionProvenance { source },
5941            )
5942        })?;
5943        let input = file
5944            .input
5945            .as_ref()
5946            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5947        let primary = provenance.raw_transform_paths().primary_input();
5948        if input.sha256.as_deref() != Some(primary.sha256()) || input.bytes != Some(primary.bytes())
5949        {
5950            return Err(prediction_file_error(
5951                file_index,
5952                MeasurementFileError::PredictionPrimaryInputMismatch,
5953            ));
5954        }
5955    }
5956    let mut available = 0usize;
5957    let mut unavailable = 0usize;
5958    let mut facets = 0usize;
5959    let mut references = 0usize;
5960    let mut has_facet_budget_summary = false;
5961    let mut text = provenance
5962        .map(PredictionProvenanceV6::retained_text_bytes)
5963        .transpose()
5964        .map_err(|source| {
5965            prediction_file_error(
5966                file_index,
5967                MeasurementFileError::InvalidPredictionProvenance { source },
5968            )
5969        })?
5970        .unwrap_or(0);
5971    for (check_index, check) in checks.iter().enumerate() {
5972        check
5973            .validate(check_index, provenance)
5974            .map_err(|source| prediction_file_error(file_index, source))?;
5975        if let Some(prediction) = &check.prediction {
5976            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
5977            facets = facets
5978                .checked_add(prediction.facets().len())
5979                .ok_or_else(|| {
5980                    prediction_file_error(
5981                        file_index,
5982                        MeasurementFileError::PredictionAccountingOverflow,
5983                    )
5984                })?;
5985            references = references
5986                .checked_add(prediction.basis_reference_count())
5987                .ok_or_else(|| {
5988                    prediction_file_error(
5989                        file_index,
5990                        MeasurementFileError::PredictionAccountingOverflow,
5991                    )
5992                })?;
5993            text = text
5994                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5995                    prediction_file_error(
5996                        file_index,
5997                        MeasurementFileError::InvalidPrediction {
5998                            check_index,
5999                            source,
6000                        },
6001                    )
6002                })?)
6003                .ok_or_else(|| {
6004                    prediction_file_error(
6005                        file_index,
6006                        MeasurementFileError::PredictionAccountingOverflow,
6007                    )
6008                })?;
6009            for facet in prediction.facets() {
6010                match facet.state() {
6011                    EnginePredictionFacetStateV1::Available => available += 1,
6012                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6013                }
6014            }
6015        }
6016    }
6017    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6018        return Err(prediction_file_error(
6019            file_index,
6020            MeasurementFileError::TooManyPredictionFacets {
6021                found: facets,
6022                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6023            },
6024        ));
6025    }
6026    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6027        return Err(prediction_file_error(
6028            file_index,
6029            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6030                found: facets,
6031                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6032            },
6033        ));
6034    }
6035    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6036        return Err(prediction_file_error(
6037            file_index,
6038            MeasurementFileError::TooManyPredictionBasisReferences {
6039                found: references,
6040                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6041            },
6042        ));
6043    }
6044    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6045        return Err(prediction_file_error(
6046            file_index,
6047            MeasurementFileError::TooMuchPredictionText {
6048                found: text,
6049                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6050            },
6051        ));
6052    }
6053    Ok((available, unavailable))
6054}
6055
6056fn validate_prediction_summary(
6057    command: &str,
6058    summary: Option<&MeasurementReportSummaryInput>,
6059    available: usize,
6060    unavailable: usize,
6061) -> Result<(), MeasurementReportError> {
6062    let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
6063    match (command, summary) {
6064        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6065        ("measure", None) => Ok(()),
6066        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6067        ("lint", Some(summary))
6068            if summary.available != available
6069                || summary.required_prediction_unavailable != unavailable =>
6070        {
6071            Err(MeasurementReportError::PredictionFacetSummaryMismatch)
6072        }
6073        ("lint", Some(_)) => Ok(()),
6074        _ => unreachable!("command was validated before prediction summary"),
6075    }
6076}
6077
6078fn validate_prediction_summary_presence(
6079    command: &str,
6080    summary: Option<&MeasurementReportSummaryInput>,
6081) -> Result<(), MeasurementReportError> {
6082    match (
6083        command,
6084        summary.and_then(|summary| summary.prediction_facets.as_ref()),
6085    ) {
6086        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6087        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6088        ("measure", None) | ("lint", Some(_)) => Ok(()),
6089        _ => unreachable!("command was validated before prediction summary"),
6090    }
6091}
6092
6093impl PredictionCheckInput {
6094    fn validate(
6095        &self,
6096        check_index: usize,
6097        provenance: Option<&PredictionProvenanceV2>,
6098        expected_measurement_schema: &'static str,
6099    ) -> Result<(), MeasurementFileError> {
6100        let gap_refs = self
6101            .gaps
6102            .iter()
6103            .map(|gap| CheckEvaluationGapRef {
6104                code: &gap.code,
6105                scope: gap.scope.as_ref(),
6106            })
6107            .collect::<Vec<_>>();
6108        let finding_check_ids = self
6109            .findings
6110            .iter()
6111            .map(|finding| finding.check_id.as_str())
6112            .collect::<Vec<_>>();
6113        let prediction_scopes = self
6114            .prediction
6115            .as_ref()
6116            .into_iter()
6117            .flat_map(EnginePredictionV2::facets)
6118            .map(|facet| facet.scope())
6119            .collect::<Vec<_>>();
6120        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6121            check_id: &self.check_id,
6122            selection: self.selection,
6123            configuration: self.configuration,
6124            applicability: self.applicability,
6125            finding_check_ids: &finding_check_ids,
6126            evaluated_scopes: &self.evaluated_scopes,
6127            gaps: &gap_refs,
6128            prediction_scopes: &prediction_scopes,
6129            has_prediction: self.prediction.is_some(),
6130            prediction_has_required_unavailable: self
6131                .prediction
6132                .as_ref()
6133                .is_some_and(EnginePredictionV2::has_required_unavailable),
6134        })
6135        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6136            check_index,
6137            reason: error.reason(),
6138        })?;
6139        if self.evaluation != derived {
6140            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6141                check_index,
6142                reason: "evaluation does not match completed and missing prediction work",
6143            });
6144        }
6145
6146        let Some(prediction) = &self.prediction else {
6147            if self
6148                .findings
6149                .iter()
6150                .any(|finding| finding.prediction_scope.is_some())
6151            {
6152                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6153                    check_index,
6154                    reason: "finding has prediction_scope without prediction",
6155                });
6156            }
6157            return Ok(());
6158        };
6159        let provenance =
6160            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6161        prediction
6162            .validate_against_provenance_with_measurement_schema(
6163                provenance,
6164                expected_measurement_schema,
6165            )
6166            .map_err(|source| MeasurementFileError::InvalidPrediction {
6167                check_index,
6168                source,
6169            })?;
6170        prediction
6171            .validate_facet_budget_summary_for_check(&self.check_id)
6172            .map_err(|source| MeasurementFileError::InvalidPrediction {
6173                check_index,
6174                source,
6175            })?;
6176        validate_current_engine_addressability_prediction_v2(
6177            &self.check_id,
6178            prediction,
6179            provenance,
6180        )
6181        .map_err(|source| MeasurementFileError::InvalidPrediction {
6182            check_index,
6183            source,
6184        })?;
6185        for facet in prediction.facets() {
6186            let evaluated = self
6187                .evaluated_scopes
6188                .iter()
6189                .filter(|scope| *scope == facet.scope())
6190                .count();
6191            let duplicated_gap = self
6192                .gaps
6193                .iter()
6194                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6195            match facet.state() {
6196                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6197                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6198                        check_index,
6199                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6200                    });
6201                }
6202                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6203                    if evaluated != 0 || duplicated_gap =>
6204                {
6205                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6206                        check_index,
6207                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6208                    });
6209                }
6210                _ => {}
6211            }
6212        }
6213        for finding in &self.findings {
6214            let Some(scope) = &finding.prediction_scope else {
6215                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6216                    check_index,
6217                    reason: "prediction-backed finding must carry prediction_scope",
6218                });
6219            };
6220            if prediction
6221                .facets()
6222                .iter()
6223                .filter(|facet| {
6224                    facet.scope() == scope
6225                        && facet.state() == EnginePredictionFacetStateV1::Available
6226                })
6227                .count()
6228                != 1
6229            {
6230                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6231                    check_index,
6232                    reason: "finding prediction_scope must name one available facet",
6233                });
6234            }
6235        }
6236        Ok(())
6237    }
6238}
6239
6240impl PredictionCheckInputV3 {
6241    fn validate(
6242        &self,
6243        check_index: usize,
6244        provenance: Option<&PredictionProvenanceV3>,
6245    ) -> Result<(), MeasurementFileError> {
6246        let gap_refs = self
6247            .gaps
6248            .iter()
6249            .map(|gap| CheckEvaluationGapRef {
6250                code: &gap.code,
6251                scope: gap.scope.as_ref(),
6252            })
6253            .collect::<Vec<_>>();
6254        let finding_check_ids = self
6255            .findings
6256            .iter()
6257            .map(|finding| finding.check_id.as_str())
6258            .collect::<Vec<_>>();
6259        let prediction_scopes = self
6260            .prediction
6261            .as_ref()
6262            .into_iter()
6263            .flat_map(EnginePredictionV3::facets)
6264            .map(|facet| facet.scope())
6265            .collect::<Vec<_>>();
6266        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6267            check_id: &self.check_id,
6268            selection: self.selection,
6269            configuration: self.configuration,
6270            applicability: self.applicability,
6271            finding_check_ids: &finding_check_ids,
6272            evaluated_scopes: &self.evaluated_scopes,
6273            gaps: &gap_refs,
6274            prediction_scopes: &prediction_scopes,
6275            has_prediction: self.prediction.is_some(),
6276            prediction_has_required_unavailable: self
6277                .prediction
6278                .as_ref()
6279                .is_some_and(EnginePredictionV3::has_required_unavailable),
6280        })
6281        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6282            check_index,
6283            reason: error.reason(),
6284        })?;
6285        if self.evaluation != derived {
6286            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6287                check_index,
6288                reason: "evaluation does not match completed and missing prediction work",
6289            });
6290        }
6291        validate_current_engine_clip_boundary_applicability_v3(
6292            &self.check_id,
6293            self.applicability,
6294            provenance,
6295        )
6296        .map_err(|source| MeasurementFileError::InvalidPrediction {
6297            check_index,
6298            source,
6299        })?;
6300        let Some(prediction) = &self.prediction else {
6301            if self
6302                .findings
6303                .iter()
6304                .any(|finding| finding.prediction_scope.is_some())
6305            {
6306                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6307                    check_index,
6308                    reason: "finding has prediction_scope without prediction",
6309                });
6310            }
6311            if self.check_id == "engine-clip-boundary"
6312                && self.selection == SelectionState::Selected
6313                && self.configuration == ConfigurationState::Enabled
6314                && self.applicability == Applicability::Applicable
6315            {
6316                return Err(MeasurementFileError::InvalidPrediction {
6317                    check_index,
6318                    source: PredictionContractError::EngineClipBoundaryFacetMismatch,
6319                });
6320            }
6321            return Ok(());
6322        };
6323        let provenance =
6324            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6325        prediction
6326            .validate_against_provenance(provenance)
6327            .map_err(|source| MeasurementFileError::InvalidPrediction {
6328                check_index,
6329                source,
6330            })?;
6331        prediction
6332            .validate_facet_budget_summary_for_check(&self.check_id)
6333            .map_err(|source| MeasurementFileError::InvalidPrediction {
6334                check_index,
6335                source,
6336            })?;
6337        validate_current_engine_addressability_prediction_v3(
6338            &self.check_id,
6339            prediction,
6340            provenance,
6341        )
6342        .map_err(|source| MeasurementFileError::InvalidPrediction {
6343            check_index,
6344            source,
6345        })?;
6346        for facet in prediction.facets() {
6347            let evaluated = self
6348                .evaluated_scopes
6349                .iter()
6350                .filter(|scope| *scope == facet.scope())
6351                .count();
6352            let duplicated_gap = self
6353                .gaps
6354                .iter()
6355                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6356            match facet.state() {
6357                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6358                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6359                        check_index,
6360                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6361                    });
6362                }
6363                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6364                    if evaluated != 0 || duplicated_gap =>
6365                {
6366                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6367                        check_index,
6368                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6369                    });
6370                }
6371                _ => {}
6372            }
6373        }
6374        for finding in &self.findings {
6375            let Some(scope) = &finding.prediction_scope else {
6376                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6377                    check_index,
6378                    reason: "prediction-backed finding must carry prediction_scope",
6379                });
6380            };
6381            if prediction
6382                .facets()
6383                .iter()
6384                .filter(|facet| {
6385                    facet.scope() == scope
6386                        && facet.state() == EnginePredictionFacetStateV1::Available
6387                })
6388                .count()
6389                != 1
6390            {
6391                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6392                    check_index,
6393                    reason: "finding prediction_scope must name one available facet",
6394                });
6395            }
6396        }
6397        let finding_scopes = self
6398            .findings
6399            .iter()
6400            .filter_map(|finding| finding.prediction_scope.as_ref())
6401            .collect::<Vec<_>>();
6402        validate_current_engine_clip_boundary_prediction_v3(
6403            &self.check_id,
6404            prediction,
6405            provenance,
6406            &self.evaluated_scopes,
6407            &finding_scopes,
6408        )
6409        .map_err(|source| MeasurementFileError::InvalidPrediction {
6410            check_index,
6411            source,
6412        })?;
6413        Ok(())
6414    }
6415}
6416
6417impl PredictionCheckInputV4 {
6418    fn validate(
6419        &self,
6420        check_index: usize,
6421        provenance: Option<&PredictionProvenanceV4>,
6422    ) -> Result<(), MeasurementFileError> {
6423        let gap_refs = self
6424            .gaps
6425            .iter()
6426            .map(|gap| CheckEvaluationGapRef {
6427                code: &gap.code,
6428                scope: gap.scope.as_ref(),
6429            })
6430            .collect::<Vec<_>>();
6431        let finding_check_ids = self
6432            .findings
6433            .iter()
6434            .map(|finding| finding.check_id.as_str())
6435            .collect::<Vec<_>>();
6436        let prediction_scopes = self
6437            .prediction
6438            .as_ref()
6439            .into_iter()
6440            .flat_map(EnginePredictionV4::facets)
6441            .map(|facet| facet.scope())
6442            .collect::<Vec<_>>();
6443        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6444            check_id: &self.check_id,
6445            selection: self.selection,
6446            configuration: self.configuration,
6447            applicability: self.applicability,
6448            finding_check_ids: &finding_check_ids,
6449            evaluated_scopes: &self.evaluated_scopes,
6450            gaps: &gap_refs,
6451            prediction_scopes: &prediction_scopes,
6452            has_prediction: self.prediction.is_some(),
6453            prediction_has_required_unavailable: self
6454                .prediction
6455                .as_ref()
6456                .is_some_and(EnginePredictionV4::has_required_unavailable),
6457        })
6458        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6459            check_index,
6460            reason: error.reason(),
6461        })?;
6462        if self.evaluation != derived {
6463            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6464                check_index,
6465                reason: "evaluation does not match completed and missing prediction work",
6466            });
6467        }
6468        let Some(prediction) = &self.prediction else {
6469            if self
6470                .findings
6471                .iter()
6472                .any(|finding| finding.prediction_scope.is_some())
6473            {
6474                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6475                    check_index,
6476                    reason: "finding has prediction_scope without prediction",
6477                });
6478            }
6479            return Ok(());
6480        };
6481        let provenance =
6482            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6483        prediction
6484            .validate_against_provenance(provenance)
6485            .map_err(|source| MeasurementFileError::InvalidPrediction {
6486                check_index,
6487                source,
6488            })?;
6489        prediction
6490            .validate_facet_budget_summary_for_check(&self.check_id)
6491            .map_err(|source| MeasurementFileError::InvalidPrediction {
6492                check_index,
6493                source,
6494            })?;
6495        for facet in prediction.facets() {
6496            let evaluated = self
6497                .evaluated_scopes
6498                .iter()
6499                .filter(|scope| *scope == facet.scope())
6500                .count();
6501            let duplicated_gap = self
6502                .gaps
6503                .iter()
6504                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6505            match facet.state() {
6506                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6507                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6508                        check_index,
6509                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6510                    });
6511                }
6512                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6513                    if evaluated != 0 || duplicated_gap =>
6514                {
6515                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6516                        check_index,
6517                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6518                    });
6519                }
6520                _ => {}
6521            }
6522        }
6523        for finding in &self.findings {
6524            let Some(scope) = &finding.prediction_scope else {
6525                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6526                    check_index,
6527                    reason: "prediction-backed finding must carry prediction_scope",
6528                });
6529            };
6530            if prediction
6531                .facets()
6532                .iter()
6533                .filter(|facet| {
6534                    facet.scope() == scope
6535                        && facet.state() == EnginePredictionFacetStateV1::Available
6536                })
6537                .count()
6538                != 1
6539            {
6540                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6541                    check_index,
6542                    reason: "finding prediction_scope must name one available facet",
6543                });
6544            }
6545        }
6546        Ok(())
6547    }
6548}
6549
6550impl PredictionCheckInputV5 {
6551    fn validate(
6552        &self,
6553        check_index: usize,
6554        provenance: Option<&PredictionProvenanceV5>,
6555    ) -> Result<(), MeasurementFileError> {
6556        let gap_refs = self
6557            .gaps
6558            .iter()
6559            .map(|gap| CheckEvaluationGapRef {
6560                code: &gap.code,
6561                scope: gap.scope.as_ref(),
6562            })
6563            .collect::<Vec<_>>();
6564        let finding_check_ids = self
6565            .findings
6566            .iter()
6567            .map(|finding| finding.check_id.as_str())
6568            .collect::<Vec<_>>();
6569        let prediction_scopes = self
6570            .prediction
6571            .as_ref()
6572            .into_iter()
6573            .flat_map(EnginePredictionV5::facets)
6574            .map(|facet| facet.scope())
6575            .collect::<Vec<_>>();
6576        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6577            check_id: &self.check_id,
6578            selection: self.selection,
6579            configuration: self.configuration,
6580            applicability: self.applicability,
6581            finding_check_ids: &finding_check_ids,
6582            evaluated_scopes: &self.evaluated_scopes,
6583            gaps: &gap_refs,
6584            prediction_scopes: &prediction_scopes,
6585            has_prediction: self.prediction.is_some(),
6586            prediction_has_required_unavailable: self
6587                .prediction
6588                .as_ref()
6589                .is_some_and(EnginePredictionV5::has_required_unavailable),
6590        })
6591        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6592            check_index,
6593            reason: error.reason(),
6594        })?;
6595        if self.evaluation != derived {
6596            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6597                check_index,
6598                reason: "evaluation does not match completed and missing prediction work",
6599            });
6600        }
6601        let Some(prediction) = &self.prediction else {
6602            if self
6603                .findings
6604                .iter()
6605                .any(|finding| finding.prediction_scope.is_some())
6606            {
6607                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6608                    check_index,
6609                    reason: "finding has prediction_scope without prediction",
6610                });
6611            }
6612            return Ok(());
6613        };
6614        let provenance =
6615            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6616        prediction
6617            .validate_against_provenance(provenance)
6618            .map_err(|source| MeasurementFileError::InvalidPrediction {
6619                check_index,
6620                source,
6621            })?;
6622        prediction
6623            .base_prediction()
6624            .validate_facet_budget_summary_for_check(&self.check_id)
6625            .map_err(|source| MeasurementFileError::InvalidPrediction {
6626                check_index,
6627                source,
6628            })?;
6629        for facet in prediction.facets() {
6630            let evaluated = self
6631                .evaluated_scopes
6632                .iter()
6633                .filter(|scope| *scope == facet.scope())
6634                .count();
6635            let duplicated_gap = self
6636                .gaps
6637                .iter()
6638                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6639            match facet.state() {
6640                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6641                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6642                        check_index,
6643                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6644                    });
6645                }
6646                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6647                    if evaluated != 0 || duplicated_gap =>
6648                {
6649                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6650                        check_index,
6651                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6652                    });
6653                }
6654                _ => {}
6655            }
6656        }
6657        for finding in &self.findings {
6658            let Some(scope) = &finding.prediction_scope else {
6659                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6660                    check_index,
6661                    reason: "prediction-backed finding must carry prediction_scope",
6662                });
6663            };
6664            if prediction
6665                .facets()
6666                .iter()
6667                .filter(|facet| {
6668                    facet.scope() == scope
6669                        && facet.state() == EnginePredictionFacetStateV1::Available
6670                })
6671                .count()
6672                != 1
6673            {
6674                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6675                    check_index,
6676                    reason: "finding prediction_scope must name one available facet",
6677                });
6678            }
6679        }
6680        Ok(())
6681    }
6682}
6683
6684impl PredictionCheckInputV6 {
6685    fn validate(
6686        &self,
6687        check_index: usize,
6688        provenance: Option<&PredictionProvenanceV6>,
6689    ) -> Result<(), MeasurementFileError> {
6690        let gap_refs = self
6691            .gaps
6692            .iter()
6693            .map(|gap| CheckEvaluationGapRef {
6694                code: &gap.code,
6695                scope: gap.scope.as_ref(),
6696            })
6697            .collect::<Vec<_>>();
6698        let finding_check_ids = self
6699            .findings
6700            .iter()
6701            .map(|finding| finding.check_id.as_str())
6702            .collect::<Vec<_>>();
6703        let prediction_scopes = self
6704            .prediction
6705            .as_ref()
6706            .into_iter()
6707            .flat_map(EnginePredictionV6::facets)
6708            .map(|facet| facet.scope())
6709            .collect::<Vec<_>>();
6710        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6711            check_id: &self.check_id,
6712            selection: self.selection,
6713            configuration: self.configuration,
6714            applicability: self.applicability,
6715            finding_check_ids: &finding_check_ids,
6716            evaluated_scopes: &self.evaluated_scopes,
6717            gaps: &gap_refs,
6718            prediction_scopes: &prediction_scopes,
6719            has_prediction: self.prediction.is_some(),
6720            prediction_has_required_unavailable: self
6721                .prediction
6722                .as_ref()
6723                .is_some_and(EnginePredictionV6::has_required_unavailable),
6724        })
6725        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6726            check_index,
6727            reason: error.reason(),
6728        })?;
6729        if self.evaluation != derived {
6730            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6731                check_index,
6732                reason: "evaluation does not match completed and missing prediction work",
6733            });
6734        }
6735        let Some(prediction) = &self.prediction else {
6736            if self
6737                .findings
6738                .iter()
6739                .any(|finding| finding.prediction_scope.is_some())
6740            {
6741                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6742                    check_index,
6743                    reason: "finding has prediction_scope without prediction",
6744                });
6745            }
6746            return Ok(());
6747        };
6748        let provenance =
6749            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6750        prediction
6751            .validate_against_provenance(provenance)
6752            .map_err(|source| MeasurementFileError::InvalidPrediction {
6753                check_index,
6754                source,
6755            })?;
6756        prediction
6757            .base_prediction()
6758            .validate_facet_budget_summary_for_check(&self.check_id)
6759            .map_err(|source| MeasurementFileError::InvalidPrediction {
6760                check_index,
6761                source,
6762            })?;
6763        for facet in prediction.facets() {
6764            let evaluated = self
6765                .evaluated_scopes
6766                .iter()
6767                .filter(|scope| *scope == facet.scope())
6768                .count();
6769            let duplicated_gap = self
6770                .gaps
6771                .iter()
6772                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6773            match facet.state() {
6774                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6775                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6776                        check_index,
6777                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6778                    });
6779                }
6780                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6781                    if evaluated != 0 || duplicated_gap =>
6782                {
6783                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6784                        check_index,
6785                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6786                    });
6787                }
6788                _ => {}
6789            }
6790        }
6791        for finding in &self.findings {
6792            let Some(scope) = &finding.prediction_scope else {
6793                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6794                    check_index,
6795                    reason: "prediction-backed finding must carry prediction_scope",
6796                });
6797            };
6798            if prediction
6799                .facets()
6800                .iter()
6801                .filter(|facet| {
6802                    facet.scope() == scope
6803                        && facet.state() == EnginePredictionFacetStateV1::Available
6804                })
6805                .count()
6806                != 1
6807            {
6808                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6809                    check_index,
6810                    reason: "finding prediction_scope must name one available facet",
6811                });
6812            }
6813        }
6814        Ok(())
6815    }
6816}
6817
6818/// Validate the frozen current-lint addressability inventory contract.  This
6819/// is deliberately output-facing: standalone V1 engine artifacts retain their
6820/// historic provenance and reason vocabulary.
6821fn validate_current_engine_addressability_prediction_v2(
6822    check_id: &str,
6823    prediction: &EnginePredictionV2,
6824    provenance: &PredictionProvenanceV2,
6825) -> Result<(), PredictionContractError> {
6826    if check_id != "engine-addressability" {
6827        return Ok(());
6828    }
6829    let raw_partial =
6830        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
6831    let settings_partial = matches!(
6832        provenance.settings().clip_coverage().state(),
6833        ResolvedEngineSettingsCoverageStateV2::Partial
6834    );
6835    let inventories = prediction
6836        .facets()
6837        .iter()
6838        .filter(|facet| {
6839            facet.scope().code.as_str() == "animation_asset_label_inventory"
6840                && facet.scope().subject.is_none()
6841        })
6842        .collect::<Vec<_>>();
6843    if !raw_partial && !settings_partial {
6844        if prediction.facets().iter().any(|facet| {
6845            facet
6846                .reasons()
6847                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
6848        }) {
6849            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6850        }
6851        let available = prediction
6852            .facets()
6853            .iter()
6854            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
6855            .collect::<Vec<_>>();
6856        let expected_rows = provenance.settings().clips().len();
6857        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
6858            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
6859        {
6860            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6861        }
6862        let mut seen = vec![false; available.len()];
6863        for facet in available {
6864            let Some(ordinal) = facet
6865                .scope()
6866                .subject
6867                .as_deref()
6868                .and_then(|subject| subject.strip_prefix("Animation"))
6869                .and_then(|ordinal| ordinal.parse::<usize>().ok())
6870            else {
6871                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6872            };
6873            if facet.scope().code.as_str() != "animation_asset_label"
6874                || ordinal >= seen.len()
6875                || std::mem::replace(&mut seen[ordinal], true)
6876                || !facet.basis().references().iter().any(|reference| {
6877                    matches!(
6878                        reference,
6879                        PredictionBasisReferenceV1::RawSource { reference }
6880                            if reference.domain() == RawSourceDomainV1::Clip
6881                                && matches!(
6882                                    reference.key(),
6883                                    RawSourceKeyV1::Clip { source_clip_index }
6884                                        if *source_clip_index == ordinal as u64
6885                                )
6886                                && reference.field().as_str() == "source_name.state"
6887                    )
6888                })
6889            {
6890                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6891            }
6892        }
6893        if seen.iter().any(|seen| !seen) {
6894            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6895        }
6896        return Ok(());
6897    }
6898
6899    let mut expected = Vec::new();
6900    if raw_partial {
6901        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
6902    }
6903    if settings_partial {
6904        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
6905    }
6906    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
6907    {
6908        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6909    }
6910    if let Some(inventory) = inventories.first()
6911        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6912            || inventory.reasons() != expected)
6913    {
6914        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6915    }
6916    if inventories.is_empty() {
6917        // The allocator may replace this sole incomplete-inventory candidate
6918        // with its canonical budget summary.  The enclosing file validator
6919        // separately proves that the shared 4,096-slot budget was exhausted.
6920        return Ok(());
6921    }
6922    Ok(())
6923}
6924
6925/// Validate the frozen V14/V3 addressability inventory without retargeting
6926/// the immutable V13/V2 contract.
6927fn validate_current_engine_addressability_prediction_v3(
6928    check_id: &str,
6929    prediction: &EnginePredictionV3,
6930    provenance: &PredictionProvenanceV3,
6931) -> Result<(), PredictionContractError> {
6932    if check_id != "engine-addressability" {
6933        return Ok(());
6934    }
6935    let raw_partial =
6936        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
6937    let settings_partial = matches!(
6938        provenance.settings().clip_coverage().state(),
6939        ResolvedEngineSettingsCoverageStateV2::Partial
6940    );
6941    let inventories = prediction
6942        .facets()
6943        .iter()
6944        .filter(|facet| {
6945            facet.scope().code.as_str() == "animation_asset_label_inventory"
6946                && facet.scope().subject.is_none()
6947        })
6948        .collect::<Vec<_>>();
6949    if !raw_partial && !settings_partial {
6950        if prediction.facets().iter().any(|facet| {
6951            facet
6952                .reasons()
6953                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
6954        }) {
6955            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
6956        }
6957        let available = prediction
6958            .facets()
6959            .iter()
6960            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
6961            .collect::<Vec<_>>();
6962        let expected_rows = provenance.settings().clips().len();
6963        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
6964            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
6965        {
6966            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6967        }
6968        let mut seen = vec![false; available.len()];
6969        for facet in available {
6970            let Some(ordinal) = facet
6971                .scope()
6972                .subject
6973                .as_deref()
6974                .and_then(|subject| subject.strip_prefix("Animation"))
6975                .and_then(|ordinal| ordinal.parse::<usize>().ok())
6976            else {
6977                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6978            };
6979            if facet.scope().code.as_str() != "animation_asset_label"
6980                || ordinal >= seen.len()
6981                || std::mem::replace(&mut seen[ordinal], true)
6982                || !facet.basis().references().iter().any(|reference| {
6983                    matches!(
6984                        reference,
6985                        PredictionBasisReferenceV2::V1(PredictionBasisReferenceV1::RawSource { reference })
6986                            if reference.domain() == RawSourceDomainV1::Clip
6987                                && matches!(
6988                                    reference.key(),
6989                                    RawSourceKeyV1::Clip { source_clip_index }
6990                                        if *source_clip_index == ordinal as u64
6991                                )
6992                                && reference.field().as_str() == "source_name.state"
6993                    )
6994                })
6995            {
6996                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
6997            }
6998        }
6999        if seen.iter().any(|seen| !seen) {
7000            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7001        }
7002        return Ok(());
7003    }
7004
7005    let mut expected = Vec::new();
7006    if raw_partial {
7007        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7008    }
7009    if settings_partial {
7010        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7011    }
7012    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7013    {
7014        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7015    }
7016    if let Some(inventory) = inventories.first()
7017        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7018            || inventory.reasons() != expected)
7019    {
7020        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7021    }
7022    Ok(())
7023}
7024
7025const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
7026const ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE: &str = "engine-track-support:animation";
7027const ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
7028const ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE: &str = "engine-track-support:inventory";
7029const ENGINE_TRACK_SUPPORT_BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
7030
7031fn validate_current_engine_track_support_prediction_v5(
7032    check_id: &str,
7033    selection_state: SelectionState,
7034    configuration: ConfigurationState,
7035    applicability: Applicability,
7036    prediction: Option<&EnginePredictionV5>,
7037    provenance: Option<&PredictionProvenanceV5>,
7038    findings_empty: bool,
7039) -> Result<(), PredictionContractError> {
7040    if check_id != ENGINE_TRACK_SUPPORT_CHECK_ID {
7041        if prediction.is_some_and(|prediction| {
7042            prediction.facets().iter().any(|facet| {
7043                matches!(
7044                    facet.result(),
7045                    Some(EngineMachineResultV1::SourceImportDisposition(_))
7046                )
7047            })
7048        }) {
7049            return Err(PredictionContractError::InvalidMachineResult(
7050                "source-import disposition is confined to engine-track-support",
7051            ));
7052        }
7053        return Ok(());
7054    }
7055    let exact_profile = provenance.is_some_and(|provenance| {
7056        let base = provenance.base();
7057        let selected = base.profile().selection();
7058        selected.family() == "bevy"
7059            && selected.profile_revision() == 3
7060            && selected.engine_version() == "0.19.0"
7061            && selected.importer() == "gltf-asset-loader"
7062            && base.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
7063            && base.profile().facts_identity().sha256()
7064                == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
7065            && base.profile().facts_identity().bytes() == 4_849
7066            && matches!(
7067                base.source_format(),
7068                SourceFormatV1::GltfJson | SourceFormatV1::Glb
7069            )
7070            && !provenance.raw_animation_channels().is_complete_empty()
7071    });
7072    let expected_applicability = if exact_profile {
7073        Applicability::Applicable
7074    } else {
7075        Applicability::NotApplicable
7076    };
7077    if applicability != expected_applicability {
7078        return Err(PredictionContractError::InvalidMachineResult(
7079            "engine-track-support applicability mismatch",
7080        ));
7081    }
7082    if !exact_profile
7083        || selection_state != SelectionState::Selected
7084        || configuration != ConfigurationState::Enabled
7085    {
7086        return if prediction.is_none() {
7087            Ok(())
7088        } else {
7089            Err(PredictionContractError::InvalidMachineResult(
7090                "inactive engine-track-support carried prediction",
7091            ))
7092        };
7093    }
7094    if !findings_empty {
7095        return Err(PredictionContractError::InvalidMachineResult(
7096            "engine-track-support must not emit findings",
7097        ));
7098    }
7099    let provenance = provenance.ok_or(PredictionContractError::ProvenanceIdentityMismatch)?;
7100    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7101        "applicable engine-track-support check requires prediction",
7102    ))?;
7103    let inventory = provenance.raw_animation_channels();
7104    let mut expected = Vec::new();
7105    let has_summary = prediction.base_prediction().has_facet_budget_summary();
7106    let expected_demand = if !inventory.source_coverage_complete() {
7107        1
7108    } else {
7109        inventory.rows().len()
7110    };
7111    if inventory.source_coverage_complete() && inventory.candidate_overflow() && !has_summary {
7112        return Err(PredictionContractError::InvalidMachineResult(
7113            "saturated engine-track-support demand requires facet-budget summary",
7114        ));
7115    }
7116    let candidate_capacity = if has_summary {
7117        prediction.facets().len().saturating_sub(1)
7118    } else {
7119        expected_demand
7120    };
7121    if !inventory.is_complete_empty() && candidate_capacity != 0 {
7122        if !inventory.source_coverage_complete() {
7123            expected.push(track_unavailable(
7124                EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7125                    ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE,
7126                )),
7127                track_inventory_basis(inventory),
7128                PredictionUnavailableReasonV2::RawSourceIncomplete,
7129            ));
7130        } else {
7131            for row in inventory.rows().iter().take(candidate_capacity) {
7132                let animation = row.source_animation_index();
7133                if let Some(channel) = row.source_channel_index() {
7134                    expected.push(track_subject_facet(
7135                        track_scope(
7136                            ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE,
7137                            format!("source_animation:{animation}:source_channel:{channel}"),
7138                        ),
7139                        track_row_basis(inventory, animation, Some(channel)),
7140                        SourceImportSubjectKindV1::AnimationChannel,
7141                        track_gate(provenance),
7142                    ));
7143                } else {
7144                    expected.push(track_subject_facet(
7145                        track_scope(
7146                            ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE,
7147                            format!("source_animation:{animation}"),
7148                        ),
7149                        track_row_basis(inventory, animation, None),
7150                        SourceImportSubjectKindV1::Animation,
7151                        track_gate(provenance),
7152                    ));
7153                }
7154            }
7155        }
7156    }
7157    if has_summary {
7158        expected.push(track_unavailable(
7159            EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7160                ENGINE_TRACK_SUPPORT_BUDGET_SCOPE,
7161            )),
7162            track_static_basis(),
7163            PredictionUnavailableReasonV2::FacetBudgetExceeded,
7164        ));
7165    }
7166    let expected = EnginePredictionV4::new(provenance.base().identity().clone(), expected)?
7167        .facets()
7168        .to_vec();
7169    if prediction.facets() != expected {
7170        return Err(PredictionContractError::InvalidMachineResult(
7171            "engine-track-support facets do not reconstruct from V5 provenance",
7172        ));
7173    }
7174    Ok(())
7175}
7176
7177/// Shared producer/readback hook for the immutable engine-root-motion rule.
7178///
7179/// The exact Unity revision-2 reconstruction is filled beside the production
7180/// rule once its final profile identity and facet grammar are frozen. Keeping
7181/// this hook at the common boundary prevents the writer and strict reader from
7182/// acquiring separate acceptance paths in the meantime.
7183#[allow(clippy::too_many_arguments)]
7184fn validate_current_engine_root_motion_prediction_v6<F: RootMotionFindingEvidence>(
7185    check_id: &str,
7186    selection: SelectionState,
7187    configuration: ConfigurationState,
7188    applicability: Applicability,
7189    prediction: Option<&EnginePredictionV6>,
7190    provenance: Option<&PredictionProvenanceV6>,
7191    findings: &[F],
7192    rig: &RigInfo,
7193    measurements: &MeasurementContract,
7194) -> Result<(), PredictionContractError> {
7195    const CHECK_ID: &str = "engine-root-motion";
7196    if check_id != CHECK_ID
7197        && prediction.is_some_and(|prediction| {
7198            prediction.facets().iter().any(|facet| {
7199                matches!(
7200                    facet.result(),
7201                    Some(EngineMachineResultV1::RootMotionRouting(_))
7202                )
7203            })
7204        })
7205    {
7206        return Err(PredictionContractError::InvalidMachineResult(
7207            "root-motion routing is confined to engine-root-motion",
7208        ));
7209    }
7210    if check_id != CHECK_ID {
7211        return Ok(());
7212    }
7213    let active =
7214        selection == SelectionState::Selected && configuration == ConfigurationState::Enabled;
7215    let exact = provenance.is_some_and(root_motion_is_exact_unity_v2);
7216    let has_work = provenance.is_some_and(root_motion_has_work);
7217    let expected_applicability = if exact && has_work {
7218        Applicability::Applicable
7219    } else {
7220        Applicability::NotApplicable
7221    };
7222    if applicability != expected_applicability {
7223        return Err(PredictionContractError::InvalidMachineResult(
7224            "engine-root-motion applicability does not reconstruct from V6 provenance",
7225        ));
7226    }
7227    if !active || expected_applicability == Applicability::NotApplicable {
7228        if prediction.is_some() || !findings.is_empty() {
7229            return Err(PredictionContractError::InvalidMachineResult(
7230                "inactive or inapplicable engine-root-motion must carry no prediction or findings",
7231            ));
7232        }
7233        return Ok(());
7234    }
7235    let provenance = provenance.ok_or(PredictionContractError::InvalidMachineResult(
7236        "applicable engine-root-motion has no V6 provenance",
7237    ))?;
7238    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7239        "applicable engine-root-motion has no V6 prediction",
7240    ))?;
7241    validate_root_motion_facets_v6(prediction, provenance, findings, rig, measurements)
7242}
7243
7244/// An exact profile remains applicable while declaration evidence needed to
7245/// prove complete-empty intent is incomplete. This keeps the strict reader
7246/// aligned with the producer: an unvisited ownerless tail is unavailable work,
7247/// not N/A.
7248fn root_motion_has_work(provenance: &PredictionProvenanceV6) -> bool {
7249    let intent = provenance.root_motion_project_intent();
7250    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete
7251        || provenance.base().base().settings().clip_coverage().state()
7252            != ResolvedEngineSettingsCoverageStateV2::Complete
7253    {
7254        return true;
7255    }
7256    match intent.declared_axis_candidates() {
7257        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
7258        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => true,
7259    }
7260}
7261
7262fn root_motion_is_exact_unity_v2(provenance: &PredictionProvenanceV6) -> bool {
7263    let profile = provenance.base().base().profile();
7264    let selection = profile.selection();
7265    selection.family() == "unity-generic"
7266        && selection.profile_revision() == 2
7267        && selection.engine_version() == "6000.3"
7268        && selection.importer() == "fbx-model-importer"
7269        && profile.fact_bundle_urn() == "urn:animsmith:engine-profile:unity-generic:2"
7270        && profile.facts_identity().sha256()
7271            == "740e1c324a7a5b13efa2d9980fe255a6245d858adec55fb3387614a3ff45274c"
7272        && profile.facts_identity().bytes() == 2_776
7273        && provenance.base().base().source_format() == SourceFormatV1::Fbx
7274        && profile.setting_descriptors().len() == 7
7275        && profile.primary_sources().len() == 3
7276        && [
7277            "unity-fbx-animation-clip-6000.3",
7278            "unity-fbx-model-importer-6000.3",
7279            "unity-fbx-motion-node-6000.3",
7280        ]
7281        .into_iter()
7282        .all(|id| profile.source(id).is_some())
7283}
7284
7285fn validate_root_motion_facets_v6<F: RootMotionFindingEvidence>(
7286    prediction: &EnginePredictionV6,
7287    provenance: &PredictionProvenanceV6,
7288    findings: &[F],
7289    rig: &RigInfo,
7290    measurements: &MeasurementContract,
7291) -> Result<(), PredictionContractError> {
7292    const INVENTORY_SCOPE: &str = "engine-root-motion:inventory";
7293    const AXIS_SCOPE: &str = "engine-root-motion:clip-axis";
7294    const BUDGET_SCOPE: &str = "engine-root-motion:facet-budget";
7295    let intent = provenance.root_motion_project_intent();
7296    let mut atomic = Vec::new();
7297    if provenance
7298        .base()
7299        .base()
7300        .raw_source()
7301        .clips_coverage()
7302        .state()
7303        != RawSourceSetCoverageStateV1::Complete
7304        || !matches!(
7305            provenance.raw_transform_paths().coverage(),
7306            crate::RawTransformPathCoverageV1::Complete
7307        )
7308    {
7309        atomic.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7310    }
7311    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete {
7312        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7313    }
7314    if intent.declared_axis_candidates().overflowed()
7315        || intent.unmapped_declared_axis_candidates().overflowed()
7316    {
7317        atomic.push(PredictionUnavailableReasonV2::custom(
7318            "animsmith:root_motion_intent_work_budget_exceeded",
7319        )?);
7320    }
7321    if !matches!(
7322        intent.unmapped_declared_axis_candidates(),
7323        crate::EngineRootMotionProjectIntentCountV1::Exact { count: 0 }
7324    ) {
7325        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7326    }
7327    if provenance.base().base().settings().clip_coverage().state()
7328        != ResolvedEngineSettingsCoverageStateV2::Complete
7329    {
7330        atomic.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7331    }
7332    atomic.sort_by(|left, right| left.as_str().cmp(right.as_str()));
7333    atomic.dedup();
7334    if !atomic.is_empty() {
7335        let facets = prediction.facets();
7336        let valid_inventory = facets.len() == 1
7337            && facets[0].scope().code.as_str() == INVENTORY_SCOPE
7338            && facets[0].scope().subject.is_none()
7339            && facets[0].state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7340            && facets[0].reasons() == atomic
7341            && facets[0].basis() == &root_motion_inventory_basis(provenance)?;
7342        if !valid_inventory || !findings.is_empty() {
7343            return Err(PredictionContractError::InvalidMachineResult(
7344                "engine-root-motion atomic unavailable summary is not canonical",
7345            ));
7346        }
7347        return Ok(());
7348    }
7349
7350    let configured_path = provenance
7351        .base()
7352        .base()
7353        .settings()
7354        .document_setting(EngineSettingIdV2::RootMotionSource)
7355        .and_then(|row| match row.value() {
7356            EngineSettingValueV2::SourceTransformPath(path) => {
7357                crate::RawTransformPathV1::parse(path).ok()
7358            }
7359            _ => None,
7360        });
7361    let path_resolution = configured_path
7362        .as_ref()
7363        .map(|path| provenance.raw_transform_paths().resolve(path));
7364    let root_name = rig.resolved_roles.get("root").map(String::as_str);
7365    let mut name_counts = BTreeMap::new();
7366    for clip in intent.clips() {
7367        if let Some(name) = clip.normalized_clip_name() {
7368            *name_counts.entry(name).or_insert(0usize) += 1;
7369        }
7370    }
7371    let mut expected = Vec::new();
7372    for clip in intent.clips() {
7373        let Some(name) = clip.normalized_clip_name() else {
7374            continue;
7375        };
7376        for (axis, owner) in [
7377            (
7378                crate::RootMotionAxisV1::HorizontalXz,
7379                clip.movement_owner_xz(),
7380            ),
7381            (crate::RootMotionAxisV1::VerticalY, clip.movement_owner_y()),
7382            (crate::RootMotionAxisV1::Yaw, clip.movement_owner_yaw()),
7383        ]
7384        .into_iter()
7385        .filter_map(|(axis, owner)| owner.map(|owner| (axis, owner)))
7386        {
7387            let axis_name = match axis {
7388                crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7389                crate::RootMotionAxisV1::VerticalY => "vertical_y",
7390                crate::RootMotionAxisV1::Yaw => "yaw",
7391            };
7392            let scope = EvaluationScope::new(crate::EvaluationScopeCode::custom(AXIS_SCOPE))
7393                .subject(format!(
7394                    "source_clip:{:020}:axis:{axis_name}",
7395                    clip.source_clip_index()
7396                ));
7397            let setting_id = match axis {
7398                crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7399                crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7400                crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7401            };
7402            let setting = provenance
7403                .base()
7404                .base()
7405                .settings()
7406                .clip_row(
7407                    clip.normalized_clip_index().ok_or(
7408                        PredictionContractError::InvalidMachineResult(
7409                            "mapped root-motion intent is missing normalized clip index",
7410                        ),
7411                    )?,
7412                    name,
7413                )
7414                .and_then(|row| row.setting(setting_id));
7415            let measurement = measurements.clips().get(name);
7416            let reason = if name_counts.get(name).copied().unwrap_or(0) > 1 {
7417                Some(PredictionUnavailableReasonV2::MeasurementUnavailable)
7418            } else {
7419                root_motion_unavailable_reason(
7420                    path_resolution.as_ref(),
7421                    intent.resolved_root_bone_index(),
7422                    root_name,
7423                    measurement,
7424                    setting.map(|row| row.value()),
7425                    axis,
7426                )?
7427            };
7428            let basis = root_motion_candidate_basis(
7429                provenance,
7430                clip,
7431                name,
7432                name_counts.get(name).copied().unwrap_or(0),
7433                axis,
7434                owner,
7435                configured_path.as_ref(),
7436                path_resolution.as_ref(),
7437                setting.map(|row| row.value_origin()),
7438                measurement,
7439            )?;
7440            expected.push((
7441                scope.clone(),
7442                name.to_owned(),
7443                axis,
7444                owner,
7445                setting.map(|row| row.value()).cloned(),
7446                reason,
7447                basis,
7448            ));
7449        }
7450    }
7451    let facets = prediction.facets();
7452    let has_budget = facets
7453        .last()
7454        .is_some_and(|facet| facet.scope().code.as_str() == BUDGET_SCOPE);
7455    let candidates = if has_budget {
7456        &facets[..facets.len() - 1]
7457    } else {
7458        facets
7459    };
7460    if candidates.len() > expected.len() || (candidates.len() < expected.len() && !has_budget) {
7461        return Err(PredictionContractError::InvalidMachineResult(
7462            "engine-root-motion facet allocation is not a canonical prefix",
7463        ));
7464    }
7465    if has_budget {
7466        let facet = facets.last().unwrap();
7467        if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7468            || facet.scope().subject.is_some()
7469            || facet.reasons() != [PredictionUnavailableReasonV2::FacetBudgetExceeded]
7470            || facet.basis() != &root_motion_static_basis()?
7471        {
7472            return Err(PredictionContractError::InvalidMachineResult(
7473                "engine-root-motion facet-budget summary is invalid",
7474            ));
7475        }
7476    }
7477    let mut retained_conflicts = Vec::new();
7478    for (facet, (scope, clip_name, axis, owner, setting, reason, basis)) in
7479        candidates.iter().zip(expected.iter())
7480    {
7481        if facet.scope() != scope || facet.basis() != basis {
7482            return Err(PredictionContractError::InvalidMachineResult(
7483                "engine-root-motion scope or basis is not canonical",
7484            ));
7485        }
7486        match reason {
7487            Some(reason)
7488                if facet.state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7489                    && facet.reasons() == std::slice::from_ref(reason) => {}
7490            None => {
7491                let disposition = root_motion_disposition(setting.as_ref().unwrap()).unwrap();
7492                let compatible = matches!(
7493                    (owner, disposition),
7494                    (
7495                        crate::RootMotionProjectOwnerV1::Gameplay,
7496                        crate::RootMotionImporterDispositionV1::BakedIntoPose
7497                    ) | (
7498                        crate::RootMotionProjectOwnerV1::Animation,
7499                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion
7500                    )
7501                );
7502                let expected_result = crate::RootMotionRoutingResultV1 {
7503                    axis: *axis,
7504                    project_owner: *owner,
7505                    importer_disposition: disposition,
7506                    compatibility: if compatible {
7507                        crate::RootMotionCompatibilityV1::Compatible
7508                    } else {
7509                        crate::RootMotionCompatibilityV1::Conflict
7510                    },
7511                };
7512                if !matches!(facet.result(), Some(EngineMachineResultV1::RootMotionRouting(result)) if result == &expected_result)
7513                {
7514                    return Err(PredictionContractError::InvalidMachineResult(
7515                        "engine-root-motion result is not canonical",
7516                    ));
7517                }
7518                if !compatible {
7519                    let axis_label = match axis {
7520                        crate::RootMotionAxisV1::HorizontalXz => "horizontal XZ",
7521                        crate::RootMotionAxisV1::VerticalY => "vertical Y",
7522                        crate::RootMotionAxisV1::Yaw => "yaw",
7523                    };
7524                    let owner_label = match owner {
7525                        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7526                        crate::RootMotionProjectOwnerV1::Animation => "animation",
7527                    };
7528                    let disposition_label = match disposition {
7529                        crate::RootMotionImporterDispositionV1::BakedIntoPose => {
7530                            "baked into the pose"
7531                        }
7532                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion => {
7533                            "stored as root motion"
7534                        }
7535                    };
7536                    retained_conflicts.push(serde_json::json!({
7537                        "check_id": "engine-root-motion",
7538                        "severity": "error",
7539                        "clip": clip_name,
7540                        "prediction_scope": scope,
7541                        "message": format!("clip {:?} assigns {} movement to {}, but Unity imports that axis as {}", clip_name, axis_label, owner_label, disposition_label),
7542                    }));
7543                }
7544            }
7545            _ => {
7546                return Err(PredictionContractError::InvalidMachineResult(
7547                    "engine-root-motion unavailable facet is not canonical",
7548                ));
7549            }
7550        }
7551    }
7552    let actual_findings = findings
7553        .iter()
7554        .map(RootMotionFindingEvidence::root_motion_wire_value)
7555        .collect::<Vec<_>>();
7556    if actual_findings != retained_conflicts {
7557        return Err(PredictionContractError::InvalidMachineResult(
7558            "engine-root-motion conflict findings are not canonical",
7559        ));
7560    }
7561    Ok(())
7562}
7563
7564fn root_motion_lift(reference: PredictionBasisReferenceV1) -> PredictionBasisReferenceV4 {
7565    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference))
7566}
7567
7568fn root_motion_static_basis() -> Result<EnginePredictionBasisV4, PredictionContractError> {
7569    let mut references = vec![root_motion_lift(PredictionBasisReferenceV1::profile_fact(
7570        "root_motion_addressability",
7571    )?)];
7572    for source in [
7573        "unity-fbx-model-importer-6000.3",
7574        "unity-fbx-animation-clip-6000.3",
7575        "unity-fbx-motion-node-6000.3",
7576    ] {
7577        references.push(root_motion_lift(
7578            PredictionBasisReferenceV1::primary_source(source)?,
7579        ));
7580    }
7581    for setting in [
7582        EngineSettingIdV2::AnimationType,
7583        EngineSettingIdV2::AvatarSetup,
7584        EngineSettingIdV2::ImportAnimation,
7585        EngineSettingIdV2::RootMotionSource,
7586    ] {
7587        references.push(root_motion_lift(
7588            PredictionBasisReferenceV1::resolved_setting(
7589                ResolvedSettingLocationV1::Document,
7590                setting.as_str(),
7591            )?,
7592        ));
7593    }
7594    EnginePredictionBasisV4::new(references)
7595}
7596
7597fn root_motion_inventory_basis(
7598    provenance: &PredictionProvenanceV6,
7599) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7600    let mut references = root_motion_static_basis()?.references().to_vec();
7601    let path_coverage = match provenance.raw_transform_paths().coverage() {
7602        crate::RawTransformPathCoverageV1::Complete => "complete",
7603        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7604        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7605    };
7606    let clip_coverage = match provenance.root_motion_project_intent().clip_coverage() {
7607        crate::EngineRootMotionProjectIntentCoverageV1::Complete => "complete",
7608        crate::EngineRootMotionProjectIntentCoverageV1::PartialProjectionBudgetExceeded => {
7609            "partial_projection_budget_exceeded"
7610        }
7611    };
7612    let count = match provenance
7613        .root_motion_project_intent()
7614        .declared_axis_candidates()
7615    {
7616        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7617            PredictionScalarV1::UnsignedInteger { value: count }
7618        }
7619        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7620            PredictionScalarV1::token("n_plus_one")?
7621        }
7622    };
7623    let unmapped_count = match provenance
7624        .root_motion_project_intent()
7625        .unmapped_declared_axis_candidates()
7626    {
7627        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7628            PredictionScalarV1::UnsignedInteger { value: count }
7629        }
7630        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7631            PredictionScalarV1::token("n_plus_one")?
7632        }
7633    };
7634    let raw_clip_coverage = match provenance
7635        .base()
7636        .base()
7637        .raw_source()
7638        .clips_coverage()
7639        .state()
7640    {
7641        RawSourceSetCoverageStateV1::Complete => "complete",
7642        RawSourceSetCoverageStateV1::Partial => "partial",
7643        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7644    };
7645    let settings_coverage = match provenance.base().base().settings().clip_coverage().state() {
7646        ResolvedEngineSettingsCoverageStateV2::Complete => "complete",
7647        ResolvedEngineSettingsCoverageStateV2::Partial => "partial",
7648    };
7649    for (field, value) in [
7650        (
7651            "raw_source.clips.coverage",
7652            PredictionScalarV1::token(raw_clip_coverage)?,
7653        ),
7654        (
7655            "raw_transform_path_inventory.coverage",
7656            PredictionScalarV1::token(path_coverage)?,
7657        ),
7658        (
7659            "root_motion_project_intent.clip_coverage",
7660            PredictionScalarV1::token(clip_coverage)?,
7661        ),
7662        ("root_motion_project_intent.declared_axis_candidates", count),
7663        (
7664            "root_motion_project_intent.unmapped_declared_axis_candidates",
7665            unmapped_count,
7666        ),
7667        (
7668            "resolved_settings.clips.coverage",
7669            PredictionScalarV1::token(settings_coverage)?,
7670        ),
7671    ] {
7672        references.push(root_motion_lift(PredictionBasisReferenceV1::project_field(
7673            field, value,
7674        )?));
7675    }
7676    EnginePredictionBasisV4::new(references)
7677}
7678
7679fn root_motion_project_reference(
7680    field: &'static str,
7681    value: PredictionScalarV1,
7682) -> Result<PredictionBasisReferenceV4, PredictionContractError> {
7683    Ok(root_motion_lift(PredictionBasisReferenceV1::project_field(
7684        field, value,
7685    )?))
7686}
7687
7688fn root_motion_token(value: &'static str) -> Result<PredictionScalarV1, PredictionContractError> {
7689    PredictionScalarV1::token(value)
7690}
7691
7692#[allow(clippy::too_many_arguments)]
7693fn root_motion_candidate_basis(
7694    provenance: &PredictionProvenanceV6,
7695    clip: &crate::EngineRootMotionClipIntentV1,
7696    name: &str,
7697    duplicate_count: usize,
7698    axis: crate::RootMotionAxisV1,
7699    owner: crate::RootMotionProjectOwnerV1,
7700    configured_path: Option<&crate::RawTransformPathV1>,
7701    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
7702    setting_origin: Option<crate::EngineSettingValueOriginV3>,
7703    measurement: Option<&ClipMeasurements>,
7704) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7705    let mut refs = root_motion_static_basis()?.references().to_vec();
7706    let settings = provenance.base().base().settings();
7707    for (id, field) in [
7708        (
7709            EngineSettingIdV2::AnimationType,
7710            "resolved_setting.document.animation_type.value_origin",
7711        ),
7712        (
7713            EngineSettingIdV2::AvatarSetup,
7714            "resolved_setting.document.avatar_setup.value_origin",
7715        ),
7716        (
7717            EngineSettingIdV2::ImportAnimation,
7718            "resolved_setting.document.import_animation.value_origin",
7719        ),
7720        (
7721            EngineSettingIdV2::RootMotionSource,
7722            "resolved_setting.document.root_motion_source.value_origin",
7723        ),
7724    ] {
7725        let value = settings
7726            .document_setting(id)
7727            .map_or(PredictionScalarV1::Null, |row| {
7728                root_motion_token(match row.value_origin() {
7729                    crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7730                    crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7731                })
7732                .expect("static origin token")
7733            });
7734        refs.push(root_motion_project_reference(field, value)?);
7735    }
7736    let setting_id = match axis {
7737        crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7738        crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7739        crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7740    };
7741    if let Some(index) = clip.normalized_clip_index() {
7742        refs.push(root_motion_lift(
7743            PredictionBasisReferenceV1::resolved_setting(
7744                ResolvedSettingLocationV1::Clip {
7745                    clip_ordinal: index,
7746                    clip_name: name.to_owned(),
7747                },
7748                setting_id.as_str(),
7749            )?,
7750        ));
7751    }
7752    refs.push(root_motion_project_reference(
7753        "root_motion_project_intent.source_clip_index",
7754        PredictionScalarV1::UnsignedInteger {
7755            value: clip.source_clip_index(),
7756        },
7757    )?);
7758    let mapping_state = match clip.normalized_clip_mapping_state() {
7759        crate::EngineRootMotionClipMappingStateV1::Observed => "observed",
7760        crate::EngineRootMotionClipMappingStateV1::ProvenAbsent => "proven_absent",
7761        crate::EngineRootMotionClipMappingStateV1::Unavailable => "unavailable",
7762    };
7763    for field in ["normalized_clip_index.state", "normalized_clip_index.value"] {
7764        if field.ends_with(".value") && clip.normalized_clip_index().is_none() {
7765            continue;
7766        }
7767        let value = if field.ends_with(".state") {
7768            root_motion_token(mapping_state)?
7769        } else {
7770            PredictionScalarV1::UnsignedInteger {
7771                value: clip.normalized_clip_index().unwrap(),
7772            }
7773        };
7774        let raw: RawSourceBasisReferenceV1 = serde_json::from_value(serde_json::json!({
7775            "domain": "clip", "key": {"kind": "clip", "source_clip_index": clip.source_clip_index()}, "field": field, "value": value
7776        })).map_err(|_| PredictionContractError::InvalidMachineResult("failed to reconstruct root-motion raw clip reference"))?;
7777        refs.push(root_motion_lift(PredictionBasisReferenceV1::raw_source(
7778            raw,
7779        )));
7780    }
7781    let axis_name = match axis {
7782        crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7783        crate::RootMotionAxisV1::VerticalY => "vertical_y",
7784        crate::RootMotionAxisV1::Yaw => "yaw",
7785    };
7786    let owner_name = match owner {
7787        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7788        crate::RootMotionProjectOwnerV1::Animation => "animation",
7789    };
7790    let origin = setting_origin.map_or(PredictionScalarV1::Null, |origin| {
7791        root_motion_token(match origin {
7792            crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7793            crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7794        })
7795        .expect("origin token")
7796    });
7797    let path_coverage = match provenance.raw_transform_paths().coverage() {
7798        crate::RawTransformPathCoverageV1::Complete => "complete",
7799        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7800        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7801    };
7802    let raw_coverage = match provenance
7803        .base()
7804        .base()
7805        .raw_source()
7806        .clips_coverage()
7807        .state()
7808    {
7809        RawSourceSetCoverageStateV1::Complete => "complete",
7810        RawSourceSetCoverageStateV1::Partial => "partial",
7811        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7812    };
7813    let resolution = match path_resolution {
7814        Some(crate::RawTransformPathResolutionV1::Exact(_)) => "exact",
7815        Some(crate::RawTransformPathResolutionV1::NoMatch) => "no_match",
7816        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => "ambiguous",
7817        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
7818            "coverage_incomplete"
7819        }
7820        None => "invalid_configured_path",
7821    };
7822    for (field, value) in [
7823        (
7824            "root_motion_project_intent.clip_mapping_state",
7825            root_motion_token(mapping_state)?,
7826        ),
7827        (
7828            "root_motion_project_intent.normalized_clip_index",
7829            clip.normalized_clip_index()
7830                .map_or(PredictionScalarV1::Null, |value| {
7831                    PredictionScalarV1::UnsignedInteger { value }
7832                }),
7833        ),
7834        (
7835            "root_motion_project_intent.normalized_clip_name",
7836            PredictionScalarV1::text(name)?,
7837        ),
7838        (
7839            "measurement.clip_name_match_count",
7840            PredictionScalarV1::UnsignedInteger {
7841                value: duplicate_count as u64,
7842            },
7843        ),
7844        (
7845            "measurement.clip_identity_state",
7846            root_motion_token(if duplicate_count > 1 {
7847                "duplicate"
7848            } else {
7849                "unique"
7850            })?,
7851        ),
7852        (
7853            "root_motion_project_intent.axis",
7854            root_motion_token(axis_name)?,
7855        ),
7856        (
7857            "root_motion_project_intent.owner",
7858            root_motion_token(owner_name)?,
7859        ),
7860        ("resolved_setting.clip.value_origin", origin),
7861        (
7862            "raw_source.clips.coverage",
7863            root_motion_token(raw_coverage)?,
7864        ),
7865        (
7866            "raw_transform_path_inventory.coverage",
7867            root_motion_token(path_coverage)?,
7868        ),
7869        (
7870            "resolved_role.root.bone_index",
7871            provenance
7872                .root_motion_project_intent()
7873                .resolved_root_bone_index()
7874                .map_or(PredictionScalarV1::Null, |value| {
7875                    PredictionScalarV1::UnsignedInteger { value }
7876                }),
7877        ),
7878        (
7879            "root_motion_source.configured_path",
7880            configured_path.map_or(PredictionScalarV1::Null, |path| {
7881                PredictionScalarV1::text(path.as_str()).expect("path text")
7882            }),
7883        ),
7884        (
7885            "root_motion_source.resolution_state",
7886            root_motion_token(resolution)?,
7887        ),
7888    ] {
7889        refs.push(root_motion_project_reference(field, value)?);
7890    }
7891    if let Some(crate::RawTransformPathResolutionV1::Ambiguous { matches }) = path_resolution {
7892        refs.push(root_motion_project_reference(
7893            "root_motion_source.match_count",
7894            PredictionScalarV1::UnsignedInteger {
7895                value: matches.len() as u64,
7896            },
7897        )?);
7898    }
7899    if let Some(crate::RawTransformPathResolutionV1::Exact(path_match)) = path_resolution {
7900        let digest = crate::sha256_hex(
7901            &serde_json::to_vec(path_match.parent_chain()).expect("parent chain serializes"),
7902        );
7903        for (field, value) in [
7904            (
7905                "root_motion_source.source_node_index",
7906                PredictionScalarV1::UnsignedInteger {
7907                    value: path_match.source_node_index(),
7908                },
7909            ),
7910            (
7911                "root_motion_source.projected_bone_index",
7912                path_match
7913                    .projected_bone_index()
7914                    .map_or(PredictionScalarV1::Null, |value| {
7915                        PredictionScalarV1::UnsignedInteger { value }
7916                    }),
7917            ),
7918            (
7919                "root_motion_source.path",
7920                PredictionScalarV1::text(path_match.path().as_str())?,
7921            ),
7922            ("root_motion_source.node_kind", root_motion_token("source")?),
7923            (
7924                "root_motion_source.parent_chain_count",
7925                PredictionScalarV1::UnsignedInteger {
7926                    value: path_match.parent_chain().len() as u64,
7927                },
7928            ),
7929            (
7930                "root_motion_source.parent_chain_sha256",
7931                PredictionScalarV1::text(digest)?,
7932            ),
7933        ] {
7934            refs.push(root_motion_project_reference(field, value)?);
7935        }
7936    }
7937    if duplicate_count == 1
7938        && let Some(measurement) = measurement
7939    {
7940        let escaped = name.replace('~', "~0").replace('/', "~1");
7941        let prefix = format!("/measurements/clips/{escaped}/root_trajectory");
7942        let availability = |value| {
7943            root_motion_token(match value {
7944                MeasurementAvailability::Measured => "measured",
7945                MeasurementAvailability::NotApplicable => "not_applicable",
7946                MeasurementAvailability::Unavailable => "unavailable",
7947            })
7948        };
7949        let mut add_measurement = |pointer: String, value| -> Result<(), PredictionContractError> {
7950            refs.push(root_motion_lift(
7951                PredictionBasisReferenceV1::measurement_v16(
7952                    crate::MeasurementPointerV1::new(pointer)?,
7953                    value,
7954                ),
7955            ));
7956            Ok(())
7957        };
7958        add_measurement(
7959            format!("{prefix}_availability"),
7960            availability(measurement.root_trajectory_availability)?,
7961        )?;
7962        if let Some(trajectory) = measurement.root_trajectory.as_ref() {
7963            add_measurement(
7964                format!("{prefix}/bone_index"),
7965                PredictionScalarV1::UnsignedInteger {
7966                    value: u64::from(trajectory.bone_index),
7967                },
7968            )?;
7969            add_measurement(
7970                format!("{prefix}/source_role"),
7971                root_motion_token(trajectory.source_role.as_str())?,
7972            )?;
7973            let (suffix, present_field, availability_value, present) = match axis {
7974                crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => (
7975                    "translation_availability",
7976                    "measurement.root_translation_present",
7977                    trajectory.translation_availability,
7978                    trajectory.translation.is_some(),
7979                ),
7980                crate::RootMotionAxisV1::Yaw => (
7981                    "yaw_availability",
7982                    "measurement.root_yaw_present",
7983                    trajectory.yaw_availability,
7984                    trajectory.yaw.is_some(),
7985                ),
7986            };
7987            add_measurement(
7988                format!("{prefix}/{suffix}"),
7989                availability(availability_value)?,
7990            )?;
7991            refs.push(root_motion_project_reference(
7992                present_field,
7993                PredictionScalarV1::Boolean { value: present },
7994            )?);
7995        }
7996    }
7997    EnginePredictionBasisV4::new(refs)
7998}
7999
8000trait RootMotionFindingEvidence {
8001    fn root_motion_wire_value(&self) -> serde_json::Value;
8002}
8003
8004impl RootMotionFindingEvidence for crate::Finding {
8005    fn root_motion_wire_value(&self) -> serde_json::Value {
8006        serde_json::to_value(self).expect("Finding serializes")
8007    }
8008}
8009
8010impl RootMotionFindingEvidence for PredictionFindingInput {
8011    fn root_motion_wire_value(&self) -> serde_json::Value {
8012        serde_json::to_value(self).expect("finding input serializes")
8013    }
8014}
8015
8016fn root_motion_disposition(
8017    setting: &EngineSettingValueV2,
8018) -> Option<crate::RootMotionImporterDispositionV1> {
8019    match setting {
8020        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Bake) => {
8021            Some(crate::RootMotionImporterDispositionV1::BakedIntoPose)
8022        }
8023        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Extract) => {
8024            Some(crate::RootMotionImporterDispositionV1::StoredAsRootMotion)
8025        }
8026        _ => None,
8027    }
8028}
8029
8030fn root_motion_unavailable_reason(
8031    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
8032    resolved_root_bone_index: Option<u64>,
8033    root_name: Option<&str>,
8034    measurement: Option<&ClipMeasurements>,
8035    setting: Option<&EngineSettingValueV2>,
8036    axis: crate::RootMotionAxisV1,
8037) -> Result<Option<PredictionUnavailableReasonV2>, PredictionContractError> {
8038    let path_match = match path_resolution {
8039        Some(crate::RawTransformPathResolutionV1::Exact(path_match)) => path_match,
8040        Some(crate::RawTransformPathResolutionV1::NoMatch) | None => {
8041            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch));
8042        }
8043        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => {
8044            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous));
8045        }
8046        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8047            return Ok(Some(PredictionUnavailableReasonV2::RawSourceIncomplete));
8048        }
8049    };
8050    if resolved_root_bone_index.is_none()
8051        || path_match.projected_bone_index() != resolved_root_bone_index
8052    {
8053        return Ok(Some(PredictionUnavailableReasonV2::custom(
8054            "animsmith:root_motion_source_not_explicit_root",
8055        )?));
8056    }
8057    let Some(trajectory) = measurement
8058        .filter(|measurement| {
8059            measurement.root_trajectory_availability == MeasurementAvailability::Measured
8060        })
8061        .and_then(|measurement| measurement.root_trajectory.as_ref())
8062    else {
8063        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8064    };
8065    if trajectory.source_role != crate::measure::RootTrajectorySourceRole::Root
8066        || root_name != Some(trajectory.bone_name.as_str())
8067        || resolved_root_bone_index != Some(u64::from(trajectory.bone_index))
8068    {
8069        return Ok(Some(PredictionUnavailableReasonV2::custom(
8070            "animsmith:root_motion_source_not_explicit_root",
8071        )?));
8072    }
8073    let measured = match axis {
8074        crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => {
8075            trajectory.translation_availability == MeasurementAvailability::Measured
8076                && trajectory.translation.is_some()
8077        }
8078        crate::RootMotionAxisV1::Yaw => {
8079            trajectory.yaw_availability == MeasurementAvailability::Measured
8080                && trajectory.yaw.is_some()
8081        }
8082    };
8083    if !measured {
8084        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8085    }
8086    if setting.and_then(root_motion_disposition).is_none() {
8087        return Ok(Some(
8088            PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
8089        ));
8090    }
8091    Ok(None)
8092}
8093
8094fn track_scope(code: &'static str, subject: String) -> EvaluationScope {
8095    EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code)).subject(subject)
8096}
8097
8098fn track_subject_facet(
8099    scope: EvaluationScope,
8100    basis: EnginePredictionBasisV4,
8101    subject_kind: SourceImportSubjectKindV1,
8102    gate: Option<EngineSettingIdV2>,
8103) -> crate::EnginePredictionFacetV4 {
8104    match gate {
8105        None => track_unavailable(
8106            scope,
8107            basis,
8108            PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
8109        ),
8110        Some(controlling_gate) => crate::EnginePredictionFacetV4::available(
8111            scope,
8112            basis,
8113            EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
8114                subject_kind,
8115                disposition: SourceImportDispositionV1::Dropped,
8116                controlling_gate: Some(controlling_gate),
8117            }),
8118        )
8119        .expect("reconstructed track result is valid"),
8120    }
8121}
8122
8123fn track_unavailable(
8124    scope: EvaluationScope,
8125    basis: EnginePredictionBasisV4,
8126    reason: PredictionUnavailableReasonV2,
8127) -> crate::EnginePredictionFacetV4 {
8128    crate::EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
8129        .expect("reconstructed track unavailable facet is valid")
8130}
8131
8132fn track_gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
8133    let settings = provenance.base().settings();
8134    if matches!(
8135        settings
8136            .document_setting(EngineSettingIdV2::BevyAnimationFeature)
8137            .map(|row| row.value()),
8138        Some(EngineSettingValueV2::Boolean(false))
8139    ) {
8140        Some(EngineSettingIdV2::BevyAnimationFeature)
8141    } else if matches!(
8142        settings
8143            .document_setting(EngineSettingIdV2::LoadAnimations)
8144            .map(|row| row.value()),
8145        Some(EngineSettingValueV2::Boolean(false))
8146    ) {
8147        Some(EngineSettingIdV2::LoadAnimations)
8148    } else {
8149        None
8150    }
8151}
8152
8153fn track_static_basis() -> EnginePredictionBasisV4 {
8154    let v1 = |reference| PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference));
8155    EnginePredictionBasisV4::new(vec![
8156        v1(PredictionBasisReferenceV1::profile_fact("source_import_disposition").unwrap()),
8157        v1(PredictionBasisReferenceV1::primary_source("bevy-gltf-loader-0.19.0-c6f634ca").unwrap()),
8158        v1(
8159            PredictionBasisReferenceV1::primary_source("bevy-feature-manifest-0.19.0-c6f634ca")
8160                .unwrap(),
8161        ),
8162        v1(PredictionBasisReferenceV1::resolved_setting(
8163            ResolvedSettingLocationV1::Document,
8164            EngineSettingIdV2::BevyAnimationFeature.as_str(),
8165        )
8166        .unwrap()),
8167        v1(PredictionBasisReferenceV1::resolved_setting(
8168            ResolvedSettingLocationV1::Document,
8169            EngineSettingIdV2::LoadAnimations.as_str(),
8170        )
8171        .unwrap()),
8172    ])
8173    .unwrap()
8174}
8175
8176fn track_inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
8177    let mut references = track_static_basis().references().to_vec();
8178    references.push(PredictionBasisReferenceV4::v2(
8179        PredictionBasisReferenceV2::v1(
8180            PredictionBasisReferenceV1::project_field(
8181                "raw_animation_channel_inventory.animation_coverage",
8182                PredictionScalarV1::text(match inventory.animation_coverage().state() {
8183                    RawSourceSetCoverageStateV1::Complete => "complete",
8184                    RawSourceSetCoverageStateV1::Partial => "partial",
8185                    RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8186                })
8187                .unwrap(),
8188            )
8189            .unwrap(),
8190        ),
8191    ));
8192    references.push(PredictionBasisReferenceV4::v2(
8193        PredictionBasisReferenceV2::v1(
8194            PredictionBasisReferenceV1::project_field(
8195                "raw_animation_channel_inventory.source_coverage_complete",
8196                PredictionScalarV1::Boolean {
8197                    value: inventory.source_coverage_complete(),
8198                },
8199            )
8200            .unwrap(),
8201        ),
8202    ));
8203    if let Some(row) = inventory.rows().iter().find(|row| {
8204        row.channel_coverage()
8205            .is_some_and(|coverage| coverage.state() != RawSourceSetCoverageStateV1::Complete)
8206    }) {
8207        let coverage = row.channel_coverage().unwrap();
8208        let state = match coverage.state() {
8209            RawSourceSetCoverageStateV1::Complete => "complete",
8210            RawSourceSetCoverageStateV1::Partial => "partial",
8211            RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8212        };
8213        references.push(PredictionBasisReferenceV4::v2(
8214            PredictionBasisReferenceV2::v1(
8215                PredictionBasisReferenceV1::project_field(
8216                    "raw_animation_channel_inventory.incomplete_channel_animation_row",
8217                    PredictionScalarV1::UnsignedInteger {
8218                        value: row.source_animation_index(),
8219                    },
8220                )
8221                .unwrap(),
8222            ),
8223        ));
8224        references.push(PredictionBasisReferenceV4::v2(
8225            PredictionBasisReferenceV2::v1(
8226                PredictionBasisReferenceV1::project_field(
8227                    "raw_animation_channel_inventory.incomplete_channel_coverage",
8228                    PredictionScalarV1::text(state).unwrap(),
8229                )
8230                .unwrap(),
8231            ),
8232        ));
8233        if let Some(reason) = coverage.reason() {
8234            let reason = serde_json::to_value(reason).unwrap();
8235            references.push(PredictionBasisReferenceV4::v2(
8236                PredictionBasisReferenceV2::v1(
8237                    PredictionBasisReferenceV1::project_field(
8238                        "raw_animation_channel_inventory.incomplete_channel_reason",
8239                        PredictionScalarV1::text(reason.as_str().unwrap()).unwrap(),
8240                    )
8241                    .unwrap(),
8242                ),
8243            ));
8244        }
8245    }
8246    EnginePredictionBasisV4::new(references).unwrap()
8247}
8248
8249fn track_row_basis(
8250    inventory: &RawAnimationChannelInventoryV1,
8251    animation: u64,
8252    channel: Option<u64>,
8253) -> EnginePredictionBasisV4 {
8254    let mut references = track_inventory_basis(inventory).references().to_vec();
8255    references.push(PredictionBasisReferenceV4::v2(
8256        PredictionBasisReferenceV2::v1(
8257            PredictionBasisReferenceV1::project_field(
8258                "raw_animation_channel_inventory.animation_row",
8259                PredictionScalarV1::UnsignedInteger { value: animation },
8260            )
8261            .unwrap(),
8262        ),
8263    ));
8264    if let Some(channel) = channel {
8265        references.push(PredictionBasisReferenceV4::v2(
8266            PredictionBasisReferenceV2::v1(
8267                PredictionBasisReferenceV1::project_field(
8268                    "raw_animation_channel_inventory.channel_row",
8269                    PredictionScalarV1::UnsignedInteger { value: channel },
8270                )
8271                .unwrap(),
8272            ),
8273        ));
8274    }
8275    EnginePredictionBasisV4::new(references).unwrap()
8276}
8277
8278const ENGINE_UNIT_SCALE_CHECK_ID: &str = "engine-unit-scale";
8279const ENGINE_UNIT_SCALE_FILE_SCOPE: &str = "engine-unit-scale:file-unit";
8280const ENGINE_UNIT_SCALE_SCENE_SCOPE: &str = "engine-unit-scale:loader-scene-root";
8281const ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE: &str = "engine-unit-scale:scene-inventory";
8282const ENGINE_UNIT_SCALE_MESH_SCOPE: &str = "engine-unit-scale:loader-mesh-primitive";
8283const ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE: &str = "engine-unit-scale:mesh-inventory";
8284const ENGINE_UNIT_SCALE_SELECTED_SCOPE: &str = "engine-unit-scale:selected-source-node";
8285const ENGINE_UNIT_SCALE_BUDGET_SCOPE: &str = "engine-unit-scale:facet-budget";
8286const ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON: &str =
8287    "animsmith:selected_node_scene_reachability_unavailable";
8288const ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES: usize = 128;
8289
8290#[derive(Clone, PartialEq, Eq)]
8291struct ExpectedUnitScaleFacet {
8292    scope: EvaluationScope,
8293    result: Option<EngineMachineResultV1>,
8294    reasons: Vec<PredictionUnavailableReasonV2>,
8295}
8296
8297fn unit_scale_scope(code: &'static str, subject: Option<String>) -> EvaluationScope {
8298    let scope = EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code));
8299    subject.map_or(scope.clone(), |subject| scope.subject(subject))
8300}
8301
8302fn unit_scale_unavailable_reasons(
8303    reason: PredictionUnavailableReasonV2,
8304    dependency_complete: bool,
8305) -> Vec<PredictionUnavailableReasonV2> {
8306    let mut reasons = vec![reason];
8307    if !dependency_complete {
8308        reasons.push(PredictionUnavailableReasonV2::DependencyClosureIncomplete);
8309    }
8310    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
8311    reasons
8312}
8313
8314#[derive(Clone, Copy, PartialEq, Eq)]
8315enum UnitScaleSelectedReachability {
8316    Reachable(u64, u64),
8317    Unreachable,
8318    Unavailable,
8319    WorkBudgetExceeded,
8320}
8321
8322enum UnitScaleSelectedReachabilityPlan {
8323    Complete {
8324        node_index: u64,
8325        scene_witnesses: BTreeMap<u64, (u64, u64)>,
8326    },
8327    Refused {
8328        node_index: u64,
8329    },
8330}
8331
8332enum UnitScaleSelectedReachabilityPlans {
8333    Complete {
8334        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8335        selected_facet_count: usize,
8336    },
8337    Overflow {
8338        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8339    },
8340}
8341
8342impl UnitScaleSelectedReachabilityPlans {
8343    fn get(&self, selector: &str) -> Option<&UnitScaleSelectedReachabilityPlan> {
8344        match self {
8345            Self::Complete { plans, .. } | Self::Overflow { plans } => plans.get(selector),
8346        }
8347    }
8348
8349    const fn selected_facet_count(&self) -> usize {
8350        match self {
8351            Self::Complete {
8352                selected_facet_count,
8353                ..
8354            } => *selected_facet_count,
8355            Self::Overflow { .. } => PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE + 1,
8356        }
8357    }
8358}
8359
8360impl UnitScaleSelectedReachabilityPlan {
8361    const fn node_index(&self) -> u64 {
8362        match self {
8363            Self::Complete { node_index, .. } | Self::Refused { node_index } => *node_index,
8364        }
8365    }
8366}
8367
8368fn unit_scale_selected_reachability_budget_exceeded(work: &mut usize) -> bool {
8369    *work = work.saturating_add(1);
8370    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8371}
8372
8373fn unit_scale_selected_reachability(
8374    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8375    start: u64,
8376    roots: &[u64],
8377    work: &mut usize,
8378) -> UnitScaleSelectedReachability {
8379    let Ok(start) = usize::try_from(start) else {
8380        return UnitScaleSelectedReachability::Unavailable;
8381    };
8382    if *work >= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8383        return UnitScaleSelectedReachability::WorkBudgetExceeded;
8384    }
8385    let roots = roots
8386        .iter()
8387        .copied()
8388        .enumerate()
8389        .map(|(ordinal, node)| (node, ordinal as u64))
8390        .collect::<BTreeMap<_, _>>();
8391    let mut seen = BTreeSet::new();
8392    let mut current = Some(start);
8393    for _ in 0..ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES {
8394        let Some(index) = current else {
8395            return UnitScaleSelectedReachability::Unreachable;
8396        };
8397        if unit_scale_selected_reachability_budget_exceeded(work) {
8398            return UnitScaleSelectedReachability::WorkBudgetExceeded;
8399        }
8400        if let Some(ordinal) = roots.get(&(index as u64)) {
8401            return UnitScaleSelectedReachability::Reachable(*ordinal, index as u64);
8402        }
8403        if !seen.insert(index) {
8404            return UnitScaleSelectedReachability::Unavailable;
8405        }
8406        current = match nodes.get(&index) {
8407            Some(node) => node.parent_node_index,
8408            None => return UnitScaleSelectedReachability::Unavailable,
8409        };
8410    }
8411    match current {
8412        Some(_) => UnitScaleSelectedReachability::Unavailable,
8413        None => UnitScaleSelectedReachability::Unreachable,
8414    }
8415}
8416
8417fn unit_scale_selected_reachability_plans(
8418    provenance: &PredictionProvenanceV4,
8419    measurements: &MeasurementContract,
8420) -> UnitScaleSelectedReachabilityPlans {
8421    let assets = measurements.assets();
8422    let inventory = provenance
8423        .raw_scene_attachment()
8424        .inventory()
8425        .filter(|inventory| {
8426            assets.skeleton_source_coverage == SourceSkeletonCoverage::Complete
8427                && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8428                && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8429        });
8430    let nodes = assets
8431        .skeleton_nodes
8432        .iter()
8433        .map(|node| (node.node_index, node))
8434        .collect::<BTreeMap<_, _>>();
8435    let mut plans = BTreeMap::new();
8436    let mut selected_facet_count = 0usize;
8437    for selector in provenance.rule_inputs().runtime_node_selectors() {
8438        let Some(inventory) = inventory else {
8439            selected_facet_count = selected_facet_count.saturating_add(1);
8440            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8441                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8442            }
8443            continue;
8444        };
8445        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8446            node.name
8447                .as_deref()
8448                .is_some_and(|name| crate::config::glob_match(selector, name))
8449        });
8450        let Some(node) = matches.next() else {
8451            selected_facet_count = selected_facet_count.saturating_add(1);
8452            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8453                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8454            }
8455            continue;
8456        };
8457        if matches.next().is_some() {
8458            selected_facet_count = selected_facet_count.saturating_add(1);
8459            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8460                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8461            }
8462            continue;
8463        }
8464        let node_index = node.node_index as u64;
8465        let mut work = 0usize;
8466        let remaining_capacity =
8467            PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(selected_facet_count);
8468        let mut would_overflow = false;
8469        let mut plan = UnitScaleSelectedReachabilityPlan::Complete {
8470            node_index,
8471            scene_witnesses: BTreeMap::new(),
8472        };
8473        for scene in inventory.scenes().rows() {
8474            match unit_scale_selected_reachability(
8475                &nodes,
8476                node_index,
8477                scene.root_node_indices(),
8478                &mut work,
8479            ) {
8480                UnitScaleSelectedReachability::Reachable(root_ordinal, root_node_index) => {
8481                    let UnitScaleSelectedReachabilityPlan::Complete {
8482                        scene_witnesses, ..
8483                    } = &mut plan
8484                    else {
8485                        unreachable!("a reachable scene cannot follow a refusal");
8486                    };
8487                    if scene_witnesses.len() < remaining_capacity {
8488                        scene_witnesses
8489                            .insert(scene.source_scene_index(), (root_ordinal, root_node_index));
8490                    } else {
8491                        would_overflow = true;
8492                    }
8493                }
8494                UnitScaleSelectedReachability::Unreachable => {}
8495                UnitScaleSelectedReachability::Unavailable
8496                | UnitScaleSelectedReachability::WorkBudgetExceeded => {
8497                    plan = UnitScaleSelectedReachabilityPlan::Refused { node_index };
8498                    break;
8499                }
8500            }
8501        }
8502        if matches!(&plan, UnitScaleSelectedReachabilityPlan::Complete { .. }) && would_overflow {
8503            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8504        }
8505        let facets = match &plan {
8506            UnitScaleSelectedReachabilityPlan::Complete {
8507                scene_witnesses, ..
8508            } => scene_witnesses.len().max(1),
8509            UnitScaleSelectedReachabilityPlan::Refused { .. } => 1,
8510        };
8511        selected_facet_count = selected_facet_count.saturating_add(facets);
8512        if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8513            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8514        }
8515        plans.insert(selector.clone(), plan);
8516    }
8517    UnitScaleSelectedReachabilityPlans::Complete {
8518        plans,
8519        selected_facet_count,
8520    }
8521}
8522
8523fn unit_scale_selected_ancestry_reason(
8524    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8525    start: usize,
8526) -> Option<PredictionUnavailableReasonV2> {
8527    let mut current = start;
8528    let mut seen = BTreeSet::new();
8529    for _ in 0..128 {
8530        if !seen.insert(current) {
8531            return Some(
8532                PredictionUnavailableReasonV2::custom(
8533                    "animsmith:selected_node_ancestry_unavailable",
8534                )
8535                .expect("static reason is valid"),
8536            );
8537        }
8538        let Some(node) = nodes.get(&current) else {
8539            return Some(
8540                PredictionUnavailableReasonV2::custom(
8541                    "animsmith:selected_node_ancestry_unavailable",
8542                )
8543                .expect("static reason is valid"),
8544            );
8545        };
8546        match node.local_rest {
8547            SkeletonNodeLocalRestMeasurements::Matrix { .. } => {
8548                return Some(
8549                    PredictionUnavailableReasonV2::custom(
8550                        "animsmith:matrix_authored_selected_node_or_ancestry",
8551                    )
8552                    .expect("static reason is valid"),
8553                );
8554            }
8555            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {
8556                return Some(
8557                    PredictionUnavailableReasonV2::custom(
8558                        "animsmith:selected_node_ancestry_unavailable",
8559                    )
8560                    .expect("static reason is valid"),
8561                );
8562            }
8563            SkeletonNodeLocalRestMeasurements::Trs { .. } => {}
8564        }
8565        let parent = node.parent_node_index?;
8566        current = parent;
8567    }
8568    Some(
8569        PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
8570            .expect("static reason is valid"),
8571    )
8572}
8573
8574#[derive(Clone, Copy)]
8575struct CurrentUnitScaleMeshRow {
8576    source_scene_index: u64,
8577    source_root_ordinal: u64,
8578    root_node_index: u64,
8579    source_node_index: u64,
8580    source_mesh_index: u64,
8581    source_primitive_index: u64,
8582}
8583
8584enum CurrentUnitScaleMeshPlan {
8585    Detailed(Vec<CurrentUnitScaleMeshRow>),
8586    CompleteEmpty,
8587    Incomplete,
8588    JoinOverflow,
8589}
8590
8591fn unit_scale_join_budget_exceeded(work: &mut usize) -> bool {
8592    *work = work.saturating_add(1);
8593    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8594}
8595
8596fn unit_scale_reachable_root_indexed(
8597    start: u64,
8598    roots: &BTreeMap<u64, u64>,
8599    parents: &BTreeMap<u64, Option<u64>>,
8600    work: &mut usize,
8601) -> Result<Option<(u64, u64)>, ()> {
8602    let mut seen = BTreeSet::new();
8603    let mut current = Some(start);
8604    while let Some(index) = current {
8605        if unit_scale_join_budget_exceeded(work) {
8606            return Err(());
8607        }
8608        if let Some(ordinal) = roots.get(&index) {
8609            return Ok(Some((*ordinal, index)));
8610        }
8611        if !seen.insert(index) {
8612            return Ok(None);
8613        }
8614        current = match parents.get(&index) {
8615            Some(parent) => *parent,
8616            None => return Ok(None),
8617        };
8618    }
8619    Ok(None)
8620}
8621
8622fn current_unit_scale_mesh_plan(
8623    provenance: &PredictionProvenanceV4,
8624    measurements: &MeasurementContract,
8625) -> CurrentUnitScaleMeshPlan {
8626    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
8627        return CurrentUnitScaleMeshPlan::Incomplete;
8628    };
8629    if inventory.scenes().coverage() != RawSceneAttachmentCoverageV1::Complete
8630        || inventory.node_mesh_attachments().coverage() != RawSceneAttachmentCoverageV1::Complete
8631        || inventory.mesh_primitives().coverage() != RawSceneAttachmentCoverageV1::Complete
8632        || inventory.source_skeleton().coverage() != RawSceneAttachmentCoverageV1::Complete
8633        || measurements.assets().skeleton_source_coverage != SourceSkeletonCoverage::Complete
8634    {
8635        return CurrentUnitScaleMeshPlan::Incomplete;
8636    }
8637    let parents = measurements
8638        .assets()
8639        .skeleton_nodes
8640        .iter()
8641        .map(|node| {
8642            (
8643                node.node_index as u64,
8644                node.parent_node_index.map(|parent| parent as u64),
8645            )
8646        })
8647        .collect::<BTreeMap<_, _>>();
8648    let scene_roots = inventory
8649        .scenes()
8650        .rows()
8651        .iter()
8652        .map(|scene| {
8653            (
8654                scene.source_scene_index(),
8655                scene
8656                    .root_node_indices()
8657                    .iter()
8658                    .copied()
8659                    .enumerate()
8660                    .map(|(ordinal, node)| (node, ordinal as u64))
8661                    .collect::<BTreeMap<_, _>>(),
8662            )
8663        })
8664        .collect::<Vec<_>>();
8665    let primitives_by_mesh = inventory.mesh_primitives().rows().iter().fold(
8666        BTreeMap::<u64, Vec<u64>>::new(),
8667        |mut grouped, primitive| {
8668            grouped
8669                .entry(primitive.source_mesh_index())
8670                .or_default()
8671                .push(primitive.source_primitive_index());
8672            grouped
8673        },
8674    );
8675    let mut work = 0usize;
8676    let mut rows = Vec::new();
8677    for (source_scene_index, roots) in scene_roots {
8678        for attachment in inventory.node_mesh_attachments().rows() {
8679            if unit_scale_join_budget_exceeded(&mut work) {
8680                return CurrentUnitScaleMeshPlan::JoinOverflow;
8681            }
8682            let Ok(reachable) = unit_scale_reachable_root_indexed(
8683                attachment.source_node_index(),
8684                &roots,
8685                &parents,
8686                &mut work,
8687            ) else {
8688                return CurrentUnitScaleMeshPlan::JoinOverflow;
8689            };
8690            let Some((source_root_ordinal, root_node_index)) = reachable else {
8691                continue;
8692            };
8693            for &source_primitive_index in primitives_by_mesh
8694                .get(&attachment.source_mesh_index())
8695                .into_iter()
8696                .flatten()
8697            {
8698                if unit_scale_join_budget_exceeded(&mut work) {
8699                    return CurrentUnitScaleMeshPlan::JoinOverflow;
8700                }
8701                rows.push(CurrentUnitScaleMeshRow {
8702                    source_scene_index,
8703                    source_root_ordinal,
8704                    root_node_index,
8705                    source_node_index: attachment.source_node_index(),
8706                    source_mesh_index: attachment.source_mesh_index(),
8707                    source_primitive_index,
8708                });
8709            }
8710        }
8711    }
8712    if rows.is_empty() {
8713        CurrentUnitScaleMeshPlan::CompleteEmpty
8714    } else {
8715        CurrentUnitScaleMeshPlan::Detailed(rows)
8716    }
8717}
8718
8719fn current_unit_scale_selected_facet_count(
8720    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8721) -> usize {
8722    reachability_plans.selected_facet_count()
8723}
8724
8725fn expected_current_engine_unit_scale_facets(
8726    provenance: &PredictionProvenanceV4,
8727    measurements: &MeasurementContract,
8728    mesh_plan: &CurrentUnitScaleMeshPlan,
8729    candidate_capacity: usize,
8730    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8731) -> Option<Vec<ExpectedUnitScaleFacet>> {
8732    let dependency_complete = matches!(
8733        provenance.dependency_closure().coverage(),
8734        DependencyClosureCoverageV1::Complete
8735    );
8736    let available = |scope, result| ExpectedUnitScaleFacet {
8737        scope,
8738        result: Some(result),
8739        reasons: vec![],
8740    };
8741    let unavailable = |scope, reasons| ExpectedUnitScaleFacet {
8742        scope,
8743        result: None,
8744        reasons,
8745    };
8746    let mut expected = Vec::with_capacity(candidate_capacity);
8747    if expected.len() < candidate_capacity {
8748        expected.push(if dependency_complete {
8749            available(
8750                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8751                EngineMachineResultV1::UnitMapping(
8752                    UnitMappingResultV1::gltf_to_engine_world_length_unit(),
8753                ),
8754            )
8755        } else {
8756            unavailable(
8757                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8758                vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8759            )
8760        });
8761    }
8762
8763    let inventory = provenance.raw_scene_attachment().inventory();
8764    match inventory
8765        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
8766    {
8767        Some(inventory) => {
8768            for scene in inventory.scenes().rows() {
8769                if expected.len() >= candidate_capacity {
8770                    break;
8771                }
8772                let scope = unit_scale_scope(
8773                    ENGINE_UNIT_SCALE_SCENE_SCOPE,
8774                    Some(format!("source_scene:{}", scene.source_scene_index())),
8775                );
8776                expected.push(if dependency_complete {
8777                    available(
8778                        scope,
8779                        EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8780                            subject_kind: TransformScaleSubjectKindV1::LoaderSceneEntity,
8781                            creation: ImporterSubjectCreationV1::Created,
8782                            domain: TransformScaleDomainV1::Local,
8783                            classification: Some(LinearTransformClassification::UnitOrthonormal),
8784                        }),
8785                    )
8786                } else {
8787                    unavailable(
8788                        scope,
8789                        vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8790                    )
8791                });
8792            }
8793        }
8794        None if expected.len() < candidate_capacity => expected.push(unavailable(
8795            unit_scale_scope(ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE, None),
8796            unit_scale_unavailable_reasons(
8797                PredictionUnavailableReasonV2::RawSourceIncomplete,
8798                dependency_complete,
8799            ),
8800        )),
8801        None => {}
8802    }
8803
8804    let assets = measurements.assets();
8805    let nodes = assets
8806        .skeleton_nodes
8807        .iter()
8808        .map(|node| (node.node_index, node))
8809        .collect::<BTreeMap<_, _>>();
8810    if expected.len() < candidate_capacity {
8811        match mesh_plan {
8812            CurrentUnitScaleMeshPlan::Incomplete => expected.push(unavailable(
8813                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8814                unit_scale_unavailable_reasons(
8815                    PredictionUnavailableReasonV2::RawSourceIncomplete,
8816                    dependency_complete,
8817                ),
8818            )),
8819            CurrentUnitScaleMeshPlan::JoinOverflow => expected.push(unavailable(
8820                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8821                vec![
8822                    PredictionUnavailableReasonV2::custom(
8823                        "animsmith:mesh_join_work_budget_exceeded",
8824                    )
8825                    .expect("static reason is valid"),
8826                ],
8827            )),
8828            CurrentUnitScaleMeshPlan::CompleteEmpty => expected.push(if dependency_complete {
8829                available(
8830                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8831                    EngineMachineResultV1::InventoryCoverage(InventoryCoverageResultV1 {
8832                        domain: PredictionInventoryDomainV1::LoaderMeshPrimitiveSubjects,
8833                        coverage: PredictionInventoryCoverageStateV1::Complete,
8834                        retained_rows: 0,
8835                    }),
8836                )
8837            } else {
8838                unavailable(
8839                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
8840                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8841                )
8842            }),
8843            CurrentUnitScaleMeshPlan::Detailed(rows) => {
8844                let load_meshes = matches!(
8845                    provenance
8846                        .settings()
8847                        .document_setting(EngineSettingIdV2::LoadMeshes)
8848                        .map(|setting| setting.value()),
8849                    Some(EngineSettingValueV2::Token(value)) if value == "nonempty"
8850                );
8851                for row in rows {
8852                    if expected.len() >= candidate_capacity {
8853                        break;
8854                    }
8855                    let scope = unit_scale_scope(
8856                        ENGINE_UNIT_SCALE_MESH_SCOPE,
8857                        Some(format!(
8858                            "source_scene:{}:source_node:{}:source_mesh:{}:source_primitive:{}",
8859                            row.source_scene_index,
8860                            row.source_node_index,
8861                            row.source_mesh_index,
8862                            row.source_primitive_index,
8863                        )),
8864                    );
8865                    expected.push(if dependency_complete {
8866                        available(
8867                            scope,
8868                            EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8869                                subject_kind:
8870                                    TransformScaleSubjectKindV1::LoaderMeshPrimitiveEntity,
8871                                creation: if load_meshes {
8872                                    ImporterSubjectCreationV1::Created
8873                                } else {
8874                                    ImporterSubjectCreationV1::SuppressedBySetting
8875                                },
8876                                domain: TransformScaleDomainV1::Local,
8877                                classification: load_meshes
8878                                    .then_some(LinearTransformClassification::UnitOrthonormal),
8879                            }),
8880                        )
8881                    } else {
8882                        unavailable(
8883                            scope,
8884                            vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8885                        )
8886                    });
8887                }
8888            }
8889        }
8890    }
8891
8892    for selector in provenance.rule_inputs().runtime_node_selectors() {
8893        if expected.len() >= candidate_capacity {
8894            break;
8895        }
8896        if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete
8897            || !inventory.is_some_and(|inventory| {
8898                inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8899                    && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8900            })
8901        {
8902            expected.push(unavailable(
8903                unit_scale_scope(
8904                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
8905                    Some(format!("selector:{selector}")),
8906                ),
8907                unit_scale_unavailable_reasons(
8908                    PredictionUnavailableReasonV2::RawSourceIncomplete,
8909                    dependency_complete,
8910                ),
8911            ));
8912            continue;
8913        }
8914        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8915            node.name
8916                .as_deref()
8917                .is_some_and(|name| crate::config::glob_match(selector, name))
8918        });
8919        let first = matches.next();
8920        let second = matches.next();
8921        if first.is_none() || second.is_some() {
8922            expected.push(unavailable(
8923                unit_scale_scope(
8924                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
8925                    Some(format!("selector:{selector}")),
8926                ),
8927                unit_scale_unavailable_reasons(
8928                    if first.is_none() {
8929                        PredictionUnavailableReasonV2::SourceSelectorNoMatch
8930                    } else {
8931                        PredictionUnavailableReasonV2::SourceSelectorAmbiguous
8932                    },
8933                    dependency_complete,
8934                ),
8935            ));
8936            continue;
8937        }
8938        let node = first.expect("one selected source node was established");
8939        let inventory = inventory.expect("selected inventory was proven complete");
8940        let mut reachable_scenes = Vec::new();
8941        let plan = reachability_plans.get(selector)?;
8942        let reachability_unavailable =
8943            match plan {
8944                UnitScaleSelectedReachabilityPlan::Refused {
8945                    node_index: cached_node_index,
8946                } if *cached_node_index == node.node_index as u64 => true,
8947                UnitScaleSelectedReachabilityPlan::Complete {
8948                    node_index: cached_node_index,
8949                    scene_witnesses,
8950                } if *cached_node_index == node.node_index as u64 => {
8951                    reachable_scenes.extend(
8952                        inventory.scenes().rows().iter().filter(|scene| {
8953                            scene_witnesses.contains_key(&scene.source_scene_index())
8954                        }),
8955                    );
8956                    false
8957                }
8958                UnitScaleSelectedReachabilityPlan::Refused { .. }
8959                | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
8960            };
8961        if reachability_unavailable {
8962            expected.push(unavailable(
8963                unit_scale_scope(
8964                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
8965                    Some(format!("selector:{selector}")),
8966                ),
8967                unit_scale_unavailable_reasons(
8968                    PredictionUnavailableReasonV2::custom(
8969                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
8970                    )
8971                    .expect("static reason is valid"),
8972                    dependency_complete,
8973                ),
8974            ));
8975            continue;
8976        }
8977        let reason = unit_scale_selected_ancestry_reason(&nodes, node.node_index).or_else(|| {
8978            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
8979                || node.rest_world_matrix.is_none())
8980            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
8981        });
8982        let mut reachable_found = false;
8983        for scene in reachable_scenes {
8984            reachable_found = true;
8985            if expected.len() >= candidate_capacity {
8986                break;
8987            }
8988            let scope = unit_scale_scope(
8989                ENGINE_UNIT_SCALE_SELECTED_SCOPE,
8990                Some(format!(
8991                    "selector:{selector}:source_scene:{}:source_node:{}",
8992                    scene.source_scene_index(),
8993                    node.node_index,
8994                )),
8995            );
8996            expected.push(if let Some(reason) = reason.clone() {
8997                unavailable(
8998                    scope,
8999                    unit_scale_unavailable_reasons(reason, dependency_complete),
9000                )
9001            } else if dependency_complete {
9002                available(
9003                    scope,
9004                    EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9005                        subject_kind: TransformScaleSubjectKindV1::SelectedSourceNode,
9006                        creation: ImporterSubjectCreationV1::Created,
9007                        domain: TransformScaleDomainV1::LoaderRootToSubject,
9008                        classification: Some(node.rest_world_linear.classification),
9009                    }),
9010                )
9011            } else {
9012                unavailable(
9013                    scope,
9014                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9015                )
9016            });
9017        }
9018        if !reachable_found && expected.len() < candidate_capacity {
9019            expected.push(unavailable(
9020                unit_scale_scope(
9021                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9022                    Some(format!("selector:{selector}")),
9023                ),
9024                unit_scale_unavailable_reasons(
9025                    PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9026                        .expect("static reason is valid"),
9027                    dependency_complete,
9028                ),
9029            ));
9030        }
9031    }
9032    Some(expected)
9033}
9034
9035fn unit_scale_exact_raw_row_references(
9036    basis: &EnginePredictionBasisV4,
9037    expected: &[RawSceneAttachmentBasisReferenceV1],
9038) -> bool {
9039    let actual = basis
9040        .references()
9041        .iter()
9042        .filter_map(|reference| match reference {
9043            PredictionBasisReferenceV4::RawSceneAttachment(reference)
9044                if !matches!(
9045                    reference,
9046                    RawSceneAttachmentBasisReferenceV1::Coverage { .. }
9047                ) =>
9048            {
9049                Some(reference)
9050            }
9051            _ => None,
9052        })
9053        .collect::<Vec<_>>();
9054    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9055}
9056
9057fn unit_scale_mesh_scope_keys(subject: &str) -> Option<(u64, u64, u64, u64)> {
9058    let values = subject
9059        .strip_prefix("source_scene:")?
9060        .split(':')
9061        .collect::<Vec<_>>();
9062    if values.len() != 7
9063        || values[1] != "source_node"
9064        || values[3] != "source_mesh"
9065        || values[5] != "source_primitive"
9066    {
9067        return None;
9068    }
9069    Some((
9070        values[0].parse().ok()?,
9071        values[2].parse().ok()?,
9072        values[4].parse().ok()?,
9073        values[6].parse().ok()?,
9074    ))
9075}
9076
9077fn unit_scale_expected_mesh_raw_rows(
9078    subject: &str,
9079    mesh_plan: &CurrentUnitScaleMeshPlan,
9080) -> Option<Vec<RawSceneAttachmentBasisReferenceV1>> {
9081    let (source_scene_index, source_node_index, source_mesh_index, source_primitive_index) =
9082        unit_scale_mesh_scope_keys(subject)?;
9083    let CurrentUnitScaleMeshPlan::Detailed(rows) = mesh_plan else {
9084        return None;
9085    };
9086    let row = rows.iter().find(|row| {
9087        row.source_scene_index == source_scene_index
9088            && row.source_node_index == source_node_index
9089            && row.source_mesh_index == source_mesh_index
9090            && row.source_primitive_index == source_primitive_index
9091    })?;
9092    Some(vec![
9093        RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9094        RawSceneAttachmentBasisReferenceV1::SceneRoot {
9095            source_scene_index,
9096            source_root_ordinal: row.source_root_ordinal,
9097            source_node_index: row.root_node_index,
9098        },
9099        RawSceneAttachmentBasisReferenceV1::NodeMeshAttachmentRow {
9100            source_node_index,
9101            source_mesh_index,
9102        },
9103        RawSceneAttachmentBasisReferenceV1::MeshPrimitiveRow {
9104            source_mesh_index,
9105            source_primitive_index,
9106        },
9107    ])
9108}
9109
9110fn unit_scale_selected_scope_keys<'a>(
9111    subject: &str,
9112    selectors: &'a [String],
9113) -> Option<(&'a str, Option<(u64, u64)>)> {
9114    for selector in selectors {
9115        let prefix = format!("selector:{selector}");
9116        if subject == prefix {
9117            return Some((selector, None));
9118        }
9119        let Some(values) = subject
9120            .strip_prefix(&prefix)
9121            .and_then(|suffix| suffix.strip_prefix(":source_scene:"))
9122        else {
9123            continue;
9124        };
9125        let values = values.split(':').collect::<Vec<_>>();
9126        if values.len() == 3 && values[1] == "source_node" {
9127            return Some((
9128                selector,
9129                Some((values[0].parse().ok()?, values[2].parse().ok()?)),
9130            ));
9131        }
9132    }
9133    None
9134}
9135
9136fn unit_scale_classification_name(value: LinearTransformClassification) -> &'static str {
9137    match value {
9138        LinearTransformClassification::UnitOrthonormal => "unit_orthonormal",
9139        LinearTransformClassification::UniformScaled => "uniform_scaled",
9140        LinearTransformClassification::NonUniform => "non_uniform",
9141        LinearTransformClassification::Sheared => "sheared",
9142        LinearTransformClassification::Reflected => "reflected",
9143        LinearTransformClassification::Singular => "singular",
9144        LinearTransformClassification::NonFinite => "non_finite",
9145    }
9146}
9147
9148fn unit_scale_raw_source_node_reference(
9149    source_index: u64,
9150    field: &str,
9151    value: PredictionScalarV1,
9152) -> Option<RawSourceBasisReferenceV1> {
9153    RawSourceBasisReferenceV1::from_wire(
9154        RawSourceDomainV1::SourceNode,
9155        RawSourceKeyV1::SourceSkeleton {
9156            row_kind: SourceSkeletonRowKindV1::SourceNode,
9157            source_index,
9158        },
9159        RawSourceFieldIdV1::new(field).ok()?,
9160        value,
9161    )
9162    .ok()
9163}
9164
9165fn unit_scale_exact_raw_source_references(
9166    basis: &EnginePredictionBasisV4,
9167    expected: &[RawSourceBasisReferenceV1],
9168) -> bool {
9169    let actual = basis
9170        .references()
9171        .iter()
9172        .filter_map(|reference| match reference {
9173            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9174                PredictionBasisReferenceV1::RawSource { reference },
9175            )) => Some(reference),
9176            _ => None,
9177        })
9178        .collect::<Vec<_>>();
9179    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9180}
9181
9182fn unit_scale_selected_authored_kind(
9183    basis: &EnginePredictionBasisV4,
9184    source_index: u64,
9185) -> Option<&str> {
9186    let mut values = basis.references().iter().filter_map(|reference| {
9187        let PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9188            PredictionBasisReferenceV1::RawSource { reference },
9189        )) = reference
9190        else {
9191            return None;
9192        };
9193        if reference.domain() != RawSourceDomainV1::SourceNode
9194            || reference.key()
9195                != &(RawSourceKeyV1::SourceSkeleton {
9196                    row_kind: SourceSkeletonRowKindV1::SourceNode,
9197                    source_index,
9198                })
9199            || reference.field().as_str() != "local_rest.kind"
9200        {
9201            return None;
9202        }
9203        match reference.value() {
9204            PredictionScalarV1::Token { value } if matches!(value.as_str(), "trs" | "matrix") => {
9205                Some(value.as_str())
9206            }
9207            _ => None,
9208        }
9209    });
9210    let value = values.next()?;
9211    values.next().is_none().then_some(value)
9212}
9213
9214fn unit_scale_selected_expected_reasons(
9215    primary: Option<PredictionUnavailableReasonV2>,
9216    dependency_complete: bool,
9217) -> Vec<PredictionUnavailableReasonV2> {
9218    match primary {
9219        Some(reason) => unit_scale_unavailable_reasons(reason, dependency_complete),
9220        None if dependency_complete => Vec::new(),
9221        None => vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9222    }
9223}
9224
9225struct CurrentUnitScaleSelectedEvidence {
9226    raw_source_references: Vec<RawSourceBasisReferenceV1>,
9227    reasons: Vec<PredictionUnavailableReasonV2>,
9228}
9229
9230fn unit_scale_expected_selected_raw_source_references(
9231    selector: &str,
9232    keys: Option<(u64, u64)>,
9233    basis: &EnginePredictionBasisV4,
9234    provenance: &PredictionProvenanceV4,
9235    measurements: &MeasurementContract,
9236    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9237) -> Option<CurrentUnitScaleSelectedEvidence> {
9238    let assets = measurements.assets();
9239    let dependency_complete = matches!(
9240        provenance.dependency_closure().coverage(),
9241        DependencyClosureCoverageV1::Complete
9242    );
9243    let inventory = provenance.raw_scene_attachment().inventory();
9244    let inventory_complete = inventory.is_some_and(|inventory| {
9245        inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9246            && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9247    });
9248    if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete || !inventory_complete {
9249        return Some(CurrentUnitScaleSelectedEvidence {
9250            raw_source_references: Vec::new(),
9251            reasons: unit_scale_selected_expected_reasons(
9252                Some(PredictionUnavailableReasonV2::RawSourceIncomplete),
9253                dependency_complete,
9254            ),
9255        });
9256    }
9257    let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9258        node.name
9259            .as_deref()
9260            .is_some_and(|name| crate::config::glob_match(selector, name))
9261    });
9262    let first = matches.next();
9263    let second = matches.next();
9264    let name_reference = |node: &crate::measure::SkeletonNodeMeasurements| {
9265        unit_scale_raw_source_node_reference(
9266            node.node_index as u64,
9267            "name",
9268            node.name.as_ref().map_or(PredictionScalarV1::Null, |name| {
9269                PredictionScalarV1::text(name).expect("retained measurement text is bounded")
9270            }),
9271        )
9272    };
9273    if first.is_none() {
9274        return keys.is_none().then(|| CurrentUnitScaleSelectedEvidence {
9275            raw_source_references: Vec::new(),
9276            reasons: unit_scale_selected_expected_reasons(
9277                Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch),
9278                dependency_complete,
9279            ),
9280        });
9281    }
9282    if let Some(second) = second {
9283        if keys.is_some() {
9284            return None;
9285        }
9286        return Some(CurrentUnitScaleSelectedEvidence {
9287            raw_source_references: vec![name_reference(first?)?, name_reference(second)?],
9288            reasons: unit_scale_selected_expected_reasons(
9289                Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous),
9290                dependency_complete,
9291            ),
9292        });
9293    }
9294    let node = first?;
9295    if keys.is_some_and(|(_, source_node_index)| source_node_index != node.node_index as u64) {
9296        return None;
9297    }
9298    let mut expected = vec![name_reference(node)?];
9299    let nodes = assets
9300        .skeleton_nodes
9301        .iter()
9302        .map(|node| (node.node_index, node))
9303        .collect::<BTreeMap<_, _>>();
9304    let plan = reachability_plans.get(selector)?;
9305    let reachability_unavailable = match plan {
9306        UnitScaleSelectedReachabilityPlan::Refused {
9307            node_index: cached_node_index,
9308        } if *cached_node_index == node.node_index as u64 => true,
9309        UnitScaleSelectedReachabilityPlan::Complete {
9310            node_index: cached_node_index,
9311            ..
9312        } if *cached_node_index == node.node_index as u64 => false,
9313        UnitScaleSelectedReachabilityPlan::Refused { .. }
9314        | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9315    };
9316    if reachability_unavailable {
9317        if keys.is_some() {
9318            return None;
9319        }
9320        return Some(CurrentUnitScaleSelectedEvidence {
9321            raw_source_references: expected,
9322            reasons: unit_scale_selected_expected_reasons(
9323                Some(
9324                    PredictionUnavailableReasonV2::custom(
9325                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9326                    )
9327                    .expect("static reason is valid"),
9328                ),
9329                dependency_complete,
9330            ),
9331        });
9332    }
9333    let mut current = node.node_index;
9334    let mut seen = BTreeSet::new();
9335    let mut ancestry_reason = None;
9336    let mut ancestry_complete = false;
9337    for _ in 0..128 {
9338        if !seen.insert(current) {
9339            ancestry_reason = Some(
9340                PredictionUnavailableReasonV2::custom(
9341                    "animsmith:selected_node_ancestry_unavailable",
9342                )
9343                .expect("static reason is valid"),
9344            );
9345            break;
9346        }
9347        let Some(ancestry_node) = nodes.get(&current).copied() else {
9348            ancestry_reason = Some(
9349                PredictionUnavailableReasonV2::custom(
9350                    "animsmith:selected_node_ancestry_unavailable",
9351                )
9352                .expect("static reason is valid"),
9353            );
9354            break;
9355        };
9356        // The normalized measurement deliberately erases whether a non-finite
9357        // local rest was authored as TRS or as a matrix. The exact same-load
9358        // raw-source scalar in the basis retains that distinction per row.
9359        let retained_kind = unit_scale_selected_authored_kind(basis, current as u64)?;
9360        let local_kind = match ancestry_node.local_rest {
9361            SkeletonNodeLocalRestMeasurements::Trs { .. } if retained_kind == "trs" => "trs",
9362            SkeletonNodeLocalRestMeasurements::Matrix { .. } if retained_kind == "matrix" => {
9363                "matrix"
9364            }
9365            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => retained_kind,
9366            _ => return None,
9367        };
9368        expected.push(unit_scale_raw_source_node_reference(
9369            current as u64,
9370            "local_rest.kind",
9371            PredictionScalarV1::token(local_kind).ok()?,
9372        )?);
9373        expected.push(unit_scale_raw_source_node_reference(
9374            current as u64,
9375            "parent_source_node_index",
9376            ancestry_node
9377                .parent_node_index
9378                .map_or(PredictionScalarV1::Null, |parent| {
9379                    PredictionScalarV1::UnsignedInteger {
9380                        value: parent as u64,
9381                    }
9382                }),
9383        )?);
9384        if local_kind == "matrix" {
9385            ancestry_reason = Some(
9386                PredictionUnavailableReasonV2::custom(
9387                    "animsmith:matrix_authored_selected_node_or_ancestry",
9388                )
9389                .expect("static reason is valid"),
9390            );
9391            break;
9392        }
9393        let Some(parent) = ancestry_node.parent_node_index else {
9394            ancestry_complete = true;
9395            break;
9396        };
9397        current = parent;
9398    }
9399    if ancestry_reason.is_none() && !ancestry_complete {
9400        ancestry_reason = Some(
9401            PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
9402                .expect("static reason is valid"),
9403        );
9404    }
9405    let primary_reason = if keys.is_none() {
9406        Some(
9407            PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9408                .expect("static reason is valid"),
9409        )
9410    } else {
9411        ancestry_reason.or_else(|| {
9412            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9413                || node.rest_world_matrix.is_none())
9414            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9415        })
9416    };
9417    Some(CurrentUnitScaleSelectedEvidence {
9418        raw_source_references: expected,
9419        reasons: unit_scale_selected_expected_reasons(primary_reason, dependency_complete),
9420    })
9421}
9422
9423fn unit_scale_exact_selected_evidence(
9424    selector: &str,
9425    keys: Option<(u64, u64)>,
9426    reasons: &[PredictionUnavailableReasonV2],
9427    basis: &EnginePredictionBasisV4,
9428    provenance: &PredictionProvenanceV4,
9429    measurements: &MeasurementContract,
9430    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9431) -> bool {
9432    let Some(expected) = unit_scale_expected_selected_raw_source_references(
9433        selector,
9434        keys,
9435        basis,
9436        provenance,
9437        measurements,
9438        reachability_plans,
9439    ) else {
9440        return false;
9441    };
9442    if reasons != expected.reasons
9443        || !unit_scale_exact_raw_source_references(basis, &expected.raw_source_references)
9444    {
9445        return false;
9446    }
9447    let measurement_references = basis
9448        .references()
9449        .iter()
9450        .filter_map(|reference| match reference {
9451            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9452                PredictionBasisReferenceV1::Measurement {
9453                    schema,
9454                    pointer,
9455                    value,
9456                },
9457            )) => Some((*schema, pointer.as_str(), value)),
9458            _ => None,
9459        })
9460        .collect::<Vec<_>>();
9461    let Some((source_scene_index, source_node_index)) = keys else {
9462        return unit_scale_exact_raw_row_references(basis, &[])
9463            && measurement_references.is_empty();
9464    };
9465    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
9466        return false;
9467    };
9468    let Some(_scene) = inventory
9469        .scenes()
9470        .rows()
9471        .iter()
9472        .find(|scene| scene.source_scene_index() == source_scene_index)
9473    else {
9474        return false;
9475    };
9476    let witness = reachability_plans
9477        .get(selector)
9478        .filter(|plan| plan.node_index() == source_node_index)
9479        .and_then(|plan| match plan {
9480            UnitScaleSelectedReachabilityPlan::Complete {
9481                scene_witnesses, ..
9482            } => scene_witnesses.get(&source_scene_index).copied(),
9483            UnitScaleSelectedReachabilityPlan::Refused { .. } => None,
9484        });
9485    let Some((source_root_ordinal, source_node_index_at_root)) = witness else {
9486        return false;
9487    };
9488    if !unit_scale_exact_raw_row_references(
9489        basis,
9490        &[
9491            RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9492            RawSceneAttachmentBasisReferenceV1::SceneRoot {
9493                source_scene_index,
9494                source_root_ordinal,
9495                source_node_index: source_node_index_at_root,
9496            },
9497        ],
9498    ) {
9499        return false;
9500    }
9501    let Some((ordinal, node)) = measurements
9502        .assets()
9503        .skeleton_nodes
9504        .iter()
9505        .enumerate()
9506        .find(|(_, node)| node.node_index as u64 == source_node_index)
9507    else {
9508        return measurement_references.is_empty();
9509    };
9510    let pointer =
9511        format!("/measurements/skeleton_nodes/{ordinal}/rest_world_linear/classification");
9512    matches!(
9513        measurement_references.as_slice(),
9514        [(
9515            MEASUREMENTS_SCHEMA_ID,
9516            actual_pointer,
9517            PredictionScalarV1::Token { value },
9518        )] if *actual_pointer == pointer
9519            && value == unit_scale_classification_name(node.rest_world_linear.classification)
9520    )
9521}
9522
9523fn validate_current_engine_unit_scale_basis(
9524    scope: &EvaluationScope,
9525    basis: &EnginePredictionBasisV4,
9526    reasons: &[PredictionUnavailableReasonV2],
9527    provenance: &PredictionProvenanceV4,
9528    measurements: &MeasurementContract,
9529    mesh_plan: &CurrentUnitScaleMeshPlan,
9530    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9531) -> bool {
9532    let v1 = |predicate: &dyn Fn(&PredictionBasisReferenceV1) -> bool| {
9533        basis.references().iter().any(|reference| {
9534            matches!(
9535                reference,
9536                PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(reference))
9537                    if predicate(reference)
9538            )
9539        })
9540    };
9541    let fact = |expected: &str| {
9542        v1(
9543            &|reference| matches!(reference, PredictionBasisReferenceV1::ProfileFact { fact_id } if fact_id == expected),
9544        )
9545    };
9546    let setting = |expected: &str| {
9547        v1(
9548            &|reference| matches!(reference, PredictionBasisReferenceV1::ResolvedSetting { setting_id, .. } if setting_id == expected),
9549        )
9550    };
9551    let source = |expected: &str| {
9552        v1(
9553            &|reference| matches!(reference, PredictionBasisReferenceV1::PrimarySource { source_id } if source_id == expected),
9554        )
9555    };
9556    let raw_coverage = |expected: RawSceneAttachmentBasisDomainV1| {
9557        basis.references().iter().any(|reference| {
9558            matches!(
9559                reference,
9560                PredictionBasisReferenceV4::RawSceneAttachment(
9561                    RawSceneAttachmentBasisReferenceV1::Coverage { domain }
9562                ) if *domain == expected
9563            )
9564        })
9565    };
9566    let common_transform = source("bevy-gltf-loader-0.19.0-c6f634ca")
9567        && source("bevy-gltf-coordinate-conversion-0.19.0-c6f634ca")
9568        && fact("resulting_transform_scale")
9569        && setting("extension_handler_environment");
9570    match scope.code.as_str() {
9571        ENGINE_UNIT_SCALE_FILE_SCOPE => {
9572            unit_scale_exact_raw_row_references(basis, &[])
9573                && [
9574                    "application_world_unit_policy",
9575                    "importer_scale_conversion",
9576                    "physical_dimensions_preserved",
9577                    "source_to_target_unit_mapping",
9578                    "target_linear_unit",
9579                ]
9580                .into_iter()
9581                .all(fact)
9582                && [
9583                    "bevy-gltf-loader-0.19.0-c6f634ca",
9584                    "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
9585                    "khronos-gltf-2.0-coordinate-units",
9586                ]
9587                .into_iter()
9588                .all(source)
9589        }
9590        ENGINE_UNIT_SCALE_SCENE_SCOPE | ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE => {
9591            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE {
9592                Some(Vec::new())
9593            } else {
9594                scope
9595                    .subject
9596                    .as_deref()
9597                    .and_then(|subject| subject.strip_prefix("source_scene:"))
9598                    .and_then(|index| index.parse::<u64>().ok())
9599                    .filter(|index| {
9600                        scope.subject.as_deref() == Some(&format!("source_scene:{index}"))
9601                    })
9602                    .map(|source_scene_index| {
9603                        vec![RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index }]
9604                    })
9605            };
9606            expected_rows.as_deref().is_some_and(|expected_rows| {
9607                unit_scale_exact_raw_row_references(basis, expected_rows)
9608            }) && common_transform
9609                && setting("rotate_scene_entity")
9610                && (provenance.raw_scene_attachment().inventory().is_none()
9611                    || raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes))
9612        }
9613        ENGINE_UNIT_SCALE_MESH_SCOPE | ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE => {
9614            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE {
9615                Some(Vec::new())
9616            } else {
9617                scope
9618                    .subject
9619                    .as_deref()
9620                    .and_then(|subject| unit_scale_expected_mesh_raw_rows(subject, mesh_plan))
9621            };
9622            expected_rows.as_deref().is_some_and(|expected_rows| {
9623                unit_scale_exact_raw_row_references(basis, expected_rows)
9624            }) && common_transform
9625                && source("bevy-render-asset-usages-0.19.0-c6f634ca")
9626                && setting("load_meshes")
9627                && setting("rotate_meshes")
9628                && (provenance.raw_scene_attachment().inventory().is_none()
9629                    || [
9630                        RawSceneAttachmentBasisDomainV1::SourceSkeleton,
9631                        RawSceneAttachmentBasisDomainV1::Scenes,
9632                        RawSceneAttachmentBasisDomainV1::NodeMeshAttachments,
9633                        RawSceneAttachmentBasisDomainV1::MeshPrimitives,
9634                    ]
9635                    .into_iter()
9636                    .all(raw_coverage))
9637        }
9638        ENGINE_UNIT_SCALE_SELECTED_SCOPE => {
9639            let selected = scope.subject.as_deref().and_then(|subject| {
9640                unit_scale_selected_scope_keys(
9641                    subject,
9642                    provenance.rule_inputs().runtime_node_selectors(),
9643                )
9644            });
9645            let selector = selected.map(|(selector, _)| selector);
9646            common_transform
9647                && setting("rotate_scene_entity")
9648                && selected.is_some_and(|(selector, keys)| {
9649                    unit_scale_exact_selected_evidence(
9650                        selector,
9651                        keys,
9652                        reasons,
9653                        basis,
9654                        provenance,
9655                        measurements,
9656                        reachability_plans,
9657                    )
9658                })
9659                && selector.is_some_and(|selector| {
9660                    v1(&|reference| {
9661                        matches!(
9662                            reference,
9663                            PredictionBasisReferenceV1::ProjectField {
9664                                field_id,
9665                                value: PredictionScalarV1::Text { value }
9666                            } if field_id == "runtime_nodes.selector" && value == selector
9667                        )
9668                    })
9669                })
9670                && (provenance.raw_scene_attachment().inventory().is_none()
9671                    || (raw_coverage(RawSceneAttachmentBasisDomainV1::SourceSkeleton)
9672                        && raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes)))
9673        }
9674        ENGINE_UNIT_SCALE_BUDGET_SCOPE => {
9675            unit_scale_exact_raw_row_references(basis, &[])
9676                && source("bevy-gltf-loader-0.19.0-c6f634ca")
9677        }
9678        _ => false,
9679    }
9680}
9681
9682fn validate_current_engine_unit_scale_prediction_v4(
9683    check_id: &str,
9684    selection: SelectionState,
9685    configuration: ConfigurationState,
9686    applicability: Applicability,
9687    prediction: Option<&EnginePredictionV4>,
9688    provenance: Option<&PredictionProvenanceV4>,
9689    measurements: &MeasurementContract,
9690) -> Result<(), PredictionContractError> {
9691    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9692        return Ok(());
9693    }
9694    let exact_profile = provenance.is_some_and(|provenance| {
9695        let selection = provenance.profile().selection();
9696        selection.family() == "bevy"
9697            && selection.profile_revision() == 2
9698            && selection.engine_version() == "0.19.0"
9699            && selection.importer() == "gltf-asset-loader"
9700            && provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9701            && provenance.profile().facts_identity().sha256()
9702                == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9703            && provenance.profile().facts_identity().bytes() == 4_783
9704            && matches!(
9705                provenance.source_format(),
9706                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9707            )
9708    });
9709    validate_current_engine_unit_scale_prediction_common(
9710        check_id,
9711        selection,
9712        configuration,
9713        applicability,
9714        prediction,
9715        provenance,
9716        measurements,
9717        exact_profile,
9718    )
9719}
9720
9721fn validate_current_engine_unit_scale_prediction_v5(
9722    check_id: &str,
9723    selection: SelectionState,
9724    configuration: ConfigurationState,
9725    applicability: Applicability,
9726    prediction: Option<&EnginePredictionV5>,
9727    provenance: Option<&PredictionProvenanceV5>,
9728    measurements: &MeasurementContract,
9729) -> Result<(), PredictionContractError> {
9730    let base = provenance.map(PredictionProvenanceV5::base);
9731    let exact_profile = base.is_some_and(|provenance| {
9732        let selection = provenance.profile().selection();
9733        let exact_identity = match selection.profile_revision() {
9734            2 => {
9735                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9736                    && provenance.profile().facts_identity().sha256()
9737                        == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9738                    && provenance.profile().facts_identity().bytes() == 4_783
9739            }
9740            3 => {
9741                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
9742                    && provenance.profile().facts_identity().sha256()
9743                        == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
9744                    && provenance.profile().facts_identity().bytes() == 4_849
9745            }
9746            _ => false,
9747        };
9748        selection.family() == "bevy"
9749            && selection.engine_version() == "0.19.0"
9750            && selection.importer() == "gltf-asset-loader"
9751            && exact_identity
9752            && matches!(
9753                provenance.source_format(),
9754                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9755            )
9756    });
9757    validate_current_engine_unit_scale_prediction_common(
9758        check_id,
9759        selection,
9760        configuration,
9761        applicability,
9762        prediction.map(EnginePredictionV5::base_prediction),
9763        base,
9764        measurements,
9765        exact_profile,
9766    )
9767}
9768
9769#[allow(clippy::too_many_arguments)]
9770fn validate_current_engine_unit_scale_prediction_common(
9771    check_id: &str,
9772    selection: SelectionState,
9773    configuration: ConfigurationState,
9774    applicability: Applicability,
9775    prediction: Option<&EnginePredictionV4>,
9776    provenance: Option<&PredictionProvenanceV4>,
9777    measurements: &MeasurementContract,
9778    exact_profile: bool,
9779) -> Result<(), PredictionContractError> {
9780    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9781        return Ok(());
9782    }
9783    if applicability
9784        != if exact_profile {
9785            Applicability::Applicable
9786        } else {
9787            Applicability::NotApplicable
9788        }
9789    {
9790        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9791    }
9792    if !exact_profile {
9793        return if prediction.is_none() {
9794            Ok(())
9795        } else {
9796            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9797        };
9798    }
9799    if selection != SelectionState::Selected || configuration != ConfigurationState::Enabled {
9800        return if prediction.is_none() {
9801            Ok(())
9802        } else {
9803            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9804        };
9805    }
9806    let provenance = provenance.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9807    let prediction = prediction.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9808    let mesh_plan = current_unit_scale_mesh_plan(provenance, measurements);
9809    let reachability_plans = unit_scale_selected_reachability_plans(provenance, measurements);
9810    if prediction.facets().iter().any(|facet| {
9811        !validate_current_engine_unit_scale_basis(
9812            facet.scope(),
9813            facet.basis(),
9814            facet.reasons(),
9815            provenance,
9816            measurements,
9817            &mesh_plan,
9818            &reachability_plans,
9819        )
9820    }) {
9821        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9822    }
9823    let has_summary = prediction.facets().iter().any(|facet| {
9824        facet.scope().code.as_str() == ENGINE_UNIT_SCALE_BUDGET_SCOPE
9825            && facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded]
9826    });
9827    let retained = prediction
9828        .facets()
9829        .iter()
9830        .filter(|facet| facet.scope().code.as_str() != ENGINE_UNIT_SCALE_BUDGET_SCOPE)
9831        .collect::<Vec<_>>();
9832    let scene_facets = provenance
9833        .raw_scene_attachment()
9834        .inventory()
9835        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
9836        .map_or(1, |inventory| inventory.scenes().rows().len());
9837    let mesh_facets = match &mesh_plan {
9838        CurrentUnitScaleMeshPlan::Detailed(rows) => rows.len(),
9839        CurrentUnitScaleMeshPlan::CompleteEmpty
9840        | CurrentUnitScaleMeshPlan::Incomplete
9841        | CurrentUnitScaleMeshPlan::JoinOverflow => 1,
9842    };
9843    let selected_facets = current_unit_scale_selected_facet_count(&reachability_plans);
9844    let expected_count = 1usize
9845        .checked_add(scene_facets)
9846        .and_then(|count| count.checked_add(mesh_facets))
9847        .and_then(|count| count.checked_add(selected_facets))
9848        .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9849    if (!has_summary && expected_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
9850        || (has_summary && retained.len() >= expected_count)
9851    {
9852        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9853    }
9854    let candidate_capacity = if has_summary {
9855        retained.len()
9856    } else {
9857        expected_count
9858    };
9859    let mut expected_retained = expected_current_engine_unit_scale_facets(
9860        provenance,
9861        measurements,
9862        &mesh_plan,
9863        candidate_capacity,
9864        &reachability_plans,
9865    )
9866    .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9867    if expected_retained.len() != candidate_capacity {
9868        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9869    }
9870    expected_retained.sort_by(|left, right| {
9871        left.scope
9872            .code
9873            .as_str()
9874            .cmp(right.scope.code.as_str())
9875            .then_with(|| left.scope.subject.cmp(&right.scope.subject))
9876    });
9877    if retained.len() != expected_retained.len()
9878        || retained
9879            .iter()
9880            .zip(&expected_retained)
9881            .any(|(actual, expected)| {
9882                actual.scope() != &expected.scope
9883                    || actual.result() != expected.result.as_ref()
9884                    || (actual.scope().code.as_str() != ENGINE_UNIT_SCALE_SELECTED_SCOPE
9885                        && actual.reasons() != expected.reasons)
9886            })
9887    {
9888        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9889    }
9890    Ok(())
9891}
9892
9893const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
9894const ENGINE_CLIP_BOUNDARY_SOURCE_ID: &str = "unreal-animation-sequences-5.8";
9895const ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY: &str = "unreal";
9896const ENGINE_CLIP_BOUNDARY_PROFILE_REVISION: u32 = 1;
9897const ENGINE_CLIP_BOUNDARY_ENGINE_VERSION: &str = "5.8";
9898const ENGINE_CLIP_BOUNDARY_IMPORTER: &str = "fbx-importer";
9899const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256: &str =
9900    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759";
9901const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES: u64 = 2_169;
9902
9903fn current_engine_clip_boundary_profile_matches_v3(provenance: &PredictionProvenanceV3) -> bool {
9904    let selection = provenance.profile().selection();
9905    provenance.source_format() == SourceFormatV1::Fbx
9906        && provenance.raw_source().source_format() == SourceFormatV1::Fbx
9907        && selection.family() == ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY
9908        && selection.profile_revision() == ENGINE_CLIP_BOUNDARY_PROFILE_REVISION
9909        && selection.engine_version() == ENGINE_CLIP_BOUNDARY_ENGINE_VERSION
9910        && selection.importer() == ENGINE_CLIP_BOUNDARY_IMPORTER
9911        && provenance.profile().facts_identity().sha256()
9912            == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
9913        && provenance.profile().facts_identity().bytes() == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
9914        && matches!(
9915            provenance
9916                .profile()
9917                .fact(EngineFactIdV1::WholeEndFrameRequired)
9918                .map(|fact| fact.state()),
9919            Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
9920        )
9921        && provenance
9922            .profile()
9923            .source(ENGINE_CLIP_BOUNDARY_SOURCE_ID)
9924            .is_some()
9925}
9926
9927fn validate_current_engine_clip_boundary_applicability_v3(
9928    check_id: &str,
9929    applicability: Applicability,
9930    provenance: Option<&PredictionProvenanceV3>,
9931) -> Result<(), PredictionContractError> {
9932    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
9933        return Ok(());
9934    }
9935    let expected = match provenance {
9936        Some(provenance)
9937            if current_engine_clip_boundary_profile_matches_v3(provenance)
9938                && !(provenance.raw_source().clips_coverage().state()
9939                    == RawSourceSetCoverageStateV1::Complete
9940                    && provenance
9941                        .raw_source()
9942                        .exact_source_timing()
9943                        .is_some_and(|timing| timing.clips().is_empty())) =>
9944        {
9945            Applicability::Applicable
9946        }
9947        _ => Applicability::NotApplicable,
9948    };
9949    if applicability != expected {
9950        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
9951    }
9952    Ok(())
9953}
9954
9955/// Re-derive the frozen output-v14 clip-boundary rule from embedded V3
9956/// provenance. This keeps readback and producer construction from accepting a
9957/// merely well-shaped facet whose scope, basis, availability, reason, or
9958/// finding disagrees with the retained exact source timing.
9959fn validate_current_engine_clip_boundary_prediction_v3(
9960    check_id: &str,
9961    prediction: &EnginePredictionV3,
9962    provenance: &PredictionProvenanceV3,
9963    evaluated_scopes: &[EvaluationScope],
9964    finding_scopes: &[&EvaluationScope],
9965) -> Result<(), PredictionContractError> {
9966    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
9967        return Ok(());
9968    }
9969
9970    if !current_engine_clip_boundary_profile_matches_v3(provenance) {
9971        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
9972    }
9973
9974    let expected_rows = provenance.settings().clips().len();
9975    let timing = provenance.raw_source().exact_source_timing();
9976    if timing.is_some_and(|timing| timing.clips().len() != expected_rows) {
9977        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
9978    }
9979    let inventory_incomplete =
9980        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
9981    let has_budget_summary = prediction.has_facet_budget_summary();
9982    let mut seen_rows = vec![false; expected_rows];
9983    let mut row_facets = 0usize;
9984    let mut inventory_facets = 0usize;
9985    let mut available_scopes = Vec::new();
9986    let mut expected_finding_scopes = Vec::new();
9987
9988    for facet in prediction.facets() {
9989        if facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded] {
9990            if facet.basis() != &engine_clip_boundary_inventory_basis(timing)? {
9991                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
9992            }
9993            continue;
9994        }
9995        if facet.scope().code.as_str() == "engine_clip_boundary" {
9996            let Some(source_clip_index) = facet
9997                .scope()
9998                .subject
9999                .as_deref()
10000                .and_then(|subject| subject.strip_prefix("source_stack:"))
10001                .and_then(|index| index.parse::<usize>().ok())
10002            else {
10003                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10004            };
10005            if source_clip_index >= expected_rows
10006                || std::mem::replace(&mut seen_rows[source_clip_index], true)
10007            {
10008                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10009            }
10010            row_facets += 1;
10011            let expected_basis = engine_clip_boundary_stack_basis(timing, source_clip_index)?;
10012            if facet.basis() != &expected_basis {
10013                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10014            }
10015
10016            let exact = timing.and_then(|timing| {
10017                let declared = matches!(
10018                    timing.declared_time_mode().state(),
10019                    ExactSourceTimingObservationStateWireV1::Observed(_)
10020                );
10021                let period = match timing.frame_period().state() {
10022                    ExactSourceTimingObservationStateWireV1::Observed(period) => {
10023                        Some(period.units_per_frame())
10024                    }
10025                    _ => None,
10026                };
10027                let end = match timing.clips()[source_clip_index]
10028                    .source_time_range()
10029                    .state()
10030                {
10031                    ExactSourceTimingObservationStateWireV1::Observed(range) => {
10032                        Some(range.end_units())
10033                    }
10034                    _ => None,
10035                };
10036                declared.then_some(())?;
10037                Some((period?, end?))
10038            });
10039            match exact {
10040                Some((period, end)) => {
10041                    if facet.state() != EnginePredictionFacetStateV1::Available
10042                        || !facet.reasons().is_empty()
10043                    {
10044                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10045                    }
10046                    available_scopes.push(facet.scope());
10047                    if end.rem_euclid(period) != 0 {
10048                        expected_finding_scopes.push(facet.scope());
10049                    }
10050                }
10051                None => {
10052                    let expected_reasons =
10053                        engine_clip_boundary_unavailable_reasons(timing, source_clip_index)?;
10054                    if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10055                        || facet.reasons() != expected_reasons
10056                    {
10057                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10058                    }
10059                }
10060            }
10061        } else if facet.scope().code.as_str() == "engine_clip_boundary_inventory"
10062            && facet.scope().subject.is_none()
10063        {
10064            inventory_facets += 1;
10065            if inventory_facets != 1
10066                || !inventory_incomplete
10067                || facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10068                || facet.reasons() != [PredictionUnavailableReasonV2::RawSourceIncomplete]
10069                || facet.basis() != &engine_clip_boundary_inventory_basis(timing)?
10070            {
10071                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10072            }
10073        } else {
10074            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10075        }
10076    }
10077
10078    if seen_rows[..row_facets].iter().any(|seen| !seen)
10079        || seen_rows[row_facets..].iter().any(|seen| *seen)
10080    {
10081        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10082    }
10083    let candidate_facets = row_facets + inventory_facets;
10084    let expected_demand = expected_rows + usize::from(inventory_incomplete);
10085    if has_budget_summary {
10086        if candidate_facets >= expected_demand
10087            || inventory_facets != usize::from(inventory_incomplete && candidate_facets != 0)
10088        {
10089            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10090        }
10091    } else if row_facets != expected_rows || inventory_facets != usize::from(inventory_incomplete) {
10092        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10093    }
10094    if evaluated_scopes.len() != available_scopes.len()
10095        || evaluated_scopes
10096            .iter()
10097            .any(|scope| !available_scopes.contains(&scope))
10098    {
10099        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10100    }
10101    if finding_scopes.len() != expected_finding_scopes.len()
10102        || expected_finding_scopes.iter().any(|expected| {
10103            finding_scopes
10104                .iter()
10105                .filter(|actual| **actual == *expected)
10106                .count()
10107                != 1
10108        })
10109    {
10110        return Err(PredictionContractError::EngineClipBoundaryFindingMismatch);
10111    }
10112    Ok(())
10113}
10114
10115fn engine_clip_boundary_common_basis()
10116-> Result<Vec<PredictionBasisReferenceV2>, PredictionContractError> {
10117    Ok(vec![
10118        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::profile_fact(
10119            "whole_end_frame_required",
10120        )?),
10121        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::primary_source(
10122            ENGINE_CLIP_BOUNDARY_SOURCE_ID,
10123        )?),
10124    ])
10125}
10126
10127fn engine_clip_boundary_exact_reference(
10128    binding: &ExactSourceTimingBindingV1,
10129    domain: ExactSourceTimingDomainV1,
10130    key: ExactSourceTimingKeyV1,
10131    field: &'static str,
10132) -> Result<PredictionBasisReferenceV2, PredictionContractError> {
10133    Ok(PredictionBasisReferenceV2::exact_source_timing(
10134        ExactSourceTimingBasisReferenceV1::from_binding(
10135            domain,
10136            key,
10137            RawSourceFieldIdV1::new(field)?,
10138            binding,
10139        )?,
10140    ))
10141}
10142
10143fn engine_clip_boundary_stack_basis(
10144    timing: Option<&ExactSourceTimingBindingV1>,
10145    source_clip_index: usize,
10146) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10147    let mut references = engine_clip_boundary_common_basis()?;
10148    let Some(timing) = timing else {
10149        return EnginePredictionBasisV2::new(references);
10150    };
10151    let stack_key = ExactSourceTimingKeyV1::Clip {
10152        source_clip_index: source_clip_index as u64,
10153    };
10154    for (domain, key, field) in [
10155        (
10156            ExactSourceTimingDomainV1::Document,
10157            ExactSourceTimingKeyV1::Document,
10158            "declared_time_mode.state",
10159        ),
10160        (
10161            ExactSourceTimingDomainV1::Document,
10162            ExactSourceTimingKeyV1::Document,
10163            "frame_period.state",
10164        ),
10165        (
10166            ExactSourceTimingDomainV1::Clip,
10167            stack_key.clone(),
10168            "source_time_range.state",
10169        ),
10170    ] {
10171        references.push(engine_clip_boundary_exact_reference(
10172            timing, domain, key, field,
10173        )?);
10174    }
10175    if matches!(
10176        timing.declared_time_mode().state(),
10177        ExactSourceTimingObservationStateWireV1::Observed(_)
10178    ) {
10179        references.push(engine_clip_boundary_exact_reference(
10180            timing,
10181            ExactSourceTimingDomainV1::Document,
10182            ExactSourceTimingKeyV1::Document,
10183            "declared_time_mode.value.time_mode",
10184        )?);
10185    }
10186    if matches!(
10187        timing.frame_period().state(),
10188        ExactSourceTimingObservationStateWireV1::Observed(_)
10189    ) {
10190        references.push(engine_clip_boundary_exact_reference(
10191            timing,
10192            ExactSourceTimingDomainV1::Document,
10193            ExactSourceTimingKeyV1::Document,
10194            "frame_period.value.units_per_frame",
10195        )?);
10196    }
10197    if matches!(
10198        timing.clips()[source_clip_index]
10199            .source_time_range()
10200            .state(),
10201        ExactSourceTimingObservationStateWireV1::Observed(_)
10202    ) {
10203        references.push(engine_clip_boundary_exact_reference(
10204            timing,
10205            ExactSourceTimingDomainV1::Clip,
10206            stack_key,
10207            "source_time_range.value.end_units",
10208        )?);
10209    }
10210    EnginePredictionBasisV2::new(references)
10211}
10212
10213fn engine_clip_boundary_inventory_basis(
10214    timing: Option<&ExactSourceTimingBindingV1>,
10215) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10216    let mut references = engine_clip_boundary_common_basis()?;
10217    if let Some(timing) = timing {
10218        for field in ["clip_coverage.state", "clip_coverage.reason"] {
10219            references.push(engine_clip_boundary_exact_reference(
10220                timing,
10221                ExactSourceTimingDomainV1::Document,
10222                ExactSourceTimingKeyV1::Document,
10223                field,
10224            )?);
10225        }
10226    }
10227    EnginePredictionBasisV2::new(references)
10228}
10229
10230fn engine_clip_boundary_unavailable_reasons(
10231    timing: Option<&ExactSourceTimingBindingV1>,
10232    source_clip_index: usize,
10233) -> Result<Vec<PredictionUnavailableReasonV2>, PredictionContractError> {
10234    let Some(timing) = timing else {
10235        return Ok(vec![PredictionUnavailableReasonV2::custom(
10236            "animsmith:exact_source_timing_unavailable",
10237        )?]);
10238    };
10239    let mut reasons = Vec::new();
10240    if !matches!(
10241        timing.declared_time_mode().state(),
10242        ExactSourceTimingObservationStateWireV1::Observed(_)
10243    ) {
10244        reasons.push(PredictionUnavailableReasonV2::custom(
10245            "animsmith:source_declared_time_mode_unavailable",
10246        )?);
10247    }
10248    if !matches!(
10249        timing.frame_period().state(),
10250        ExactSourceTimingObservationStateWireV1::Observed(_)
10251    ) {
10252        reasons.push(PredictionUnavailableReasonV2::custom(
10253            "animsmith:source_frame_period_unavailable",
10254        )?);
10255    }
10256    if !matches!(
10257        timing.clips()[source_clip_index]
10258            .source_time_range()
10259            .state(),
10260        ExactSourceTimingObservationStateWireV1::Observed(_)
10261    ) {
10262        reasons.push(PredictionUnavailableReasonV2::custom(
10263            "animsmith:source_clip_time_range_unavailable",
10264        )?);
10265    }
10266    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
10267    Ok(reasons)
10268}
10269
10270impl MeasurementReportInput {
10271    /// Read one report through the immutable output-v11 byte bound before
10272    /// UTF-8 or JSON parsing.
10273    ///
10274    /// The JSON parser receives at most [`OUTPUT_V11_MAX_REPORT_BYTES`] bytes
10275    /// and retains its recursion limit. This function never performs an
10276    /// unbounded `read_to_end` or constructs a generic JSON value.
10277    ///
10278    /// # Errors
10279    ///
10280    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic contract
10281    /// validation remains in [`Self::into_files`].
10282    pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
10283        Self::read_from_with_limit(reader, OUTPUT_V11_MAX_REPORT_BYTES)
10284    }
10285
10286    fn read_from_with_limit(
10287        reader: impl Read,
10288        limit: u64,
10289    ) -> Result<Self, MeasurementReportReadError> {
10290        let mut bounded = reader.take(limit + 1);
10291        let mut bytes = Vec::new();
10292        bounded
10293            .read_to_end(&mut bytes)
10294            .map_err(|source| MeasurementReportReadError::Io { source })?;
10295        if bytes.len() as u64 > limit {
10296            return Err(MeasurementReportReadError::ReportTooLarge { limit });
10297        }
10298        serde_json::from_slice(&bytes)
10299            .map_err(|source| MeasurementReportReadError::InvalidJson { source })
10300    }
10301
10302    /// Number of file records present before nested record validation.
10303    ///
10304    /// Returns `None` when the report omitted its file array. Consumers can
10305    /// retain this count while [`MeasurementReportInput::into_files`] performs
10306    /// full validation, then apply their own cardinality and error policy.
10307    pub fn file_count(&self) -> Option<usize> {
10308        self.files.as_ref().map(Vec::len)
10309    }
10310
10311    /// Validate current output/measurement identities and recover every file's
10312    /// complete measurement record from a `measure` or `lint` report.
10313    ///
10314    /// File order is preserved. Empty and multi-file reports are accepted so
10315    /// callers can apply their own cardinality policy.
10316    ///
10317    /// # Errors
10318    ///
10319    /// Returns a typed error for a missing or unsupported identity, command,
10320    /// file shape, nested measurement contract, or measurement payload.
10321    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
10322        #[derive(Clone, Copy, PartialEq, Eq)]
10323        enum ReaderRevision {
10324            V11,
10325            V12,
10326            V13,
10327            V14,
10328            V15,
10329            V16,
10330            V17,
10331        }
10332
10333        let revision = match self.schema_version {
10334            Some(OUTPUT_V11_SCHEMA_VERSION) => ReaderRevision::V11,
10335            Some(OUTPUT_V12_SCHEMA_VERSION) => ReaderRevision::V12,
10336            Some(OUTPUT_V13_SCHEMA_VERSION) => ReaderRevision::V13,
10337            Some(OUTPUT_V14_SCHEMA_VERSION) => ReaderRevision::V14,
10338            Some(OUTPUT_V15_SCHEMA_VERSION) => ReaderRevision::V15,
10339            Some(OUTPUT_V16_SCHEMA_VERSION) => ReaderRevision::V16,
10340            Some(OUTPUT_SCHEMA_VERSION) => ReaderRevision::V17,
10341            Some(found) => {
10342                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
10343            }
10344            None => return Err(MeasurementReportError::MissingOutputVersion),
10345        };
10346        let expected_schema = match revision {
10347            ReaderRevision::V11 => OUTPUT_V11_SCHEMA_ID,
10348            ReaderRevision::V12 => OUTPUT_V12_SCHEMA_ID,
10349            ReaderRevision::V13 => OUTPUT_V13_SCHEMA_ID,
10350            ReaderRevision::V14 => OUTPUT_V14_SCHEMA_ID,
10351            ReaderRevision::V15 => OUTPUT_V15_SCHEMA_ID,
10352            ReaderRevision::V16 => OUTPUT_V16_SCHEMA_ID,
10353            ReaderRevision::V17 => OUTPUT_SCHEMA_ID,
10354        };
10355        if self.schema.as_deref() != Some(expected_schema) {
10356            return Err(MeasurementReportError::WrongOutputIdentity);
10357        }
10358        let command = match self.command.as_deref() {
10359            Some(command @ ("measure" | "lint")) => command,
10360            Some(command) => {
10361                return Err(MeasurementReportError::UnsupportedCommand {
10362                    command: command.to_owned(),
10363                });
10364            }
10365            None => return Err(MeasurementReportError::MissingCommand),
10366        };
10367        if let Some(field) = self.extra.keys().next() {
10368            return Err(MeasurementReportError::UnknownOutputField {
10369                field: field.clone(),
10370            });
10371        }
10372        if self._tool.is_none() {
10373            return Err(MeasurementReportError::MissingTool);
10374        }
10375        // The V11 reader retains the same summary obligation as the released
10376        // V1 contract; only its prediction attachment identity differs.
10377        validate_prediction_summary_presence(command, self.summary.as_ref())?;
10378        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
10379        if files.len() > OUTPUT_V11_MAX_FILES {
10380            return Err(MeasurementReportError::TooManyFiles {
10381                found: files.len(),
10382                limit: OUTPUT_V11_MAX_FILES,
10383            });
10384        }
10385        let mut available = 0usize;
10386        let mut unavailable = 0usize;
10387        let mut decoded_files = Vec::with_capacity(files.len());
10388        for (file_index, raw) in files.into_iter().enumerate() {
10389            let file = if revision == ReaderRevision::V11 {
10390                let file = decode_legacy_v11_file(command, file_index, &raw)?;
10391                let (file_available, file_unavailable) =
10392                    validate_legacy_v11_prediction_phase_file(command, file_index, &file)?;
10393                available = available
10394                    .checked_add(file_available)
10395                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10396                unavailable = unavailable
10397                    .checked_add(file_unavailable)
10398                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10399                file
10400            } else if revision == ReaderRevision::V14 {
10401                let file = decode_prediction_phase_file_v14(command, file_index, &raw)?;
10402                let (file_available, file_unavailable) =
10403                    validate_prediction_phase_file_v14(command, file_index, &file)?;
10404                available = available
10405                    .checked_add(file_available)
10406                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10407                unavailable = unavailable
10408                    .checked_add(file_unavailable)
10409                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10410                file
10411            } else if revision == ReaderRevision::V15 {
10412                let file = decode_prediction_phase_file_v15(command, file_index, &raw)?;
10413                let (file_available, file_unavailable) =
10414                    validate_prediction_phase_file_v15(command, file_index, &file)?;
10415                available = available
10416                    .checked_add(file_available)
10417                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10418                unavailable = unavailable
10419                    .checked_add(file_unavailable)
10420                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10421                file
10422            } else if revision == ReaderRevision::V16 {
10423                let file = decode_prediction_phase_file_v16(command, file_index, &raw)?;
10424                let (file_available, file_unavailable) =
10425                    validate_prediction_phase_file_v16(command, file_index, &file)?;
10426                available = available
10427                    .checked_add(file_available)
10428                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10429                unavailable = unavailable
10430                    .checked_add(file_unavailable)
10431                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10432                file
10433            } else if revision == ReaderRevision::V17 {
10434                let file = decode_prediction_phase_file_v17(command, file_index, &raw)?;
10435                let (file_available, file_unavailable) =
10436                    validate_prediction_phase_file_v17(command, file_index, &file)?;
10437                available = available
10438                    .checked_add(file_available)
10439                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10440                unavailable = unavailable
10441                    .checked_add(file_unavailable)
10442                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10443                file
10444            } else {
10445                let expected_measurement_schema = if revision == ReaderRevision::V12 {
10446                    MEASUREMENTS_V15_SCHEMA_ID
10447                } else {
10448                    MEASUREMENTS_SCHEMA_ID
10449                };
10450                let file = decode_prediction_phase_file(
10451                    command,
10452                    file_index,
10453                    &raw,
10454                    expected_measurement_schema,
10455                )?;
10456                let (file_available, file_unavailable) = validate_prediction_phase_file(
10457                    command,
10458                    file_index,
10459                    &file,
10460                    expected_measurement_schema,
10461                )?;
10462                available = available
10463                    .checked_add(file_available)
10464                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10465                unavailable = unavailable
10466                    .checked_add(file_unavailable)
10467                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10468                file
10469            };
10470            decoded_files.push(file);
10471        }
10472        validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
10473        let parsed = decoded_files
10474            .into_iter()
10475            .enumerate()
10476            .map(|(file_index, file)| {
10477                let path = file.path.ok_or_else(|| {
10478                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
10479                })?;
10480                let input = file.input.ok_or_else(|| {
10481                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
10482                })?;
10483                let sha256 = input.sha256.ok_or_else(|| {
10484                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
10485                })?;
10486                if sha256.len() != 64
10487                    || !sha256
10488                        .bytes()
10489                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
10490                {
10491                    return Err(MeasurementReportError::file(
10492                        file_index,
10493                        MeasurementFileError::InvalidSha256,
10494                    ));
10495                }
10496                let bytes = input.bytes.ok_or_else(|| {
10497                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
10498                })?;
10499                let measurements = file.measurements.ok_or_else(|| {
10500                    MeasurementReportError::file(
10501                        file_index,
10502                        MeasurementFileError::MissingMeasurements,
10503                    )
10504                })?;
10505                let measurements = decode_measurement_payload(
10506                    &measurements,
10507                    matches!(
10508                        revision,
10509                        ReaderRevision::V13
10510                            | ReaderRevision::V14
10511                            | ReaderRevision::V15
10512                            | ReaderRevision::V16
10513                            | ReaderRevision::V17
10514                    ),
10515                )
10516                .map_err(|source| {
10517                    MeasurementReportError::file(
10518                        file_index,
10519                        MeasurementFileError::InvalidMeasurementsShape {
10520                            reason: source.to_string(),
10521                        },
10522                    )
10523                })?;
10524                let (expected_measurement_version, expected_measurement_schema) = match revision {
10525                    ReaderRevision::V11 | ReaderRevision::V12 => {
10526                        (MEASUREMENTS_V15_SCHEMA_VERSION, MEASUREMENTS_V15_SCHEMA_ID)
10527                    }
10528                    ReaderRevision::V13
10529                    | ReaderRevision::V14
10530                    | ReaderRevision::V15
10531                    | ReaderRevision::V16
10532                    | ReaderRevision::V17 => (MEASUREMENTS_SCHEMA_VERSION, MEASUREMENTS_SCHEMA_ID),
10533                };
10534                match measurements.schema_version {
10535                    Some(found) if found == expected_measurement_version => {}
10536                    Some(found) => {
10537                        return Err(MeasurementReportError::file(
10538                            file_index,
10539                            MeasurementFileError::UnsupportedMeasurementVersion { found },
10540                        ));
10541                    }
10542                    None => {
10543                        return Err(MeasurementReportError::file(
10544                            file_index,
10545                            MeasurementFileError::MissingMeasurementVersion,
10546                        ));
10547                    }
10548                }
10549                if measurements.schema.as_deref() != Some(expected_measurement_schema) {
10550                    return Err(MeasurementReportError::file(
10551                        file_index,
10552                        MeasurementFileError::WrongMeasurementIdentity,
10553                    ));
10554                }
10555                let clips = measurements.clips.ok_or_else(|| {
10556                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
10557                })?;
10558                let material_resource_coverage =
10559                    measurements.material_resource_coverage.ok_or_else(|| {
10560                        MeasurementReportError::file(
10561                            file_index,
10562                            MeasurementFileError::MissingMaterialResourceCoverage,
10563                        )
10564                    })?;
10565                let material_definitions = measurements.material_definitions.ok_or_else(|| {
10566                    MeasurementReportError::file(
10567                        file_index,
10568                        MeasurementFileError::MissingMaterialDefinitions,
10569                    )
10570                })?;
10571                let textures = measurements.textures.ok_or_else(|| {
10572                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
10573                })?;
10574                let images = measurements.images.ok_or_else(|| {
10575                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
10576                })?;
10577                let skeleton_source_coverage =
10578                    measurements.skeleton_source_coverage.ok_or_else(|| {
10579                        MeasurementReportError::file(
10580                            file_index,
10581                            MeasurementFileError::MissingSkeletonSourceCoverage,
10582                        )
10583                    })?;
10584                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
10585                    MeasurementReportError::file(
10586                        file_index,
10587                        MeasurementFileError::MissingSkeletonNodes,
10588                    )
10589                })?;
10590                let skeleton_nodes = skeleton_nodes
10591                    .into_iter()
10592                    .enumerate()
10593                    .map(|(offset, node)| match node {
10594                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
10595                        SkeletonNodeMeasurementInput::Earlier { .. } => {
10596                            Err(MeasurementReportError::file(
10597                                file_index,
10598                                MeasurementFileError::InvalidMeasurements {
10599                                    source: MeasurementContractError::InvalidStructure {
10600                                        path: format!("skeleton_nodes[{offset}]"),
10601                                        reason: "uses a shape from an earlier measurement contract"
10602                                            .into(),
10603                                    },
10604                                },
10605                            ))
10606                        }
10607                    })
10608                    .collect::<Result<Vec<_>, _>>()?;
10609                let skins = measurements.skins.ok_or_else(|| {
10610                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
10611                })?;
10612                let skins = skins
10613                    .into_iter()
10614                    .enumerate()
10615                    .map(|(offset, skin)| match skin {
10616                        SkinMeasurementInput::Current(skin) => Ok(*skin),
10617                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
10618                            file_index,
10619                            MeasurementFileError::InvalidMeasurements {
10620                                source: MeasurementContractError::InvalidStructure {
10621                                    path: format!("skins[{offset}]"),
10622                                    reason: "uses a shape from an earlier measurement contract"
10623                                        .into(),
10624                                },
10625                            },
10626                        )),
10627                    })
10628                    .collect::<Result<Vec<_>, _>>()?;
10629                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
10630                    MeasurementReportError::file(
10631                        file_index,
10632                        MeasurementFileError::MissingMeshDefinitions,
10633                    )
10634                })?;
10635                let node_instances = measurements.node_instances.ok_or_else(|| {
10636                    MeasurementReportError::file(
10637                        file_index,
10638                        MeasurementFileError::MissingNodeInstances,
10639                    )
10640                })?;
10641                let scenes = measurements.scenes.ok_or_else(|| {
10642                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
10643                })?;
10644                let assets = AssetMeasurements {
10645                    material_resource_coverage,
10646                    material_definitions,
10647                    textures,
10648                    images,
10649                    skeleton_source_coverage,
10650                    skeleton_nodes,
10651                    skins,
10652                    mesh_definitions,
10653                    node_instances,
10654                    scenes,
10655                    default_scene_index: measurements.default_scene_index,
10656                };
10657                let measurements = match revision {
10658                    ReaderRevision::V11 | ReaderRevision::V12 => {
10659                        MeasurementContract::historical_v15(clips, assets)
10660                    }
10661                    ReaderRevision::V13
10662                    | ReaderRevision::V14
10663                    | ReaderRevision::V15
10664                    | ReaderRevision::V16
10665                    | ReaderRevision::V17 => MeasurementContract::new(clips, assets),
10666                }
10667                .map_err(|source| {
10668                    MeasurementReportError::file(
10669                        file_index,
10670                        MeasurementFileError::InvalidMeasurements { source },
10671                    )
10672                })?;
10673                if revision == ReaderRevision::V15 {
10674                    let provenance = file.prediction_provenance_v4.as_present();
10675                    for (check_index, check) in file
10676                        .checks_v3
10677                        .as_deref()
10678                        .unwrap_or_default()
10679                        .iter()
10680                        .enumerate()
10681                    {
10682                        validate_current_engine_unit_scale_prediction_v4(
10683                            &check.check_id,
10684                            check.selection,
10685                            check.configuration,
10686                            check.applicability,
10687                            None,
10688                            None,
10689                            &measurements,
10690                        )
10691                        .map_err(|source| {
10692                            MeasurementReportError::file(
10693                                file_index,
10694                                MeasurementFileError::InvalidPrediction {
10695                                    check_index,
10696                                    source,
10697                                },
10698                            )
10699                        })?;
10700                    }
10701                    for (check_index, check) in file
10702                        .checks_v4
10703                        .as_deref()
10704                        .unwrap_or_default()
10705                        .iter()
10706                        .enumerate()
10707                    {
10708                        validate_current_engine_unit_scale_prediction_v4(
10709                            &check.check_id,
10710                            check.selection,
10711                            check.configuration,
10712                            check.applicability,
10713                            check.prediction.as_ref(),
10714                            provenance,
10715                            &measurements,
10716                        )
10717                        .map_err(|source| {
10718                            MeasurementReportError::file(
10719                                file_index,
10720                                MeasurementFileError::InvalidPrediction {
10721                                    check_index,
10722                                    source,
10723                                },
10724                            )
10725                        })?;
10726                    }
10727                }
10728                if matches!(revision, ReaderRevision::V16 | ReaderRevision::V17)
10729                    && !matches!(file.prediction_provenance_v5, RequiredNullable::Missing)
10730                {
10731                    let provenance = file.prediction_provenance_v5.as_present();
10732                    for (check_index, check) in file
10733                        .checks_v5
10734                        .as_deref()
10735                        .unwrap_or_default()
10736                        .iter()
10737                        .enumerate()
10738                    {
10739                        validate_current_engine_unit_scale_prediction_v5(
10740                            &check.check_id,
10741                            check.selection,
10742                            check.configuration,
10743                            check.applicability,
10744                            check.prediction.as_ref(),
10745                            provenance,
10746                            &measurements,
10747                        )
10748                        .map_err(|source| {
10749                            MeasurementReportError::file(
10750                                file_index,
10751                                MeasurementFileError::InvalidPrediction {
10752                                    check_index,
10753                                    source,
10754                                },
10755                            )
10756                        })?;
10757                    }
10758                }
10759                if revision == ReaderRevision::V17
10760                    && !matches!(file.prediction_provenance_v6, RequiredNullable::Missing)
10761                {
10762                    let provenance = file.prediction_provenance_v6.as_present();
10763                    let rig = file.rig_v17.as_ref().ok_or_else(|| {
10764                        MeasurementReportError::file(
10765                            file_index,
10766                            MeasurementFileError::InvalidFileShape {
10767                                reason: "output-v17 rig evidence was not retained".into(),
10768                            },
10769                        )
10770                    })?;
10771                    for (check_index, check) in file
10772                        .checks_v6
10773                        .as_deref()
10774                        .unwrap_or_default()
10775                        .iter()
10776                        .enumerate()
10777                    {
10778                        validate_current_engine_root_motion_prediction_v6(
10779                            &check.check_id,
10780                            check.selection,
10781                            check.configuration,
10782                            check.applicability,
10783                            check.prediction.as_ref(),
10784                            provenance,
10785                            &check.findings,
10786                            rig,
10787                            &measurements,
10788                        )
10789                        .map_err(|source| {
10790                            MeasurementReportError::file(
10791                                file_index,
10792                                MeasurementFileError::InvalidPrediction {
10793                                    check_index,
10794                                    source,
10795                                },
10796                            )
10797                        })?;
10798                    }
10799                }
10800                Ok((
10801                    MeasurementReportFile {
10802                        path,
10803                        input: InputIdentity { sha256, bytes },
10804                        measurements,
10805                    },
10806                    (
10807                        file.checks.unwrap_or_default(),
10808                        file.legacy_checks.unwrap_or_default(),
10809                        file.checks_v3.unwrap_or_default(),
10810                        file.checks_v4.unwrap_or_default(),
10811                        file.checks_v5.unwrap_or_default(),
10812                        file.checks_v6.unwrap_or_default(),
10813                    ),
10814                ))
10815            })
10816            .collect::<Result<Vec<_>, _>>()?;
10817
10818        // Measurement-dependent basis pointers are deliberately resolved only
10819        // after every file's complete, version-routed measurements contract has passed.
10820        for (
10821            file_index,
10822            (file, (checks, legacy_checks, checks_v3, checks_v4, checks_v5, checks_v6)),
10823        ) in parsed.iter().enumerate()
10824        {
10825            validate_measurement_references_batch_v4(
10826                &file.measurements,
10827                checks_v6
10828                    .iter()
10829                    .enumerate()
10830                    .filter_map(|(check_index, check)| {
10831                        check
10832                            .prediction
10833                            .as_ref()
10834                            .map(|prediction| (check_index, prediction.base_prediction()))
10835                    }),
10836            )
10837            .map_err(|error| {
10838                MeasurementReportError::file(
10839                    file_index,
10840                    MeasurementFileError::InvalidPrediction {
10841                        check_index: error.prediction_index,
10842                        source: error.source,
10843                    },
10844                )
10845            })?;
10846            validate_measurement_references_batch_v4(
10847                &file.measurements,
10848                checks_v5
10849                    .iter()
10850                    .enumerate()
10851                    .filter_map(|(check_index, check)| {
10852                        check
10853                            .prediction
10854                            .as_ref()
10855                            .map(|prediction| (check_index, prediction.base_prediction()))
10856                    }),
10857            )
10858            .map_err(|error| {
10859                MeasurementReportError::file(
10860                    file_index,
10861                    MeasurementFileError::InvalidPrediction {
10862                        check_index: error.prediction_index,
10863                        source: error.source,
10864                    },
10865                )
10866            })?;
10867            validate_measurement_references_batch_v4(
10868                &file.measurements,
10869                checks_v4
10870                    .iter()
10871                    .enumerate()
10872                    .filter_map(|(check_index, check)| {
10873                        check
10874                            .prediction
10875                            .as_ref()
10876                            .map(|prediction| (check_index, prediction))
10877                    }),
10878            )
10879            .map_err(|error| {
10880                MeasurementReportError::file(
10881                    file_index,
10882                    MeasurementFileError::InvalidPrediction {
10883                        check_index: error.prediction_index,
10884                        source: error.source,
10885                    },
10886                )
10887            })?;
10888            validate_measurement_references_batch_v3(
10889                &file.measurements,
10890                checks_v3
10891                    .iter()
10892                    .enumerate()
10893                    .filter_map(|(check_index, check)| {
10894                        check
10895                            .prediction
10896                            .as_ref()
10897                            .map(|prediction| (check_index, prediction))
10898                    }),
10899            )
10900            .map_err(|error| {
10901                MeasurementReportError::file(
10902                    file_index,
10903                    MeasurementFileError::InvalidPrediction {
10904                        check_index: error.prediction_index,
10905                        source: error.source,
10906                    },
10907                )
10908            })?;
10909            validate_measurement_references_batch_v2(
10910                &file.measurements,
10911                checks
10912                    .iter()
10913                    .enumerate()
10914                    .filter_map(|(check_index, check)| {
10915                        check
10916                            .prediction
10917                            .as_ref()
10918                            .map(|prediction| (check_index, prediction))
10919                    }),
10920            )
10921            .map_err(|error| {
10922                MeasurementReportError::file(
10923                    file_index,
10924                    MeasurementFileError::InvalidPrediction {
10925                        check_index: error.prediction_index,
10926                        source: error.source,
10927                    },
10928                )
10929            })?;
10930            validate_measurement_references_batch(
10931                &file.measurements,
10932                legacy_checks
10933                    .iter()
10934                    .enumerate()
10935                    .filter_map(|(check_index, check)| {
10936                        check
10937                            .prediction
10938                            .as_ref()
10939                            .map(|prediction| (check_index, prediction))
10940                    }),
10941            )
10942            .map_err(|error| {
10943                MeasurementReportError::file(
10944                    file_index,
10945                    MeasurementFileError::InvalidPrediction {
10946                        check_index: error.prediction_index,
10947                        source: error.source,
10948                    },
10949                )
10950            })?;
10951        }
10952        Ok(parsed.into_iter().map(|(file, _)| file).collect())
10953    }
10954}
10955
10956#[cfg(test)]
10957mod measurement_report_input_tests {
10958    use std::collections::BTreeMap;
10959
10960    use super::*;
10961    use crate::engine_contract::{
10962        EngineClipSettingsV1, EngineConversionControlV1, EngineCoordinateBasisV1, EngineFactIdV1,
10963        EngineFactStateV1, EngineFactValueV1, EngineForwardAxisV1, EngineHandednessV1,
10964        EngineLinearUnitV1, EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1,
10965        EngineUpAxisV1, ResolvedEngineProfileV1, ResolvedEngineSettingsCoverageV2,
10966        ResolvedEngineSettingsV1, ResolvedEngineSettingsV2, ResolvedEngineSettingsWorkV2,
10967    };
10968    use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
10969    use crate::measure::{
10970        AssetMeasurements, ImageMeasurements, MeshDefinitionMeasurements, PrimitiveMeasurements,
10971    };
10972    use crate::prediction::{
10973        EngineMachineResultV1, EnginePredictionBasisV1, EnginePredictionBasisV2,
10974        EnginePredictionBasisV4, EnginePredictionFacetV1, EnginePredictionFacetV2,
10975        EnginePredictionFacetV3, EnginePredictionFacetV4, EnginePredictionV1, EnginePredictionV2,
10976        EnginePredictionV3, EnginePredictionV4, PredictionBasisReferenceV1,
10977        PredictionBasisReferenceV2, PredictionBasisReferenceV4, PredictionProvenanceIdentityV4,
10978        PredictionScalarV1, PredictionUnavailableReasonV1, PredictionUnavailableReasonV2,
10979        RawSourceBindingV1, RawSourceBindingV2, UnitMappingResultV1,
10980    };
10981    use crate::source_facts::SourceFormatV1;
10982    use crate::{
10983        DependencyClosureV1, Document, Finding, ImageSourceKind, ImageUnavailableReason,
10984        MaterialResourceCoverage, ResolvedRoles,
10985    };
10986
10987    fn prediction_test_profile() -> ResolvedEngineProfileV1 {
10988        let all_fact_ids = [
10989            EngineFactIdV1::AcceptedInputs,
10990            EngineFactIdV1::AnimationAddressability,
10991            EngineFactIdV1::AnimationChannelHandling,
10992            EngineFactIdV1::AnimationTargetAddressability,
10993            EngineFactIdV1::AxisConversionControl,
10994            EngineFactIdV1::ConstructHandling,
10995            EngineFactIdV1::ExactAxisConversion,
10996            EngineFactIdV1::ExtensionHandling,
10997            EngineFactIdV1::ResultingHierarchyScale,
10998            EngineFactIdV1::RootMotionAddressability,
10999            EngineFactIdV1::TargetCoordinateBasis,
11000            EngineFactIdV1::TargetLinearUnit,
11001            EngineFactIdV1::UnitConversionControl,
11002            EngineFactIdV1::WholeEndFrameRequired,
11003        ];
11004        let facts = all_fact_ids
11005            .into_iter()
11006            .map(|id| {
11007                let state = if id == EngineFactIdV1::AcceptedInputs {
11008                    EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11009                        SourceFormatV1::Glb,
11010                    ]))
11011                } else {
11012                    EngineFactStateV1::Unknown
11013                };
11014                EngineProfileFactV1::new(id, state)
11015            })
11016            .collect();
11017        ResolvedEngineProfileV1::new(
11018            EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
11019            "urn:animsmith:engine-profile:test:1",
11020            facts,
11021            vec![],
11022            vec![
11023                EnginePrimarySourceV1::new(
11024                    "test-source",
11025                    "1",
11026                    "https://example.invalid/test",
11027                    "2026-08-20",
11028                    vec![EngineFactIdV1::AcceptedInputs],
11029                    vec![],
11030                )
11031                .unwrap(),
11032            ],
11033        )
11034        .unwrap()
11035    }
11036
11037    fn prediction_test_provenance_v2() -> PredictionProvenanceV2 {
11038        let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
11039            "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11040            "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11041            "source_format": "glb",
11042            "linear_unit": {
11043                "state": "observed", "value": 1.0, "disposition": "preserved",
11044                "provenance": {"kind": "format_defined"}
11045            },
11046            "coordinate_basis": {
11047                "state": "observed",
11048                "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11049                "disposition": "preserved", "provenance": {"kind": "format_defined"}
11050            },
11051            "frames_per_second": {
11052                "state": "observed", "value": 30.0, "disposition": "preserved",
11053                "provenance": {"kind": "format_defined"}
11054            },
11055            "clips_coverage": {"state": "complete"},
11056            "constructs_coverage": {"state": "complete"},
11057            "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11058            "source_skeleton_coverage": "unavailable",
11059            "work": {
11060                "inspected_rows": 0, "retained_rows": 0,
11061                "retained_text_bytes": 0, "max_traversal_depth": 0
11062            }
11063        }))
11064        .unwrap();
11065        let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
11066        let profile = prediction_test_profile();
11067        let settings = ResolvedEngineSettingsV2::new(
11068            &profile,
11069            vec![],
11070            vec![],
11071            ResolvedEngineSettingsCoverageV2::complete(),
11072            ResolvedEngineSettingsWorkV2::new(0, 0, 0),
11073        )
11074        .unwrap();
11075        PredictionProvenanceV2::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
11076    }
11077
11078    fn prediction_test_provenance() -> PredictionProvenanceV3 {
11079        let prior = prediction_test_provenance_v2();
11080        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11081            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11082            "source_facts": prior.raw_source(),
11083            "exact_source_timing": null
11084        }))
11085        .unwrap();
11086        PredictionProvenanceV3::new(
11087            prior.profile().clone(),
11088            prior.source_format(),
11089            prior.settings().clone(),
11090            raw,
11091            prior.dependency_closure().clone(),
11092        )
11093        .unwrap()
11094    }
11095
11096    fn partial_engine_provenance() -> PredictionProvenanceV3 {
11097        let complete = prediction_test_provenance();
11098        let mut raw_wire = serde_json::to_value(complete.raw_source().source_facts()).unwrap();
11099        raw_wire["clips_coverage"] = serde_json::json!({
11100            "state": "partial",
11101            "reason": "projection_budget_exceeded"
11102        });
11103        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11104            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11105            "source_facts": raw_wire,
11106            "exact_source_timing": null
11107        }))
11108        .unwrap();
11109        let clips = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
11110            .map(|index| EngineClipSettingsV1::new(format!("clip-{index:04}"), Vec::new()).unwrap())
11111            .collect();
11112        let settings = ResolvedEngineSettingsV2::new(
11113            complete.profile(),
11114            Vec::new(),
11115            clips,
11116            ResolvedEngineSettingsCoverageV2::actual_clip_rows_exceeded(),
11117            ResolvedEngineSettingsWorkV2::new(4_097, 4_096, 4_096),
11118        )
11119        .unwrap();
11120        PredictionProvenanceV3::new(
11121            complete.profile().clone(),
11122            complete.source_format(),
11123            settings,
11124            raw,
11125            complete.dependency_closure().clone(),
11126        )
11127        .unwrap()
11128    }
11129
11130    fn prediction_test_measurements() -> MeasurementContract {
11131        MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11132    }
11133
11134    fn measure_wire(measurements: MeasurementContract) -> serde_json::Value {
11135        let file = MeasureFileReport::new(
11136            "test.glb",
11137            InputIdentity::from_bytes(&[]),
11138            prediction_test_rig(),
11139            measurements,
11140        );
11141        let envelope =
11142            MeasureEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11143                .unwrap();
11144        let wire = serde_json::to_value(envelope).unwrap();
11145        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11146            .unwrap()
11147            .into_files()
11148            .expect("current measurement fixture reads back");
11149        wire
11150    }
11151
11152    fn primitive_measurement_contract() -> MeasurementContract {
11153        let mut assets = AssetMeasurements::default();
11154        assets.mesh_definitions.push(MeshDefinitionMeasurements {
11155            mesh_index: 0,
11156            name: "mesh".into(),
11157            primitives: Some(vec![
11158                PrimitiveMeasurements {
11159                    primitive_index: 1,
11160                    material_index: Some(7),
11161                    vertex_count: 2,
11162                    finite_vertex_count: 1,
11163                    geometry_aabb: Some(Aabb {
11164                        min: [-2.0, 1.0, 0.0],
11165                        max: [-2.0, 1.0, 0.0],
11166                    }),
11167                    geometry_centroid: Some([-2.0, 1.0, 0.0]),
11168                },
11169                PrimitiveMeasurements {
11170                    primitive_index: 3,
11171                    material_index: None,
11172                    vertex_count: 2,
11173                    finite_vertex_count: 2,
11174                    geometry_aabb: Some(Aabb {
11175                        min: [4.0, 3.0, 0.0],
11176                        max: [6.0, 3.0, 0.0],
11177                    }),
11178                    geometry_centroid: Some([5.0, 3.0, 0.0]),
11179                },
11180            ]),
11181            vertex_count: 4,
11182            geometry_aabb: Some(Aabb {
11183                min: [-2.0, 1.0, 0.0],
11184                max: [6.0, 3.0, 0.0],
11185            }),
11186            geometry_centroid: Some([8.0 / 3.0, 7.0 / 3.0, 0.0]),
11187            max_joints_per_vertex: 0,
11188            weight_sum_min: None,
11189            weight_sum_max: None,
11190            additional_influence_sets: Vec::new(),
11191        });
11192        MeasurementContract::new(BTreeMap::new(), assets).unwrap()
11193    }
11194
11195    fn prediction_test_rig() -> RigInfo {
11196        RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
11197    }
11198
11199    fn basis_v2(basis: EnginePredictionBasisV1) -> EnginePredictionBasisV2 {
11200        EnginePredictionBasisV2::new(
11201            basis
11202                .references()
11203                .iter()
11204                .cloned()
11205                .map(PredictionBasisReferenceV2::v1)
11206                .collect(),
11207        )
11208        .unwrap()
11209    }
11210
11211    fn unavailable_facet(
11212        subject: String,
11213        basis: EnginePredictionBasisV1,
11214    ) -> EnginePredictionFacetV3 {
11215        EnginePredictionFacetV3::required_unavailable(
11216            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
11217                .subject(subject),
11218            basis_v2(basis),
11219            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
11220        )
11221        .unwrap()
11222    }
11223
11224    fn unavailable_check(
11225        check_id: &'static str,
11226        provenance: &PredictionProvenanceV3,
11227        facets: Vec<EnginePredictionFacetV3>,
11228    ) -> CheckEvaluation {
11229        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11230        CheckEvaluation::evaluated(
11231            check_id,
11232            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11233                .with_engine_prediction_v3(prediction),
11234        )
11235        .unwrap()
11236    }
11237
11238    fn unavailable_check_v2(
11239        check_id: &'static str,
11240        provenance: &PredictionProvenanceV2,
11241        facets: Vec<EnginePredictionFacetV2>,
11242    ) -> CheckEvaluation {
11243        let prediction = EnginePredictionV2::new(provenance.identity().clone(), facets).unwrap();
11244        CheckEvaluation::evaluated(
11245            check_id,
11246            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11247                .with_engine_prediction_v2(prediction),
11248        )
11249        .unwrap()
11250    }
11251
11252    fn lint_file(
11253        provenance: &PredictionProvenanceV3,
11254        checks: Vec<CheckEvaluation>,
11255    ) -> Result<LintFileReport, OutputContractError> {
11256        LintFileReport::new(
11257            "limit.glb",
11258            provenance.raw_source().primary_input().clone(),
11259            prediction_test_rig(),
11260            Some(provenance.clone()),
11261            checks,
11262            prediction_test_measurements(),
11263        )
11264    }
11265
11266    #[test]
11267    fn output_v15_rejects_mixed_v3_provenance_and_v4_predictions() {
11268        let provenance = prediction_test_provenance();
11269        let identity: PredictionProvenanceIdentityV4 =
11270            serde_json::from_value(serde_json::to_value(provenance.identity()).unwrap()).unwrap();
11271        let basis = EnginePredictionBasisV4::new(vec![PredictionBasisReferenceV4::v2(
11272            PredictionBasisReferenceV2::v1(
11273                PredictionBasisReferenceV1::profile_fact("accepted_inputs").unwrap(),
11274            ),
11275        )])
11276        .unwrap();
11277        let scope = EvaluationScope::new(EvaluationScopeCode::custom("acme:v4-mixed"));
11278        let facet = EnginePredictionFacetV4::available(
11279            scope.clone(),
11280            basis,
11281            EngineMachineResultV1::UnitMapping(
11282                UnitMappingResultV1::gltf_to_engine_world_length_unit(),
11283            ),
11284        )
11285        .unwrap();
11286        let prediction = EnginePredictionV4::new(identity, vec![facet]).unwrap();
11287        let check = CheckEvaluation::evaluated(
11288            "acme-v4-mixed",
11289            CheckOutput::from_coverage(vec![], vec![scope], vec![])
11290                .with_engine_prediction_v4(prediction),
11291        )
11292        .unwrap();
11293        assert!(matches!(
11294            lint_file(&provenance, vec![check]),
11295            Err(OutputContractError::PredictionRevisionMismatch)
11296        ));
11297    }
11298
11299    #[test]
11300    fn output_v15_preserves_prediction_without_provenance_error_precedence() {
11301        let provenance = prediction_test_provenance();
11302        let check = unavailable_check(
11303            "acme-v3-without-provenance",
11304            &provenance,
11305            vec![unavailable_facet(
11306                "row".to_owned(),
11307                EnginePredictionBasisV1::new(Vec::new()).unwrap(),
11308            )],
11309        );
11310        let error = LintFileReport::new(
11311            "without-provenance.glb",
11312            provenance.raw_source().primary_input().clone(),
11313            prediction_test_rig(),
11314            None,
11315            vec![check],
11316            prediction_test_measurements(),
11317        )
11318        .unwrap_err();
11319        assert_eq!(error, OutputContractError::PredictionWithoutProvenance);
11320    }
11321
11322    #[test]
11323    fn output_v15_rejects_active_unit_scale_without_v4_provenance() {
11324        let check = CheckEvaluation::evaluated(
11325            ENGINE_UNIT_SCALE_CHECK_ID,
11326            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
11327        )
11328        .unwrap();
11329        let error = LintFileReport::new_v4(
11330            "unit-scale-without-provenance.glb",
11331            InputIdentity::from_bytes(&[]),
11332            prediction_test_rig(),
11333            None,
11334            vec![check],
11335            prediction_test_measurements(),
11336        )
11337        .unwrap_err();
11338        assert_eq!(
11339            error,
11340            OutputContractError::InvalidPrediction(
11341                PredictionContractError::EngineUnitScaleFacetMismatch
11342            )
11343        );
11344    }
11345
11346    fn validated_lint_wire(
11347        provenance: &PredictionProvenanceV3,
11348        checks: Vec<CheckEvaluation>,
11349    ) -> serde_json::Value {
11350        let file = lint_file(provenance, checks).expect("producer accepts exact N");
11351        let envelope =
11352            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11353                .unwrap();
11354        let wire = serde_json::to_value(envelope).unwrap();
11355        let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
11356        read.into_files().expect("reader accepts exact N");
11357        wire
11358    }
11359
11360    fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
11361        let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
11362        read.into_files().expect_err("reader must reject N+1")
11363    }
11364
11365    fn clip_boundary_profile() -> ResolvedEngineProfileV1 {
11366        let all_fact_ids = [
11367            EngineFactIdV1::AcceptedInputs,
11368            EngineFactIdV1::AnimationAddressability,
11369            EngineFactIdV1::AnimationChannelHandling,
11370            EngineFactIdV1::AnimationTargetAddressability,
11371            EngineFactIdV1::AxisConversionControl,
11372            EngineFactIdV1::ConstructHandling,
11373            EngineFactIdV1::ExactAxisConversion,
11374            EngineFactIdV1::ExtensionHandling,
11375            EngineFactIdV1::ResultingHierarchyScale,
11376            EngineFactIdV1::RootMotionAddressability,
11377            EngineFactIdV1::TargetCoordinateBasis,
11378            EngineFactIdV1::TargetLinearUnit,
11379            EngineFactIdV1::UnitConversionControl,
11380            EngineFactIdV1::WholeEndFrameRequired,
11381        ];
11382        let facts = all_fact_ids
11383            .into_iter()
11384            .map(|id| {
11385                let state = match id {
11386                    EngineFactIdV1::AcceptedInputs => {
11387                        EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11388                            SourceFormatV1::Fbx,
11389                        ]))
11390                    }
11391                    EngineFactIdV1::TargetCoordinateBasis => EngineFactStateV1::Known(
11392                        EngineFactValueV1::CoordinateBasis(EngineCoordinateBasisV1 {
11393                            handedness: EngineHandednessV1::Left,
11394                            up_axis: EngineUpAxisV1::Z,
11395                            forward_axis: EngineForwardAxisV1::PositiveX,
11396                        }),
11397                    ),
11398                    EngineFactIdV1::TargetLinearUnit => EngineFactStateV1::Known(
11399                        EngineFactValueV1::LinearUnit(EngineLinearUnitV1::Centimetre),
11400                    ),
11401                    EngineFactIdV1::UnitConversionControl
11402                    | EngineFactIdV1::AxisConversionControl => {
11403                        EngineFactStateV1::Known(EngineFactValueV1::ConversionControl(
11404                            EngineConversionControlV1::ImporterOption,
11405                        ))
11406                    }
11407                    EngineFactIdV1::WholeEndFrameRequired => {
11408                        EngineFactStateV1::Known(EngineFactValueV1::Boolean(true))
11409                    }
11410                    _ => EngineFactStateV1::Unknown,
11411                };
11412                EngineProfileFactV1::new(id, state)
11413            })
11414            .collect();
11415        ResolvedEngineProfileV1::new(
11416            EngineProfileSelectionV1::new("unreal", 1, "5.8", "fbx-importer").unwrap(),
11417            "urn:animsmith:engine-profile:unreal:1",
11418            facts,
11419            vec![],
11420            vec![
11421                EnginePrimarySourceV1::new(
11422                    ENGINE_CLIP_BOUNDARY_SOURCE_ID,
11423                    "5.8",
11424                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
11425                    "2026-08-20",
11426                    vec![EngineFactIdV1::WholeEndFrameRequired],
11427                    vec![],
11428                )
11429                .unwrap(),
11430                EnginePrimarySourceV1::new(
11431                    "unreal-coordinate-system-5.8",
11432                    "5.8",
11433                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
11434                    "2026-08-20",
11435                    vec![EngineFactIdV1::TargetCoordinateBasis],
11436                    vec![],
11437                )
11438                .unwrap(),
11439                EnginePrimarySourceV1::new(
11440                    "unreal-fbx-import-options-5.8",
11441                    "5.8",
11442                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
11443                    "2026-08-20",
11444                    vec![
11445                        EngineFactIdV1::AcceptedInputs,
11446                        EngineFactIdV1::UnitConversionControl,
11447                        EngineFactIdV1::AxisConversionControl,
11448                    ],
11449                    vec![],
11450                )
11451                .unwrap(),
11452                EnginePrimarySourceV1::new(
11453                    "unreal-units-5.8",
11454                    "5.8",
11455                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
11456                    "2026-08-20",
11457                    vec![EngineFactIdV1::TargetLinearUnit],
11458                    vec![],
11459                )
11460                .unwrap(),
11461            ],
11462        )
11463        .unwrap()
11464    }
11465
11466    fn exact_observed(value: serde_json::Value, kind: &'static str) -> serde_json::Value {
11467        serde_json::json!({
11468            "state": {"kind": "observed", "value": value},
11469            "disposition": "preserved",
11470            "provenance": {"kind": kind}
11471        })
11472    }
11473
11474    fn clip_boundary_raw_wire(unavailable_last: bool) -> serde_json::Value {
11475        let last_range = if unavailable_last {
11476            serde_json::json!({
11477                "state": {"kind": "unavailable", "value": "malformed"},
11478                "disposition": "baked",
11479                "provenance": null
11480            })
11481        } else {
11482            exact_observed(
11483                serde_json::json!({
11484                    "selection": "primary", "begin_units": 0, "end_units": 9_408_000
11485                }),
11486                "parser_projected",
11487            )
11488        };
11489        serde_json::json!({
11490            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11491            "source_facts": {
11492                "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11493                "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11494                "source_format": "fbx",
11495                "linear_unit": {
11496                    "state": "observed", "value": 0.01, "disposition": "preserved",
11497                    "provenance": {"kind": "format_defined"}
11498                },
11499                "coordinate_basis": {
11500                    "state": "observed",
11501                    "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11502                    "disposition": "preserved", "provenance": {"kind": "format_defined"}
11503                },
11504                "frames_per_second": {
11505                    "state": "observed", "value": 30.0, "disposition": "preserved",
11506                    "provenance": {"kind": "format_defined"}
11507                },
11508                "clips_coverage": {"state": "complete"},
11509                "constructs_coverage": {"state": "complete"},
11510                "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11511                "source_skeleton_coverage": "unavailable",
11512                "work": {
11513                    "inspected_rows": 3, "retained_rows": 3,
11514                    "retained_text_bytes": 0, "max_traversal_depth": 0
11515                }
11516            },
11517            "exact_source_timing": {
11518                "schema": crate::EXACT_SOURCE_TIMING_V1_ID,
11519                "time_basis": exact_observed(
11520                    serde_json::json!({"units_per_second": 141_120_000}),
11521                    "format_defined"
11522                ),
11523                "declared_time_mode": exact_observed(
11524                    serde_json::json!("fps30"), "source_declared"
11525                ),
11526                "effective_time_mode": exact_observed(
11527                    serde_json::json!("fps30"), "parser_projected"
11528                ),
11529                "declared_custom_frame_rate": exact_observed(
11530                    serde_json::json!({"binary64_bits": 30.0_f64.to_bits()}),
11531                    "source_declared"
11532                ),
11533                "frame_period": exact_observed(
11534                    serde_json::json!({"units_per_frame": 4_704_000}),
11535                    "derived_from_source"
11536                ),
11537                "declared_time_protocol": exact_observed(
11538                    serde_json::json!("default"), "source_declared"
11539                ),
11540                "effective_time_protocol": exact_observed(
11541                    serde_json::json!("default"), "parser_projected"
11542                ),
11543                "clip_coverage": {"state": "complete"},
11544                "clips": [
11545                    {
11546                        "source_clip_index": 0,
11547                        "source_time_range": exact_observed(
11548                            serde_json::json!({
11549                                "selection": "primary", "begin_units": 0,
11550                                "end_units": 4_704_000
11551                            }),
11552                            "parser_projected"
11553                        )
11554                    },
11555                    {
11556                        "source_clip_index": 1,
11557                        "source_time_range": exact_observed(
11558                            serde_json::json!({
11559                                "selection": "primary", "begin_units": 0,
11560                                "end_units": 4_704_001
11561                            }),
11562                            "parser_projected"
11563                        )
11564                    },
11565                    {"source_clip_index": 2, "source_time_range": last_range}
11566                ]
11567            }
11568        })
11569    }
11570
11571    fn clip_boundary_provenance(unavailable_last: bool) -> PredictionProvenanceV3 {
11572        let raw: RawSourceBindingV2 =
11573            serde_json::from_value(clip_boundary_raw_wire(unavailable_last)).unwrap();
11574        let profile = clip_boundary_profile();
11575        let clips = (0..3)
11576            .map(|index| EngineClipSettingsV1::new(format!("stack-{index}"), vec![]).unwrap())
11577            .collect();
11578        let settings = ResolvedEngineSettingsV2::new(
11579            &profile,
11580            vec![],
11581            clips,
11582            ResolvedEngineSettingsCoverageV2::complete(),
11583            ResolvedEngineSettingsWorkV2::new(3, 3, 3),
11584        )
11585        .unwrap();
11586        PredictionProvenanceV3::new(
11587            profile,
11588            SourceFormatV1::Fbx,
11589            settings,
11590            raw.clone(),
11591            DependencyClosureV1::unavailable(raw.primary_input().clone()),
11592        )
11593        .unwrap()
11594    }
11595
11596    fn clip_boundary_provenance_with_settings(
11597        provenance: &PredictionProvenanceV3,
11598        profile: ResolvedEngineProfileV1,
11599        clips: Vec<EngineClipSettingsV1>,
11600    ) -> PredictionProvenanceV3 {
11601        let retained = clips.len();
11602        let settings = ResolvedEngineSettingsV2::new(
11603            &profile,
11604            vec![],
11605            clips,
11606            ResolvedEngineSettingsCoverageV2::complete(),
11607            ResolvedEngineSettingsWorkV2::new(retained, retained, retained),
11608        )
11609        .unwrap();
11610        PredictionProvenanceV3::new(
11611            profile,
11612            provenance.source_format(),
11613            settings,
11614            provenance.raw_source().clone(),
11615            provenance.dependency_closure().clone(),
11616        )
11617        .unwrap()
11618    }
11619
11620    fn altered_clip_boundary_source_profile(
11621        provenance: &PredictionProvenanceV3,
11622    ) -> ResolvedEngineProfileV1 {
11623        let sources = provenance
11624            .profile()
11625            .primary_sources()
11626            .iter()
11627            .map(|source| {
11628                let url = if source.id() == ENGINE_CLIP_BOUNDARY_SOURCE_ID {
11629                    format!("{}#altered", source.url())
11630                } else {
11631                    source.url().to_owned()
11632                };
11633                EnginePrimarySourceV1::new(
11634                    source.id(),
11635                    source.target_version(),
11636                    url,
11637                    source.verified_on(),
11638                    source.supported_fact_ids().to_vec(),
11639                    source.supported_setting_ids().to_vec(),
11640                )
11641                .unwrap()
11642            })
11643            .collect();
11644        ResolvedEngineProfileV1::new(
11645            provenance.profile().selection().clone(),
11646            provenance.profile().fact_bundle_urn(),
11647            provenance.profile().facts().to_vec(),
11648            provenance.profile().setting_descriptors().to_vec(),
11649            sources,
11650        )
11651        .unwrap()
11652    }
11653
11654    fn clip_boundary_scope(source_clip_index: usize) -> EvaluationScope {
11655        EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
11656            .subject(format!("source_stack:{source_clip_index}"))
11657    }
11658
11659    struct InapplicableClipBoundaryCheck;
11660
11661    impl crate::Check for InapplicableClipBoundaryCheck {
11662        fn id(&self) -> &'static str {
11663            ENGINE_CLIP_BOUNDARY_CHECK_ID
11664        }
11665
11666        fn applicability(&self, _ctx: &crate::CheckCtx<'_>) -> Applicability {
11667            Applicability::NotApplicable
11668        }
11669
11670        fn evaluate(&self, _ctx: &crate::CheckCtx<'_>) -> CheckOutput {
11671            panic!("an inapplicable check must not be evaluated")
11672        }
11673    }
11674
11675    fn inapplicable_clip_boundary_check() -> CheckEvaluation {
11676        let document = Document::default();
11677        let grids = crate::MetricGrids::new(&document);
11678        let roles = ResolvedRoles::default();
11679        let config = crate::Config::default();
11680        let context = crate::CheckCtx::new(&grids, &roles, &config);
11681        let checks: Vec<Box<dyn crate::Check>> = vec![Box::new(InapplicableClipBoundaryCheck)];
11682        crate::evaluate_checks(&context, &checks, crate::CheckSelection::All)
11683            .unwrap()
11684            .pop()
11685            .unwrap()
11686    }
11687
11688    fn clip_boundary_check(
11689        provenance: &PredictionProvenanceV3,
11690        unavailable_last: bool,
11691        first_basis: Option<EnginePredictionBasisV2>,
11692    ) -> CheckEvaluation {
11693        let timing = provenance.raw_source().exact_source_timing();
11694        let scopes = (0..3).map(clip_boundary_scope).collect::<Vec<_>>();
11695        let mut facets = Vec::new();
11696        for (index, scope) in scopes.iter().cloned().enumerate() {
11697            let basis = if index == 0 {
11698                first_basis
11699                    .clone()
11700                    .unwrap_or_else(|| engine_clip_boundary_stack_basis(timing, index).unwrap())
11701            } else {
11702                engine_clip_boundary_stack_basis(timing, index).unwrap()
11703            };
11704            if unavailable_last && index == 2 {
11705                facets.push(
11706                    EnginePredictionFacetV3::required_unavailable(
11707                        scope,
11708                        basis,
11709                        engine_clip_boundary_unavailable_reasons(timing, index).unwrap(),
11710                    )
11711                    .unwrap(),
11712                );
11713            } else {
11714                facets.push(EnginePredictionFacetV3::available(scope, basis).unwrap());
11715            }
11716        }
11717        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11718        let finding = Finding::new(
11719            ENGINE_CLIP_BOUNDARY_CHECK_ID,
11720            Severity::Warning,
11721            "fractional exact source clip end",
11722        )
11723        .prediction_scope(scopes[1].clone());
11724        let evaluated_scopes = if unavailable_last {
11725            scopes[..2].to_vec()
11726        } else {
11727            scopes
11728        };
11729        CheckEvaluation::evaluated(
11730            ENGINE_CLIP_BOUNDARY_CHECK_ID,
11731            CheckOutput::from_coverage(vec![finding], evaluated_scopes, vec![])
11732                .with_engine_prediction_v3(prediction),
11733        )
11734        .unwrap()
11735    }
11736
11737    fn clip_boundary_lint_wire(unavailable_last: bool) -> serde_json::Value {
11738        let provenance = clip_boundary_provenance(unavailable_last);
11739        let check = clip_boundary_check(&provenance, unavailable_last, None);
11740        let file = LintFileReport::new(
11741            "test.fbx",
11742            provenance.raw_source().primary_input().clone(),
11743            prediction_test_rig(),
11744            Some(provenance),
11745            vec![check],
11746            prediction_test_measurements(),
11747        )
11748        .unwrap();
11749        let envelope =
11750            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11751                .unwrap();
11752        let wire = serde_json::to_value(envelope).unwrap();
11753        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11754            .unwrap()
11755            .into_files()
11756            .unwrap();
11757        wire
11758    }
11759
11760    fn assert_clip_boundary_read_error(wire: serde_json::Value, expected: PredictionContractError) {
11761        assert_eq!(
11762            lint_read_error(wire),
11763            MeasurementReportError::File {
11764                file_index: 0,
11765                source: MeasurementFileError::InvalidPrediction {
11766                    check_index: 0,
11767                    source: expected,
11768                },
11769            }
11770        );
11771    }
11772
11773    #[test]
11774    fn exact_source_raw_source_v2_observed_values_round_trip_and_reject_hostile_mutations() {
11775        let wire = clip_boundary_raw_wire(false);
11776        let binding: RawSourceBindingV2 = serde_json::from_value(wire.clone()).unwrap();
11777        assert_eq!(serde_json::to_value(binding).unwrap(), wire);
11778
11779        let mut invalid_value = wire.clone();
11780        invalid_value["exact_source_timing"]["frame_period"]["state"]["value"]["units_per_frame"] =
11781            serde_json::json!(0);
11782        assert_eq!(
11783            serde_json::from_value::<RawSourceBindingV2>(invalid_value)
11784                .unwrap_err()
11785                .to_string(),
11786            PredictionContractError::ExactSourceTimingValueMismatch.to_string()
11787        );
11788
11789        let mut invalid_coverage = wire.clone();
11790        invalid_coverage["exact_source_timing"]["clip_coverage"] = serde_json::json!({
11791            "state": "partial", "reason": "projection_budget_exceeded"
11792        });
11793        assert_eq!(
11794            serde_json::from_value::<RawSourceBindingV2>(invalid_coverage)
11795                .unwrap_err()
11796                .to_string(),
11797            PredictionContractError::ExactSourceTimingCoverageMismatch.to_string()
11798        );
11799
11800        let mut invalid_prefix = wire;
11801        invalid_prefix["exact_source_timing"]["clips"][1]["source_clip_index"] =
11802            serde_json::json!(2);
11803        assert_eq!(
11804            serde_json::from_value::<RawSourceBindingV2>(invalid_prefix)
11805                .unwrap_err()
11806                .to_string(),
11807            PredictionContractError::ExactSourceTimingClipPrefixMismatch.to_string()
11808        );
11809    }
11810
11811    #[test]
11812    fn clip_boundary_v3_readback_rejects_scope_basis_reason_and_finding_mutations() {
11813        let wire = clip_boundary_lint_wire(false);
11814
11815        let mut wrong_scope = wire.clone();
11816        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["subject"] =
11817            serde_json::json!("source_stack:9");
11818        wrong_scope["files"][0]["checks"][0]["evaluated_scopes"][0]["subject"] =
11819            serde_json::json!("source_stack:9");
11820        assert_clip_boundary_read_error(
11821            wrong_scope,
11822            PredictionContractError::EngineClipBoundaryFacetMismatch,
11823        );
11824
11825        let mut wrong_basis = wire.clone();
11826        wrong_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"] =
11827            serde_json::to_value(
11828                EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap(),
11829            )
11830            .unwrap();
11831        assert_clip_boundary_read_error(
11832            wrong_basis,
11833            PredictionContractError::EngineClipBoundaryFacetMismatch,
11834        );
11835
11836        let mut missing_finding = wire;
11837        missing_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([]);
11838        assert_clip_boundary_read_error(
11839            missing_finding,
11840            PredictionContractError::EngineClipBoundaryFindingMismatch,
11841        );
11842
11843        let mut wrong_reason = clip_boundary_lint_wire(true);
11844        wrong_reason["files"][0]["checks"][0]["prediction"]["facets"][2]["reasons"] =
11845            serde_json::json!(["animsmith:source_frame_period_unavailable"]);
11846        assert_clip_boundary_read_error(
11847            wrong_reason,
11848            PredictionContractError::EngineClipBoundaryFacetMismatch,
11849        );
11850    }
11851
11852    #[test]
11853    fn clip_boundary_v3_rederives_applicability_for_producer_and_readback() {
11854        let provenance = clip_boundary_provenance(false);
11855        assert!(matches!(
11856            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
11857            Err(OutputContractError::InvalidPrediction(
11858                PredictionContractError::EngineClipBoundaryFacetMismatch
11859            ))
11860        ));
11861
11862        let mut wire = clip_boundary_lint_wire(false);
11863        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
11864        check.insert(
11865            "applicability".to_owned(),
11866            serde_json::json!("not_applicable"),
11867        );
11868        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
11869        check.insert("findings".to_owned(), serde_json::json!([]));
11870        check.remove("evaluated_scopes");
11871        check.remove("gaps");
11872        check.remove("prediction");
11873        assert_clip_boundary_read_error(
11874            wire,
11875            PredictionContractError::EngineClipBoundaryFacetMismatch,
11876        );
11877    }
11878
11879    #[test]
11880    fn clip_boundary_v3_applicability_uses_raw_exact_stack_inventory() {
11881        let original = clip_boundary_provenance(false);
11882        let provenance = clip_boundary_provenance_with_settings(
11883            &original,
11884            original.profile().clone(),
11885            Vec::new(),
11886        );
11887        assert_eq!(
11888            provenance
11889                .raw_source()
11890                .exact_source_timing()
11891                .unwrap()
11892                .clips()
11893                .len(),
11894            3
11895        );
11896        assert!(provenance.settings().clips().is_empty());
11897        assert!(matches!(
11898            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
11899            Err(OutputContractError::InvalidPrediction(
11900                PredictionContractError::EngineClipBoundaryFacetMismatch
11901            ))
11902        ));
11903
11904        let mut wire = clip_boundary_lint_wire(false);
11905        wire["files"][0]["prediction_provenance"] = serde_json::to_value(provenance).unwrap();
11906        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
11907        check.insert(
11908            "applicability".to_owned(),
11909            serde_json::json!("not_applicable"),
11910        );
11911        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
11912        check.insert("findings".to_owned(), serde_json::json!([]));
11913        check.remove("evaluated_scopes");
11914        check.remove("gaps");
11915        check.remove("prediction");
11916        assert_clip_boundary_read_error(
11917            wire,
11918            PredictionContractError::EngineClipBoundaryFacetMismatch,
11919        );
11920    }
11921
11922    #[test]
11923    fn clip_boundary_v3_binds_the_frozen_unreal_profile_identity() {
11924        let original = clip_boundary_provenance(false);
11925        assert_eq!(
11926            original.profile().facts_identity().sha256(),
11927            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
11928        );
11929        assert_eq!(
11930            original.profile().facts_identity().bytes(),
11931            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
11932        );
11933        let altered_profile = altered_clip_boundary_source_profile(&original);
11934        assert_ne!(
11935            altered_profile.facts_identity(),
11936            original.profile().facts_identity()
11937        );
11938        let altered = clip_boundary_provenance_with_settings(
11939            &original,
11940            altered_profile,
11941            original.settings().clips().to_vec(),
11942        );
11943        let altered_check = clip_boundary_check(&altered, false, None);
11944        assert!(matches!(
11945            lint_file(&altered, vec![altered_check.clone()]),
11946            Err(OutputContractError::InvalidPrediction(
11947                PredictionContractError::EngineClipBoundaryFacetMismatch
11948            ))
11949        ));
11950
11951        let mut wire = clip_boundary_lint_wire(false);
11952        wire["files"][0]["prediction_provenance"] = serde_json::to_value(altered).unwrap();
11953        wire["files"][0]["checks"][0] = serde_json::to_value(altered_check).unwrap();
11954        assert_clip_boundary_read_error(
11955            wire,
11956            PredictionContractError::EngineClipBoundaryFacetMismatch,
11957        );
11958    }
11959
11960    #[test]
11961    fn clip_boundary_v3_producer_rejects_incomplete_exact_basis() {
11962        let provenance = clip_boundary_provenance(false);
11963        let incomplete_basis =
11964            EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap();
11965        let check = clip_boundary_check(&provenance, false, Some(incomplete_basis));
11966        assert!(matches!(
11967            lint_file(&provenance, vec![check]),
11968            Err(OutputContractError::InvalidPrediction(
11969                PredictionContractError::EngineClipBoundaryFacetMismatch
11970            ))
11971        ));
11972    }
11973
11974    fn prediction_with_retained_text(
11975        provenance: &PredictionProvenanceV3,
11976        retained_text: usize,
11977    ) -> EnginePredictionV3 {
11978        const FIELD_ID_BYTES: usize = 16;
11979        const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
11980        let fixed = "test:prediction-limit".len()
11981            + PredictionUnavailableReasonV2::ProjectIntentUnavailable
11982                .as_str()
11983                .len();
11984        let remaining = retained_text.checked_sub(fixed).unwrap();
11985        let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
11986        let full_rows = remaining / full_row;
11987        let remainder = remaining % full_row;
11988        let (full_rows, tail_lengths) = if remainder == 0 {
11989            (full_rows, Vec::new())
11990        } else if remainder >= FIELD_ID_BYTES {
11991            (full_rows, vec![remainder - FIELD_ID_BYTES])
11992        } else {
11993            (
11994                full_rows - 1,
11995                vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
11996            )
11997        };
11998        let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
11999        for index in 0..full_rows {
12000            references.push(
12001                PredictionBasisReferenceV1::project_field(
12002                    format!("f{index:015}"),
12003                    PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
12004                )
12005                .unwrap(),
12006            );
12007        }
12008        for length in tail_lengths {
12009            let index = references.len();
12010            references.push(
12011                PredictionBasisReferenceV1::project_field(
12012                    format!("f{index:015}"),
12013                    PredictionScalarV1::text("x".repeat(length)).unwrap(),
12014                )
12015                .unwrap(),
12016            );
12017        }
12018        let basis = EnginePredictionBasisV1::new(references).unwrap();
12019        let facet = EnginePredictionFacetV3::required_unavailable(
12020            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
12021            basis_v2(basis),
12022            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12023        )
12024        .unwrap();
12025        let prediction =
12026            EnginePredictionV3::new(provenance.identity().clone(), vec![facet]).unwrap();
12027        assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
12028        prediction
12029    }
12030
12031    #[test]
12032    fn report_reader_enforces_the_byte_cap_before_json_parsing() {
12033        let bytes = br#"{"schema_version":10,"tool":{}}"#;
12034        let report =
12035            MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
12036                .expect("exact N must parse");
12037        assert_eq!(report.schema_version, Some(10));
12038
12039        assert!(matches!(
12040            MeasurementReportInput::read_from_with_limit(
12041                bytes.as_slice(),
12042                bytes.len() as u64 - 1,
12043            ),
12044            Err(MeasurementReportReadError::ReportTooLarge { limit })
12045                if limit == bytes.len() as u64 - 1
12046        ));
12047    }
12048
12049    #[test]
12050    fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12051        let provenance = prediction_test_provenance();
12052        let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12053        let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
12054            .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
12055            .collect();
12056        let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
12057        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12058        let extra = unavailable_check(
12059            "test:facet-extra",
12060            &provenance,
12061            vec![unavailable_facet("facet-extra".into(), empty_basis)],
12062        );
12063
12064        assert_eq!(
12065            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12066            OutputContractError::TooManyPredictionFacets {
12067                found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12068                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12069            }
12070        );
12071
12072        wire["files"][0]["checks"]
12073            .as_array_mut()
12074            .unwrap()
12075            .push(serde_json::to_value(extra).unwrap());
12076        assert_eq!(
12077            lint_read_error(wire),
12078            MeasurementReportError::File {
12079                file_index: 0,
12080                source: MeasurementFileError::TooManyPredictionFacets {
12081                    found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12082                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12083                },
12084            }
12085        );
12086    }
12087
12088    #[test]
12089    fn v2_budget_summary_is_canonical_and_requires_an_exhausted_file_budget() {
12090        let provenance = prediction_test_provenance();
12091        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12092        let mut facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12093            .map(|index| unavailable_facet(format!("facet-{index:04}"), basis.clone()))
12094            .collect::<Vec<_>>();
12095        facets.push(
12096            EnginePredictionFacetV3::required_unavailable(
12097                EvaluationScope::new(EvaluationScopeCode::custom("test:budget:facet-budget")),
12098                basis_v2(basis),
12099                vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12100            )
12101            .unwrap(),
12102        );
12103        let check = unavailable_check("test:budget", &provenance, facets);
12104        let wire = validated_lint_wire(&provenance, vec![check]);
12105        let facets = wire["files"][0]["checks"][0]["prediction"]["facets"]
12106            .as_array()
12107            .unwrap();
12108        let summary_index = facets
12109            .iter()
12110            .position(|facet| facet["reasons"] == serde_json::json!(["facet_budget_exceeded"]))
12111            .unwrap();
12112
12113        let mut wrong_scope = wire.clone();
12114        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["code"] =
12115            serde_json::json!("test:wrong:facet-budget");
12116        assert!(matches!(
12117            lint_read_error(wrong_scope),
12118            MeasurementReportError::File {
12119                source: MeasurementFileError::InvalidPrediction {
12120                    source: PredictionContractError::InvalidFacetBudgetSummary,
12121                    ..
12122                },
12123                ..
12124            }
12125        ));
12126
12127        let mut subject = wire.clone();
12128        subject["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["subject"] =
12129            serde_json::json!("forged");
12130        assert!(matches!(
12131            lint_read_error(subject),
12132            MeasurementReportError::File {
12133                source: MeasurementFileError::InvalidPrediction {
12134                    source: PredictionContractError::InvalidFacetBudgetSummary,
12135                    ..
12136                },
12137                ..
12138            }
12139        ));
12140
12141        let mut available = wire.clone();
12142        available["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["state"] =
12143            serde_json::json!("available");
12144        let available_error = lint_read_error(available);
12145        assert!(
12146            matches!(
12147                available_error,
12148                MeasurementReportError::File {
12149                    source: MeasurementFileError::InvalidPrediction {
12150                        source: PredictionContractError::AvailableBasisEmpty,
12151                        ..
12152                    },
12153                    ..
12154                }
12155            ),
12156            "unexpected available mutation: {available_error:?}"
12157        );
12158
12159        let mut duplicate = wire.clone();
12160        let duplicate_summary =
12161            duplicate["files"][0]["checks"][0]["prediction"]["facets"][summary_index].clone();
12162        duplicate["files"][0]["checks"][0]["prediction"]["facets"]
12163            [if summary_index == 0 { 1 } else { 0 }] = duplicate_summary;
12164        assert!(matches!(
12165            lint_read_error(duplicate),
12166            MeasurementReportError::File {
12167                source: MeasurementFileError::InvalidPrediction {
12168                    source: PredictionContractError::DuplicateFacetScope,
12169                    ..
12170                },
12171                ..
12172            }
12173        ));
12174
12175        let mut under_full = wire;
12176        under_full["files"][0]["checks"][0]["prediction"]["facets"]
12177            .as_array_mut()
12178            .unwrap()
12179            .remove(if summary_index == 0 { 1 } else { 0 });
12180        under_full["summary"]["prediction_facets"]["required_prediction_unavailable"] =
12181            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE - 1);
12182        assert_eq!(
12183            lint_read_error(under_full),
12184            MeasurementReportError::File {
12185                file_index: 0,
12186                source: MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
12187                    found: PREDICTION_V1_MAX_FACETS_PER_FILE - 1,
12188                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12189                },
12190            }
12191        );
12192    }
12193
12194    #[test]
12195    fn partial_engine_inventory_can_be_replaced_by_its_budget_summary() {
12196        let provenance = partial_engine_provenance();
12197        let summary = EnginePredictionFacetV3::required_unavailable(
12198            EvaluationScope::new(EvaluationScopeCode::custom(
12199                "engine-addressability:facet-budget",
12200            )),
12201            basis_v2(EnginePredictionBasisV1::new(Vec::new()).unwrap()),
12202            vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12203        )
12204        .unwrap();
12205        let engine = unavailable_check("engine-addressability", &provenance, vec![summary]);
12206        let filler_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12207        let filler = unavailable_check(
12208            "test:filler",
12209            &provenance,
12210            (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12211                .map(|index| unavailable_facet(format!("filler-{index:04}"), filler_basis.clone()))
12212                .collect(),
12213        );
12214        // A capacity-zero incomplete inventory is represented by the one
12215        // canonical summary while other rules consume the retained slots.
12216        let wire = validated_lint_wire(&provenance, vec![engine, filler]);
12217        assert_eq!(
12218            wire["summary"]["prediction_facets"]["required_prediction_unavailable"],
12219            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE)
12220        );
12221    }
12222
12223    #[test]
12224    fn engine_addressability_inventory_reasons_follow_raw_and_settings_coverage() {
12225        let partial = partial_engine_provenance();
12226        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12227        let inventory = EnginePredictionFacetV3::required_unavailable(
12228            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12229            basis_v2(basis.clone()),
12230            vec![
12231                PredictionUnavailableReasonV2::RawSourceIncomplete,
12232                PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
12233            ],
12234        )
12235        .unwrap();
12236        let check = unavailable_check("engine-addressability", &partial, vec![inventory]);
12237        let mut wire = validated_lint_wire(&partial, vec![check]);
12238        wire["files"][0]["checks"][0]["prediction"]["facets"][0]["reasons"] =
12239            serde_json::json!(["raw_source_incomplete"]);
12240        assert!(matches!(
12241            lint_read_error(wire),
12242            MeasurementReportError::File {
12243                source: MeasurementFileError::InvalidPrediction {
12244                    source: PredictionContractError::EngineAddressabilityInventoryReasonsMismatch,
12245                    ..
12246                },
12247                ..
12248            }
12249        ));
12250
12251        let complete = prediction_test_provenance();
12252        let forged = EnginePredictionFacetV3::required_unavailable(
12253            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12254            basis_v2(basis),
12255            vec![PredictionUnavailableReasonV2::ResolvedSettingsOverflow],
12256        )
12257        .unwrap();
12258        assert!(matches!(
12259            lint_file(
12260                &complete,
12261                vec![unavailable_check(
12262                    "engine-addressability",
12263                    &complete,
12264                    vec![forged]
12265                )],
12266            ),
12267            Err(OutputContractError::InvalidPrediction(
12268                PredictionContractError::EngineAddressabilityInventoryReasonsMismatch
12269            ))
12270        ));
12271    }
12272
12273    #[test]
12274    fn engine_addressability_rejects_a_non_addressability_available_facet_prefix() {
12275        let mut wire = clip_boundary_lint_wire(false);
12276        let check = &mut wire["files"][0]["checks"][0];
12277        check["check_id"] = serde_json::json!("engine-addressability");
12278        for (index, facet) in check["prediction"]["facets"]
12279            .as_array_mut()
12280            .unwrap()
12281            .iter_mut()
12282            .enumerate()
12283        {
12284            facet["scope"]["code"] = serde_json::json!("animation_asset_label");
12285            facet["scope"]["subject"] = serde_json::json!(format!("Animation{index}"));
12286        }
12287        for (index, scope) in check["evaluated_scopes"]
12288            .as_array_mut()
12289            .unwrap()
12290            .iter_mut()
12291            .enumerate()
12292        {
12293            scope["code"] = serde_json::json!("animation_asset_label");
12294            scope["subject"] = serde_json::json!(format!("Animation{index}"));
12295        }
12296        check["findings"][0]["check_id"] = serde_json::json!("engine-addressability");
12297        check["findings"][0]["prediction_scope"]["code"] =
12298            serde_json::json!("animation_asset_label");
12299        check["findings"][0]["prediction_scope"]["subject"] = serde_json::json!("Animation1");
12300
12301        assert!(matches!(
12302            lint_read_error(wire),
12303            MeasurementReportError::File {
12304                source: MeasurementFileError::InvalidPrediction {
12305                    source: PredictionContractError::EngineAddressabilityFacetPrefixMismatch,
12306                    ..
12307                },
12308                ..
12309            }
12310        ));
12311    }
12312
12313    #[test]
12314    fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12315        let provenance = prediction_test_provenance();
12316        let basis = EnginePredictionBasisV1::new(
12317            (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
12318                .map(|index| {
12319                    PredictionBasisReferenceV1::project_field(
12320                        format!("project.field.{index:04}"),
12321                        PredictionScalarV1::Null,
12322                    )
12323                    .unwrap()
12324                })
12325                .collect(),
12326        )
12327        .unwrap();
12328        let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
12329            / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
12330        let facets = (0..facet_count)
12331            .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
12332            .collect();
12333        let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
12334        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12335        let extra_basis = EnginePredictionBasisV1::new(vec![
12336            PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
12337                .unwrap(),
12338        ])
12339        .unwrap();
12340        let extra = unavailable_check(
12341            "test:basis-extra",
12342            &provenance,
12343            vec![unavailable_facet("basis-extra".into(), extra_basis)],
12344        );
12345
12346        assert_eq!(
12347            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12348            OutputContractError::TooManyPredictionBasisReferences {
12349                found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
12350                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
12351            }
12352        );
12353
12354        wire["files"][0]["checks"]
12355            .as_array_mut()
12356            .unwrap()
12357            .push(serde_json::to_value(extra).unwrap());
12358        *wire["files"][0]["checks"]
12359            .as_array_mut()
12360            .unwrap()
12361            .last_mut()
12362            .unwrap()
12363            .get_mut("prediction")
12364            .unwrap()
12365            .get_mut("facets")
12366            .and_then(serde_json::Value::as_array_mut)
12367            .and_then(|facets| facets.first_mut())
12368            .and_then(|facet| facet.get_mut("basis"))
12369            .and_then(|basis| basis.get_mut("references"))
12370            .and_then(serde_json::Value::as_array_mut)
12371            .and_then(|references| references.first_mut())
12372            .unwrap() = serde_json::Value::Null;
12373        assert!(matches!(
12374            lint_read_error(wire),
12375            MeasurementReportError::File {
12376                file_index: 0,
12377                source: MeasurementFileError::TooManyPredictionBasisReferences { .. },
12378            }
12379        ));
12380    }
12381
12382    #[test]
12383    fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12384        let provenance = prediction_test_provenance();
12385        let provenance_text = provenance.retained_text_bytes().unwrap();
12386        let at_limit_prediction = prediction_with_retained_text(
12387            &provenance,
12388            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
12389        );
12390        let at_limit = CheckEvaluation::evaluated(
12391            "test:text-limit",
12392            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12393                .with_engine_prediction_v3(at_limit_prediction),
12394        )
12395        .unwrap();
12396        let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
12397
12398        let above_limit_prediction = prediction_with_retained_text(
12399            &provenance,
12400            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
12401        );
12402        let above_limit = CheckEvaluation::evaluated(
12403            "test:text-limit",
12404            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12405                .with_engine_prediction_v3(above_limit_prediction),
12406        )
12407        .unwrap();
12408        let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
12409        assert_eq!(
12410            lint_file(&provenance, vec![above_limit]).unwrap_err(),
12411            OutputContractError::TooMuchPredictionText {
12412                found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12413                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12414            }
12415        );
12416
12417        wire["files"][0]["checks"][0] = above_limit_wire;
12418        assert_eq!(
12419            lint_read_error(wire),
12420            MeasurementReportError::File {
12421                file_index: 0,
12422                source: MeasurementFileError::TooMuchPredictionText {
12423                    found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12424                    limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12425                },
12426            }
12427        );
12428    }
12429
12430    fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
12431        serde_json::from_value::<MeasurementReportInput>(wire)
12432            .expect("outer v11 shape remains valid")
12433            .into_files()
12434            .expect_err("mutated report must fail")
12435    }
12436
12437    fn empty_check(check_id: &'static str) -> CheckEvaluation {
12438        CheckEvaluation::evaluated(
12439            check_id,
12440            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
12441        )
12442        .unwrap()
12443    }
12444
12445    #[test]
12446    fn staged_reader_rejects_unknown_root_file_and_check_fields() {
12447        let provenance = prediction_test_provenance();
12448        let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12449
12450        let mut root = wire.clone();
12451        root["unknown_root"] = serde_json::json!(true);
12452        let bytes = serde_json::to_vec(&root).unwrap();
12453        assert_eq!(
12454            MeasurementReportInput::read_from(bytes.as_slice())
12455                .expect("unknown root fields are retained through the staged read")
12456                .into_files()
12457                .unwrap_err(),
12458            MeasurementReportError::UnknownOutputField {
12459                field: "unknown_root".into(),
12460            }
12461        );
12462
12463        let mut missing_tool = wire.clone();
12464        missing_tool.as_object_mut().unwrap().remove("tool");
12465        assert_eq!(
12466            reader_error(missing_tool),
12467            MeasurementReportError::MissingTool
12468        );
12469
12470        let bare = br#"{"walk":true}"#;
12471        assert_eq!(
12472            MeasurementReportInput::read_from(bare.as_slice())
12473                .expect("unknown root fields remain staged until header validation")
12474                .into_files()
12475                .unwrap_err(),
12476            MeasurementReportError::MissingOutputVersion,
12477        );
12478
12479        let unsupported = br#"{"schema_version":9,"walk":true}"#;
12480        assert_eq!(
12481            MeasurementReportInput::read_from(unsupported.as_slice())
12482                .expect("unknown root fields remain staged until header validation")
12483                .into_files()
12484                .unwrap_err(),
12485            MeasurementReportError::UnsupportedOutputVersion { found: 9 },
12486        );
12487
12488        let mut file = wire.clone();
12489        file["files"][0]["unknown_file"] = serde_json::json!(true);
12490        assert!(matches!(
12491            reader_error(file),
12492            MeasurementReportError::File {
12493                file_index: 0,
12494                source: MeasurementFileError::InvalidFileShape { reason },
12495            } if reason.contains("unknown field `unknown_file`")
12496        ));
12497
12498        let mut check = wire;
12499        check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
12500        assert!(matches!(
12501            reader_error(check),
12502            MeasurementReportError::File {
12503                file_index: 0,
12504                source: MeasurementFileError::InvalidPredictionShape {
12505                    check_index: 0,
12506                    reason,
12507                },
12508            } if reason.contains("unknown field `unknown_check`")
12509        ));
12510
12511        let provenance = prediction_test_provenance();
12512        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12513        summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
12514        let bytes = serde_json::to_vec(&summary).unwrap();
12515        assert!(matches!(
12516            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12517            MeasurementReportReadError::InvalidJson { source }
12518                if source.to_string().contains("unknown field `unknown_prediction_total`")
12519        ));
12520
12521        let provenance = prediction_test_provenance();
12522        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12523        summary["summary"]["unknown_summary"] = serde_json::json!(0);
12524        let bytes = serde_json::to_vec(&summary).unwrap();
12525        assert!(matches!(
12526            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12527            MeasurementReportReadError::InvalidJson { source }
12528                if source.to_string().contains("unknown field `unknown_summary`")
12529        ));
12530    }
12531
12532    #[test]
12533    fn staged_reader_preserves_typed_prediction_semantic_errors() {
12534        let provenance = prediction_test_provenance();
12535        let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
12536        provenance_wire["files"][0]["prediction_provenance"]["schema"] =
12537            serde_json::json!("urn:changed");
12538        assert!(matches!(
12539            reader_error(provenance_wire),
12540            MeasurementReportError::File {
12541                file_index: 0,
12542                source: MeasurementFileError::InvalidPredictionProvenance { .. },
12543            }
12544        ));
12545
12546        let basis = EnginePredictionBasisV1::new(vec![
12547            PredictionBasisReferenceV1::project_field(
12548                "test:project",
12549                PredictionScalarV1::Boolean { value: true },
12550            )
12551            .unwrap(),
12552        ])
12553        .unwrap();
12554        let facet = EnginePredictionFacetV3::required_unavailable(
12555            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12556            basis_v2(basis),
12557            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12558        )
12559        .unwrap();
12560        let prediction_wire = validated_lint_wire(
12561            &provenance,
12562            vec![unavailable_check("test:reader", &provenance, vec![facet])],
12563        );
12564
12565        let mut wrong_emitter = prediction_wire.clone();
12566        wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12567            serde_json::json!("member_existence");
12568        assert!(matches!(
12569            reader_error(wrong_emitter),
12570            MeasurementReportError::File {
12571                file_index: 0,
12572                source: MeasurementFileError::InvalidPredictionLifecycle {
12573                    check_index: 0,
12574                    reason: "prediction facet scope code is invalid for its parent check",
12575                },
12576            }
12577        ));
12578
12579        let mut empty_scope = prediction_wire.clone();
12580        empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12581            serde_json::json!("");
12582        assert!(matches!(
12583            reader_error(empty_scope),
12584            MeasurementReportError::File {
12585                file_index: 0,
12586                source: MeasurementFileError::InvalidPrediction { check_index: 0, .. },
12587            }
12588        ));
12589
12590        let mut prediction_wire = prediction_wire;
12591        prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
12592            ["bytes"] = serde_json::json!(0);
12593        assert!(matches!(
12594            reader_error(prediction_wire),
12595            MeasurementReportError::File {
12596                file_index: 0,
12597                source: MeasurementFileError::InvalidPrediction {
12598                    check_index: 0,
12599                    source: PredictionContractError::IdentityMismatch {
12600                        contract: "engine prediction basis v2",
12601                    },
12602                },
12603            }
12604        ));
12605    }
12606
12607    #[test]
12608    fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
12609        let provenance = prediction_test_provenance();
12610        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12611
12612        for (field, state) in [
12613            ("selection", "unselected"),
12614            ("configuration", "disabled"),
12615            ("applicability", "not_applicable"),
12616        ] {
12617            let mut inactive = base.clone();
12618            inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
12619            assert!(matches!(
12620                reader_error(inactive),
12621                MeasurementReportError::File {
12622                    file_index: 0,
12623                    source: MeasurementFileError::InvalidPredictionLifecycle {
12624                        check_index: 0,
12625                        reason: "evaluation does not match completed and missing prediction work",
12626                    },
12627                }
12628            ));
12629        }
12630
12631        let mut inactive = base.clone();
12632        inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
12633        inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12634        serde_json::from_value::<MeasurementReportInput>(inactive)
12635            .unwrap()
12636            .into_files()
12637            .expect("empty inactive record is valid");
12638
12639        let mut not_evaluated = base.clone();
12640        not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12641            "code": "test:missing",
12642            "message": "missing",
12643        }]);
12644        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12645        serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
12646            .unwrap()
12647            .into_files()
12648            .expect("missing-only active record derives not_evaluated");
12649        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
12650        assert!(matches!(
12651            reader_error(not_evaluated),
12652            MeasurementReportError::File {
12653                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12654                ..
12655            }
12656        ));
12657
12658        let mut partial = base.clone();
12659        partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12660            "code": "test:missing",
12661            "message": "missing",
12662        }]);
12663        partial["files"][0]["checks"][0]["evaluated_scopes"] =
12664            serde_json::json!([{ "code": "test:completed" }]);
12665        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12666        serde_json::from_value::<MeasurementReportInput>(partial.clone())
12667            .unwrap()
12668            .into_files()
12669            .expect("mixed active record derives partial");
12670        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
12671        assert!(matches!(
12672            reader_error(partial),
12673            MeasurementReportError::File {
12674                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12675                ..
12676            }
12677        ));
12678
12679        let mut wrong_complete = base;
12680        wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12681        assert!(matches!(
12682            reader_error(wrong_complete),
12683            MeasurementReportError::File {
12684                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12685                ..
12686            }
12687        ));
12688    }
12689
12690    #[test]
12691    fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
12692        let provenance = prediction_test_provenance();
12693        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12694
12695        let mut empty_scope = base.clone();
12696        empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
12697            serde_json::json!([{ "code": "" }]);
12698        assert!(matches!(
12699            reader_error(empty_scope),
12700            MeasurementReportError::File {
12701                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12702                ..
12703            }
12704        ));
12705
12706        let mut malformed_gap = base.clone();
12707        malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12708            "code": "",
12709            "message": "missing",
12710        }]);
12711        malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12712        assert!(matches!(
12713            reader_error(malformed_gap),
12714            MeasurementReportError::File {
12715                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12716                ..
12717            }
12718        ));
12719
12720        let mut incomplete_finding = base;
12721        incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
12722            "check_id": "test:reader",
12723        }]);
12724        assert!(matches!(
12725            reader_error(incomplete_finding),
12726            MeasurementReportError::File {
12727                source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
12728                ..
12729            }
12730        ));
12731    }
12732
12733    #[test]
12734    fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
12735        let provenance = prediction_test_provenance();
12736        let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12737        let mut later_file = wire["files"][0].clone();
12738        later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
12739        wire["files"].as_array_mut().unwrap().push(later_file);
12740        wire["summary"]["files"] = serde_json::json!(2);
12741        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12742
12743        assert!(matches!(
12744            reader_error(wire),
12745            MeasurementReportError::File {
12746                file_index: 0,
12747                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12748            }
12749        ));
12750    }
12751
12752    #[test]
12753    fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
12754        let provenance = prediction_test_provenance();
12755        let basis = EnginePredictionBasisV1::new(vec![
12756            PredictionBasisReferenceV1::project_field(
12757                "test:project",
12758                PredictionScalarV1::Boolean { value: true },
12759            )
12760            .unwrap(),
12761        ])
12762        .unwrap();
12763        let facet = EnginePredictionFacetV3::required_unavailable(
12764            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12765            basis_v2(basis),
12766            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12767        )
12768        .unwrap();
12769        let mut wire = validated_lint_wire(
12770            &provenance,
12771            vec![
12772                empty_check("test:first"),
12773                unavailable_check("test:second", &provenance, vec![facet]),
12774            ],
12775        );
12776        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
12777        wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
12778
12779        assert!(matches!(
12780            reader_error(wire),
12781            MeasurementReportError::File {
12782                file_index: 0,
12783                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
12784            }
12785        ));
12786    }
12787
12788    #[test]
12789    fn v11_nested_version_is_rejected_before_current_shape_decode() {
12790        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
12791            "schema_version": OUTPUT_SCHEMA_VERSION,
12792            "schema": OUTPUT_SCHEMA_ID,
12793            "tool": {},
12794            "command": "measure",
12795            "files": [{
12796                "path": "measurements-v11.json",
12797                "input": { "sha256": "0".repeat(64), "bytes": 0 },
12798                "rig": {},
12799                "measurements": {
12800                    "schema_version": 11,
12801                    "schema": "urn:animsmith:schema:measurements:11",
12802                    "skeleton_nodes": [{
12803                        "node_index": 0,
12804                        "scene_root_indices": [],
12805                        "local_rest": {
12806                            "kind": "trs",
12807                            "translation_m": [0.0, 0.0, 0.0],
12808                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
12809                            "scale": [1.0, 1.0, 1.0]
12810                        },
12811                        "rest_world_matrix": [
12812                            1.0, 0.0, 0.0, 0.0,
12813                            0.0, 1.0, 0.0, 0.0,
12814                            0.0, 0.0, 1.0, 0.0,
12815                            0.0, 0.0, 0.0, 1.0
12816                        ]
12817                    }],
12818                    "skins": [{ "skin_index": 0 }]
12819                }
12820            }]
12821        }))
12822        .expect("unsupported payload shapes remain decodable for version rejection");
12823
12824        assert!(matches!(
12825            report.into_files(),
12826            Err(MeasurementReportError::File {
12827                file_index: 0,
12828                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
12829            })
12830        ));
12831    }
12832
12833    #[test]
12834    fn current_v16_primitive_mutations_fail_closed_on_readback() {
12835        let wire = measure_wire(primitive_measurement_contract());
12836        let primitive = &wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0];
12837        assert_eq!(primitive["primitive_index"], serde_json::json!(1));
12838        assert_eq!(primitive["material_index"], serde_json::json!(7));
12839        assert_eq!(
12840            wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["material_index"],
12841            serde_json::Value::Null
12842        );
12843
12844        let mut missing_material = wire.clone();
12845        missing_material["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]
12846            .as_object_mut()
12847            .unwrap()
12848            .remove("material_index");
12849        assert!(matches!(
12850            reader_error(missing_material),
12851            MeasurementReportError::File {
12852                source: MeasurementFileError::InvalidMeasurementsShape { .. },
12853                ..
12854            }
12855        ));
12856
12857        let mut mutations = Vec::new();
12858        let mut missing = wire.clone();
12859        missing["files"][0]["measurements"]["mesh_definitions"][0]
12860            .as_object_mut()
12861            .unwrap()
12862            .remove("primitives");
12863        mutations.push(missing);
12864
12865        let mut duplicate_index = wire.clone();
12866        duplicate_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
12867            serde_json::json!(1);
12868        mutations.push(duplicate_index);
12869
12870        let mut decreasing_index = wire.clone();
12871        decreasing_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
12872            serde_json::json!(0);
12873        mutations.push(decreasing_index);
12874
12875        let mut finite_over = wire.clone();
12876        finite_over["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
12877            serde_json::json!(3);
12878        mutations.push(finite_over);
12879
12880        let mut zero_with_facts = wire.clone();
12881        zero_with_facts["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
12882            serde_json::json!(0);
12883        mutations.push(zero_with_facts);
12884
12885        let mut positive_without_centroid = wire.clone();
12886        positive_without_centroid["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
12887            [0]
12888        .as_object_mut()
12889        .unwrap()
12890        .remove("geometry_centroid");
12891        mutations.push(positive_without_centroid);
12892
12893        let mut centroid_outside_aabb = wire.clone();
12894        centroid_outside_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
12895            ["geometry_centroid"] = serde_json::json!([-3.0, 1.0, 0.0]);
12896        mutations.push(centroid_outside_aabb);
12897
12898        let mut missing_mesh_geometry = wire.clone();
12899        missing_mesh_geometry["files"][0]["measurements"]["mesh_definitions"][0]
12900            .as_object_mut()
12901            .unwrap()
12902            .remove("geometry_centroid");
12903        mutations.push(missing_mesh_geometry);
12904
12905        let mut wrong_mesh_aabb = wire.clone();
12906        wrong_mesh_aabb["files"][0]["measurements"]["mesh_definitions"][0]["geometry_aabb"]["max"]
12907            [0] = serde_json::json!(7.0);
12908        mutations.push(wrong_mesh_aabb);
12909
12910        let mut wrong_mesh_centroid = wire.clone();
12911        wrong_mesh_centroid["files"][0]["measurements"]["mesh_definitions"][0]["geometry_centroid"] =
12912            serde_json::json!([3.0, 7.0 / 3.0, 0.0]);
12913        mutations.push(wrong_mesh_centroid);
12914
12915        let mut wrong_sum = wire.clone();
12916        wrong_sum["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
12917            serde_json::json!(3);
12918        mutations.push(wrong_sum);
12919
12920        let mut finite_sum_overflow = wire.clone();
12921        for primitive in
12922            finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
12923                .as_array_mut()
12924                .unwrap()
12925        {
12926            primitive["vertex_count"] = serde_json::json!(u64::MAX);
12927            primitive["finite_vertex_count"] = serde_json::json!(u64::MAX);
12928        }
12929        finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
12930            serde_json::json!(u64::MAX);
12931        mutations.push(finite_sum_overflow);
12932
12933        for mutation in mutations {
12934            assert!(matches!(
12935                reader_error(mutation),
12936                MeasurementReportError::File {
12937                    source: MeasurementFileError::InvalidMeasurements { .. },
12938                    ..
12939                }
12940            ));
12941        }
12942
12943        let mut unknown_root = wire.clone();
12944        unknown_root["files"][0]["measurements"]
12945            .as_object_mut()
12946            .unwrap()
12947            .insert("bogus".into(), serde_json::json!(true));
12948        let mut unknown_mesh = wire.clone();
12949        unknown_mesh["files"][0]["measurements"]["mesh_definitions"][0]
12950            .as_object_mut()
12951            .unwrap()
12952            .insert("bogus".into(), serde_json::json!(true));
12953        let mut unknown_primitive = wire.clone();
12954        unknown_primitive["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
12955            .as_object_mut()
12956            .unwrap()
12957            .insert("bogus".into(), serde_json::json!(true));
12958        let mut unknown_aabb = wire;
12959        unknown_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
12960            ["geometry_aabb"]
12961            .as_object_mut()
12962            .unwrap()
12963            .insert("bogus".into(), serde_json::json!(true));
12964        for mutation in [unknown_root, unknown_mesh, unknown_primitive, unknown_aabb] {
12965            assert!(matches!(
12966                reader_error(mutation),
12967                MeasurementReportError::File {
12968                    source: MeasurementFileError::InvalidMeasurementsShape { reason },
12969                    ..
12970                } if reason.contains("unknown field `bogus`")
12971            ));
12972        }
12973    }
12974
12975    #[test]
12976    fn current_v16_material_indices_follow_resource_coverage() {
12977        let unavailable = measure_wire(primitive_measurement_contract());
12978        serde_json::from_value::<MeasurementReportInput>(unavailable.clone())
12979            .unwrap()
12980            .into_files()
12981            .expect("unavailable inventory may retain a source material index");
12982
12983        let mut complete_out_of_range = unavailable.clone();
12984        complete_out_of_range["files"][0]["measurements"]["material_resource_coverage"] =
12985            serde_json::json!("complete");
12986        assert!(matches!(
12987            reader_error(complete_out_of_range),
12988            MeasurementReportError::File {
12989                source: MeasurementFileError::InvalidMeasurements { .. },
12990                ..
12991            }
12992        ));
12993
12994        let mut complete_in_range = unavailable;
12995        complete_in_range["files"][0]["measurements"]["material_resource_coverage"] =
12996            serde_json::json!("complete");
12997        complete_in_range["files"][0]["measurements"]["material_definitions"] = serde_json::json!([{
12998            "material_index": 0,
12999            "name": null,
13000            "texture_bindings": []
13001        }]);
13002        complete_in_range["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["material_index"] =
13003            serde_json::json!(0);
13004        serde_json::from_value::<MeasurementReportInput>(complete_in_range)
13005            .unwrap()
13006            .into_files()
13007            .expect("complete inventory accepts an in-range primitive material index");
13008    }
13009
13010    #[test]
13011    fn current_v16_leading_magic_is_bounded_reason_specific_hex() {
13012        let mut assets = AssetMeasurements {
13013            material_resource_coverage: MaterialResourceCoverage::Complete,
13014            ..AssetMeasurements::default()
13015        };
13016        assets.images.push(ImageMeasurements {
13017            image_index: 0,
13018            name: None,
13019            source_kind: ImageSourceKind::Embedded,
13020            declared_mime_type: None,
13021            detected_container: None,
13022            leading_magic_hex: Some("00ff10".into()),
13023            width: None,
13024            height: None,
13025            channel_count: None,
13026            decoded_color_type: None,
13027            unavailable_reason: Some(ImageUnavailableReason::UnsupportedContainer),
13028        });
13029        let wire = measure_wire(MeasurementContract::new(BTreeMap::new(), assets).unwrap());
13030
13031        let mut unknown_image = wire.clone();
13032        unknown_image["files"][0]["measurements"]["images"][0]
13033            .as_object_mut()
13034            .unwrap()
13035            .insert("bogus".into(), serde_json::json!(true));
13036        assert!(matches!(
13037            reader_error(unknown_image),
13038            MeasurementReportError::File {
13039                source: MeasurementFileError::InvalidMeasurementsShape { reason },
13040                ..
13041            } if reason.contains("unknown field `bogus`")
13042        ));
13043
13044        for magic in ["", "0", "0F", "00ff00112233445566778899aabbccdde"] {
13045            let mut mutation = wire.clone();
13046            mutation["files"][0]["measurements"]["images"][0]["leading_magic_hex"] =
13047                serde_json::json!(magic);
13048            assert!(matches!(
13049                reader_error(mutation),
13050                MeasurementReportError::File {
13051                    source: MeasurementFileError::InvalidMeasurements { .. },
13052                    ..
13053                }
13054            ));
13055        }
13056
13057        let mut wrong_reason = wire;
13058        wrong_reason["files"][0]["measurements"]["images"][0]["unavailable_reason"] =
13059            serde_json::json!("resource_limit");
13060        assert!(matches!(
13061            reader_error(wrong_reason),
13062            MeasurementReportError::File {
13063                source: MeasurementFileError::InvalidMeasurements { .. },
13064                ..
13065            }
13066        ));
13067    }
13068
13069    #[test]
13070    fn v12_v15_round_trips_without_inventing_primitive_rows() {
13071        let current_provenance = prediction_test_provenance_v2();
13072        let historical_provenance = current_provenance.clone().historical_v15_for_test();
13073        let basis =
13074            EnginePredictionBasisV1::new_v16(vec![PredictionBasisReferenceV1::measurement_v16(
13075                crate::MeasurementPointerV1::new("/measurements/schema_version").unwrap(),
13076                PredictionScalarV1::UnsignedInteger { value: 15 },
13077            )])
13078            .unwrap();
13079        let facet = EnginePredictionFacetV2::required_unavailable(
13080            EvaluationScope::new(EvaluationScopeCode::custom("test:v12")),
13081            basis,
13082            vec![PredictionUnavailableReasonV2::MeasurementUnavailable],
13083        )
13084        .unwrap();
13085        let current_check =
13086            unavailable_check_v2("test:v12", &current_provenance, vec![facet.clone()]);
13087        let prediction =
13088            EnginePredictionV2::new(current_provenance.identity().clone(), vec![facet])
13089                .unwrap()
13090                .historical_v15_for_test(historical_provenance.identity().clone());
13091        let mut check_wire = serde_json::to_value(current_check).unwrap();
13092        check_wire["prediction"] = serde_json::to_value(prediction).unwrap();
13093        let mut historical_assets = AssetMeasurements::default();
13094        historical_assets
13095            .mesh_definitions
13096            .push(MeshDefinitionMeasurements {
13097                mesh_index: 0,
13098                name: "legacy-mesh".into(),
13099                primitives: None,
13100                vertex_count: 0,
13101                geometry_aabb: None,
13102                geometry_centroid: None,
13103                max_joints_per_vertex: 0,
13104                weight_sum_min: None,
13105                weight_sum_max: None,
13106                additional_influence_sets: Vec::new(),
13107            });
13108        let historical_measurements =
13109            MeasurementContract::historical_v15(BTreeMap::new(), historical_assets).unwrap();
13110        let wire = serde_json::json!({
13111            "schema_version": OUTPUT_V12_SCHEMA_VERSION,
13112            "schema": OUTPUT_V12_SCHEMA_ID,
13113            "tool": {},
13114            "command": "lint",
13115            "summary": {"prediction_facets": {
13116                "available": 0,
13117                "required_prediction_unavailable": 1
13118            }},
13119            "files": [{
13120                "path": "historical-v12.glb",
13121                "input": {"sha256": "00".repeat(32), "bytes": 0},
13122                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13123                "measurements": serde_json::to_value(historical_measurements).unwrap(),
13124                "prediction_provenance": serde_json::to_value(historical_provenance).unwrap(),
13125                "checks": [check_wire]
13126            }]
13127        });
13128
13129        let mut historical_with_unknowns = wire.clone();
13130        historical_with_unknowns["files"][0]["measurements"]
13131            .as_object_mut()
13132            .unwrap()
13133            .insert("future_root_field".into(), serde_json::json!(true));
13134        historical_with_unknowns["files"][0]["measurements"]["mesh_definitions"][0]
13135            .as_object_mut()
13136            .unwrap()
13137            .insert("future_mesh_field".into(), serde_json::json!(true));
13138        serde_json::from_value::<MeasurementReportInput>(historical_with_unknowns)
13139            .unwrap()
13140            .into_files()
13141            .expect("historical measurements-v15 retains permissive unknown-field readback");
13142
13143        let mut smuggled_primitives = wire.clone();
13144        smuggled_primitives["files"][0]["measurements"]["mesh_definitions"][0]["primitives"] =
13145            serde_json::json!([]);
13146        assert!(matches!(
13147            serde_json::from_value::<MeasurementReportInput>(smuggled_primitives)
13148                .unwrap()
13149                .into_files(),
13150            Err(MeasurementReportError::File {
13151                source: MeasurementFileError::InvalidMeasurements { source },
13152                ..
13153            }) if source.to_string().contains("measurements-v15 cannot carry per-primitive evidence")
13154        ));
13155
13156        let mut smuggled_magic = wire.clone();
13157        smuggled_magic["files"][0]["measurements"]["material_resource_coverage"] =
13158            serde_json::json!("complete");
13159        smuggled_magic["files"][0]["measurements"]["images"] = serde_json::json!([{
13160            "image_index": 0,
13161            "name": null,
13162            "source_kind": "embedded",
13163            "declared_mime_type": null,
13164            "detected_container": null,
13165            "leading_magic_hex": "00",
13166            "width": null,
13167            "height": null,
13168            "channel_count": null,
13169            "decoded_color_type": null,
13170            "unavailable_reason": "unsupported_container"
13171        }]);
13172        assert!(matches!(
13173            serde_json::from_value::<MeasurementReportInput>(smuggled_magic)
13174                .unwrap()
13175                .into_files(),
13176            Err(MeasurementReportError::File {
13177                source: MeasurementFileError::InvalidMeasurements { source },
13178                ..
13179            }) if source.to_string().contains("measurements-v15 cannot carry leading-magic evidence")
13180        ));
13181
13182        let files = serde_json::from_value::<MeasurementReportInput>(wire.clone())
13183            .unwrap()
13184            .into_files()
13185            .unwrap();
13186        let readback = serde_json::to_value(files[0].measurements()).unwrap();
13187        assert_eq!(readback["schema_version"], serde_json::json!(15));
13188        assert_eq!(
13189            readback["schema"],
13190            serde_json::json!(MEASUREMENTS_V15_SCHEMA_ID)
13191        );
13192        assert_eq!(readback["mesh_definitions"].as_array().unwrap().len(), 1);
13193        assert!(readback["mesh_definitions"][0].get("primitives").is_none());
13194
13195        let mut historical_max = wire.clone();
13196        historical_max["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13197            serde_json::json!(u32::MAX);
13198        serde_json::from_value::<MeasurementReportInput>(historical_max)
13199            .unwrap()
13200            .into_files()
13201            .expect("measurements-v15 retains its historical inclusive u32 maximum");
13202
13203        let mut historical_overflow = wire.clone();
13204        historical_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13205            serde_json::json!(u64::from(u32::MAX) + 1);
13206        assert!(matches!(
13207            lint_read_error(historical_overflow),
13208            MeasurementReportError::File {
13209                source: MeasurementFileError::InvalidMeasurements { .. },
13210                ..
13211            }
13212        ));
13213
13214        let mut v16_basis = wire;
13215        v16_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["references"][0]["schema"] =
13216            serde_json::json!(MEASUREMENTS_SCHEMA_ID);
13217        assert!(matches!(
13218            lint_read_error(v16_basis),
13219            MeasurementReportError::File {
13220                source: MeasurementFileError::InvalidPrediction {
13221                    source: PredictionContractError::InvalidSchema {
13222                        field: "basis.measurement.schema",
13223                        expected: MEASUREMENTS_V15_SCHEMA_ID,
13224                        ..
13225                    },
13226                    ..
13227                },
13228                ..
13229            }
13230        ));
13231    }
13232
13233    #[test]
13234    fn adjacent_output_revisions_reject_each_others_nested_measurements() {
13235        let current = measure_wire(prediction_test_measurements());
13236        let mut v12_with_v16 = current.clone();
13237        v12_with_v16["schema_version"] = serde_json::json!(OUTPUT_V12_SCHEMA_VERSION);
13238        v12_with_v16["schema"] = serde_json::json!(OUTPUT_V12_SCHEMA_ID);
13239        assert!(matches!(
13240            reader_error(v12_with_v16),
13241            MeasurementReportError::File {
13242                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 16 },
13243                ..
13244            }
13245        ));
13246
13247        let historical =
13248            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13249                .unwrap();
13250        let mut v13_with_v15 = current;
13251        v13_with_v15["files"][0]["measurements"] = serde_json::to_value(historical).unwrap();
13252        assert!(matches!(
13253            reader_error(v13_with_v15),
13254            MeasurementReportError::File {
13255                source: MeasurementFileError::UnsupportedMeasurementVersion { found: 15 },
13256                ..
13257            }
13258        ));
13259    }
13260
13261    #[test]
13262    fn legacy_output_v11_reader_dispatch_preserves_measurement_recovery() {
13263        let provenance = prediction_test_provenance_v2();
13264        let legacy = PredictionProvenanceV1::new(
13265            provenance.profile().clone(),
13266            provenance.source_format(),
13267            ResolvedEngineSettingsV1::new(provenance.profile(), Vec::new(), Vec::new()).unwrap(),
13268            provenance.raw_source().clone(),
13269            provenance.dependency_closure().clone(),
13270        )
13271        .unwrap()
13272        .historical_v15_for_test();
13273        let legacy_prediction = EnginePredictionV1::new(
13274            legacy.identity().clone(),
13275            vec![
13276                EnginePredictionFacetV1::required_unavailable(
13277                    EvaluationScope::new(EvaluationScopeCode::custom("test:legacy-v11")),
13278                    EnginePredictionBasisV1::new(Vec::new()).unwrap(),
13279                    vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
13280                )
13281                .unwrap(),
13282            ],
13283        )
13284        .unwrap()
13285        .historical_v15_for_test(legacy.identity().clone());
13286        let legacy_measurements =
13287            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13288                .unwrap();
13289        // This is an actual V11/V1 wire shape, deliberately constructed
13290        // without producing a V12 envelope and swapping only its header.
13291        let wire = serde_json::json!({
13292            "schema_version": OUTPUT_V11_SCHEMA_VERSION,
13293            "schema": OUTPUT_V11_SCHEMA_ID,
13294            "tool": {},
13295            "command": "lint",
13296            "summary": {
13297                "prediction_facets": {
13298                    "available": 0,
13299                    "required_prediction_unavailable": 1,
13300                },
13301            },
13302            "files": [{
13303                "path": "legacy-v11.glb",
13304                "input": { "sha256": "00".repeat(32), "bytes": 0 },
13305                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13306                "measurements": serde_json::to_value(legacy_measurements).unwrap(),
13307                "prediction_provenance": serde_json::to_value(legacy.clone()).unwrap(),
13308                "checks": [{
13309                    "check_id": "test:legacy-v11",
13310                    "selection": "selected",
13311                    "configuration": "enabled",
13312                    "applicability": "applicable",
13313                    "evaluation": "not_evaluated",
13314                    "findings": [],
13315                    "evaluated_scopes": [],
13316                    "gaps": [],
13317                    "prediction": legacy_prediction,
13318                }],
13319            }],
13320        });
13321        let report: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
13322        assert_eq!(report.file_count(), Some(1));
13323        assert_eq!(report.into_files().unwrap().len(), 1);
13324
13325        let mut bad_provenance = wire.clone();
13326        bad_provenance["files"][0]["prediction_provenance"]["schema"] =
13327            serde_json::json!("urn:forged");
13328        assert!(matches!(
13329            lint_read_error(bad_provenance),
13330            MeasurementReportError::File {
13331                source: MeasurementFileError::InvalidPredictionProvenance { .. },
13332                ..
13333            }
13334        ));
13335
13336        let mut bad_prediction = wire.clone();
13337        bad_prediction["files"][0]["checks"] = serde_json::json!([{
13338            "check_id": "test:legacy-v11",
13339            "selection": "selected",
13340            "configuration": "enabled",
13341            "applicability": "applicable",
13342            "evaluation": "not_evaluated",
13343            "findings": [],
13344            "evaluated_scopes": [],
13345            "gaps": [],
13346            "prediction": { "schema": "urn:forged" }
13347        }]);
13348        assert!(matches!(
13349            lint_read_error(bad_prediction),
13350            MeasurementReportError::File {
13351                source: MeasurementFileError::InvalidPredictionShape { .. },
13352                ..
13353            }
13354        ));
13355
13356        let mut missing_provenance_before_malformed_prediction = wire.clone();
13357        missing_provenance_before_malformed_prediction["files"][0]["prediction_provenance"] =
13358            serde_json::Value::Null;
13359        missing_provenance_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13360            serde_json::json!({ "schema": "urn:forged" });
13361        assert!(matches!(
13362            lint_read_error(missing_provenance_before_malformed_prediction),
13363            MeasurementReportError::File {
13364                source: MeasurementFileError::PredictionWithoutProvenance { check_index: 0 },
13365                ..
13366            }
13367        ));
13368
13369        let mut inactive_before_malformed_prediction = wire.clone();
13370        inactive_before_malformed_prediction["files"][0]["checks"][0]["selection"] =
13371            serde_json::json!("unselected");
13372        inactive_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13373            serde_json::json!({ "schema": "urn:forged" });
13374        assert!(matches!(
13375            lint_read_error(inactive_before_malformed_prediction),
13376            MeasurementReportError::File {
13377                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13378                ..
13379            }
13380        ));
13381
13382        // A V11 aggregate overflow is terminal at the check that creates it;
13383        // a malformed later check must never change that first error.
13384        let mut overbudget_before_later_malformed = wire.clone();
13385        let facet =
13386            overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"][0]
13387                .clone();
13388        overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"] =
13389            serde_json::Value::Array(
13390                std::iter::repeat_n(facet, PREDICTION_V1_MAX_FACETS_PER_FILE + 1).collect(),
13391            );
13392        overbudget_before_later_malformed["files"][0]["checks"]
13393            .as_array_mut()
13394            .unwrap()
13395            .push(serde_json::json!({ "check_id": 7 }));
13396        let precedence_error = lint_read_error(overbudget_before_later_malformed);
13397        assert!(
13398            matches!(
13399                precedence_error,
13400                MeasurementReportError::File {
13401                    source: MeasurementFileError::InvalidPrediction {
13402                        source: PredictionContractError::TooManyFacets { .. },
13403                        ..
13404                    },
13405                    ..
13406                },
13407            ),
13408            "{precedence_error:?}"
13409        );
13410    }
13411}
13412
13413#[derive(Debug, Clone, Serialize)]
13414struct FileEvidence {
13415    path: String,
13416    input: InputIdentity,
13417    rig: RigInfo,
13418    measurements: MeasurementContract,
13419}
13420
13421impl FileEvidence {
13422    fn new(
13423        path: impl Into<String>,
13424        input: InputIdentity,
13425        rig: RigInfo,
13426        measurements: MeasurementContract,
13427    ) -> Self {
13428        Self {
13429            path: path.into(),
13430            input,
13431            rig,
13432            measurements,
13433        }
13434    }
13435}
13436
13437/// One source file and its measurement-command evidence.
13438#[derive(Debug, Clone, Serialize)]
13439pub struct MeasureFileReport {
13440    #[serde(flatten)]
13441    evidence: FileEvidence,
13442}
13443
13444impl MeasureFileReport {
13445    /// Construct a measurement-command file report.
13446    pub fn new(
13447        path: impl Into<String>,
13448        input: InputIdentity,
13449        rig: RigInfo,
13450        measurements: MeasurementContract,
13451    ) -> Self {
13452        Self {
13453            evidence: FileEvidence::new(path, input, rig, measurements),
13454        }
13455    }
13456
13457    /// Display path supplied by the producer.
13458    pub fn path(&self) -> &str {
13459        &self.evidence.path
13460    }
13461
13462    /// Immutable identity of the source bytes used to produce this record.
13463    pub fn input(&self) -> &InputIdentity {
13464        &self.evidence.input
13465    }
13466
13467    /// Nested measurement evidence.
13468    pub fn measurements(&self) -> &MeasurementContract {
13469        &self.evidence.measurements
13470    }
13471}
13472
13473/// A producer attempted to construct output outside the immutable v11 contract.
13474#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13475#[non_exhaustive]
13476pub enum OutputContractError {
13477    /// One envelope carried too many file records.
13478    #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
13479    TooManyFiles {
13480        /// Supplied file count.
13481        found: usize,
13482        /// Immutable v10 limit.
13483        limit: usize,
13484    },
13485    /// One lint file carried too many check records.
13486    #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
13487    TooManyChecks {
13488        /// Supplied check count.
13489        found: usize,
13490        /// Immutable v10 limit.
13491        limit: usize,
13492    },
13493    /// A check carried prediction evidence without its file-scoped authority.
13494    #[error("engine prediction requires non-null file prediction_provenance")]
13495    PredictionWithoutProvenance,
13496    /// A current output-v15 lint record attempted to attach historical prediction evidence.
13497    #[error("output-v15 lint cannot carry historical engine-prediction evidence")]
13498    HistoricalPredictionInV2Output,
13499    /// File provenance and check prediction used different contract revisions.
13500    #[error(
13501        "output-v15 prediction provenance and check attachments must use one correlated revision"
13502    )]
13503    PredictionRevisionMismatch,
13504    /// File and provenance primary-input identities differed.
13505    #[error("prediction provenance primary input does not match the lint file input")]
13506    PredictionPrimaryInputMismatch,
13507    /// Aggregate prediction facets exceeded the V1 file limit.
13508    #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
13509    TooManyPredictionFacets {
13510        /// Supplied facet count.
13511        found: usize,
13512        /// Immutable V1 limit.
13513        limit: usize,
13514    },
13515    /// A V2 rule emitted a budget summary although the file did not consume
13516    /// every one of its shared prediction-facet slots.
13517    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
13518    FacetBudgetSummaryWithoutExhaustedFileBudget {
13519        /// Aggregate facet count.
13520        found: usize,
13521        /// Immutable shared file limit.
13522        limit: usize,
13523    },
13524    /// Aggregate basis references exceeded the V1 file limit.
13525    #[error(
13526        "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
13527    )]
13528    TooManyPredictionBasisReferences {
13529        /// Supplied reference count.
13530        found: usize,
13531        /// Immutable V1 limit.
13532        limit: usize,
13533    },
13534    /// Aggregate retained provenance/prediction text exceeded the V1 limit.
13535    #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
13536    TooMuchPredictionText {
13537        /// Supplied UTF-8 byte count.
13538        found: usize,
13539        /// Immutable V1 limit.
13540        limit: usize,
13541    },
13542    /// Checked aggregate accounting overflowed.
13543    #[error("checked arithmetic overflow while validating output-v11 bounds")]
13544    ArithmeticOverflow,
13545    /// Nested prediction evidence violated its contract.
13546    #[error("invalid prediction evidence: {0}")]
13547    InvalidPrediction(#[from] PredictionContractError),
13548}
13549
13550#[derive(Debug, Clone, Serialize)]
13551struct EnvelopeHeader {
13552    schema_version: u32,
13553    schema: &'static str,
13554    tool: ToolInfo,
13555    command: &'static str,
13556}
13557
13558impl EnvelopeHeader {
13559    fn new(tool: ToolInfo, command: &'static str) -> Self {
13560        Self {
13561            schema_version: OUTPUT_SCHEMA_VERSION,
13562            schema: OUTPUT_SCHEMA_ID,
13563            tool,
13564            command,
13565        }
13566    }
13567}
13568
13569#[derive(Debug, Clone, Serialize)]
13570struct MeasureSummary {
13571    files: usize,
13572}
13573
13574#[derive(Debug, Clone, Default, Serialize)]
13575struct FindingSummary {
13576    error: usize,
13577    warning: usize,
13578    note: usize,
13579}
13580
13581impl FindingSummary {
13582    fn add(&mut self, severity: Severity) {
13583        match severity {
13584            Severity::Error => self.error += 1,
13585            Severity::Warning => self.warning += 1,
13586            Severity::Note => self.note += 1,
13587        }
13588    }
13589}
13590
13591#[derive(Debug, Clone, Default, Serialize)]
13592struct SelectionSummary {
13593    selected: usize,
13594    unselected: usize,
13595}
13596
13597#[derive(Debug, Clone, Default, Serialize)]
13598struct ConfigurationSummary {
13599    enabled: usize,
13600    disabled: usize,
13601}
13602
13603#[derive(Debug, Clone, Default, Serialize)]
13604struct ApplicabilitySummary {
13605    applicable: usize,
13606    not_applicable: usize,
13607}
13608
13609#[derive(Debug, Clone, Default, Serialize)]
13610struct EvaluationStateSummary {
13611    complete: usize,
13612    partial: usize,
13613    not_evaluated: usize,
13614}
13615
13616#[derive(Debug, Clone, Default, Serialize)]
13617struct CheckSummary {
13618    total: usize,
13619    selection: SelectionSummary,
13620    configuration: ConfigurationSummary,
13621    applicability: ApplicabilitySummary,
13622    evaluation: EvaluationStateSummary,
13623    gaps: usize,
13624}
13625
13626#[derive(Debug, Clone, Serialize)]
13627struct LintSummary {
13628    files: usize,
13629    findings: FindingSummary,
13630    checks: CheckSummary,
13631    prediction_facets: PredictionFacetSummary,
13632}
13633
13634#[derive(Debug, Clone, Default, Serialize)]
13635struct PredictionFacetSummary {
13636    available: usize,
13637    required_prediction_unavailable: usize,
13638}
13639
13640/// Current measure-command result envelope.
13641#[derive(Debug, Clone, Serialize)]
13642pub struct MeasureEnvelope {
13643    #[serde(flatten)]
13644    header: EnvelopeHeader,
13645    summary: MeasureSummary,
13646    files: Vec<MeasureFileReport>,
13647}
13648
13649impl MeasureEnvelope {
13650    /// Construct a schema-valid measurement envelope.
13651    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
13652        if files.len() > OUTPUT_V11_MAX_FILES {
13653            return Err(OutputContractError::TooManyFiles {
13654                found: files.len(),
13655                limit: OUTPUT_V11_MAX_FILES,
13656            });
13657        }
13658        Ok(Self {
13659            header: EnvelopeHeader::new(tool, "measure"),
13660            summary: MeasureSummary { files: files.len() },
13661            files,
13662        })
13663    }
13664}
13665
13666#[derive(Debug, Clone, Serialize)]
13667#[serde(untagged)]
13668#[allow(
13669    clippy::large_enum_variant,
13670    reason = "the internal correlated revision enum preserves value ownership for both immutable wire types"
13671)]
13672enum CurrentPredictionProvenance {
13673    V3(PredictionProvenanceV3),
13674    V4(PredictionProvenanceV4),
13675}
13676
13677/// Current output-v15 lint file evidence with correlated V3 or V4 prediction provenance.
13678#[derive(Debug, Clone, Serialize)]
13679pub struct LintFileReport {
13680    #[serde(flatten)]
13681    evidence: FileEvidence,
13682    prediction_provenance: Option<CurrentPredictionProvenance>,
13683    checks: Vec<CheckEvaluation>,
13684}
13685
13686impl LintFileReport {
13687    /// Construct a legacy-V3 lint file report inside output-v15.
13688    pub fn new(
13689        path: impl Into<String>,
13690        input: InputIdentity,
13691        rig: RigInfo,
13692        prediction_provenance: Option<PredictionProvenanceV3>,
13693        checks: Vec<CheckEvaluation>,
13694        measurements: MeasurementContract,
13695    ) -> Result<Self, OutputContractError> {
13696        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
13697            return Err(OutputContractError::TooManyChecks {
13698                found: checks.len(),
13699                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
13700            });
13701        }
13702        let report = Self {
13703            evidence: FileEvidence::new(path, input, rig, measurements),
13704            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V3),
13705            checks,
13706        };
13707        report.validate()?;
13708        Ok(report)
13709    }
13710
13711    /// V3 prediction provenance, or `None` for engine-neutral lint.
13712    pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV3> {
13713        match self.prediction_provenance.as_ref() {
13714            Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
13715            Some(CurrentPredictionProvenance::V4(_)) | None => None,
13716        }
13717    }
13718
13719    /// Construct a result-bearing V4 lint file report.
13720    pub fn new_v4(
13721        path: impl Into<String>,
13722        input: InputIdentity,
13723        rig: RigInfo,
13724        prediction_provenance: Option<PredictionProvenanceV4>,
13725        checks: Vec<CheckEvaluation>,
13726        measurements: MeasurementContract,
13727    ) -> Result<Self, OutputContractError> {
13728        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
13729            return Err(OutputContractError::TooManyChecks {
13730                found: checks.len(),
13731                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
13732            });
13733        }
13734        let report = Self {
13735            evidence: FileEvidence::new(path, input, rig, measurements),
13736            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V4),
13737            checks,
13738        };
13739        report.validate()?;
13740        Ok(report)
13741    }
13742
13743    /// V4 prediction provenance, when this file uses revision 4.
13744    pub const fn prediction_provenance_v4(&self) -> Option<&PredictionProvenanceV4> {
13745        match self.prediction_provenance.as_ref() {
13746            Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
13747            Some(CurrentPredictionProvenance::V3(_)) | None => None,
13748        }
13749    }
13750
13751    /// Display path supplied by the producer.
13752    pub fn path(&self) -> &str {
13753        &self.evidence.path
13754    }
13755
13756    /// Immutable identity of the source bytes used to produce this record.
13757    pub fn input(&self) -> &InputIdentity {
13758        &self.evidence.input
13759    }
13760
13761    /// Nested measurement evidence.
13762    pub fn measurements(&self) -> &MeasurementContract {
13763        &self.evidence.measurements
13764    }
13765
13766    /// Catalog-ordered check records.
13767    pub fn checks(&self) -> &[CheckEvaluation] {
13768        &self.checks
13769    }
13770
13771    fn validate(&self) -> Result<(), OutputContractError> {
13772        if let Some(provenance) = &self.prediction_provenance {
13773            let primary_input = match provenance {
13774                CurrentPredictionProvenance::V3(provenance) => {
13775                    provenance.validate()?;
13776                    provenance.raw_source().primary_input()
13777                }
13778                CurrentPredictionProvenance::V4(provenance) => {
13779                    provenance.validate()?;
13780                    provenance.raw_source().primary_input()
13781                }
13782            };
13783            if primary_input != &self.evidence.input {
13784                return Err(OutputContractError::PredictionPrimaryInputMismatch);
13785            }
13786        }
13787        let mut facets = 0usize;
13788        let mut references = 0usize;
13789        let mut has_facet_budget_summary = false;
13790        let mut text = self
13791            .prediction_provenance
13792            .as_ref()
13793            .map(|provenance| match provenance {
13794                CurrentPredictionProvenance::V3(provenance) => provenance.retained_text_bytes(),
13795                CurrentPredictionProvenance::V4(provenance) => provenance.retained_text_bytes(),
13796            })
13797            .transpose()?
13798            .unwrap_or(0);
13799        for check in &self.checks {
13800            if check.engine_prediction().is_some() || check.engine_prediction_v2().is_some() {
13801                return Err(OutputContractError::HistoricalPredictionInV2Output);
13802            }
13803            let provenance_revision_matches = matches!(
13804                (
13805                    &self.prediction_provenance,
13806                    check.engine_prediction_v3(),
13807                    check.engine_prediction_v4()
13808                ),
13809                (Some(CurrentPredictionProvenance::V3(_)), Some(_), None)
13810                    | (Some(CurrentPredictionProvenance::V4(_)), None, Some(_))
13811                    | (_, None, None)
13812            );
13813            if !provenance_revision_matches {
13814                if self.prediction_provenance.is_none()
13815                    && (check.engine_prediction_v3().is_some()
13816                        || check.engine_prediction_v4().is_some())
13817                {
13818                    return Err(OutputContractError::PredictionWithoutProvenance);
13819                }
13820                return Err(OutputContractError::PredictionRevisionMismatch);
13821            }
13822            let legacy_v3_provenance = match self.prediction_provenance.as_ref() {
13823                Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
13824                Some(CurrentPredictionProvenance::V4(_)) | None => None,
13825            };
13826            if !matches!(
13827                self.prediction_provenance.as_ref(),
13828                Some(CurrentPredictionProvenance::V4(_))
13829            ) {
13830                validate_current_engine_clip_boundary_applicability_v3(
13831                    check.check_id(),
13832                    check.applicability(),
13833                    legacy_v3_provenance,
13834                )?;
13835            }
13836            if check.check_id() == ENGINE_CLIP_BOUNDARY_CHECK_ID
13837                && check.selection() == SelectionState::Selected
13838                && check.configuration() == ConfigurationState::Enabled
13839                && check.applicability() == Applicability::Applicable
13840                && check.engine_prediction_v3().is_none()
13841            {
13842                return Err(OutputContractError::InvalidPrediction(
13843                    PredictionContractError::EngineClipBoundaryFacetMismatch,
13844                ));
13845            }
13846            let current_v4_provenance = match self.prediction_provenance.as_ref() {
13847                Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
13848                Some(CurrentPredictionProvenance::V3(_)) | None => None,
13849            };
13850            validate_current_engine_unit_scale_prediction_v4(
13851                check.check_id(),
13852                check.selection(),
13853                check.configuration(),
13854                check.applicability(),
13855                check.engine_prediction_v4(),
13856                current_v4_provenance,
13857                &self.evidence.measurements,
13858            )?;
13859            if let Some(prediction) = check.engine_prediction_v3() {
13860                let Some(CurrentPredictionProvenance::V3(provenance)) =
13861                    self.prediction_provenance.as_ref()
13862                else {
13863                    return Err(OutputContractError::PredictionRevisionMismatch);
13864                };
13865                prediction.validate_against_provenance(provenance)?;
13866                prediction.validate_for_check(
13867                    check.check_id(),
13868                    check.evaluated_scopes(),
13869                    check.gaps(),
13870                    check.findings(),
13871                )?;
13872                validate_current_engine_addressability_prediction_v3(
13873                    check.check_id(),
13874                    prediction,
13875                    provenance,
13876                )?;
13877                let finding_scopes = check
13878                    .findings()
13879                    .iter()
13880                    .filter_map(|finding| finding.prediction_scope.as_ref())
13881                    .collect::<Vec<_>>();
13882                validate_current_engine_clip_boundary_prediction_v3(
13883                    check.check_id(),
13884                    prediction,
13885                    provenance,
13886                    check.evaluated_scopes(),
13887                    &finding_scopes,
13888                )?;
13889                has_facet_budget_summary |= prediction.has_facet_budget_summary();
13890                facets = facets
13891                    .checked_add(prediction.facets().len())
13892                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13893                references = references
13894                    .checked_add(prediction.basis_reference_count())
13895                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13896                text = text
13897                    .checked_add(prediction.retained_text_bytes()?)
13898                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13899            }
13900            if let Some(prediction) = check.engine_prediction_v4() {
13901                let Some(CurrentPredictionProvenance::V4(provenance)) =
13902                    self.prediction_provenance.as_ref()
13903                else {
13904                    return Err(OutputContractError::PredictionRevisionMismatch);
13905                };
13906                prediction.validate_against_provenance(provenance)?;
13907                prediction.validate_for_check(
13908                    check.check_id(),
13909                    check.evaluated_scopes(),
13910                    check.gaps(),
13911                    check.findings(),
13912                )?;
13913                has_facet_budget_summary |= prediction.has_facet_budget_summary();
13914                facets = facets
13915                    .checked_add(prediction.facets().len())
13916                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13917                references = references
13918                    .checked_add(prediction.basis_reference_count())
13919                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13920                text = text
13921                    .checked_add(prediction.retained_text_bytes()?)
13922                    .ok_or(OutputContractError::ArithmeticOverflow)?;
13923            }
13924        }
13925        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
13926            return Err(OutputContractError::TooManyPredictionFacets {
13927                found: facets,
13928                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
13929            });
13930        }
13931        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
13932            return Err(
13933                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
13934                    found: facets,
13935                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
13936                },
13937            );
13938        }
13939        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
13940            return Err(OutputContractError::TooManyPredictionBasisReferences {
13941                found: references,
13942                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
13943            });
13944        }
13945        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
13946            return Err(OutputContractError::TooMuchPredictionText {
13947                found: text,
13948                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
13949            });
13950        }
13951        validate_measurement_references_batch_v3(
13952            &self.evidence.measurements,
13953            self.checks
13954                .iter()
13955                .enumerate()
13956                .filter_map(|(check_index, check)| {
13957                    check
13958                        .engine_prediction_v3()
13959                        .map(|prediction| (check_index, prediction))
13960                }),
13961        )
13962        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
13963        validate_measurement_references_batch_v4(
13964            &self.evidence.measurements,
13965            self.checks
13966                .iter()
13967                .enumerate()
13968                .filter_map(|(check_index, check)| {
13969                    check
13970                        .engine_prediction_v4()
13971                        .map(|prediction| (check_index, prediction))
13972                }),
13973        )
13974        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
13975        Ok(())
13976    }
13977}
13978
13979#[derive(Debug, Clone, Serialize)]
13980struct EnvelopeHeaderV2 {
13981    schema_version: u32,
13982    schema: &'static str,
13983    tool: ToolInfo,
13984    command: &'static str,
13985}
13986
13987/// Immutable historical output-v15 lint envelope.
13988#[derive(Debug, Clone, Serialize)]
13989pub struct LintEnvelope {
13990    #[serde(flatten)]
13991    header: EnvelopeHeaderV2,
13992    summary: LintSummary,
13993    files: Vec<LintFileReport>,
13994}
13995
13996impl LintEnvelope {
13997    /// Construct a V2 lint envelope and derive its summaries.
13998    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
13999        if files.len() > OUTPUT_V11_MAX_FILES {
14000            return Err(OutputContractError::TooManyFiles {
14001                found: files.len(),
14002                limit: OUTPUT_V11_MAX_FILES,
14003            });
14004        }
14005        let mut findings = FindingSummary::default();
14006        let mut checks = CheckSummary::default();
14007        let mut prediction_facets = PredictionFacetSummary::default();
14008        for file in &files {
14009            file.validate()?;
14010            for check in file.checks() {
14011                checks.total += 1;
14012                for finding in check.findings() {
14013                    findings.add(finding.severity);
14014                }
14015                match check.selection() {
14016                    SelectionState::Selected => checks.selection.selected += 1,
14017                    SelectionState::Unselected => checks.selection.unselected += 1,
14018                }
14019                match check.configuration() {
14020                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14021                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14022                }
14023                match check.applicability() {
14024                    Applicability::Applicable => checks.applicability.applicable += 1,
14025                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14026                }
14027                match check.evaluation() {
14028                    EvaluationState::Complete => checks.evaluation.complete += 1,
14029                    EvaluationState::Partial => checks.evaluation.partial += 1,
14030                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14031                }
14032                checks.gaps += check.gaps().len();
14033                if let Some(prediction) = check.engine_prediction_v3() {
14034                    for facet in prediction.facets() {
14035                        match facet.state() {
14036                            EnginePredictionFacetStateV1::Available => {
14037                                prediction_facets.available += 1;
14038                            }
14039                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14040                                prediction_facets.required_prediction_unavailable += 1;
14041                            }
14042                        }
14043                    }
14044                }
14045                if let Some(prediction) = check.engine_prediction_v4() {
14046                    for facet in prediction.facets() {
14047                        match facet.state() {
14048                            EnginePredictionFacetStateV1::Available => {
14049                                prediction_facets.available += 1;
14050                            }
14051                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14052                                prediction_facets.required_prediction_unavailable += 1;
14053                            }
14054                        }
14055                    }
14056                }
14057            }
14058        }
14059        Ok(Self {
14060            header: EnvelopeHeaderV2 {
14061                schema_version: OUTPUT_V15_SCHEMA_VERSION,
14062                schema: OUTPUT_V15_SCHEMA_ID,
14063                tool,
14064                command: "lint",
14065            },
14066            summary: LintSummary {
14067                files: files.len(),
14068                findings,
14069                checks,
14070                prediction_facets,
14071            },
14072            files,
14073        })
14074    }
14075}
14076
14077#[derive(Debug, Clone, Serialize)]
14078#[serde(untagged)]
14079#[allow(clippy::large_enum_variant)]
14080enum CurrentPredictionProvenanceV16 {
14081    V3(PredictionProvenanceV3),
14082    V5(PredictionProvenanceV5),
14083}
14084
14085/// Current output-v16 lint file evidence with V3 or V5 prediction provenance.
14086#[derive(Debug, Clone, Serialize)]
14087pub struct LintFileReportV16 {
14088    #[serde(flatten)]
14089    evidence: FileEvidence,
14090    prediction_provenance: Option<CurrentPredictionProvenanceV16>,
14091    checks: Vec<CheckEvaluation>,
14092}
14093
14094impl LintFileReportV16 {
14095    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14096    pub fn new(
14097        path: impl Into<String>,
14098        input: InputIdentity,
14099        rig: RigInfo,
14100        prediction_provenance: Option<PredictionProvenanceV3>,
14101        checks: Vec<CheckEvaluation>,
14102        measurements: MeasurementContract,
14103    ) -> Result<Self, OutputContractError> {
14104        let report = Self {
14105            evidence: FileEvidence::new(path, input, rig, measurements),
14106            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V3),
14107            checks,
14108        };
14109        report.validate()?;
14110        Ok(report)
14111    }
14112
14113    /// Construct a revision-2-contract record using V5 provenance and checks.
14114    pub fn new_v5(
14115        path: impl Into<String>,
14116        input: InputIdentity,
14117        rig: RigInfo,
14118        prediction_provenance: Option<PredictionProvenanceV5>,
14119        checks: Vec<CheckEvaluation>,
14120        measurements: MeasurementContract,
14121    ) -> Result<Self, OutputContractError> {
14122        let report = Self {
14123            evidence: FileEvidence::new(path, input, rig, measurements),
14124            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V5),
14125            checks,
14126        };
14127        report.validate()?;
14128        Ok(report)
14129    }
14130
14131    /// Display path supplied by the producer.
14132    pub fn path(&self) -> &str {
14133        &self.evidence.path
14134    }
14135    /// Immutable source-byte identity.
14136    pub fn input(&self) -> &InputIdentity {
14137        &self.evidence.input
14138    }
14139    /// Nested measurement evidence.
14140    pub fn measurements(&self) -> &MeasurementContract {
14141        &self.evidence.measurements
14142    }
14143    /// Catalog-ordered check records.
14144    pub fn checks(&self) -> &[CheckEvaluation] {
14145        &self.checks
14146    }
14147    /// V3 provenance for a revision-1 profile.
14148    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14149        match self.prediction_provenance.as_ref() {
14150            Some(CurrentPredictionProvenanceV16::V3(provenance)) => Some(provenance),
14151            Some(CurrentPredictionProvenanceV16::V5(_)) | None => None,
14152        }
14153    }
14154    /// V5 provenance for a revision-2-contract profile.
14155    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14156        match self.prediction_provenance.as_ref() {
14157            Some(CurrentPredictionProvenanceV16::V5(provenance)) => Some(provenance),
14158            Some(CurrentPredictionProvenanceV16::V3(_)) | None => None,
14159        }
14160    }
14161
14162    fn validate(&self) -> Result<(), OutputContractError> {
14163        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14164            return Err(OutputContractError::TooManyChecks {
14165                found: self.checks.len(),
14166                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14167            });
14168        }
14169        if let Some(CurrentPredictionProvenanceV16::V3(provenance)) = &self.prediction_provenance {
14170            return LintFileReport {
14171                evidence: self.evidence.clone(),
14172                prediction_provenance: Some(CurrentPredictionProvenance::V3(provenance.clone())),
14173                checks: self.checks.clone(),
14174            }
14175            .validate();
14176        }
14177        let provenance = match &self.prediction_provenance {
14178            Some(CurrentPredictionProvenanceV16::V5(provenance)) => {
14179                provenance.validate()?;
14180                if provenance.base().raw_source().primary_input() != &self.evidence.input {
14181                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
14182                }
14183                Some(provenance)
14184            }
14185            Some(CurrentPredictionProvenanceV16::V3(_)) => unreachable!(),
14186            None => None,
14187        };
14188        let mut facets = 0usize;
14189        let mut references = 0usize;
14190        let mut has_facet_budget_summary = false;
14191        let mut text = provenance
14192            .map(PredictionProvenanceV5::retained_text_bytes)
14193            .transpose()?
14194            .unwrap_or(0);
14195        for check in &self.checks {
14196            if check.engine_prediction().is_some()
14197                || check.engine_prediction_v2().is_some()
14198                || check.engine_prediction_v3().is_some()
14199                || check.engine_prediction_v4().is_some()
14200            {
14201                return Err(OutputContractError::HistoricalPredictionInV2Output);
14202            }
14203            let prediction = check.engine_prediction_v5();
14204            if provenance.is_none() && prediction.is_some() {
14205                return Err(OutputContractError::PredictionWithoutProvenance);
14206            }
14207            if let Some(prediction) = prediction {
14208                let provenance =
14209                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
14210                prediction.validate_against_provenance(provenance)?;
14211                prediction.validate_for_check(
14212                    check.check_id(),
14213                    check.evaluated_scopes(),
14214                    check.gaps(),
14215                    check.findings(),
14216                )?;
14217                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
14218                facets = facets
14219                    .checked_add(prediction.facets().len())
14220                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14221                references = references
14222                    .checked_add(prediction.basis_reference_count())
14223                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14224                text = text
14225                    .checked_add(prediction.retained_text_bytes()?)
14226                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14227            }
14228            validate_current_engine_track_support_prediction_v5(
14229                check.check_id(),
14230                check.selection(),
14231                check.configuration(),
14232                check.applicability(),
14233                prediction,
14234                provenance,
14235                check.findings().is_empty(),
14236            )?;
14237            validate_current_engine_unit_scale_prediction_v5(
14238                check.check_id(),
14239                check.selection(),
14240                check.configuration(),
14241                check.applicability(),
14242                prediction,
14243                provenance,
14244                &self.evidence.measurements,
14245            )?;
14246        }
14247        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14248            return Err(OutputContractError::TooManyPredictionFacets {
14249                found: facets,
14250                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14251            });
14252        }
14253        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14254            return Err(
14255                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14256                    found: facets,
14257                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14258                },
14259            );
14260        }
14261        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14262            return Err(OutputContractError::TooManyPredictionBasisReferences {
14263                found: references,
14264                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14265            });
14266        }
14267        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14268            return Err(OutputContractError::TooMuchPredictionText {
14269                found: text,
14270                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14271            });
14272        }
14273        validate_measurement_references_batch_v4(
14274            &self.evidence.measurements,
14275            self.checks.iter().enumerate().filter_map(|(index, check)| {
14276                check
14277                    .engine_prediction_v5()
14278                    .map(|prediction| (index, prediction.base_prediction()))
14279            }),
14280        )
14281        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14282        Ok(())
14283    }
14284}
14285
14286/// Current output-v16 lint envelope.
14287#[derive(Debug, Clone, Serialize)]
14288pub struct LintEnvelopeV16 {
14289    #[serde(flatten)]
14290    header: EnvelopeHeaderV2,
14291    summary: LintSummary,
14292    files: Vec<LintFileReportV16>,
14293}
14294
14295impl LintEnvelopeV16 {
14296    /// Construct a schema-valid V16 lint envelope and derive summaries.
14297    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV16>) -> Result<Self, OutputContractError> {
14298        if files.len() > OUTPUT_V11_MAX_FILES {
14299            return Err(OutputContractError::TooManyFiles {
14300                found: files.len(),
14301                limit: OUTPUT_V11_MAX_FILES,
14302            });
14303        }
14304        let mut findings = FindingSummary::default();
14305        let mut checks = CheckSummary::default();
14306        let mut prediction_facets = PredictionFacetSummary::default();
14307        for file in &files {
14308            file.validate()?;
14309            for check in file.checks() {
14310                checks.total += 1;
14311                for finding in check.findings() {
14312                    findings.add(finding.severity);
14313                }
14314                match check.selection() {
14315                    SelectionState::Selected => checks.selection.selected += 1,
14316                    SelectionState::Unselected => checks.selection.unselected += 1,
14317                }
14318                match check.configuration() {
14319                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14320                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14321                }
14322                match check.applicability() {
14323                    Applicability::Applicable => checks.applicability.applicable += 1,
14324                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14325                }
14326                match check.evaluation() {
14327                    EvaluationState::Complete => checks.evaluation.complete += 1,
14328                    EvaluationState::Partial => checks.evaluation.partial += 1,
14329                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14330                }
14331                checks.gaps += check.gaps().len();
14332                for facet in check
14333                    .engine_prediction_v3()
14334                    .into_iter()
14335                    .flat_map(EnginePredictionV3::facets)
14336                {
14337                    match facet.state() {
14338                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
14339                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14340                            prediction_facets.required_prediction_unavailable += 1
14341                        }
14342                    }
14343                }
14344                for facet in check
14345                    .engine_prediction_v5()
14346                    .into_iter()
14347                    .flat_map(EnginePredictionV5::facets)
14348                {
14349                    match facet.state() {
14350                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
14351                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14352                            prediction_facets.required_prediction_unavailable += 1
14353                        }
14354                    }
14355                }
14356            }
14357        }
14358        Ok(Self {
14359            header: EnvelopeHeaderV2 {
14360                schema_version: OUTPUT_V16_SCHEMA_VERSION,
14361                schema: OUTPUT_V16_SCHEMA_ID,
14362                tool,
14363                command: "lint",
14364            },
14365            summary: LintSummary {
14366                files: files.len(),
14367                findings,
14368                checks,
14369                prediction_facets,
14370            },
14371            files,
14372        })
14373    }
14374}
14375
14376#[derive(Debug, Clone, Serialize)]
14377#[serde(untagged)]
14378#[allow(clippy::large_enum_variant)]
14379enum CurrentPredictionProvenanceV17 {
14380    V3(PredictionProvenanceV3),
14381    V5(PredictionProvenanceV5),
14382    V6(PredictionProvenanceV6),
14383}
14384
14385/// Current output-v17 lint file evidence with immutable V3, V5, or V6 provenance.
14386#[derive(Debug, Clone, Serialize)]
14387pub struct LintFileReportV17 {
14388    #[serde(flatten)]
14389    evidence: FileEvidence,
14390    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
14391    checks: Vec<CheckEvaluation>,
14392}
14393
14394impl LintFileReportV17 {
14395    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14396    pub fn new(
14397        path: impl Into<String>,
14398        input: InputIdentity,
14399        rig: RigInfo,
14400        prediction_provenance: Option<PredictionProvenanceV3>,
14401        checks: Vec<CheckEvaluation>,
14402        measurements: MeasurementContract,
14403    ) -> Result<Self, OutputContractError> {
14404        let report = Self {
14405            evidence: FileEvidence::new(path, input, rig, measurements),
14406            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
14407            checks,
14408        };
14409        report.validate()?;
14410        Ok(report)
14411    }
14412
14413    /// Construct a V5 record whose output-v16 graph remains unchanged.
14414    pub fn new_v5(
14415        path: impl Into<String>,
14416        input: InputIdentity,
14417        rig: RigInfo,
14418        prediction_provenance: Option<PredictionProvenanceV5>,
14419        checks: Vec<CheckEvaluation>,
14420        measurements: MeasurementContract,
14421    ) -> Result<Self, OutputContractError> {
14422        let report = Self {
14423            evidence: FileEvidence::new(path, input, rig, measurements),
14424            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
14425            checks,
14426        };
14427        report.validate()?;
14428        Ok(report)
14429    }
14430
14431    /// Construct a transform-path-and-intent-bound V6 record.
14432    pub fn new_v6(
14433        path: impl Into<String>,
14434        input: InputIdentity,
14435        rig: RigInfo,
14436        prediction_provenance: Option<PredictionProvenanceV6>,
14437        checks: Vec<CheckEvaluation>,
14438        measurements: MeasurementContract,
14439    ) -> Result<Self, OutputContractError> {
14440        let report = Self {
14441            evidence: FileEvidence::new(path, input, rig, measurements),
14442            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
14443            checks,
14444        };
14445        report.validate()?;
14446        Ok(report)
14447    }
14448
14449    /// Display path supplied by the producer.
14450    pub fn path(&self) -> &str {
14451        &self.evidence.path
14452    }
14453    /// Immutable source-byte identity.
14454    pub fn input(&self) -> &InputIdentity {
14455        &self.evidence.input
14456    }
14457    /// Nested measurement evidence.
14458    pub fn measurements(&self) -> &MeasurementContract {
14459        &self.evidence.measurements
14460    }
14461    /// Catalog-ordered check records.
14462    pub fn checks(&self) -> &[CheckEvaluation] {
14463        &self.checks
14464    }
14465    /// V3 provenance for a revision-1 profile.
14466    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14467        match self.prediction_provenance.as_ref() {
14468            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
14469            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
14470            | None => None,
14471        }
14472    }
14473    /// Immutable V5 provenance.
14474    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14475        match self.prediction_provenance.as_ref() {
14476            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
14477            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
14478            | None => None,
14479        }
14480    }
14481    /// Current V6 provenance.
14482    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
14483        match self.prediction_provenance.as_ref() {
14484            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
14485            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
14486            | None => None,
14487        }
14488    }
14489
14490    fn validate(&self) -> Result<(), OutputContractError> {
14491        match &self.prediction_provenance {
14492            Some(CurrentPredictionProvenanceV17::V3(provenance)) => {
14493                return LintFileReportV16::new(
14494                    self.evidence.path.clone(),
14495                    self.evidence.input.clone(),
14496                    self.evidence.rig.clone(),
14497                    Some(provenance.clone()),
14498                    self.checks.clone(),
14499                    self.evidence.measurements.clone(),
14500                )
14501                .map(|_| ());
14502            }
14503            Some(CurrentPredictionProvenanceV17::V5(provenance)) => {
14504                return LintFileReportV16::new_v5(
14505                    self.evidence.path.clone(),
14506                    self.evidence.input.clone(),
14507                    self.evidence.rig.clone(),
14508                    Some(provenance.clone()),
14509                    self.checks.clone(),
14510                    self.evidence.measurements.clone(),
14511                )
14512                .map(|_| ());
14513            }
14514            Some(CurrentPredictionProvenanceV17::V6(_)) => {}
14515            None => {
14516                return LintFileReportV16::new(
14517                    self.evidence.path.clone(),
14518                    self.evidence.input.clone(),
14519                    self.evidence.rig.clone(),
14520                    None,
14521                    self.checks.clone(),
14522                    self.evidence.measurements.clone(),
14523                )
14524                .map(|_| ());
14525            }
14526        }
14527        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14528            return Err(OutputContractError::TooManyChecks {
14529                found: self.checks.len(),
14530                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14531            });
14532        }
14533        let provenance = match &self.prediction_provenance {
14534            Some(CurrentPredictionProvenanceV17::V6(provenance)) => {
14535                provenance.validate()?;
14536                if provenance.base().base().raw_source().primary_input() != &self.evidence.input {
14537                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
14538                }
14539                Some(provenance)
14540            }
14541            _ => unreachable!(),
14542        };
14543        let mut facets = 0usize;
14544        let mut references = 0usize;
14545        let mut has_facet_budget_summary = false;
14546        let mut text = provenance
14547            .map(PredictionProvenanceV6::retained_text_bytes)
14548            .transpose()?
14549            .unwrap_or(0);
14550        for check in &self.checks {
14551            if check.engine_prediction().is_some()
14552                || check.engine_prediction_v2().is_some()
14553                || check.engine_prediction_v3().is_some()
14554                || check.engine_prediction_v4().is_some()
14555                || check.engine_prediction_v5().is_some()
14556            {
14557                return Err(OutputContractError::HistoricalPredictionInV2Output);
14558            }
14559            let prediction = check.engine_prediction_v6();
14560            if let Some(prediction) = prediction {
14561                let provenance =
14562                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
14563                prediction.validate_against_provenance(provenance)?;
14564                prediction.validate_for_check(
14565                    check.check_id(),
14566                    check.evaluated_scopes(),
14567                    check.gaps(),
14568                    check.findings(),
14569                )?;
14570                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
14571                facets = facets
14572                    .checked_add(prediction.facets().len())
14573                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14574                references = references
14575                    .checked_add(prediction.basis_reference_count())
14576                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14577                text = text
14578                    .checked_add(prediction.retained_text_bytes()?)
14579                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14580            }
14581            validate_current_engine_root_motion_prediction_v6(
14582                check.check_id(),
14583                check.selection(),
14584                check.configuration(),
14585                check.applicability(),
14586                prediction,
14587                provenance,
14588                check.findings(),
14589                &self.evidence.rig,
14590                &self.evidence.measurements,
14591            )?;
14592        }
14593        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14594            return Err(OutputContractError::TooManyPredictionFacets {
14595                found: facets,
14596                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14597            });
14598        }
14599        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14600            return Err(
14601                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14602                    found: facets,
14603                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14604                },
14605            );
14606        }
14607        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14608            return Err(OutputContractError::TooManyPredictionBasisReferences {
14609                found: references,
14610                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14611            });
14612        }
14613        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14614            return Err(OutputContractError::TooMuchPredictionText {
14615                found: text,
14616                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14617            });
14618        }
14619        validate_measurement_references_batch_v4(
14620            &self.evidence.measurements,
14621            self.checks.iter().enumerate().filter_map(|(index, check)| {
14622                check
14623                    .engine_prediction_v6()
14624                    .map(|prediction| (index, prediction.base_prediction()))
14625            }),
14626        )
14627        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14628        Ok(())
14629    }
14630}
14631
14632/// Current output-v17 lint envelope.
14633#[derive(Debug, Clone, Serialize)]
14634pub struct LintEnvelopeV17 {
14635    #[serde(flatten)]
14636    header: EnvelopeHeaderV2,
14637    summary: LintSummary,
14638    files: Vec<LintFileReportV17>,
14639}
14640
14641impl LintEnvelopeV17 {
14642    /// Construct a schema-valid V17 lint envelope and derive summaries.
14643    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV17>) -> Result<Self, OutputContractError> {
14644        if files.len() > OUTPUT_V11_MAX_FILES {
14645            return Err(OutputContractError::TooManyFiles {
14646                found: files.len(),
14647                limit: OUTPUT_V11_MAX_FILES,
14648            });
14649        }
14650        let mut findings = FindingSummary::default();
14651        let mut checks = CheckSummary::default();
14652        let mut prediction_facets = PredictionFacetSummary::default();
14653        for file in &files {
14654            file.validate()?;
14655            for check in file.checks() {
14656                checks.total += 1;
14657                for finding in check.findings() {
14658                    findings.add(finding.severity);
14659                }
14660                match check.selection() {
14661                    SelectionState::Selected => checks.selection.selected += 1,
14662                    SelectionState::Unselected => checks.selection.unselected += 1,
14663                }
14664                match check.configuration() {
14665                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14666                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14667                }
14668                match check.applicability() {
14669                    Applicability::Applicable => checks.applicability.applicable += 1,
14670                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14671                }
14672                match check.evaluation() {
14673                    EvaluationState::Complete => checks.evaluation.complete += 1,
14674                    EvaluationState::Partial => checks.evaluation.partial += 1,
14675                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14676                }
14677                checks.gaps += check.gaps().len();
14678                let mut add_facet = |state| match state {
14679                    EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
14680                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14681                        prediction_facets.required_prediction_unavailable += 1
14682                    }
14683                };
14684                for facet in check
14685                    .engine_prediction_v3()
14686                    .into_iter()
14687                    .flat_map(EnginePredictionV3::facets)
14688                {
14689                    add_facet(facet.state());
14690                }
14691                for facet in check
14692                    .engine_prediction_v5()
14693                    .into_iter()
14694                    .flat_map(EnginePredictionV5::facets)
14695                {
14696                    add_facet(facet.state());
14697                }
14698                for facet in check
14699                    .engine_prediction_v6()
14700                    .into_iter()
14701                    .flat_map(EnginePredictionV6::facets)
14702                {
14703                    add_facet(facet.state());
14704                }
14705            }
14706        }
14707        Ok(Self {
14708            header: EnvelopeHeaderV2 {
14709                schema_version: OUTPUT_SCHEMA_VERSION,
14710                schema: OUTPUT_SCHEMA_ID,
14711                tool,
14712                command: "lint",
14713            },
14714            summary: LintSummary {
14715                files: files.len(),
14716                findings,
14717                checks,
14718                prediction_facets,
14719            },
14720            files,
14721        })
14722    }
14723}
14724
14725#[derive(Debug, Clone, Serialize)]
14726struct DiffInputs {
14727    before: String,
14728    after: String,
14729}
14730
14731#[derive(Debug, Clone, Serialize)]
14732struct DiffSummary {
14733    deltas: usize,
14734}
14735
14736/// Current diff-command result envelope.
14737#[derive(Debug, Serialize)]
14738pub struct DiffEnvelope {
14739    #[serde(flatten)]
14740    header: EnvelopeHeader,
14741    inputs: DiffInputs,
14742    summary: DiffSummary,
14743    deltas: Vec<MetricDelta>,
14744}
14745
14746impl DiffEnvelope {
14747    /// Construct a schema-valid diff envelope.
14748    pub fn new(
14749        tool: ToolInfo,
14750        before: impl Into<String>,
14751        after: impl Into<String>,
14752        deltas: Vec<MetricDelta>,
14753    ) -> Self {
14754        Self {
14755            header: EnvelopeHeader::new(tool, "diff"),
14756            inputs: DiffInputs {
14757                before: before.into(),
14758                after: after.into(),
14759            },
14760            summary: DiffSummary {
14761                deltas: deltas.len(),
14762            },
14763            deltas,
14764        }
14765    }
14766}