Skip to main content

animsmith_core/
contract.rs

1//! Versioned JSON result-contract types shared by CLI and embedded producers.
2//!
3//! The CLI is one producer of these envelopes. Embedded pipelines can use the
4//! same constructors and immutable protocol identities without duplicating the
5//! wire shape or hard-coding URNs.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt::Write as _;
9use std::io::Read;
10
11use glam::Mat4;
12use serde::de::{DeserializeSeed, EnumAccess, MapAccess, SeqAccess, VariantAccess, Visitor};
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::value::RawValue;
15use sha2::{Digest, Sha256};
16
17use crate::dependency_closure::DependencyClosureCoverageV1;
18use crate::diff::MetricDelta;
19use crate::engine_contract::{
20    EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EngineSettingIdV2, EngineSettingValueV2,
21};
22use crate::evaluation::{
23    Applicability, CheckEvaluation, CheckEvaluationGapRef, CheckEvaluationValidationInput,
24    ConfigurationState, EvaluationScope, EvaluationState, SelectionState,
25    validate_and_derive_check_evaluation,
26};
27use crate::measure::{
28    Aabb, AdditionalInfluenceSetMeasurements, AssetMeasurements, ClipMeasurements,
29    ImageMeasurements, LinearTransformClassification, LinearTransformMeasurements,
30    MaterialDefinitionMeasurements, MeasurementAvailability, MeshDefinitionMeasurements,
31    NodeInstanceMeasurements, PrimitiveMeasurements, SceneMeasurements,
32    SkeletonNodeLocalRestMeasurements, SkeletonRestWorldMatrixUnavailableReason,
33    SkinDerivedMatrixMeasurements, SkinDerivedMatrixUnavailableReason,
34    StaticNodeAabbUnavailableReason, TextureMeasurements, assess_inverse_bind,
35    measure_linear_transform, summarize_skin_bind_linear,
36};
37use crate::metrics::canonical_net_yaw_deg;
38use crate::model::{
39    DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
40    SourceSkeletonCoverage,
41};
42use crate::prediction::{
43    EngineMachineResultV1, EnginePredictionBasisV2, EnginePredictionBasisV4,
44    EnginePredictionFacetStateV1, EnginePredictionV1, EnginePredictionV4, EnginePredictionV5,
45    EnginePredictionV6, ExactSourceTimingBasisReferenceV1, ExactSourceTimingBindingV1,
46    ExactSourceTimingDomainV1, ExactSourceTimingKeyV1, ExactSourceTimingObservationStateWireV1,
47    PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE, PREDICTION_V1_MAX_FACETS_PER_FILE,
48    PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE, PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE,
49    PredictionBasisReferenceV4, PredictionContractError, PredictionDecodeError,
50    PredictionProvenanceV4, PredictionProvenanceV5, PredictionProvenanceV6, PredictionScalarV1,
51    RawSceneAttachmentBasisDomainV1, RawSceneAttachmentBasisReferenceV1, RawSourceBasisReferenceV1,
52    ResolvedSettingLocationV1, TransformScaleDomainV1, TransformScaleResultV1,
53    TransformScaleSubjectKindV1, UnitMappingResultV1,
54    decode_engine_prediction_v1_with_measurement_schema,
55    decode_engine_prediction_v2_with_measurement_schema, decode_engine_prediction_v3,
56    decode_engine_prediction_v4, decode_prediction_provenance_v1_with_measurement_schema,
57    decode_prediction_provenance_v2_with_measurement_schema, decode_prediction_provenance_v3,
58    decode_prediction_provenance_v4, validate_measurement_references_batch,
59    validate_measurement_references_batch_v2, validate_measurement_references_batch_v3,
60    validate_measurement_references_batch_v4,
61};
62use crate::profile::ResolvedRoles;
63use crate::source_facts::SourceFormatV1;
64use crate::{Document, Severity};
65use crate::{
66    EnginePredictionV2, EnginePredictionV3, ImporterSubjectCreationV1, InventoryCoverageResultV1,
67    PredictionBasisReferenceV1, PredictionBasisReferenceV2, PredictionInventoryCoverageStateV1,
68    PredictionInventoryDomainV1, PredictionProvenanceV1, PredictionProvenanceV2,
69    PredictionProvenanceV3, PredictionUnavailableReasonV2, RawAnimationChannelInventoryV1,
70    RawSceneAttachmentCoverageV1, RawSourceDomainV1, RawSourceFieldIdV1, RawSourceKeyV1,
71    RawSourceSetCoverageStateV1, ResolvedEngineSettingsCoverageStateV2,
72    SourceImportDispositionResultV1, SourceImportDispositionV1, SourceImportSubjectKindV1,
73    SourceSkeletonRowKindV1,
74};
75
76/// Current outer result-envelope version.
77pub const OUTPUT_SCHEMA_VERSION: u32 = 19;
78/// Immutable identity of the current outer result envelope.
79pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:19";
80/// Immutable output-v18 identity retained for historical V6 prediction evidence.
81pub const OUTPUT_V18_SCHEMA_ID: &str = "urn:animsmith:schema:output:18";
82/// Schema version of output-v18.
83pub const OUTPUT_V18_SCHEMA_VERSION: u32 = 18;
84/// Immutable output-v17 identity retained for historical V6 prediction evidence.
85pub const OUTPUT_V17_SCHEMA_ID: &str = "urn:animsmith:schema:output:17";
86/// Schema version of output-v17.
87pub const OUTPUT_V17_SCHEMA_VERSION: u32 = 17;
88/// Immutable output-v16 identity retained for historical V5 prediction evidence.
89pub const OUTPUT_V16_SCHEMA_ID: &str = "urn:animsmith:schema:output:16";
90/// Schema version of output-v16.
91pub const OUTPUT_V16_SCHEMA_VERSION: u32 = 16;
92/// Immutable output-v15 identity retained for historical V4 prediction evidence.
93pub const OUTPUT_V15_SCHEMA_ID: &str = "urn:animsmith:schema:output:15";
94/// Schema version of output-v15.
95pub const OUTPUT_V15_SCHEMA_VERSION: u32 = 15;
96/// Immutable output-v10 identity retained by V1 dependent contracts.
97pub const OUTPUT_V10_SCHEMA_ID: &str = "urn:animsmith:schema:output:10";
98/// Immutable output-v11 identity retained as historical schema evidence.
99pub const OUTPUT_V11_SCHEMA_ID: &str = "urn:animsmith:schema:output:11";
100/// Immutable output-v11 version retained as historical schema evidence.
101pub const OUTPUT_V11_SCHEMA_VERSION: u32 = 11;
102/// Immutable identity of the bounded-overflow outer result envelope.
103pub const OUTPUT_V12_SCHEMA_ID: &str = "urn:animsmith:schema:output:12";
104/// Schema version of output-v12.
105pub const OUTPUT_V12_SCHEMA_VERSION: u32 = 12;
106/// Immutable output-v13 identity retained as historical V2 prediction evidence.
107pub const OUTPUT_V13_SCHEMA_ID: &str = "urn:animsmith:schema:output:13";
108/// Schema version of output-v13.
109pub const OUTPUT_V13_SCHEMA_VERSION: u32 = 13;
110/// Immutable output-v14 identity retained for historical V3 prediction evidence.
111pub const OUTPUT_V14_SCHEMA_ID: &str = "urn:animsmith:schema:output:14";
112/// Schema version of output-v14.
113pub const OUTPUT_V14_SCHEMA_VERSION: u32 = 14;
114/// Maximum serialized bytes accepted by the output-v11 report reader.
115pub const OUTPUT_V11_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
116/// Maximum file records carried by one output-v11 envelope.
117pub const OUTPUT_V11_MAX_FILES: usize = 4_096;
118/// Maximum check records carried by one output-v11 lint file.
119pub const OUTPUT_V11_MAX_CHECKS_PER_FILE: usize = 4_096;
120/// Current nested measurement-contract version.
121pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 18;
122/// Immutable identity of the current nested measurement contract.
123pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:18";
124/// Immutable measurements-v17 identity retained for the output-v18 reader.
125pub const MEASUREMENTS_V17_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:17";
126/// Immutable measurements-v17 version retained for the output-v18 reader.
127pub const MEASUREMENTS_V17_SCHEMA_VERSION: u32 = 17;
128/// Immutable measurements-v16 identity retained for output-v13 through output-v17 readers.
129pub const MEASUREMENTS_V16_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:16";
130/// Immutable measurements-v16 version retained for historical report readers.
131pub const MEASUREMENTS_V16_SCHEMA_VERSION: u32 = 16;
132/// Immutable measurements-v15 identity retained for output-v11 and output-v12 readers.
133pub const MEASUREMENTS_V15_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:15";
134/// Immutable measurements-v15 version retained for historical report readers.
135pub const MEASUREMENTS_V15_SCHEMA_VERSION: u32 = 15;
136
137/// Source checkout identity for the producing animsmith build.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct ToolSource {
140    revision: Option<String>,
141    dirty: Option<bool>,
142}
143
144impl ToolSource {
145    /// Construct source identity from a full Git revision and dirty bit.
146    ///
147    /// Packaged or otherwise provenance-free builds use `None` for fields they
148    /// cannot establish rather than claiming a clean checkout. Revisions that
149    /// are not full 40-character hexadecimal Git object ids are dropped so an
150    /// envelope constructed through this API remains within output v11.
151    pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
152        let revision = revision.filter(|revision| {
153            revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
154        });
155        Self { revision, dirty }
156    }
157}
158
159/// Identity of the animsmith producer that emitted an envelope.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161pub struct ToolInfo {
162    name: &'static str,
163    version: &'static str,
164    source: ToolSource,
165}
166
167impl ToolInfo {
168    /// Construct animsmith producer identity from this package's version and
169    /// optional source-checkout metadata.
170    pub fn animsmith(source: ToolSource) -> Self {
171        Self {
172            name: "animsmith",
173            version: env!("CARGO_PKG_VERSION"),
174            source,
175        }
176    }
177}
178
179/// Immutable identity of the bytes used to produce one file report.
180///
181/// The digest is lowercase hexadecimal SHA-256 so consumers can compare
182/// identities without retaining the source bytes themselves.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
184pub struct InputIdentity {
185    sha256: String,
186    bytes: u64,
187}
188
189/// Lowercase hexadecimal SHA-256 digest of exactly these bytes.
190///
191/// The digest type carries no `LowerHex` impl, so every producer of a
192/// contract `sha256` field goes through this one formatter rather than
193/// re-deriving the encoding.
194#[must_use]
195pub fn sha256_hex(bytes: &[u8]) -> String {
196    sha256_digest_hex(Sha256::digest(bytes).into())
197}
198
199fn sha256_digest_hex(digest: [u8; 32]) -> String {
200    let mut hex = String::with_capacity(64);
201    for byte in digest {
202        let _ = write!(hex, "{byte:02x}");
203    }
204    hex
205}
206
207impl InputIdentity {
208    /// Calculate the identity for source bytes.
209    pub fn from_bytes(bytes: &[u8]) -> Self {
210        Self {
211            sha256: sha256_hex(bytes),
212            bytes: bytes.len() as u64,
213        }
214    }
215
216    /// Construct an identity from an already-computed SHA-256 digest and exact byte count.
217    ///
218    /// This keeps streaming and bounded readers on the same lowercase digest
219    /// authority without exposing an unchecked string constructor.
220    pub fn from_sha256_digest(digest: [u8; 32], bytes: u64) -> Self {
221        Self {
222            sha256: sha256_digest_hex(digest),
223            bytes,
224        }
225    }
226
227    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
228    pub fn sha256(&self) -> &str {
229        &self.sha256
230    }
231
232    /// Number of source bytes represented by this identity.
233    pub fn bytes(&self) -> u64 {
234        self.bytes
235    }
236}
237
238/// Rig profile and resolved semantic-role bindings for one input file.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct RigInfo {
242    profile: String,
243    resolution_outcome: String,
244    resolved_roles: BTreeMap<String, String>,
245    resolved_role_policies: BTreeMap<String, String>,
246}
247
248/// Resolved-role evidence did not belong to the supplied document.
249#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
250#[non_exhaustive]
251pub enum RigInfoError {
252    /// A resolved role referenced a bone outside the document's skeleton.
253    #[error(
254        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
255    )]
256    InvalidBoneId {
257        /// Stable semantic role name.
258        role: &'static str,
259        /// Invalid bone index carried by the resolution.
260        bone: usize,
261        /// Number of bones available in the supplied document.
262        bone_count: usize,
263    },
264    /// A valid bone index now names a different bone than the resolution did.
265    #[error(
266        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
267    )]
268    BoneNameMismatch {
269        /// Stable semantic role name.
270        role: &'static str,
271        /// Bone index carried by the resolution.
272        bone: usize,
273        /// Bone name captured when the role was resolved.
274        expected: String,
275        /// Bone name at that index in the supplied document.
276        found: String,
277    },
278}
279
280impl RigInfo {
281    /// Project resolved roles into their stable role names and source bone
282    /// names for the result contract.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`RigInfoError`] when `roles` references a bone outside the
287    /// supplied document, such as a resolution produced from another
288    /// skeleton.
289    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
290        let resolved = roles
291            .iter_with_details()
292            .map(|(role, bone, expected_name, policy)| {
293                let name = doc
294                    .skeleton
295                    .bones
296                    .get(bone)
297                    .ok_or(RigInfoError::InvalidBoneId {
298                        role: role.as_str(),
299                        bone,
300                        bone_count: doc.skeleton.bones.len(),
301                    })?;
302                if name.name != expected_name {
303                    return Err(RigInfoError::BoneNameMismatch {
304                        role: role.as_str(),
305                        bone,
306                        expected: expected_name.to_owned(),
307                        found: name.name.clone(),
308                    });
309                }
310                Ok((role.as_str(), (name.name.clone(), policy.as_str())))
311            })
312            .collect::<Result<BTreeMap<_, _>, _>>()?;
313        Ok(Self {
314            profile: roles.profile.clone(),
315            resolution_outcome: roles.outcome().as_str().to_owned(),
316            resolved_roles: resolved
317                .iter()
318                .map(|(&role, (name, _))| (role.to_owned(), name.clone()))
319                .collect(),
320            resolved_role_policies: resolved
321                .into_iter()
322                .map(|(role, (_, policy))| (role.to_owned(), policy.to_owned()))
323                .collect(),
324        })
325    }
326}
327
328/// Independently versioned measurement payload nested in measure and lint
329/// file records.
330#[derive(Debug, Clone, Serialize)]
331pub struct MeasurementContract {
332    schema_version: u32,
333    schema: &'static str,
334    clips: BTreeMap<String, ClipMeasurements>,
335    #[serde(flatten)]
336    assets: AssetMeasurements,
337}
338
339/// Measurement evidence could not satisfy the current measurement contract.
340#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
341#[non_exhaustive]
342pub enum MeasurementContractError {
343    /// A required or present numeric value was non-finite.
344    #[error("measurement value {path} must be finite")]
345    NonFiniteValue {
346        /// Human-readable location within the measurement contract.
347        path: String,
348    },
349    /// Related measurement fields were structurally inconsistent.
350    #[error("measurement structure {path} is invalid: {reason}")]
351    InvalidStructure {
352        /// Human-readable location within the measurement contract.
353        path: String,
354        /// Stable explanation of the violated relationship.
355        reason: String,
356    },
357}
358
359impl MeasurementContract {
360    /// Construct the current measurement contract.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`MeasurementContractError`] when required or present numeric
365    /// evidence is non-finite or structurally inconsistent.
366    pub fn new(
367        clips: BTreeMap<String, ClipMeasurements>,
368        assets: AssetMeasurements,
369    ) -> Result<Self, MeasurementContractError> {
370        validate_measurements(&clips, &assets, MeasurementRevision::V18)?;
371        Ok(Self {
372            schema_version: MEASUREMENTS_SCHEMA_VERSION,
373            schema: MEASUREMENTS_SCHEMA_ID,
374            clips,
375            assets,
376        })
377    }
378
379    pub(crate) fn historical_v15(
380        clips: BTreeMap<String, ClipMeasurements>,
381        assets: AssetMeasurements,
382    ) -> Result<Self, MeasurementContractError> {
383        validate_measurements(&clips, &assets, MeasurementRevision::V15)?;
384        Ok(Self {
385            schema_version: MEASUREMENTS_V15_SCHEMA_VERSION,
386            schema: MEASUREMENTS_V15_SCHEMA_ID,
387            clips,
388            assets,
389        })
390    }
391
392    pub(crate) fn historical_v16(
393        clips: BTreeMap<String, ClipMeasurements>,
394        assets: AssetMeasurements,
395    ) -> Result<Self, MeasurementContractError> {
396        validate_measurements(&clips, &assets, MeasurementRevision::V16)?;
397        Ok(Self {
398            schema_version: MEASUREMENTS_V16_SCHEMA_VERSION,
399            schema: MEASUREMENTS_V16_SCHEMA_ID,
400            clips,
401            assets,
402        })
403    }
404
405    pub(crate) fn historical_v17(
406        clips: BTreeMap<String, ClipMeasurements>,
407        assets: AssetMeasurements,
408    ) -> Result<Self, MeasurementContractError> {
409        validate_measurements(&clips, &assets, MeasurementRevision::V17)?;
410        Ok(Self {
411            schema_version: MEASUREMENTS_V17_SCHEMA_VERSION,
412            schema: MEASUREMENTS_V17_SCHEMA_ID,
413            clips,
414            assets,
415        })
416    }
417
418    /// Reconstruct the measurements-v18-to-v16 evidence view consumed by
419    /// immutable prediction contracts embedded in current output. V16
420    /// represented loop continuity as all-or-nothing, so any unavailable V17
421    /// bone projects to an unavailable outer fact; fully measured rows omit
422    /// the V17-only per-bone availability field, and every V18-only linear
423    /// rotation fact is removed.
424    fn prediction_v16_projection(&self) -> Result<Self, MeasurementContractError> {
425        let mut clips = self.clips.clone();
426        for clip in clips.values_mut() {
427            let has_unavailable_bone = clip.loop_continuity.as_ref().is_some_and(|continuity| {
428                continuity
429                    .bones
430                    .iter()
431                    .any(|bone| bone.availability != MeasurementAvailability::Measured)
432            });
433            if has_unavailable_bone {
434                clip.loop_continuity = None;
435                clip.loop_continuity_availability = MeasurementAvailability::Unavailable;
436            } else if let Some(continuity) = &mut clip.loop_continuity {
437                for bone in &mut continuity.bones {
438                    bone.availability_was_present = false;
439                }
440            }
441        }
442        let mut assets = self.assets.clone();
443        for node in &mut assets.skeleton_nodes {
444            node.rest_world_linear.rotation_xyzw = None;
445        }
446        for skin in &mut assets.skins {
447            for joint in &mut skin.joints {
448                if let Some(linear) = &mut joint.joint_bind_to_mesh.linear {
449                    linear.rotation_xyzw = None;
450                }
451                if let Some(linear) = &mut joint.mesh_bind_world.linear {
452                    linear.rotation_xyzw = None;
453                }
454            }
455        }
456        Self::historical_v16(clips, assets)
457    }
458
459    /// Per-clip measurements keyed by clip name.
460    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
461        &self.clips
462    }
463
464    /// Static source-geometry, node-instance, and declared-scene evidence.
465    pub fn assets(&self) -> &AssetMeasurements {
466        &self.assets
467    }
468
469    /// Consume the contract and return its clip and static asset measurements.
470    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
471        (self.clips, self.assets)
472    }
473}
474
475#[derive(Clone, Copy, PartialEq, Eq)]
476enum MeasurementRevision {
477    V15,
478    V16,
479    V17,
480    V18,
481}
482
483fn validate_measurements(
484    clips: &BTreeMap<String, ClipMeasurements>,
485    assets: &AssetMeasurements,
486    revision: MeasurementRevision,
487) -> Result<(), MeasurementContractError> {
488    let finite = |value: f64, path: String| {
489        value
490            .is_finite()
491            .then_some(())
492            .ok_or(MeasurementContractError::NonFiniteValue { path })
493    };
494    let permits_roundoff = |observed: f64, lower_bound: f64| {
495        let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
496        observed + tolerance >= lower_bound
497    };
498    let check_availability = |value_present: bool,
499                              availability: MeasurementAvailability,
500                              path: String| {
501        match (value_present, availability) {
502            (true, MeasurementAvailability::Measured) => Ok(()),
503            (
504                false,
505                MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
506            ) => Ok(()),
507            _ => Err(MeasurementContractError::InvalidStructure {
508                path,
509                reason: "value presence must match availability status".into(),
510            }),
511        }
512    };
513    for (clip_name, clip) in clips {
514        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
515        let mut previous_bone_index = None;
516        let mut covered_bone_names = BTreeSet::new();
517        for (offset, bone) in clip.bone_channels.iter().enumerate() {
518            let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
519            if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
520                return Err(MeasurementContractError::InvalidStructure {
521                    path: format!("{path}.bone_index"),
522                    reason: "bone channel entries must use strictly increasing unique bone indices"
523                        .into(),
524                });
525            }
526            previous_bone_index = Some(bone.bone_index);
527            if bone.properties.is_empty() {
528                return Err(MeasurementContractError::InvalidStructure {
529                    path: format!("{path}.properties"),
530                    reason: "bone channel coverage must contain at least one property".into(),
531                });
532            }
533            if bone
534                .properties
535                .windows(2)
536                .any(|properties| properties[0] >= properties[1])
537            {
538                return Err(MeasurementContractError::InvalidStructure {
539                    path: format!("{path}.properties"),
540                    reason:
541                        "channel properties must be unique and ordered translation, rotation, scale"
542                            .into(),
543                });
544            }
545            covered_bone_names.insert(bone.bone_name.clone());
546        }
547        let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
548        if clip.animated_bones != expected_animated_bones {
549            return Err(MeasurementContractError::InvalidStructure {
550                path: format!("clips[{clip_name:?}].animated_bones"),
551                reason: "animated_bones must equal the sorted unique bone names in bone_channels"
552                    .into(),
553            });
554        }
555        for (bone, value) in &clip.bone_rotation_range_deg {
556            if clip.animated_bones.binary_search(bone).is_err() {
557                return Err(MeasurementContractError::InvalidStructure {
558                    path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
559                    reason: "rotation-range bones must be present in animated_bones".into(),
560                });
561            }
562            finite(
563                *value,
564                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
565            )?;
566        }
567        check_availability(
568            clip.loop_continuity.is_some(),
569            clip.loop_continuity_availability,
570            format!("clips[{clip_name:?}].loop_continuity"),
571        )?;
572        check_availability(
573            clip.loop_endpoint_mode.is_some(),
574            clip.loop_endpoint_mode_availability,
575            format!("clips[{clip_name:?}].loop_endpoint_mode"),
576        )?;
577        check_availability(
578            clip.frame_grid.is_some(),
579            clip.frame_grid_availability,
580            format!("clips[{clip_name:?}].frame_grid"),
581        )?;
582        check_availability(
583            clip.loop_seam_ratio.is_some(),
584            clip.loop_seam_ratio_availability,
585            format!("clips[{clip_name:?}].loop_seam_ratio"),
586        )?;
587        check_availability(
588            clip.gait.is_some(),
589            clip.gait_availability,
590            format!("clips[{clip_name:?}].gait"),
591        )?;
592        check_availability(
593            clip.root_trajectory.is_some(),
594            clip.root_trajectory_availability,
595            format!("clips[{clip_name:?}].root_trajectory"),
596        )?;
597        check_availability(
598            clip.speed_mps.is_some(),
599            clip.speed_mps_availability,
600            format!("clips[{clip_name:?}].speed_mps"),
601        )?;
602        if let Some(gait) = &clip.gait {
603            check_availability(
604                gait.phase.is_some(),
605                gait.phase_availability,
606                format!("clips[{clip_name:?}].gait.phase"),
607            )?;
608        }
609        if let Some(trajectory) = &clip.root_trajectory {
610            let path = format!("clips[{clip_name:?}].root_trajectory");
611            check_availability(
612                trajectory.translation.is_some(),
613                trajectory.translation_availability,
614                format!("{path}.translation"),
615            )?;
616            check_availability(
617                trajectory.yaw.is_some(),
618                trajectory.yaw_availability,
619                format!("{path}.yaw"),
620            )?;
621            if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
622                return Err(MeasurementContractError::InvalidStructure {
623                    path: format!("{path}.translation_availability"),
624                    reason:
625                        "translation remains applicable when a root-trajectory bone is selected"
626                            .into(),
627                });
628            }
629            if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
630                return Err(MeasurementContractError::InvalidStructure {
631                    path: format!("{path}.yaw_availability"),
632                    reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
633                });
634            }
635            if let Some(translation) = trajectory.translation {
636                for (field, value) in [
637                    (
638                        "horizontal_displacement_x_m",
639                        translation.horizontal_displacement_x_m,
640                    ),
641                    (
642                        "horizontal_displacement_z_m",
643                        translation.horizontal_displacement_z_m,
644                    ),
645                    ("horizontal_travel_m", translation.horizontal_travel_m),
646                    (
647                        "vertical_displacement_m",
648                        translation.vertical_displacement_m,
649                    ),
650                    (
651                        "vertical_min_displacement_m",
652                        translation.vertical_min_displacement_m,
653                    ),
654                    (
655                        "vertical_max_displacement_m",
656                        translation.vertical_max_displacement_m,
657                    ),
658                ] {
659                    finite(value, format!("{path}.translation.{field}"))?;
660                }
661                if translation.horizontal_travel_m < 0.0 {
662                    return Err(MeasurementContractError::InvalidStructure {
663                        path: format!("{path}.translation.horizontal_travel_m"),
664                        reason: "sampled horizontal travel must be non-negative".into(),
665                    });
666                }
667                let horizontal_displacement_m = translation
668                    .horizontal_displacement_x_m
669                    .hypot(translation.horizontal_displacement_z_m);
670                if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
671                    return Err(MeasurementContractError::InvalidStructure {
672                        path: format!("{path}.translation.horizontal_travel_m"),
673                        reason: "sampled horizontal travel must contain endpoint displacement"
674                            .into(),
675                    });
676                }
677                if translation.vertical_min_displacement_m > 0.0
678                    || translation.vertical_max_displacement_m < 0.0
679                    || translation.vertical_displacement_m < translation.vertical_min_displacement_m
680                    || translation.vertical_displacement_m > translation.vertical_max_displacement_m
681                {
682                    return Err(MeasurementContractError::InvalidStructure {
683                        path: format!("{path}.translation"),
684                        reason: "vertical extrema must include zero and the endpoint displacement"
685                            .into(),
686                    });
687                }
688            }
689            if let Some(yaw) = trajectory.yaw {
690                finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
691                finite(
692                    yaw.unwrapped_yaw_deg,
693                    format!("{path}.yaw.unwrapped_yaw_deg"),
694                )?;
695                finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
696                if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
697                    return Err(MeasurementContractError::InvalidStructure {
698                        path: format!("{path}.yaw.net_yaw_deg"),
699                        reason: "net yaw must be in the inclusive range [-180, 180]".into(),
700                    });
701                }
702                if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
703                    return Err(MeasurementContractError::InvalidStructure {
704                        path: format!("{path}.yaw.net_yaw_deg"),
705                        reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
706                            .into(),
707                    });
708                }
709                if yaw.yaw_travel_deg < 0.0 {
710                    return Err(MeasurementContractError::InvalidStructure {
711                        path: format!("{path}.yaw.yaw_travel_deg"),
712                        reason: "sampled yaw travel must be non-negative".into(),
713                    });
714                }
715                if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
716                    return Err(MeasurementContractError::InvalidStructure {
717                        path: format!("{path}.yaw.yaw_travel_deg"),
718                        reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
719                    });
720                }
721            }
722        }
723        if let Some(loop_continuity) = &clip.loop_continuity {
724            if loop_continuity.bones.is_empty() {
725                return Err(MeasurementContractError::InvalidStructure {
726                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
727                    reason: "present loop-continuity evidence must contain at least one bone"
728                        .into(),
729                });
730            }
731            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
732                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
733                if usize::try_from(bone.bone_index) != Ok(expected_index) {
734                    return Err(MeasurementContractError::InvalidStructure {
735                        path: format!("{path}.bone_index"),
736                        reason: format!(
737                            "expected skeleton-order index {expected_index}, found {}",
738                            bone.bone_index
739                        ),
740                    });
741                }
742                if matches!(
743                    revision,
744                    MeasurementRevision::V17 | MeasurementRevision::V18
745                ) && !bone.availability_was_present
746                {
747                    return Err(MeasurementContractError::InvalidStructure {
748                        path: format!("{path}.availability"),
749                        reason: "measurements-v17 requires explicit per-bone availability".into(),
750                    });
751                }
752                if !matches!(
753                    revision,
754                    MeasurementRevision::V17 | MeasurementRevision::V18
755                ) && bone.availability_was_present
756                {
757                    return Err(MeasurementContractError::InvalidStructure {
758                        path: format!("{path}.availability"),
759                        reason:
760                            "per-bone loop-continuity availability is exclusive to measurements-v17"
761                                .into(),
762                    });
763                }
764                if !matches!(
765                    revision,
766                    MeasurementRevision::V17 | MeasurementRevision::V18
767                ) && bone.availability != MeasurementAvailability::Measured
768                {
769                    return Err(MeasurementContractError::InvalidStructure {
770                        path: format!("{path}.availability"),
771                        reason: "historical measurement contracts require every present loop-continuity row to be measured"
772                            .into(),
773                    });
774                }
775                if bone.availability == MeasurementAvailability::NotApplicable {
776                    return Err(MeasurementContractError::InvalidStructure {
777                        path: format!("{path}.availability"),
778                        reason: "an existing bone's loop-continuity evidence remains applicable"
779                            .into(),
780                    });
781                }
782                let values = [
783                    ("position_delta_m", bone.position_delta_m),
784                    ("rotation_delta_deg", bone.rotation_delta_deg),
785                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
786                    (
787                        "seam_angular_velocity_delta_degps",
788                        bone.seam_angular_velocity_delta_degps,
789                    ),
790                ];
791                let values_present = values.iter().filter(|(_, value)| value.is_some()).count();
792                match (bone.availability, values_present) {
793                    (MeasurementAvailability::Measured, 4)
794                    | (MeasurementAvailability::Unavailable, 0) => {}
795                    _ => {
796                        return Err(MeasurementContractError::InvalidStructure {
797                            path: path.clone(),
798                            reason: "all loop-continuity values must be present exactly when the bone is measured"
799                                .into(),
800                        });
801                    }
802                }
803                for (field, value) in values {
804                    let Some(value) = value else { continue };
805                    finite(value, format!("{path}.{field}"))?;
806                    if value < 0.0 {
807                        return Err(MeasurementContractError::InvalidStructure {
808                            path: format!("{path}.{field}"),
809                            reason: "loop-continuity deltas must be non-negative".into(),
810                        });
811                    }
812                }
813            }
814        }
815        if let Some(frame_grid) = &clip.frame_grid {
816            let path = format!("clips[{clip_name:?}].frame_grid");
817            finite(frame_grid.fps, format!("{path}.fps"))?;
818            if frame_grid.fps <= 0.0 {
819                return Err(MeasurementContractError::InvalidStructure {
820                    path: format!("{path}.fps"),
821                    reason: "declared frame-grid FPS must be positive".into(),
822                });
823            }
824            if frame_grid.frame_intervals == 0 {
825                return Err(MeasurementContractError::InvalidStructure {
826                    path: format!("{path}.frame_intervals"),
827                    reason: "declared frame-grid evidence must contain at least one interval"
828                        .into(),
829                });
830            }
831        }
832        if let Some(value) = clip.loop_seam_ratio {
833            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
834        }
835        if let Some(gait) = &clip.gait {
836            if let Some(value) = gait.phase {
837                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
838            }
839            finite(
840                gait.lr_amplitude_m,
841                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
842            )?;
843        }
844        if let Some(value) = clip.speed_mps {
845            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
846        }
847    }
848    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
849        path,
850        reason: reason.to_owned(),
851    };
852    let finite_aabb = |aabb: &Aabb, path: &str| {
853        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
854            for (axis, value) in values.into_iter().enumerate() {
855                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
856            }
857        }
858        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
859            if min > max {
860                return Err(invalid(
861                    format!("{path}.min[{axis}]"),
862                    "AABB minimum cannot exceed maximum",
863                ));
864            }
865        }
866        Ok(())
867    };
868
869    let mut mesh_indices = BTreeSet::new();
870    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
871        if !mesh_indices.insert(mesh.mesh_index) {
872            return Err(invalid(
873                format!("mesh_definitions[{index}].mesh_index"),
874                "mesh_index must be unique",
875            ));
876        }
877        if let Some(aabb) = &mesh.geometry_aabb {
878            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
879        }
880        if let Some(centroid) = mesh.geometry_centroid {
881            for (axis, value) in centroid.into_iter().enumerate() {
882                finite(
883                    f64::from(value),
884                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
885                )?;
886            }
887        }
888        if let Some(value) = mesh.weight_sum_min {
889            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
890        }
891        if let Some(value) = mesh.weight_sum_max {
892            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
893        }
894        if revision == MeasurementRevision::V15 && mesh.vertex_count > u64::from(u32::MAX) {
895            return Err(invalid(
896                format!("mesh_definitions[{index}].vertex_count"),
897                "measurements-v15 vertex_count cannot exceed its historical u32 maximum",
898            ));
899        }
900        match (&mesh.primitives, revision) {
901            (
902                None,
903                MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
904            ) => {
905                return Err(invalid(
906                    format!("mesh_definitions[{index}].primitives"),
907                    "measurements-v16 requires per-primitive evidence",
908                ));
909            }
910            (Some(_), MeasurementRevision::V15) => {
911                return Err(invalid(
912                    format!("mesh_definitions[{index}].primitives"),
913                    "measurements-v15 cannot carry per-primitive evidence",
914                ));
915            }
916            (
917                Some(primitives),
918                MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
919            ) => {
920                let mut summed_vertex_count = 0u64;
921                let mut summed_finite_vertex_count = 0u64;
922                let mut aggregate_min = [f32::INFINITY; 3];
923                let mut aggregate_max = [f32::NEG_INFINITY; 3];
924                let mut weighted_centroid_sum = [0.0f64; 3];
925                let mut previous_primitive_index = None;
926                for (primitive_offset, primitive) in primitives.iter().enumerate() {
927                    let path = format!("mesh_definitions[{index}].primitives[{primitive_offset}]");
928                    if previous_primitive_index
929                        .is_some_and(|previous| previous >= primitive.primitive_index)
930                    {
931                        return Err(invalid(
932                            format!("{path}.primitive_index"),
933                            "primitive_index must be unique and strictly increasing in source order",
934                        ));
935                    }
936                    previous_primitive_index = Some(primitive.primitive_index);
937                    if primitive.finite_vertex_count > primitive.vertex_count {
938                        return Err(invalid(
939                            format!("{path}.finite_vertex_count"),
940                            "finite_vertex_count cannot exceed vertex_count",
941                        ));
942                    }
943                    match (
944                        primitive.finite_vertex_count,
945                        primitive.geometry_aabb.as_ref(),
946                        primitive.geometry_centroid,
947                    ) {
948                        (0, None, None) => {}
949                        (1.., Some(aabb), Some(centroid)) => {
950                            finite_aabb(aabb, &format!("{path}.geometry_aabb"))?;
951                            for (axis, value) in centroid.into_iter().enumerate() {
952                                finite(
953                                    f64::from(value),
954                                    format!("{path}.geometry_centroid[{axis}]"),
955                                )?;
956                                if value < aabb.min[axis] || value > aabb.max[axis] {
957                                    return Err(invalid(
958                                        format!("{path}.geometry_centroid[{axis}]"),
959                                        "primitive centroid must lie inside its geometry AABB",
960                                    ));
961                                }
962                                aggregate_min[axis] = aggregate_min[axis].min(aabb.min[axis]);
963                                aggregate_max[axis] = aggregate_max[axis].max(aabb.max[axis]);
964                                weighted_centroid_sum[axis] +=
965                                    f64::from(value) * primitive.finite_vertex_count as f64;
966                            }
967                        }
968                        (0, _, _) => {
969                            return Err(invalid(
970                                path,
971                                "a primitive with no finite vertices cannot carry geometry facts",
972                            ));
973                        }
974                        (1.., _, _) => {
975                            return Err(invalid(
976                                path,
977                                "a primitive with finite vertices requires both geometry facts",
978                            ));
979                        }
980                    }
981                    if assets.material_resource_coverage == MaterialResourceCoverage::Complete
982                        && primitive.material_index.is_some_and(|material_index| {
983                            material_index >= assets.material_definitions.len()
984                        })
985                    {
986                        return Err(invalid(
987                            format!("{path}.material_index"),
988                            "material_index must reference a source material when material resource coverage is complete",
989                        ));
990                    }
991                    summed_vertex_count = summed_vertex_count
992                        .checked_add(primitive.vertex_count)
993                        .ok_or_else(|| {
994                        invalid(
995                            format!("mesh_definitions[{index}].vertex_count"),
996                            "primitive vertex-count sum overflows u64",
997                        )
998                    })?;
999                    summed_finite_vertex_count = summed_finite_vertex_count
1000                        .checked_add(primitive.finite_vertex_count)
1001                        .ok_or_else(|| {
1002                            invalid(
1003                                format!("mesh_definitions[{index}].primitives"),
1004                                "primitive finite-vertex-count sum overflows u64",
1005                            )
1006                        })?;
1007                }
1008                if summed_vertex_count != mesh.vertex_count {
1009                    return Err(invalid(
1010                        format!("mesh_definitions[{index}].vertex_count"),
1011                        "vertex_count must equal the checked sum of primitive vertex counts",
1012                    ));
1013                }
1014                let expected_aabb = (summed_finite_vertex_count != 0).then_some(Aabb {
1015                    min: aggregate_min,
1016                    max: aggregate_max,
1017                });
1018                let expected_centroid = (summed_finite_vertex_count != 0).then(|| {
1019                    let count = summed_finite_vertex_count as f64;
1020                    weighted_centroid_sum.map(|sum| (sum / count) as f32)
1021                });
1022                match (
1023                    summed_finite_vertex_count,
1024                    mesh.geometry_aabb.as_ref(),
1025                    mesh.geometry_centroid,
1026                ) {
1027                    (0, None, None) | (1.., Some(_), Some(_)) => {}
1028                    (0, _, _) => {
1029                        return Err(invalid(
1030                            format!("mesh_definitions[{index}]"),
1031                            "a mesh with no finite primitive vertices cannot carry geometry facts",
1032                        ));
1033                    }
1034                    (1.., _, _) => {
1035                        return Err(invalid(
1036                            format!("mesh_definitions[{index}]"),
1037                            "a mesh with finite primitive vertices requires both geometry facts",
1038                        ));
1039                    }
1040                }
1041                if mesh.geometry_aabb != expected_aabb {
1042                    return Err(invalid(
1043                        format!("mesh_definitions[{index}].geometry_aabb"),
1044                        "mesh AABB must equal the exact union of primitive AABBs",
1045                    ));
1046                }
1047                if mesh.geometry_centroid != expected_centroid {
1048                    return Err(invalid(
1049                        format!("mesh_definitions[{index}].geometry_centroid"),
1050                        "mesh centroid must equal the finite-count-weighted primitive centroids",
1051                    ));
1052                }
1053            }
1054            (None, MeasurementRevision::V15) => {}
1055        }
1056        let mut previous_set_index = None;
1057        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
1058            let path = format!(
1059                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
1060            );
1061            if set.set_index == 0 {
1062                return Err(invalid(path, "set_index must be at least 1"));
1063            }
1064            if !set.joints_present && !set.weights_present {
1065                return Err(invalid(
1066                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
1067                    "an additional influence set must declare joints, weights, or both",
1068                ));
1069            }
1070            if set.joints_without_weights_present && !set.joints_present {
1071                return Err(invalid(
1072                    format!(
1073                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1074                    ),
1075                    "joints_without_weights_present requires joints_present",
1076                ));
1077            }
1078            if set.weights_without_joints_present && !set.weights_present {
1079                return Err(invalid(
1080                    format!(
1081                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1082                    ),
1083                    "weights_without_joints_present requires weights_present",
1084                ));
1085            }
1086            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
1087                return Err(invalid(
1088                    format!(
1089                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1090                    ),
1091                    "joints_without_weights_present is required when weights_present is false",
1092                ));
1093            }
1094            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
1095                return Err(invalid(
1096                    format!(
1097                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1098                    ),
1099                    "weights_without_joints_present is required when joints_present is false",
1100                ));
1101            }
1102            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
1103                return Err(invalid(
1104                    path,
1105                    "set_index values must be strictly increasing and unique",
1106                ));
1107            }
1108            previous_set_index = Some(set.set_index);
1109        }
1110    }
1111
1112    let mut node_indices = BTreeSet::new();
1113    for (index, instance) in assets.node_instances.iter().enumerate() {
1114        if !node_indices.insert(instance.node_index) {
1115            return Err(invalid(
1116                format!("node_instances[{index}].node_index"),
1117                "node_index must be unique",
1118            ));
1119        }
1120        if !mesh_indices.contains(&instance.mesh_index) {
1121            return Err(invalid(
1122                format!("node_instances[{index}].mesh_index"),
1123                "mesh_index must reference a mesh definition",
1124            ));
1125        }
1126        match (
1127            instance.static_node_world_aabb.as_ref(),
1128            instance.static_node_world_aabb_unavailable_reason,
1129        ) {
1130            (Some(aabb), None) => finite_aabb(
1131                aabb,
1132                &format!("node_instances[{index}].static_node_world_aabb"),
1133            )?,
1134            (None, Some(_)) => {}
1135            (Some(_), Some(_)) => {
1136                return Err(invalid(
1137                    format!("node_instances[{index}]"),
1138                    "an available static node AABB cannot have an unavailable reason",
1139                ));
1140            }
1141            (None, None) => {
1142                return Err(invalid(
1143                    format!("node_instances[{index}]"),
1144                    "a missing static node AABB requires an unavailable reason",
1145                ));
1146            }
1147        }
1148    }
1149
1150    let mut scene_indices = BTreeSet::new();
1151    for (index, scene) in assets.scenes.iter().enumerate() {
1152        if !scene_indices.insert(scene.scene_index) {
1153            return Err(invalid(
1154                format!("scenes[{index}].scene_index"),
1155                "scene_index must be unique",
1156            ));
1157        }
1158        if scene.excluded_instance_count > scene.instance_count {
1159            return Err(invalid(
1160                format!("scenes[{index}].excluded_instance_count"),
1161                "excluded_instance_count cannot exceed instance_count",
1162            ));
1163        }
1164        let available = scene.instance_count - scene.excluded_instance_count;
1165        match (&scene.static_scene_world_aabb, available) {
1166            (Some(aabb), 1..) => {
1167                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
1168            }
1169            (None, 0) => {}
1170            (Some(_), 0) => {
1171                return Err(invalid(
1172                    format!("scenes[{index}].static_scene_world_aabb"),
1173                    "a scene with no available instances cannot have an AABB",
1174                ));
1175            }
1176            (None, _) => {
1177                return Err(invalid(
1178                    format!("scenes[{index}].static_scene_world_aabb"),
1179                    "a scene with available instances requires an AABB",
1180                ));
1181            }
1182        }
1183    }
1184    if let Some(default_scene_index) = assets.default_scene_index
1185        && !scene_indices.contains(&default_scene_index)
1186    {
1187        return Err(invalid(
1188            "default_scene_index".into(),
1189            "default_scene_index must reference a declared scene",
1190        ));
1191    }
1192    validate_skeleton_measurements(assets, revision, &invalid)?;
1193    validate_material_resources(assets, revision, &invalid)?;
1194    Ok(())
1195}
1196
1197fn validate_linear_transform_fields(
1198    linear: &LinearTransformMeasurements,
1199    path: &str,
1200    revision: MeasurementRevision,
1201    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1202) -> Result<(), MeasurementContractError> {
1203    let numeric_fields_present = linear.axis_lengths.is_some()
1204        && linear.determinant.is_some()
1205        && linear.orientation.is_some();
1206    if linear.classification == LinearTransformClassification::NonFinite {
1207        if linear.axis_lengths.is_some()
1208            || linear.determinant.is_some()
1209            || linear.orientation.is_some()
1210            || linear.rotation_xyzw.is_some()
1211            || linear.uniform_scale.is_some()
1212        {
1213            return Err(invalid(
1214                path.into(),
1215                "a non_finite classification cannot carry numeric linear-transform facts",
1216            ));
1217        }
1218        return Ok(());
1219    }
1220    if !numeric_fields_present {
1221        return Err(invalid(
1222            path.into(),
1223            "a finite classification requires axis_lengths, determinant, and orientation",
1224        ));
1225    }
1226    let rotation_present = linear.rotation_xyzw.is_some();
1227    if revision == MeasurementRevision::V18 {
1228        let rotation_required =
1229            linear.classification == LinearTransformClassification::UnitOrthonormal;
1230        if rotation_present != rotation_required {
1231            return Err(invalid(
1232                path.into(),
1233                "rotation_xyzw must be present exactly for unit_orthonormal linear transforms",
1234            ));
1235        }
1236        if let Some(rotation) = linear.rotation_xyzw {
1237            for (component, value) in rotation.into_iter().enumerate() {
1238                if !value.is_finite() {
1239                    return Err(MeasurementContractError::NonFiniteValue {
1240                        path: format!("{path}.rotation_xyzw[{component}]"),
1241                    });
1242                }
1243            }
1244        }
1245    } else if rotation_present {
1246        return Err(invalid(
1247            path.into(),
1248            "rotation_xyzw is exclusive to measurements-v18",
1249        ));
1250    }
1251    for (axis, value) in linear
1252        .axis_lengths
1253        .expect("presence checked")
1254        .into_iter()
1255        .enumerate()
1256    {
1257        if !value.is_finite() {
1258            return Err(MeasurementContractError::NonFiniteValue {
1259                path: format!("{path}.axis_lengths[{axis}]"),
1260            });
1261        }
1262        if value < 0.0 {
1263            return Err(invalid(
1264                format!("{path}.axis_lengths[{axis}]"),
1265                "axis lengths must be non-negative",
1266            ));
1267        }
1268    }
1269    if !linear.determinant.expect("presence checked").is_finite() {
1270        return Err(MeasurementContractError::NonFiniteValue {
1271            path: format!("{path}.determinant"),
1272        });
1273    }
1274    if let Some(scale) = linear.uniform_scale {
1275        if !scale.is_finite() {
1276            return Err(MeasurementContractError::NonFiniteValue {
1277                path: format!("{path}.uniform_scale"),
1278            });
1279        }
1280        if scale < 0.0 {
1281            return Err(invalid(
1282                format!("{path}.uniform_scale"),
1283                "uniform scale must be non-negative",
1284            ));
1285        }
1286    }
1287    Ok(())
1288}
1289
1290fn validate_skeleton_measurements(
1291    assets: &AssetMeasurements,
1292    revision: MeasurementRevision,
1293    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1294) -> Result<(), MeasurementContractError> {
1295    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
1296        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
1297            return Err(invalid(
1298                "skeleton_source_coverage".into(),
1299                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
1300            ));
1301        }
1302        return Ok(());
1303    }
1304
1305    let finite_matrix = |matrix: &[f32; 16], path: &str| {
1306        for (component, value) in matrix.iter().enumerate() {
1307            if !value.is_finite() {
1308                return Err(MeasurementContractError::NonFiniteValue {
1309                    path: format!("{path}[{component}]"),
1310                });
1311            }
1312        }
1313        Ok(())
1314    };
1315    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1316        if node.node_index != offset {
1317            return Err(invalid(
1318                format!("skeleton_nodes[{offset}].node_index"),
1319                "node_index must be contiguous and match source order",
1320            ));
1321        }
1322        match &node.local_rest {
1323            SkeletonNodeLocalRestMeasurements::Trs {
1324                translation_parent_space_m,
1325                rotation_xyzw,
1326                scale,
1327            } => {
1328                for (field, values) in [
1329                    (
1330                        "translation_parent_space_m",
1331                        translation_parent_space_m.as_slice(),
1332                    ),
1333                    ("rotation_xyzw", rotation_xyzw.as_slice()),
1334                    ("scale", scale.as_slice()),
1335                ] {
1336                    for (component, value) in values.iter().enumerate() {
1337                        if !value.is_finite() {
1338                            return Err(MeasurementContractError::NonFiniteValue {
1339                                path: format!(
1340                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
1341                                ),
1342                            });
1343                        }
1344                    }
1345                }
1346            }
1347            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
1348                matrix,
1349                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
1350            )?,
1351            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
1352        }
1353        let node_path = format!("skeleton_nodes[{offset}]");
1354        validate_linear_transform_fields(
1355            &node.rest_world_linear,
1356            &format!("{node_path}.rest_world_linear"),
1357            revision,
1358            invalid,
1359        )?;
1360        match (
1361            node.rest_world_matrix.as_ref(),
1362            node.rest_world_translation_m.as_ref(),
1363            node.rest_world_matrix_unavailable_reason,
1364        ) {
1365            (Some(matrix), Some(translation), None) => {
1366                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
1367                for (component, value) in translation.iter().enumerate() {
1368                    if !value.is_finite() {
1369                        return Err(MeasurementContractError::NonFiniteValue {
1370                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
1371                        });
1372                    }
1373                }
1374                let expected_translation = [matrix[12], matrix[13], matrix[14]];
1375                if *translation != expected_translation {
1376                    return Err(invalid(
1377                        format!("{node_path}.rest_world_translation_m"),
1378                        "rest_world_translation_m must equal the rest-world matrix translation column",
1379                    ));
1380                }
1381                let mut expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
1382                if revision != MeasurementRevision::V18 {
1383                    expected_linear.rotation_xyzw = None;
1384                }
1385                if node.rest_world_linear != expected_linear {
1386                    return Err(invalid(
1387                        format!("{node_path}.rest_world_linear"),
1388                        "rest_world_linear must be derived from rest_world_matrix",
1389                    ));
1390                }
1391            }
1392            (None, None, Some(_)) => {
1393                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
1394                {
1395                    return Err(invalid(
1396                        format!("{node_path}.rest_world_linear"),
1397                        "an unavailable rest-world matrix requires a non_finite linear classification",
1398                    ));
1399                }
1400            }
1401            (Some(_), Some(_), Some(_)) => {
1402                return Err(invalid(
1403                    node_path,
1404                    "an available rest_world_matrix cannot have an unavailable reason",
1405                ));
1406            }
1407            _ => {
1408                return Err(invalid(
1409                    node_path,
1410                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
1411                ));
1412            }
1413        }
1414    }
1415    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1416        if let Some(parent) = node.parent_node_index
1417            && parent >= assets.skeleton_nodes.len()
1418        {
1419            return Err(invalid(
1420                format!("skeleton_nodes[{offset}].parent_node_index"),
1421                "parent_node_index must reference a skeleton node",
1422            ));
1423        }
1424        let mut previous_scene = None;
1425        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1426            if !assets
1427                .scenes
1428                .iter()
1429                .any(|scene| scene.scene_index == *scene_index)
1430            {
1431                return Err(invalid(
1432                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1433                    "scene_root_indices values must reference declared scenes",
1434                ));
1435            }
1436            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1437                return Err(invalid(
1438                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1439                    "scene_root_indices values must be strictly increasing and unique",
1440                ));
1441            }
1442            previous_scene = Some(*scene_index);
1443        }
1444    }
1445    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1446    for start in 0..assets.skeleton_nodes.len() {
1447        if visits.get(start) != Some(&ParentVisit::Unvisited) {
1448            continue;
1449        }
1450        let mut path = Vec::new();
1451        let mut current = start;
1452        loop {
1453            match visits.get(current).copied().ok_or_else(|| {
1454                invalid(
1455                    format!("skeleton_nodes[{current}].parent_node_index"),
1456                    "parent_node_index must reference a skeleton node",
1457                )
1458            })? {
1459                ParentVisit::Done => break,
1460                ParentVisit::Visiting => {
1461                    return Err(invalid(
1462                        format!("skeleton_nodes[{current}].parent_node_index"),
1463                        "source node parent graph must be acyclic",
1464                    ));
1465                }
1466                ParentVisit::Unvisited => {
1467                    *visits.get_mut(current).ok_or_else(|| {
1468                        invalid(
1469                            format!("skeleton_nodes[{current}].parent_node_index"),
1470                            "parent_node_index must reference a skeleton node",
1471                        )
1472                    })? = ParentVisit::Visiting;
1473                    path.push(current);
1474                    match assets
1475                        .skeleton_nodes
1476                        .get(current)
1477                        .ok_or_else(|| {
1478                            invalid(
1479                                format!("skeleton_nodes[{current}].parent_node_index"),
1480                                "parent_node_index must reference a skeleton node",
1481                            )
1482                        })?
1483                        .parent_node_index
1484                    {
1485                        Some(parent) => current = parent,
1486                        None => break,
1487                    }
1488                }
1489            }
1490        }
1491        for node_index in path {
1492            *visits.get_mut(node_index).ok_or_else(|| {
1493                invalid(
1494                    format!("skeleton_nodes[{node_index}].parent_node_index"),
1495                    "parent_node_index must reference a skeleton node",
1496                )
1497            })? = ParentVisit::Done;
1498        }
1499    }
1500
1501    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1502        let local_rest_available = !matches!(
1503            node.local_rest,
1504            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1505        );
1506        let path = format!("skeleton_nodes[{offset}]");
1507        if !local_rest_available {
1508            if node.rest_world_matrix.is_some()
1509                || node.rest_world_matrix_unavailable_reason
1510                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1511            {
1512                return Err(invalid(
1513                    path,
1514                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1515                ));
1516            }
1517            continue;
1518        }
1519
1520        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1521            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1522                invalid(
1523                    format!("skeleton_nodes[{offset}].parent_node_index"),
1524                    "parent_node_index must reference a skeleton node",
1525                )
1526            })?;
1527            if parent.rest_world_matrix.is_none() {
1528                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1529            } else {
1530                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1531            }
1532        } else {
1533            None
1534        };
1535        match (
1536            node.rest_world_matrix.is_some(),
1537            expected_unavailable_reason,
1538        ) {
1539            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1540            }
1541            (false, Some(expected))
1542                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1543            _ => {
1544                return Err(invalid(
1545                    path,
1546                    "rest-world availability must agree with local rest and parent rest-world evidence",
1547                ));
1548            }
1549        }
1550    }
1551
1552    for (offset, skin) in assets.skins.iter().enumerate() {
1553        if skin.skin_index != offset {
1554            return Err(invalid(
1555                format!("skins[{offset}].skin_index"),
1556                "skin_index must be contiguous and match source order",
1557            ));
1558        }
1559        if let Some(root) = skin.skeleton_root_node_index
1560            && root >= assets.skeleton_nodes.len()
1561        {
1562            return Err(invalid(
1563                format!("skins[{offset}].skeleton_root_node_index"),
1564                "skeleton_root_node_index must reference a skeleton node",
1565            ));
1566        }
1567        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1568            if joint.joint_index != joint_offset {
1569                return Err(invalid(
1570                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1571                    "joint_index must be contiguous and match declared skin order",
1572                ));
1573            }
1574            if joint.node_index >= assets.skeleton_nodes.len() {
1575                return Err(invalid(
1576                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
1577                    "joint node_index must reference a skeleton node",
1578                ));
1579            }
1580        }
1581        match skin.inverse_bind_accessor.status {
1582            SourceInverseBindAccessorStatus::Absent => {
1583                if skin.inverse_bind_accessor.declared_count.is_some()
1584                    || !skin.inverse_bind_accessor.matrices.is_empty()
1585                {
1586                    return Err(invalid(
1587                        format!("skins[{offset}].inverse_bind_accessor"),
1588                        "an absent inverse-bind declaration has no declared count or matrices",
1589                    ));
1590                }
1591            }
1592            SourceInverseBindAccessorStatus::EmptyAccessor => {
1593                if skin.inverse_bind_accessor.declared_count != Some(0)
1594                    || !skin.inverse_bind_accessor.matrices.is_empty()
1595                {
1596                    return Err(invalid(
1597                        format!("skins[{offset}].inverse_bind_accessor"),
1598                        "an empty inverse-bind declaration has declared_count 0 and no matrices",
1599                    ));
1600                }
1601            }
1602            SourceInverseBindAccessorStatus::Available => {
1603                if skin.inverse_bind_accessor.declared_count
1604                    != Some(skin.inverse_bind_accessor.matrices.len())
1605                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1606                {
1607                    return Err(invalid(
1608                        format!("skins[{offset}].inverse_bind_accessor"),
1609                        "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1610                    ));
1611                }
1612            }
1613            SourceInverseBindAccessorStatus::CountMismatch => {
1614                if skin.inverse_bind_accessor.declared_count
1615                    != Some(skin.inverse_bind_accessor.matrices.len())
1616                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1617                {
1618                    return Err(invalid(
1619                        format!("skins[{offset}].inverse_bind_accessor"),
1620                        "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1621                    ));
1622                }
1623            }
1624            SourceInverseBindAccessorStatus::Unreadable => {
1625                if skin.inverse_bind_accessor.declared_count.is_none()
1626                    || !skin.inverse_bind_accessor.matrices.is_empty()
1627                {
1628                    return Err(invalid(
1629                        format!("skins[{offset}].inverse_bind_accessor"),
1630                        "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1631                    ));
1632                }
1633            }
1634        }
1635        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1636            finite_matrix(
1637                matrix,
1638                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1639            )?;
1640        }
1641        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1642            let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1643            let joint_bind_path =
1644                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1645            validate_derived_matrix(
1646                &joint.joint_bind_to_mesh,
1647                &joint_bind_path,
1648                revision,
1649                &finite_matrix,
1650                invalid,
1651            )?;
1652            validate_derived_reason_compatibility(
1653                &joint.joint_bind_to_mesh,
1654                skin.inverse_bind_accessor.status,
1655                skin.inverse_bind_accessor.matrices.len(),
1656                joint_offset,
1657                &joint_bind_path,
1658                DerivedMatrixDomain::JointBindToMesh,
1659                invalid,
1660            )?;
1661            validate_derived_source(
1662                &joint.joint_bind_to_mesh,
1663                expected_source,
1664                None,
1665                &joint_bind_path,
1666                DerivedMatrixDomain::JointBindToMesh,
1667                invalid,
1668            )?;
1669
1670            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1671            validate_derived_matrix(
1672                &joint.mesh_bind_world,
1673                &mesh_bind_path,
1674                revision,
1675                &finite_matrix,
1676                invalid,
1677            )?;
1678            validate_derived_reason_compatibility(
1679                &joint.mesh_bind_world,
1680                skin.inverse_bind_accessor.status,
1681                skin.inverse_bind_accessor.matrices.len(),
1682                joint_offset,
1683                &mesh_bind_path,
1684                DerivedMatrixDomain::MeshBindWorld,
1685                invalid,
1686            )?;
1687            let joint_rest_world_available = assets
1688                .skeleton_nodes
1689                .get(joint.node_index)
1690                .ok_or_else(|| {
1691                    invalid(
1692                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
1693                        "joint node_index must reference a skeleton node",
1694                    )
1695                })?
1696                .rest_world_matrix
1697                .is_some();
1698            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1699                .rest_world_matrix
1700                .as_ref();
1701            validate_mesh_bind_world_reason_compatibility(
1702                &joint.mesh_bind_world,
1703                joint_rest_world_available,
1704                &mesh_bind_path,
1705                invalid,
1706            )?;
1707            validate_derived_source(
1708                &joint.mesh_bind_world,
1709                expected_source,
1710                joint_rest_world,
1711                &mesh_bind_path,
1712                DerivedMatrixDomain::MeshBindWorld,
1713                invalid,
1714            )?;
1715        }
1716        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1717            && !scale.is_finite()
1718        {
1719            return Err(MeasurementContractError::NonFiniteValue {
1720                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1721            });
1722        }
1723        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1724        if skin.joint_bind_linear_summary != expected_summary {
1725            return Err(invalid(
1726                format!("skins[{offset}].joint_bind_linear_summary"),
1727                "joint-bind linear summary must match the skin joint observations",
1728            ));
1729        }
1730        let mut previous_attachment_node = None;
1731        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1732            if attachment.node_index >= assets.skeleton_nodes.len() {
1733                return Err(invalid(
1734                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1735                    "attachment node_index must reference a skeleton node",
1736                ));
1737            }
1738            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1739                return Err(invalid(
1740                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1741                    "attachment node_index values must be strictly increasing and unique",
1742                ));
1743            }
1744            previous_attachment_node = Some(attachment.node_index);
1745        }
1746    }
1747    Ok(())
1748}
1749
1750fn validate_derived_reason_compatibility(
1751    matrix: &SkinDerivedMatrixMeasurements,
1752    status: SourceInverseBindAccessorStatus,
1753    readable_matrix_count: usize,
1754    joint_index: usize,
1755    path: &str,
1756    domain: DerivedMatrixDomain,
1757    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1758) -> Result<(), MeasurementContractError> {
1759    let requires_accessor_reason = match status {
1760        SourceInverseBindAccessorStatus::Absent => {
1761            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1762        }
1763        SourceInverseBindAccessorStatus::EmptyAccessor => {
1764            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1765        }
1766        SourceInverseBindAccessorStatus::Unreadable => {
1767            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1768        }
1769        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1770            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1771        }
1772        SourceInverseBindAccessorStatus::Available
1773        | SourceInverseBindAccessorStatus::CountMismatch => None,
1774    };
1775    if let Some(expected) = requires_accessor_reason {
1776        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1777            return Err(invalid(
1778                path.into(),
1779                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1780            ));
1781        }
1782    } else {
1783        match (domain, matrix.unavailable_reason) {
1784            (
1785                _,
1786                Some(
1787                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1788                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1789                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1790                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1791                ),
1792            ) => {
1793                return Err(invalid(
1794                    format!("{path}.unavailable_reason"),
1795                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1796                ));
1797            }
1798            (
1799                DerivedMatrixDomain::JointBindToMesh,
1800                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1801            ) => {
1802                return Err(invalid(
1803                    format!("{path}.unavailable_reason"),
1804                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1805                ));
1806            }
1807            (
1808                DerivedMatrixDomain::MeshBindWorld,
1809                Some(
1810                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1811                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1812                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1813                ),
1814            ) => {
1815                return Err(invalid(
1816                    format!("{path}.unavailable_reason"),
1817                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1818                ));
1819            }
1820            _ => {}
1821        }
1822    }
1823    Ok(())
1824}
1825
1826fn validate_mesh_bind_world_reason_compatibility(
1827    matrix: &SkinDerivedMatrixMeasurements,
1828    joint_rest_world_available: bool,
1829    path: &str,
1830    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1831) -> Result<(), MeasurementContractError> {
1832    match matrix.unavailable_reason {
1833        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1834            if joint_rest_world_available =>
1835        {
1836            Err(invalid(
1837                format!("{path}.unavailable_reason"),
1838                "an available joint rest-world matrix cannot be reported as unavailable",
1839            ))
1840        }
1841        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1842            if !joint_rest_world_available =>
1843        {
1844            Err(invalid(
1845                format!("{path}.unavailable_reason"),
1846                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1847            ))
1848        }
1849        _ => Ok(()),
1850    }
1851}
1852
1853#[derive(Clone, Copy, PartialEq, Eq)]
1854enum ParentVisit {
1855    Unvisited,
1856    Visiting,
1857    Done,
1858}
1859
1860#[derive(Clone, Copy)]
1861enum DerivedMatrixDomain {
1862    JointBindToMesh,
1863    MeshBindWorld,
1864}
1865
1866fn validate_derived_source(
1867    measurements: &SkinDerivedMatrixMeasurements,
1868    expected_source: Option<&[f32; 16]>,
1869    joint_rest_world: Option<&[f32; 16]>,
1870    path: &str,
1871    domain: DerivedMatrixDomain,
1872    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1873) -> Result<(), MeasurementContractError> {
1874    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1875        return Err(invalid(
1876            format!("{path}.source_inverse_bind_matrix"),
1877            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1878        ));
1879    }
1880    let Some(source) = expected_source else {
1881        if measurements.inversion_quality.is_some() {
1882            return Err(invalid(
1883                format!("{path}.inversion_quality"),
1884                "inversion quality requires a readable source inverse-bind matrix",
1885            ));
1886        }
1887        return Ok(());
1888    };
1889    let raw = Mat4::from_cols_array(source);
1890    match domain {
1891        DerivedMatrixDomain::JointBindToMesh => {
1892            let assessment = assess_inverse_bind(raw);
1893            if measurements.inversion_quality != assessment.quality {
1894                return Err(invalid(
1895                    format!("{path}.inversion_quality"),
1896                    "inversion quality must be derived from the source linear 3x3",
1897                ));
1898            }
1899            match assessment.inverse {
1900                Ok(inverse) => {
1901                    if measurements.matrix != Some(inverse.to_cols_array())
1902                        || measurements.unavailable_reason.is_some()
1903                    {
1904                        return Err(invalid(
1905                            path.into(),
1906                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1907                        ));
1908                    }
1909                }
1910                Err(reason) => {
1911                    if measurements.matrix.is_some()
1912                        || measurements.unavailable_reason != Some(reason)
1913                    {
1914                        return Err(invalid(
1915                            path.into(),
1916                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1917                        ));
1918                    }
1919                }
1920            }
1921        }
1922        DerivedMatrixDomain::MeshBindWorld => {
1923            if measurements.inversion_quality.is_some() {
1924                return Err(invalid(
1925                    format!("{path}.inversion_quality"),
1926                    "mesh_bind_world does not invert its source matrix",
1927                ));
1928            }
1929            if let Some(world) = joint_rest_world {
1930                let expected = Mat4::from_cols_array(world) * raw;
1931                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1932                    if measurements.matrix != Some(expected.to_cols_array())
1933                        || measurements.unavailable_reason.is_some()
1934                    {
1935                        return Err(invalid(
1936                            path.into(),
1937                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1938                        ));
1939                    }
1940                } else if measurements.unavailable_reason
1941                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1942                {
1943                    return Err(invalid(
1944                        format!("{path}.unavailable_reason"),
1945                        "a non-finite mesh-bind product requires its typed unavailable reason",
1946                    ));
1947                }
1948            }
1949        }
1950    }
1951    Ok(())
1952}
1953
1954fn validate_derived_matrix(
1955    matrix: &SkinDerivedMatrixMeasurements,
1956    path: &str,
1957    revision: MeasurementRevision,
1958    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1959    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1960) -> Result<(), MeasurementContractError> {
1961    if let Some(source) = &matrix.source_inverse_bind_matrix {
1962        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1963    }
1964    if let Some(quality) = matrix.inversion_quality {
1965        let value = quality.reciprocal_condition_number_inf;
1966        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1967            return Err(invalid(
1968                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1969                "reciprocal condition number must be finite and between zero and one",
1970            ));
1971        }
1972    }
1973    match (
1974        &matrix.matrix,
1975        matrix.linear.as_ref(),
1976        matrix.unavailable_reason,
1977    ) {
1978        (Some(matrix), Some(linear), None) => {
1979            finite_matrix(matrix, &format!("{path}.matrix"))?;
1980            validate_linear_transform_fields(linear, &format!("{path}.linear"), revision, invalid)?;
1981            let mut expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
1982            if revision != MeasurementRevision::V18 {
1983                expected_linear.rotation_xyzw = None;
1984            }
1985            if *linear != expected_linear {
1986                return Err(invalid(
1987                    format!("{path}.linear"),
1988                    "linear facts must be derived from the available matrix",
1989                ));
1990            }
1991        }
1992        (None, None, Some(_)) => {}
1993        (Some(_), Some(_), Some(_)) => {
1994            return Err(invalid(
1995                path.into(),
1996                "an available derived matrix cannot have an unavailable reason",
1997            ));
1998        }
1999        _ => {
2000            return Err(invalid(
2001                path.into(),
2002                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
2003            ));
2004        }
2005    }
2006    Ok(())
2007}
2008
2009fn validate_material_resources(
2010    assets: &AssetMeasurements,
2011    revision: MeasurementRevision,
2012    invalid: &impl Fn(String, &str) -> MeasurementContractError,
2013) -> Result<(), MeasurementContractError> {
2014    let absent = assets.material_definitions.is_empty()
2015        && assets.textures.is_empty()
2016        && assets.images.is_empty();
2017    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
2018        return Err(invalid(
2019            "material_resource_coverage".into(),
2020            "unavailable resource coverage requires empty material, texture, and image arrays",
2021        ));
2022    }
2023
2024    for (offset, material) in assets.material_definitions.iter().enumerate() {
2025        if material.material_index != offset {
2026            return Err(invalid(
2027                format!("material_definitions[{offset}].material_index"),
2028                "material_index must be contiguous and match source order",
2029            ));
2030        }
2031        let mut previous_slot = None;
2032        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
2033            if binding.texture_index >= assets.textures.len() {
2034                return Err(invalid(
2035                    format!(
2036                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
2037                    ),
2038                    "texture_index must reference a source texture",
2039                ));
2040            }
2041            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
2042                return Err(invalid(
2043                    format!(
2044                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
2045                    ),
2046                    "texture bindings must be strictly ordered by slot and unique",
2047                ));
2048            }
2049            previous_slot = Some(binding.slot);
2050        }
2051    }
2052    for (offset, texture) in assets.textures.iter().enumerate() {
2053        if texture.texture_index != offset {
2054            return Err(invalid(
2055                format!("textures[{offset}].texture_index"),
2056                "texture_index must be contiguous and match source order",
2057            ));
2058        }
2059        if texture.image_index >= assets.images.len() {
2060            return Err(invalid(
2061                format!("textures[{offset}].image_index"),
2062                "image_index must reference a source image",
2063            ));
2064        }
2065    }
2066    for (offset, image) in assets.images.iter().enumerate() {
2067        validate_image_measurement(image, offset, revision, invalid)?;
2068    }
2069    Ok(())
2070}
2071
2072fn validate_image_measurement(
2073    image: &ImageMeasurements,
2074    offset: usize,
2075    revision: MeasurementRevision,
2076    invalid: &impl Fn(String, &str) -> MeasurementContractError,
2077) -> Result<(), MeasurementContractError> {
2078    if image.image_index != offset {
2079        return Err(invalid(
2080            format!("images[{offset}].image_index"),
2081            "image_index must be contiguous and match source order",
2082        ));
2083    }
2084    let available = [
2085        image.width.is_some(),
2086        image.height.is_some(),
2087        image.channel_count.is_some(),
2088        image.decoded_color_type.is_some(),
2089    ];
2090    match (
2091        available.into_iter().all(|value| value),
2092        image.unavailable_reason,
2093    ) {
2094        (true, None) => {
2095            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
2096                image.width,
2097                image.height,
2098                image.channel_count,
2099                image.decoded_color_type,
2100            ) else {
2101                return Err(invalid(
2102                    format!("images[{offset}]"),
2103                    "available image metadata must include width, height, channel_count, and decoded_color_type",
2104                ));
2105            };
2106            if width == 0 || height == 0 {
2107                return Err(invalid(
2108                    format!("images[{offset}]"),
2109                    "available image dimensions must be greater than zero",
2110                ));
2111            }
2112            if channel_count != color_type_channel_count(decoded_color_type) {
2113                return Err(invalid(
2114                    format!("images[{offset}].channel_count"),
2115                    "channel_count must match decoded_color_type",
2116                ));
2117            }
2118            if image.detected_container.is_none() {
2119                return Err(invalid(
2120                    format!("images[{offset}].detected_container"),
2121                    "available image metadata requires a detected_container",
2122                ));
2123            }
2124        }
2125        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
2126        (true, Some(_)) => {
2127            return Err(invalid(
2128                format!("images[{offset}]"),
2129                "available image metadata cannot have an unavailable_reason",
2130            ));
2131        }
2132        (false, None) if available.into_iter().all(|value| !value) => {
2133            return Err(invalid(
2134                format!("images[{offset}]"),
2135                "missing image metadata requires an unavailable_reason",
2136            ));
2137        }
2138        (false, _) => {
2139            return Err(invalid(
2140                format!("images[{offset}]"),
2141                "available image metadata must include width, height, channel_count, and decoded_color_type",
2142            ));
2143        }
2144    }
2145    match image.unavailable_reason {
2146        Some(crate::model::ImageUnavailableReason::DecodeFailed)
2147            if image.detected_container.is_none() =>
2148        {
2149            return Err(invalid(
2150                format!("images[{offset}].detected_container"),
2151                "decode_failed requires a detected_container",
2152            ));
2153        }
2154        Some(
2155            crate::model::ImageUnavailableReason::SourceUnavailable
2156            | crate::model::ImageUnavailableReason::InvalidDataUri
2157            | crate::model::ImageUnavailableReason::UnsupportedContainer,
2158        ) if image.detected_container.is_some() => {
2159            return Err(invalid(
2160                format!("images[{offset}].detected_container"),
2161                "this unavailable_reason cannot have a detected_container",
2162            ));
2163        }
2164        _ => {}
2165    }
2166    match (revision, image.unavailable_reason, &image.leading_magic_hex) {
2167        (MeasurementRevision::V15, _, Some(_)) => {
2168            return Err(invalid(
2169                format!("images[{offset}].leading_magic_hex"),
2170                "measurements-v15 cannot carry leading-magic evidence",
2171            ));
2172        }
2173        (
2174            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2175            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2176            Some(magic),
2177        ) => {
2178            if magic.is_empty()
2179                || magic.len() > 32
2180                || !magic.len().is_multiple_of(2)
2181                || !magic
2182                    .bytes()
2183                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2184            {
2185                return Err(invalid(
2186                    format!("images[{offset}].leading_magic_hex"),
2187                    "leading_magic_hex must be nonempty lowercase even-length hex for at most 16 bytes",
2188                ));
2189            }
2190        }
2191        (
2192            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2193            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2194            None,
2195        )
2196        | (MeasurementRevision::V15, _, None) => {}
2197        (
2198            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2199            _,
2200            Some(_),
2201        ) => {
2202            return Err(invalid(
2203                format!("images[{offset}].leading_magic_hex"),
2204                "leading_magic_hex is permitted only for unsupported_container",
2205            ));
2206        }
2207        (
2208            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2209            _,
2210            None,
2211        ) => {}
2212    }
2213    Ok(())
2214}
2215
2216fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
2217    match color_type {
2218        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
2219        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
2220        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
2221        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
2222    }
2223}
2224
2225/// Typed read-side subset accepted when a consumer needs measurements from a
2226/// current `measure` or `lint` report.
2227///
2228/// This intentionally models only the fields needed to recover the nested
2229/// measurement contract while retaining every legitimate output-v11 root
2230/// field. The frozen schema is closed, while all protocol identities and
2231/// command constraints are validated by [`MeasurementReportInput::into_files`].
2232#[derive(Debug)]
2233pub struct MeasurementReportInput {
2234    schema_version: Option<u32>,
2235    schema: Option<String>,
2236    _tool: Option<Box<RawValue>>,
2237    command: Option<String>,
2238    summary: Option<MeasurementReportSummaryInput>,
2239    files: Option<Vec<Box<RawValue>>>,
2240    _inputs: Option<Box<RawValue>>,
2241    _deltas: Option<Box<RawValue>>,
2242    extra: BTreeMap<String, Box<RawValue>>,
2243}
2244
2245impl<'de> Deserialize<'de> for MeasurementReportInput {
2246    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2247    where
2248        D: Deserializer<'de>,
2249    {
2250        struct MeasurementReportInputVisitor;
2251
2252        impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
2253            type Value = MeasurementReportInput;
2254
2255            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2256                formatter.write_str("an output report object")
2257            }
2258
2259            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
2260            where
2261                A: MapAccess<'de>,
2262            {
2263                let mut schema_version = None;
2264                let mut schema = None;
2265                let mut tool = None;
2266                let mut command = None;
2267                let mut summary = None;
2268                let mut files = None;
2269                let mut inputs = None;
2270                let mut deltas = None;
2271                let mut extra = BTreeMap::new();
2272                while let Some(field) = map.next_key::<String>()? {
2273                    match field.as_str() {
2274                        "schema_version" => {
2275                            if schema_version.is_some() {
2276                                return Err(serde::de::Error::duplicate_field("schema_version"));
2277                            }
2278                            schema_version = Some(map.next_value()?);
2279                        }
2280                        "schema" => {
2281                            if schema.is_some() {
2282                                return Err(serde::de::Error::duplicate_field("schema"));
2283                            }
2284                            schema = Some(map.next_value()?);
2285                        }
2286                        "tool" => {
2287                            if tool.is_some() {
2288                                return Err(serde::de::Error::duplicate_field("tool"));
2289                            }
2290                            tool = Some(map.next_value()?);
2291                        }
2292                        "command" => {
2293                            if command.is_some() {
2294                                return Err(serde::de::Error::duplicate_field("command"));
2295                            }
2296                            command = Some(map.next_value()?);
2297                        }
2298                        "summary" => {
2299                            if summary.is_some() {
2300                                return Err(serde::de::Error::duplicate_field("summary"));
2301                            }
2302                            summary = Some(map.next_value()?);
2303                        }
2304                        "files" => {
2305                            if files.is_some() {
2306                                return Err(serde::de::Error::duplicate_field("files"));
2307                            }
2308                            files = Some(map.next_value()?);
2309                        }
2310                        "inputs" => {
2311                            if inputs.is_some() {
2312                                return Err(serde::de::Error::duplicate_field("inputs"));
2313                            }
2314                            inputs = Some(map.next_value()?);
2315                        }
2316                        "deltas" => {
2317                            if deltas.is_some() {
2318                                return Err(serde::de::Error::duplicate_field("deltas"));
2319                            }
2320                            deltas = Some(map.next_value()?);
2321                        }
2322                        _ => {
2323                            extra.insert(field, map.next_value()?);
2324                        }
2325                    }
2326                }
2327                Ok(MeasurementReportInput {
2328                    schema_version: schema_version.unwrap_or_default(),
2329                    schema: schema.unwrap_or_default(),
2330                    _tool: tool,
2331                    command: command.unwrap_or_default(),
2332                    summary: summary.unwrap_or_default(),
2333                    files: files.unwrap_or_default(),
2334                    _inputs: inputs.unwrap_or_default(),
2335                    _deltas: deltas.unwrap_or_default(),
2336                    extra,
2337                })
2338            }
2339        }
2340
2341        deserializer.deserialize_map(MeasurementReportInputVisitor)
2342    }
2343}
2344
2345#[derive(Debug, Deserialize)]
2346#[serde(deny_unknown_fields)]
2347struct MeasurementFileWireInput {
2348    path: Option<String>,
2349    input: Option<InputIdentityInput>,
2350    rig: Box<RawValue>,
2351    measurements: Option<Box<RawValue>>,
2352    #[serde(default, deserialize_with = "deserialize_required_nullable")]
2353    prediction_provenance: RequiredNullable<Box<RawValue>>,
2354    checks: Option<Vec<Box<RawValue>>>,
2355}
2356
2357#[derive(Debug)]
2358struct MeasurementFileInput {
2359    path: Option<String>,
2360    input: Option<InputIdentityInput>,
2361    /// V17 alone retains the strictly decoded rig needed by the shared
2362    /// root-motion reconstruction hook. Historical readers keep treating rig
2363    /// as opaque evidence, preserving their released acceptance behavior.
2364    rig_v17: Option<RigInfo>,
2365    measurements: Option<Box<RawValue>>,
2366    prediction_provenance: RequiredNullable<PredictionProvenanceV2>,
2367    checks: Option<Vec<PredictionCheckInput>>,
2368    legacy_prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
2369    legacy_checks: Option<Vec<LegacyPredictionCheckInput>>,
2370    prediction_provenance_v3: RequiredNullable<PredictionProvenanceV3>,
2371    checks_v3: Option<Vec<PredictionCheckInputV3>>,
2372    prediction_provenance_v4: RequiredNullable<PredictionProvenanceV4>,
2373    checks_v4: Option<Vec<PredictionCheckInputV4>>,
2374    prediction_provenance_v5: RequiredNullable<PredictionProvenanceV5>,
2375    checks_v5: Option<Vec<PredictionCheckInputV5>>,
2376    prediction_provenance_v6: RequiredNullable<PredictionProvenanceV6>,
2377    checks_v6: Option<Vec<PredictionCheckInputV6>>,
2378}
2379
2380#[derive(Debug, Deserialize)]
2381#[serde(deny_unknown_fields)]
2382struct LegacyPredictionCheckWireV11 {
2383    check_id: String,
2384    selection: SelectionState,
2385    configuration: ConfigurationState,
2386    applicability: Applicability,
2387    evaluation: EvaluationState,
2388    findings: Vec<PredictionFindingInput>,
2389    #[serde(default)]
2390    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2391    #[serde(default)]
2392    gaps: Vec<PredictionGapInput>,
2393    prediction: Option<Box<RawValue>>,
2394}
2395
2396/// The immutable V11 check attachment.  This deliberately remains a separate
2397/// internal shape: V11 evidence is validated with its V1 identity and staged
2398/// decoding rules, never converted into a V2 attachment.
2399#[derive(Debug)]
2400struct LegacyPredictionCheckInput {
2401    check_id: String,
2402    selection: SelectionState,
2403    configuration: ConfigurationState,
2404    applicability: Applicability,
2405    evaluation: EvaluationState,
2406    findings: Vec<PredictionFindingInput>,
2407    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2408    gaps: Vec<PredictionGapInput>,
2409    prediction: Option<EnginePredictionV1>,
2410}
2411
2412#[derive(Debug, Default)]
2413enum RequiredNullable<T> {
2414    #[default]
2415    Missing,
2416    Present(Option<T>),
2417}
2418
2419impl<T> RequiredNullable<T> {
2420    fn as_present(&self) -> Option<&T> {
2421        match self {
2422            Self::Missing | Self::Present(None) => None,
2423            Self::Present(Some(value)) => Some(value),
2424        }
2425    }
2426}
2427
2428fn deserialize_required_nullable<'de, D, T>(
2429    deserializer: D,
2430) -> Result<RequiredNullable<T>, D::Error>
2431where
2432    D: Deserializer<'de>,
2433    T: Deserialize<'de>,
2434{
2435    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
2436}
2437
2438#[derive(Debug, Deserialize)]
2439#[serde(deny_unknown_fields)]
2440struct MeasurementReportSummaryInput {
2441    #[serde(rename = "files")]
2442    _files: Option<Box<RawValue>>,
2443    #[serde(rename = "findings")]
2444    _findings: Option<Box<RawValue>>,
2445    #[serde(rename = "checks")]
2446    _checks: Option<Box<RawValue>>,
2447    #[serde(rename = "deltas")]
2448    _deltas: Option<Box<RawValue>>,
2449    prediction_facets: Option<PredictionFacetSummaryInput>,
2450}
2451
2452#[derive(Debug, Deserialize)]
2453#[serde(deny_unknown_fields)]
2454struct PredictionFacetSummaryInput {
2455    available: usize,
2456    required_prediction_unavailable: usize,
2457}
2458
2459#[derive(Debug, Deserialize)]
2460#[serde(deny_unknown_fields)]
2461struct PredictionCheckWireInput {
2462    check_id: String,
2463    selection: SelectionState,
2464    configuration: ConfigurationState,
2465    applicability: Applicability,
2466    evaluation: EvaluationState,
2467    findings: Vec<PredictionFindingInput>,
2468    #[serde(default)]
2469    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2470    #[serde(default)]
2471    gaps: Vec<PredictionGapInput>,
2472    prediction: Option<Box<RawValue>>,
2473}
2474
2475#[derive(Debug)]
2476struct PredictionCheckInput {
2477    check_id: String,
2478    selection: SelectionState,
2479    configuration: ConfigurationState,
2480    applicability: Applicability,
2481    evaluation: EvaluationState,
2482    findings: Vec<PredictionFindingInput>,
2483    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2484    gaps: Vec<PredictionGapInput>,
2485    prediction: Option<EnginePredictionV2>,
2486}
2487
2488#[derive(Debug)]
2489struct PredictionCheckInputV3 {
2490    check_id: String,
2491    selection: SelectionState,
2492    configuration: ConfigurationState,
2493    applicability: Applicability,
2494    evaluation: EvaluationState,
2495    findings: Vec<PredictionFindingInput>,
2496    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2497    gaps: Vec<PredictionGapInput>,
2498    prediction: Option<EnginePredictionV3>,
2499}
2500
2501#[derive(Debug)]
2502struct PredictionCheckInputV4 {
2503    check_id: String,
2504    selection: SelectionState,
2505    configuration: ConfigurationState,
2506    applicability: Applicability,
2507    evaluation: EvaluationState,
2508    findings: Vec<PredictionFindingInput>,
2509    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2510    gaps: Vec<PredictionGapInput>,
2511    prediction: Option<EnginePredictionV4>,
2512}
2513
2514#[derive(Debug)]
2515struct PredictionCheckInputV5 {
2516    check_id: String,
2517    selection: SelectionState,
2518    configuration: ConfigurationState,
2519    applicability: Applicability,
2520    evaluation: EvaluationState,
2521    findings: Vec<PredictionFindingInput>,
2522    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2523    gaps: Vec<PredictionGapInput>,
2524    prediction: Option<EnginePredictionV5>,
2525}
2526
2527#[derive(Debug)]
2528struct PredictionCheckInputV6 {
2529    check_id: String,
2530    selection: SelectionState,
2531    configuration: ConfigurationState,
2532    applicability: Applicability,
2533    evaluation: EvaluationState,
2534    findings: Vec<PredictionFindingInput>,
2535    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2536    gaps: Vec<PredictionGapInput>,
2537    prediction: Option<EnginePredictionV6>,
2538}
2539
2540#[derive(Debug, Serialize, Deserialize)]
2541#[serde(deny_unknown_fields)]
2542struct PredictionFindingInput {
2543    check_id: String,
2544    #[serde(rename = "severity")]
2545    _severity: PredictionSeverityInput,
2546    #[serde(rename = "clip", skip_serializing_if = "Option::is_none")]
2547    _clip: Option<String>,
2548    #[serde(rename = "bone", skip_serializing_if = "Option::is_none")]
2549    _bone: Option<String>,
2550    #[serde(rename = "node", skip_serializing_if = "Option::is_none")]
2551    _node: Option<String>,
2552    #[serde(skip_serializing_if = "Option::is_none")]
2553    prediction_scope: Option<crate::evaluation::EvaluationScope>,
2554    #[serde(rename = "time_s", skip_serializing_if = "Option::is_none")]
2555    _time_s: Option<f32>,
2556    #[serde(rename = "measured", skip_serializing_if = "Option::is_none")]
2557    _measured: Option<Box<RawValue>>,
2558    #[serde(rename = "expected", skip_serializing_if = "Option::is_none")]
2559    _expected: Option<Box<RawValue>>,
2560    #[serde(rename = "members", skip_serializing_if = "Option::is_none")]
2561    _members: Option<Box<RawValue>>,
2562    #[serde(rename = "message")]
2563    _message: String,
2564}
2565
2566#[derive(Debug, Deserialize)]
2567#[serde(deny_unknown_fields)]
2568struct PredictionGapInput {
2569    code: String,
2570    #[serde(rename = "message")]
2571    _message: String,
2572    scope: Option<crate::evaluation::EvaluationScope>,
2573}
2574
2575#[derive(Debug, Serialize, Deserialize)]
2576#[serde(rename_all = "snake_case")]
2577enum PredictionSeverityInput {
2578    Error,
2579    Warning,
2580    Note,
2581}
2582
2583#[derive(Debug, Deserialize)]
2584#[serde(deny_unknown_fields)]
2585struct InputIdentityInput {
2586    sha256: Option<String>,
2587    bytes: Option<u64>,
2588}
2589
2590/// Recursively preserves the historical JSON-f64-to-Rust-f32 narrowing path
2591/// without materializing an unbounded generic JSON value.
2592///
2593/// `serde_json` rejects a finite JSON number that exceeds `f32::MAX` when it
2594/// directly services `deserialize_f32`. Output-v9 readback first retained the
2595/// number as `f64`, then narrowed it to `f32`; semantic measurement validation
2596/// consequently reported the resulting infinity as a typed non-finite value.
2597/// This adapter retains that contract while streaming directly into the bounded
2598/// typed measurement DTO.
2599struct MeasurementF32NarrowingDeserializer<D>(D);
2600
2601macro_rules! delegate_measurement_deserializer {
2602    ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2603        fn $method<V>(
2604            self,
2605            $($argument: $argument_type,)*
2606            visitor: V,
2607        ) -> Result<V::Value, Self::Error>
2608        where
2609            V: Visitor<'de>,
2610        {
2611            self.0.$method(
2612                $($argument,)*
2613                MeasurementF32NarrowingVisitor(visitor),
2614            )
2615        }
2616    };
2617}
2618
2619impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2620where
2621    D: Deserializer<'de>,
2622{
2623    type Error = D::Error;
2624
2625    delegate_measurement_deserializer!(deserialize_any);
2626    delegate_measurement_deserializer!(deserialize_bool);
2627    delegate_measurement_deserializer!(deserialize_i8);
2628    delegate_measurement_deserializer!(deserialize_i16);
2629    delegate_measurement_deserializer!(deserialize_i32);
2630    delegate_measurement_deserializer!(deserialize_i64);
2631    delegate_measurement_deserializer!(deserialize_i128);
2632    delegate_measurement_deserializer!(deserialize_u8);
2633    delegate_measurement_deserializer!(deserialize_u16);
2634    delegate_measurement_deserializer!(deserialize_u32);
2635    delegate_measurement_deserializer!(deserialize_u64);
2636    delegate_measurement_deserializer!(deserialize_u128);
2637
2638    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2639    where
2640        V: Visitor<'de>,
2641    {
2642        self.0
2643            .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2644    }
2645
2646    delegate_measurement_deserializer!(deserialize_f64);
2647    delegate_measurement_deserializer!(deserialize_char);
2648    delegate_measurement_deserializer!(deserialize_str);
2649    delegate_measurement_deserializer!(deserialize_string);
2650    delegate_measurement_deserializer!(deserialize_bytes);
2651    delegate_measurement_deserializer!(deserialize_byte_buf);
2652    delegate_measurement_deserializer!(deserialize_option);
2653    delegate_measurement_deserializer!(deserialize_unit);
2654    delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2655    delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2656    delegate_measurement_deserializer!(deserialize_seq);
2657    delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2658    delegate_measurement_deserializer!(
2659        deserialize_tuple_struct,
2660        name: &'static str,
2661        len: usize
2662    );
2663    delegate_measurement_deserializer!(deserialize_map);
2664    delegate_measurement_deserializer!(
2665        deserialize_struct,
2666        name: &'static str,
2667        fields: &'static [&'static str]
2668    );
2669    delegate_measurement_deserializer!(
2670        deserialize_enum,
2671        name: &'static str,
2672        variants: &'static [&'static str]
2673    );
2674    delegate_measurement_deserializer!(deserialize_identifier);
2675    delegate_measurement_deserializer!(deserialize_ignored_any);
2676
2677    fn is_human_readable(&self) -> bool {
2678        self.0.is_human_readable()
2679    }
2680}
2681
2682struct MeasurementF32NarrowingNumberVisitor<V>(V);
2683
2684impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2685where
2686    V: Visitor<'de>,
2687{
2688    type Value = V::Value;
2689
2690    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2691        self.0.expecting(formatter)
2692    }
2693
2694    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2695    where
2696        E: serde::de::Error,
2697    {
2698        self.0.visit_f32(value)
2699    }
2700
2701    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2702    where
2703        E: serde::de::Error,
2704    {
2705        self.0.visit_f32(value as f32)
2706    }
2707
2708    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2709    where
2710        E: serde::de::Error,
2711    {
2712        self.0.visit_f32(value as f32)
2713    }
2714
2715    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2716    where
2717        E: serde::de::Error,
2718    {
2719        self.0.visit_f32(value as f32)
2720    }
2721
2722    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2723    where
2724        E: serde::de::Error,
2725    {
2726        self.0.visit_f32(value as f32)
2727    }
2728
2729    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2730    where
2731        E: serde::de::Error,
2732    {
2733        self.0.visit_f32(value as f32)
2734    }
2735}
2736
2737struct MeasurementF32NarrowingVisitor<V>(V);
2738
2739macro_rules! delegate_measurement_visitor {
2740    ($method:ident, $value_type:ty) => {
2741        fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2742        where
2743            E: serde::de::Error,
2744        {
2745            self.0.$method(value)
2746        }
2747    };
2748}
2749
2750impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2751where
2752    V: Visitor<'de>,
2753{
2754    type Value = V::Value;
2755
2756    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2757        self.0.expecting(formatter)
2758    }
2759
2760    delegate_measurement_visitor!(visit_bool, bool);
2761    delegate_measurement_visitor!(visit_i8, i8);
2762    delegate_measurement_visitor!(visit_i16, i16);
2763    delegate_measurement_visitor!(visit_i32, i32);
2764    delegate_measurement_visitor!(visit_i64, i64);
2765    delegate_measurement_visitor!(visit_i128, i128);
2766    delegate_measurement_visitor!(visit_u8, u8);
2767    delegate_measurement_visitor!(visit_u16, u16);
2768    delegate_measurement_visitor!(visit_u32, u32);
2769    delegate_measurement_visitor!(visit_u64, u64);
2770    delegate_measurement_visitor!(visit_u128, u128);
2771    delegate_measurement_visitor!(visit_f32, f32);
2772    delegate_measurement_visitor!(visit_f64, f64);
2773    delegate_measurement_visitor!(visit_char, char);
2774
2775    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2776    where
2777        E: serde::de::Error,
2778    {
2779        self.0.visit_str(value)
2780    }
2781
2782    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2783    where
2784        E: serde::de::Error,
2785    {
2786        self.0.visit_borrowed_str(value)
2787    }
2788
2789    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2790    where
2791        E: serde::de::Error,
2792    {
2793        self.0.visit_string(value)
2794    }
2795
2796    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2797    where
2798        E: serde::de::Error,
2799    {
2800        self.0.visit_bytes(value)
2801    }
2802
2803    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2804    where
2805        E: serde::de::Error,
2806    {
2807        self.0.visit_borrowed_bytes(value)
2808    }
2809
2810    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2811    where
2812        E: serde::de::Error,
2813    {
2814        self.0.visit_byte_buf(value)
2815    }
2816
2817    fn visit_none<E>(self) -> Result<Self::Value, E>
2818    where
2819        E: serde::de::Error,
2820    {
2821        self.0.visit_none()
2822    }
2823
2824    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2825    where
2826        D: Deserializer<'de>,
2827    {
2828        self.0
2829            .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2830    }
2831
2832    fn visit_unit<E>(self) -> Result<Self::Value, E>
2833    where
2834        E: serde::de::Error,
2835    {
2836        self.0.visit_unit()
2837    }
2838
2839    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2840    where
2841        D: Deserializer<'de>,
2842    {
2843        self.0
2844            .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2845    }
2846
2847    fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2848    where
2849        A: SeqAccess<'de>,
2850    {
2851        self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2852    }
2853
2854    fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2855    where
2856        A: MapAccess<'de>,
2857    {
2858        self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2859    }
2860
2861    fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2862    where
2863        A: EnumAccess<'de>,
2864    {
2865        self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2866    }
2867}
2868
2869struct MeasurementF32NarrowingSeed<S>(S);
2870
2871impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2872where
2873    S: DeserializeSeed<'de>,
2874{
2875    type Value = S::Value;
2876
2877    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2878    where
2879        D: Deserializer<'de>,
2880    {
2881        self.0
2882            .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2883    }
2884}
2885
2886struct MeasurementF32NarrowingSeqAccess<A>(A);
2887
2888impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2889where
2890    A: SeqAccess<'de>,
2891{
2892    type Error = A::Error;
2893
2894    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2895    where
2896        T: DeserializeSeed<'de>,
2897    {
2898        self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2899    }
2900
2901    fn size_hint(&self) -> Option<usize> {
2902        self.0.size_hint()
2903    }
2904}
2905
2906struct MeasurementF32NarrowingMapAccess<A>(A);
2907
2908impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2909where
2910    A: MapAccess<'de>,
2911{
2912    type Error = A::Error;
2913
2914    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2915    where
2916        K: DeserializeSeed<'de>,
2917    {
2918        self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2919    }
2920
2921    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2922    where
2923        V: DeserializeSeed<'de>,
2924    {
2925        self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2926    }
2927
2928    fn size_hint(&self) -> Option<usize> {
2929        self.0.size_hint()
2930    }
2931}
2932
2933struct MeasurementF32NarrowingEnumAccess<A>(A);
2934
2935impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2936where
2937    A: EnumAccess<'de>,
2938{
2939    type Error = A::Error;
2940    type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2941
2942    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2943    where
2944        V: DeserializeSeed<'de>,
2945    {
2946        let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2947        Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2948    }
2949}
2950
2951struct MeasurementF32NarrowingVariantAccess<A>(A);
2952
2953impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2954where
2955    A: VariantAccess<'de>,
2956{
2957    type Error = A::Error;
2958
2959    fn unit_variant(self) -> Result<(), Self::Error> {
2960        self.0.unit_variant()
2961    }
2962
2963    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2964    where
2965        T: DeserializeSeed<'de>,
2966    {
2967        self.0
2968            .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2969    }
2970
2971    fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2972    where
2973        V: Visitor<'de>,
2974    {
2975        self.0
2976            .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2977    }
2978
2979    fn struct_variant<V>(
2980        self,
2981        fields: &'static [&'static str],
2982        visitor: V,
2983    ) -> Result<V::Value, Self::Error>
2984    where
2985        V: Visitor<'de>,
2986    {
2987        self.0
2988            .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
2989    }
2990}
2991
2992#[derive(Debug, Deserialize)]
2993#[serde(untagged)]
2994enum SkeletonNodeMeasurementInput {
2995    Current(Box<crate::measure::SkeletonNodeMeasurements>),
2996    Earlier {
2997        #[serde(rename = "node_index")]
2998        _node_index: usize,
2999    },
3000}
3001
3002#[derive(Debug, Deserialize)]
3003#[serde(untagged)]
3004enum SkinMeasurementInput {
3005    Current(Box<crate::measure::SkinMeasurements>),
3006    Earlier {
3007        #[serde(rename = "skin_index")]
3008        _skin_index: usize,
3009    },
3010}
3011
3012#[derive(Debug, Deserialize)]
3013struct MeasurementPayloadInput {
3014    schema_version: Option<u32>,
3015    schema: Option<String>,
3016    clips: Option<BTreeMap<String, ClipMeasurements>>,
3017    material_resource_coverage: Option<MaterialResourceCoverage>,
3018    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
3019    textures: Option<Vec<TextureMeasurements>>,
3020    images: Option<Vec<ImageMeasurements>>,
3021    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
3022    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
3023    skins: Option<Vec<SkinMeasurementInput>>,
3024    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
3025    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
3026    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
3027    default_scene_index: Option<usize>,
3028}
3029
3030/// Current measurement payload readback is closed at the root and at every
3031/// domain introduced or extended by measurements-v16. Historical readers use
3032/// [`MeasurementPayloadInput`] directly so their accepted JSON shape does not
3033/// change retroactively.
3034#[derive(Debug, Deserialize)]
3035#[serde(deny_unknown_fields)]
3036struct MeasurementPayloadV16Input {
3037    schema_version: Option<u32>,
3038    schema: Option<String>,
3039    clips: Option<BTreeMap<String, ClipMeasurements>>,
3040    material_resource_coverage: Option<MaterialResourceCoverage>,
3041    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
3042    textures: Option<Vec<TextureMeasurements>>,
3043    images: Option<Vec<ImageMeasurementsV16Input>>,
3044    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
3045    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
3046    skins: Option<Vec<SkinMeasurementInput>>,
3047    mesh_definitions: Option<Vec<MeshDefinitionMeasurementsV16Input>>,
3048    node_instances: Option<Vec<NodeInstanceMeasurementsV16Input>>,
3049    scenes: Option<Vec<SceneMeasurementsV16Input>>,
3050    default_scene_index: Option<usize>,
3051}
3052
3053#[derive(Debug, Deserialize)]
3054#[serde(deny_unknown_fields)]
3055struct AabbV16Input {
3056    min: [f32; 3],
3057    max: [f32; 3],
3058}
3059
3060impl From<AabbV16Input> for Aabb {
3061    fn from(value: AabbV16Input) -> Self {
3062        Self {
3063            min: value.min,
3064            max: value.max,
3065        }
3066    }
3067}
3068
3069#[derive(Debug, Deserialize)]
3070#[serde(deny_unknown_fields)]
3071struct PrimitiveMeasurementsV16Input {
3072    primitive_index: usize,
3073    #[serde(deserialize_with = "deserialize_required_optional_usize")]
3074    material_index: Option<usize>,
3075    vertex_count: u64,
3076    finite_vertex_count: u64,
3077    geometry_aabb: Option<AabbV16Input>,
3078    geometry_centroid: Option<[f32; 3]>,
3079}
3080
3081fn deserialize_required_optional_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
3082where
3083    D: Deserializer<'de>,
3084{
3085    Option::<usize>::deserialize(deserializer)
3086}
3087
3088impl From<PrimitiveMeasurementsV16Input> for PrimitiveMeasurements {
3089    fn from(value: PrimitiveMeasurementsV16Input) -> Self {
3090        Self {
3091            primitive_index: value.primitive_index,
3092            material_index: value.material_index,
3093            vertex_count: value.vertex_count,
3094            finite_vertex_count: value.finite_vertex_count,
3095            geometry_aabb: value.geometry_aabb.map(Into::into),
3096            geometry_centroid: value.geometry_centroid,
3097        }
3098    }
3099}
3100
3101#[derive(Debug, Deserialize)]
3102#[serde(deny_unknown_fields)]
3103struct MeshDefinitionMeasurementsV16Input {
3104    mesh_index: usize,
3105    name: String,
3106    primitives: Option<Vec<PrimitiveMeasurementsV16Input>>,
3107    vertex_count: u64,
3108    geometry_aabb: Option<AabbV16Input>,
3109    geometry_centroid: Option<[f32; 3]>,
3110    max_joints_per_vertex: u32,
3111    weight_sum_min: Option<f64>,
3112    weight_sum_max: Option<f64>,
3113    additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
3114}
3115
3116impl From<MeshDefinitionMeasurementsV16Input> for MeshDefinitionMeasurements {
3117    fn from(value: MeshDefinitionMeasurementsV16Input) -> Self {
3118        Self {
3119            mesh_index: value.mesh_index,
3120            name: value.name,
3121            primitives: value
3122                .primitives
3123                .map(|primitives| primitives.into_iter().map(Into::into).collect()),
3124            vertex_count: value.vertex_count,
3125            geometry_aabb: value.geometry_aabb.map(Into::into),
3126            geometry_centroid: value.geometry_centroid,
3127            max_joints_per_vertex: value.max_joints_per_vertex,
3128            weight_sum_min: value.weight_sum_min,
3129            weight_sum_max: value.weight_sum_max,
3130            additional_influence_sets: value.additional_influence_sets,
3131        }
3132    }
3133}
3134
3135#[derive(Debug, Deserialize)]
3136#[serde(deny_unknown_fields)]
3137struct ImageMeasurementsV16Input {
3138    image_index: usize,
3139    name: Option<String>,
3140    source_kind: crate::model::ImageSourceKind,
3141    declared_mime_type: Option<String>,
3142    detected_container: Option<crate::model::ImageContainerFormat>,
3143    leading_magic_hex: Option<String>,
3144    width: Option<u32>,
3145    height: Option<u32>,
3146    channel_count: Option<u8>,
3147    decoded_color_type: Option<DecodedImageColorType>,
3148    unavailable_reason: Option<crate::model::ImageUnavailableReason>,
3149}
3150
3151impl From<ImageMeasurementsV16Input> for ImageMeasurements {
3152    fn from(value: ImageMeasurementsV16Input) -> Self {
3153        Self {
3154            image_index: value.image_index,
3155            name: value.name,
3156            source_kind: value.source_kind,
3157            declared_mime_type: value.declared_mime_type,
3158            detected_container: value.detected_container,
3159            leading_magic_hex: value.leading_magic_hex,
3160            width: value.width,
3161            height: value.height,
3162            channel_count: value.channel_count,
3163            decoded_color_type: value.decoded_color_type,
3164            unavailable_reason: value.unavailable_reason,
3165        }
3166    }
3167}
3168
3169#[derive(Debug, Deserialize)]
3170#[serde(deny_unknown_fields)]
3171struct NodeInstanceMeasurementsV16Input {
3172    node_index: usize,
3173    node_name: String,
3174    mesh_index: usize,
3175    static_node_world_aabb: Option<AabbV16Input>,
3176    static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
3177}
3178
3179impl From<NodeInstanceMeasurementsV16Input> for NodeInstanceMeasurements {
3180    fn from(value: NodeInstanceMeasurementsV16Input) -> Self {
3181        Self {
3182            node_index: value.node_index,
3183            node_name: value.node_name,
3184            mesh_index: value.mesh_index,
3185            static_node_world_aabb: value.static_node_world_aabb.map(Into::into),
3186            static_node_world_aabb_unavailable_reason: value
3187                .static_node_world_aabb_unavailable_reason,
3188        }
3189    }
3190}
3191
3192#[derive(Debug, Deserialize)]
3193#[serde(deny_unknown_fields)]
3194struct SceneMeasurementsV16Input {
3195    scene_index: usize,
3196    name: Option<String>,
3197    instance_count: usize,
3198    static_scene_world_aabb: Option<AabbV16Input>,
3199    excluded_instance_count: usize,
3200}
3201
3202impl From<SceneMeasurementsV16Input> for SceneMeasurements {
3203    fn from(value: SceneMeasurementsV16Input) -> Self {
3204        Self {
3205            scene_index: value.scene_index,
3206            name: value.name,
3207            instance_count: value.instance_count,
3208            static_scene_world_aabb: value.static_scene_world_aabb.map(Into::into),
3209            excluded_instance_count: value.excluded_instance_count,
3210        }
3211    }
3212}
3213
3214impl From<MeasurementPayloadV16Input> for MeasurementPayloadInput {
3215    fn from(value: MeasurementPayloadV16Input) -> Self {
3216        Self {
3217            schema_version: value.schema_version,
3218            schema: value.schema,
3219            clips: value.clips,
3220            material_resource_coverage: value.material_resource_coverage,
3221            material_definitions: value.material_definitions,
3222            textures: value.textures,
3223            images: value
3224                .images
3225                .map(|images| images.into_iter().map(Into::into).collect()),
3226            skeleton_source_coverage: value.skeleton_source_coverage,
3227            skeleton_nodes: value.skeleton_nodes,
3228            skins: value.skins,
3229            mesh_definitions: value
3230                .mesh_definitions
3231                .map(|meshes| meshes.into_iter().map(Into::into).collect()),
3232            node_instances: value
3233                .node_instances
3234                .map(|instances| instances.into_iter().map(Into::into).collect()),
3235            scenes: value
3236                .scenes
3237                .map(|scenes| scenes.into_iter().map(Into::into).collect()),
3238            default_scene_index: value.default_scene_index,
3239        }
3240    }
3241}
3242
3243fn decode_measurement_payload(
3244    raw: &RawValue,
3245    strict_v16: bool,
3246) -> Result<MeasurementPayloadInput, serde_json::Error> {
3247    let mut deserializer = serde_json::Deserializer::from_str(raw.get());
3248    let payload = if strict_v16 {
3249        MeasurementPayloadV16Input::deserialize(MeasurementF32NarrowingDeserializer(
3250            &mut deserializer,
3251        ))?
3252        .into()
3253    } else {
3254        MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
3255            &mut deserializer,
3256        ))?
3257    };
3258    deserializer.end()?;
3259    Ok(payload)
3260}
3261
3262/// One validated file record recovered from a measurement report.
3263///
3264/// The record retains its source path and full nested measurement contract so
3265/// consumers can choose the clip, mesh, and cardinality policies appropriate
3266/// to their workflow.
3267#[derive(Debug, Clone)]
3268pub struct MeasurementReportFile {
3269    path: String,
3270    input: InputIdentity,
3271    measurements: MeasurementContract,
3272}
3273
3274impl MeasurementReportFile {
3275    /// Source path recorded by the producing report.
3276    pub fn path(&self) -> &str {
3277        &self.path
3278    }
3279
3280    /// Immutable identity of the source bytes used to produce this record.
3281    pub fn input(&self) -> &InputIdentity {
3282        &self.input
3283    }
3284
3285    /// Validated nested measurement contract.
3286    pub fn measurements(&self) -> &MeasurementContract {
3287        &self.measurements
3288    }
3289
3290    /// Consume this record and return its validated measurement contract.
3291    pub fn into_measurements(self) -> MeasurementContract {
3292        self.measurements
3293    }
3294}
3295
3296/// A typed measurement-report subset failed current-contract validation.
3297#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3298#[non_exhaustive]
3299pub enum MeasurementReportError {
3300    /// The outer envelope omitted its version.
3301    #[error("report envelope has no `schema_version`")]
3302    MissingOutputVersion,
3303    /// The outer envelope uses an unsupported version.
3304    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
3305    UnsupportedOutputVersion {
3306        /// Version found in the input.
3307        found: u32,
3308    },
3309    /// The outer envelope does not carry the immutable current identity.
3310    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
3311    WrongOutputIdentity,
3312    /// The outer envelope omitted its command.
3313    #[error("report envelope has no `command`")]
3314    MissingCommand,
3315    /// The outer envelope belongs to a command without file measurements.
3316    #[error("report command {command:?} does not carry measurement file records")]
3317    UnsupportedCommand {
3318        /// Command found in the input.
3319        command: String,
3320    },
3321    /// A current output-v11 envelope carried a field outside its closed schema.
3322    #[error("report envelope has unknown field `{field}`")]
3323    UnknownOutputField {
3324        /// Lexically first unknown root field.
3325        field: String,
3326    },
3327    /// A current output-v11 envelope omitted its producer metadata.
3328    #[error("report envelope has no `tool` object")]
3329    MissingTool,
3330    /// The outer envelope omitted its file array.
3331    #[error("report envelope has no `files` array")]
3332    MissingFiles,
3333    /// The outer envelope exceeds the immutable file-record bound.
3334    #[error("report contains {found} files, exceeding the output-v11 limit of {limit}")]
3335    TooManyFiles {
3336        /// Supplied file count.
3337        found: usize,
3338        /// Immutable output-v11 limit.
3339        limit: usize,
3340    },
3341    /// A lint report omitted the derived prediction-facet summary.
3342    #[error("lint report summary has no `prediction_facets` object")]
3343    MissingPredictionFacetSummary,
3344    /// A measure report carried a lint-only prediction-facet summary.
3345    #[error("measure report summary must not carry `prediction_facets`")]
3346    UnexpectedPredictionFacetSummary,
3347    /// Derived prediction-facet totals did not match the lint summary.
3348    #[error("lint report prediction-facet summary does not match its check records")]
3349    PredictionFacetSummaryMismatch,
3350    /// One file record failed validation.
3351    #[error("files[{file_index}] {source}")]
3352    File {
3353        /// Zero-based index of the invalid file record.
3354        file_index: usize,
3355        /// Typed record-validation failure.
3356        #[source]
3357        source: MeasurementFileError,
3358    },
3359}
3360
3361/// A serialized output-v11 report could not be read within the public bound.
3362#[derive(Debug, thiserror::Error)]
3363#[non_exhaustive]
3364pub enum MeasurementReportReadError {
3365    /// Reading the bounded input failed.
3366    #[error("cannot read report: {source}")]
3367    Io {
3368        /// Underlying bounded-reader failure.
3369        #[source]
3370        source: std::io::Error,
3371    },
3372    /// The serialized report exceeded the immutable output-v11 byte limit.
3373    #[error("report exceeds the output-v11 limit of {limit} bytes")]
3374    ReportTooLarge {
3375        /// Immutable maximum accepted byte count.
3376        limit: u64,
3377    },
3378    /// The bounded bytes were not valid JSON for the output-v11 read shape.
3379    #[error("invalid report JSON: {source}")]
3380    InvalidJson {
3381        /// JSON syntax or typed-shape failure.
3382        #[source]
3383        source: serde_json::Error,
3384    },
3385}
3386
3387/// One measurement-report file record failed validation.
3388#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3389#[non_exhaustive]
3390pub enum MeasurementFileError {
3391    /// The bounded file record could not be decoded after the outer v11
3392    /// identity was accepted.
3393    #[error("has invalid output-v11 file shape: {reason}")]
3394    InvalidFileShape {
3395        /// Stable serde diagnostic for the malformed nested record.
3396        reason: String,
3397    },
3398    /// The file record omitted its source path.
3399    #[error("has no `path`")]
3400    MissingPath,
3401    /// The file record omitted its source-byte identity.
3402    #[error("has no `input`")]
3403    MissingInput,
3404    /// The source-byte identity omitted its SHA-256 digest.
3405    #[error("input has no `sha256`")]
3406    MissingSha256,
3407    /// The source-byte identity uses a malformed SHA-256 digest.
3408    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
3409    InvalidSha256,
3410    /// The source-byte identity omitted its byte count.
3411    #[error("input has no `bytes`")]
3412    MissingBytes,
3413    /// The file record omitted its nested measurement contract.
3414    #[error("has no measurements")]
3415    MissingMeasurements,
3416    /// A lint file omitted its required nullable provenance field.
3417    #[error("has no required `prediction_provenance` field")]
3418    MissingPredictionProvenance,
3419    /// A measure file carried lint-only prediction provenance.
3420    #[error("measure file must not carry `prediction_provenance`")]
3421    UnexpectedPredictionProvenance,
3422    /// A lint file omitted its check array.
3423    #[error("lint file has no `checks` array")]
3424    MissingChecks,
3425    /// A measure file carried lint-only check records.
3426    #[error("measure file must not carry `checks`")]
3427    UnexpectedChecks,
3428    /// A lint file exceeded the immutable per-file check bound.
3429    #[error("contains {found} checks, exceeding the output-v11 limit of {limit}")]
3430    TooManyChecks {
3431        /// Supplied check count.
3432        found: usize,
3433        /// Immutable output-v11 limit.
3434        limit: usize,
3435    },
3436    /// File and prediction-provenance primary identities differ.
3437    #[error("prediction provenance primary input does not match file input")]
3438    PredictionPrimaryInputMismatch,
3439    /// File-scoped prediction provenance violated its immutable contract.
3440    #[error("has invalid prediction provenance: {source}")]
3441    InvalidPredictionProvenance {
3442        /// Typed nested provenance failure.
3443        #[source]
3444        source: PredictionContractError,
3445    },
3446    /// The serialized provenance object could not be decoded as the strict V1 wire.
3447    #[error("has invalid prediction provenance shape: {reason}")]
3448    InvalidPredictionProvenanceShape {
3449        /// Stable serde diagnostic for the malformed nested object.
3450        reason: String,
3451    },
3452    /// One check carried a prediction without file provenance.
3453    #[error("checks[{check_index}] has prediction without non-null file provenance")]
3454    PredictionWithoutProvenance {
3455        /// Zero-based check index.
3456        check_index: usize,
3457    },
3458    /// One check's prediction evidence violated its immutable contract.
3459    #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
3460    InvalidPrediction {
3461        /// Zero-based check index.
3462        check_index: usize,
3463        /// Typed nested prediction failure.
3464        #[source]
3465        source: PredictionContractError,
3466    },
3467    /// One serialized check or prediction object could not be decoded as the strict V1 wire.
3468    #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
3469    InvalidPredictionShape {
3470        /// Zero-based check index.
3471        check_index: usize,
3472        /// Stable serde diagnostic for the malformed nested object.
3473        reason: String,
3474    },
3475    /// One check's prediction attachment contradicts the sole check lifecycle.
3476    #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
3477    InvalidPredictionLifecycle {
3478        /// Zero-based check index.
3479        check_index: usize,
3480        /// Stable relationship failure.
3481        reason: &'static str,
3482    },
3483    /// Aggregate prediction facets exceeded the per-file V1 bound.
3484    #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
3485    TooManyPredictionFacets {
3486        /// Supplied facet count.
3487        found: usize,
3488        /// Immutable V1 limit.
3489        limit: usize,
3490    },
3491    /// A decoded V2 budget summary did not coincide with an exhausted shared
3492    /// file facet budget.
3493    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
3494    FacetBudgetSummaryWithoutExhaustedFileBudget {
3495        /// Aggregate facet count.
3496        found: usize,
3497        /// Immutable shared file limit.
3498        limit: usize,
3499    },
3500    /// Aggregate prediction basis rows exceeded the per-file V1 bound.
3501    #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
3502    TooManyPredictionBasisReferences {
3503        /// Supplied basis-row count.
3504        found: usize,
3505        /// Immutable V1 limit.
3506        limit: usize,
3507    },
3508    /// Aggregate prediction/provenance retained text exceeded the per-file bound.
3509    #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
3510    TooMuchPredictionText {
3511        /// Supplied UTF-8 byte count.
3512        found: usize,
3513        /// Immutable V1 limit.
3514        limit: usize,
3515    },
3516    /// Checked prediction accounting overflowed.
3517    #[error("prediction bound accounting overflowed")]
3518    PredictionAccountingOverflow,
3519    /// The nested measurement contract omitted its version.
3520    #[error("has no versioned measurement contract")]
3521    MissingMeasurementVersion,
3522    /// The nested measurement contract uses an unsupported version.
3523    #[error(
3524        "has measurement schema_version {found}; this reader expects measurement schema_version {expected}"
3525    )]
3526    UnsupportedMeasurementVersion {
3527        /// Version found in the nested contract.
3528        found: u32,
3529        /// Version required by the output reader revision.
3530        expected: u32,
3531    },
3532    /// The nested contract does not carry the immutable measurement identity.
3533    #[error("does not identify measurement contract {expected}")]
3534    WrongMeasurementIdentity {
3535        /// Identity required by the output reader revision.
3536        expected: &'static str,
3537    },
3538    /// The nested contract omitted its clip-measurement map.
3539    #[error("measurement contract has no `clips` map")]
3540    MissingClips,
3541    /// The nested contract omitted material resource coverage.
3542    #[error("measurement contract has no `material_resource_coverage`")]
3543    MissingMaterialResourceCoverage,
3544    /// The nested contract omitted its material definition array.
3545    #[error("measurement contract has no `material_definitions` array")]
3546    MissingMaterialDefinitions,
3547    /// The nested contract omitted its texture array.
3548    #[error("measurement contract has no `textures` array")]
3549    MissingTextures,
3550    /// The nested contract omitted its image array.
3551    #[error("measurement contract has no `images` array")]
3552    MissingImages,
3553    /// The nested contract omitted skeleton source coverage.
3554    #[error("measurement contract has no `skeleton_source_coverage`")]
3555    MissingSkeletonSourceCoverage,
3556    /// The nested contract omitted its source skeleton-node array.
3557    #[error("measurement contract has no `skeleton_nodes` array")]
3558    MissingSkeletonNodes,
3559    /// The nested contract omitted its source skin array.
3560    #[error("measurement contract has no `skins` array")]
3561    MissingSkins,
3562    /// The nested contract omitted its mesh-definition array.
3563    #[error("measurement contract has no `mesh_definitions` array")]
3564    MissingMeshDefinitions,
3565    /// The nested contract omitted its node-instance array.
3566    #[error("measurement contract has no `node_instances` array")]
3567    MissingNodeInstances,
3568    /// The nested contract omitted its scene array.
3569    #[error("measurement contract has no `scenes` array")]
3570    MissingScenes,
3571    /// The nested measurements object could not be decoded after prediction-independent
3572    /// validation completed.
3573    #[error("has invalid measurements shape: {reason}")]
3574    InvalidMeasurementsShape {
3575        /// Stable serde diagnostic for the malformed nested measurements.
3576        reason: String,
3577    },
3578    /// The nested measurement values do not satisfy the current contract.
3579    #[error("has invalid measurements: {source}")]
3580    InvalidMeasurements {
3581        /// Measurement validation failure.
3582        #[source]
3583        source: MeasurementContractError,
3584    },
3585}
3586
3587impl MeasurementReportError {
3588    /// Zero-based file index for an error in one report record.
3589    ///
3590    /// Envelope-level errors return `None`.
3591    pub fn file_index(&self) -> Option<usize> {
3592        match self {
3593            Self::File { file_index, .. } => Some(*file_index),
3594            _ => None,
3595        }
3596    }
3597
3598    fn file(file_index: usize, source: MeasurementFileError) -> Self {
3599        Self::File { file_index, source }
3600    }
3601}
3602
3603fn prediction_file_error(
3604    file_index: usize,
3605    source: MeasurementFileError,
3606) -> MeasurementReportError {
3607    MeasurementReportError::file(file_index, source)
3608}
3609
3610fn decode_prediction_phase_file(
3611    command: &str,
3612    file_index: usize,
3613    raw: &RawValue,
3614    expected_measurement_schema: &'static str,
3615) -> Result<MeasurementFileInput, MeasurementReportError> {
3616    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3617        prediction_file_error(
3618            file_index,
3619            MeasurementFileError::InvalidFileShape {
3620                reason: source.to_string(),
3621            },
3622        )
3623    })?;
3624
3625    if command == "measure" {
3626        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3627            return Err(prediction_file_error(
3628                file_index,
3629                MeasurementFileError::UnexpectedPredictionProvenance,
3630            ));
3631        }
3632        if wire.checks.is_some() {
3633            return Err(prediction_file_error(
3634                file_index,
3635                MeasurementFileError::UnexpectedChecks,
3636            ));
3637        }
3638        return Ok(MeasurementFileInput {
3639            path: wire.path,
3640            input: wire.input,
3641            rig_v17: None,
3642            measurements: wire.measurements,
3643            prediction_provenance: RequiredNullable::Missing,
3644            checks: None,
3645            legacy_prediction_provenance: RequiredNullable::Missing,
3646            legacy_checks: None,
3647            prediction_provenance_v3: RequiredNullable::Missing,
3648            checks_v3: None,
3649            prediction_provenance_v4: RequiredNullable::Missing,
3650            checks_v4: None,
3651            prediction_provenance_v5: RequiredNullable::Missing,
3652            checks_v5: None,
3653            prediction_provenance_v6: RequiredNullable::Missing,
3654            checks_v6: None,
3655        });
3656    }
3657
3658    if wire
3659        .checks
3660        .as_ref()
3661        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3662    {
3663        return Err(prediction_file_error(
3664            file_index,
3665            MeasurementFileError::TooManyChecks {
3666                found: wire.checks.as_ref().map_or(0, Vec::len),
3667                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3668            },
3669        ));
3670    }
3671
3672    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3673        return Err(prediction_file_error(
3674            file_index,
3675            MeasurementFileError::MissingPredictionProvenance,
3676        ));
3677    }
3678
3679    let prediction_provenance = match wire.prediction_provenance {
3680        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3681        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3682        RequiredNullable::Present(Some(raw)) => {
3683            let provenance = decode_prediction_provenance_v2_with_measurement_schema(
3684                raw.get(),
3685                expected_measurement_schema,
3686            )
3687            .map_err(|error| {
3688                let source = match error {
3689                    PredictionDecodeError::Shape(source) => {
3690                        MeasurementFileError::InvalidPredictionProvenanceShape {
3691                            reason: source.to_string(),
3692                        }
3693                    }
3694                    PredictionDecodeError::Semantic(source) => {
3695                        MeasurementFileError::InvalidPredictionProvenance { source }
3696                    }
3697                    PredictionDecodeError::TooManyFileFacets
3698                    | PredictionDecodeError::TooManyFileBasisReferences => {
3699                        unreachable!("provenance never consumes prediction budgets")
3700                    }
3701                };
3702                prediction_file_error(file_index, source)
3703            })?;
3704            RequiredNullable::Present(Some(provenance))
3705        }
3706    };
3707    let mut decoded_facets = 0usize;
3708    let mut decoded_references = 0usize;
3709    let mut has_facet_budget_summary = false;
3710    let mut decoded_text = match &prediction_provenance {
3711        RequiredNullable::Present(Some(provenance)) => {
3712            provenance.retained_text_bytes().map_err(|source| {
3713                prediction_file_error(
3714                    file_index,
3715                    MeasurementFileError::InvalidPredictionProvenance { source },
3716                )
3717            })?
3718        }
3719        RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
3720    };
3721    let provenance_for_checks = match &prediction_provenance {
3722        RequiredNullable::Present(provenance) => provenance.as_ref(),
3723        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3724    };
3725    let checks = wire
3726        .checks
3727        .map(|raw_checks| {
3728            let mut checks = Vec::with_capacity(raw_checks.len());
3729            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3730                let wire: PredictionCheckWireInput =
3731                    serde_json::from_str(raw.get()).map_err(|source| {
3732                        prediction_file_error(
3733                            file_index,
3734                            MeasurementFileError::InvalidPredictionShape {
3735                                check_index,
3736                                reason: source.to_string(),
3737                            },
3738                        )
3739                    })?;
3740                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3741                    return Err(prediction_file_error(
3742                        file_index,
3743                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3744                    ));
3745                }
3746                if (wire.selection == SelectionState::Unselected
3747                    || wire.configuration == ConfigurationState::Disabled
3748                    || wire.applicability == Applicability::NotApplicable)
3749                    && wire.prediction.is_some()
3750                {
3751                    return Err(prediction_file_error(
3752                        file_index,
3753                        MeasurementFileError::InvalidPredictionLifecycle {
3754                            check_index,
3755                            reason: "inactive check must have empty output",
3756                        },
3757                    ));
3758                }
3759                // Allocate the remaining aggregate facet/reference budgets
3760                // before parsing this attachment.  This prevents an over-limit
3761                // later prediction from retaining a prefix whose findings can
3762                // no longer be represented by the file contract.
3763                let prediction = wire
3764                    .prediction
3765                    .map(|raw| {
3766                        let facet_limit =
3767                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets);
3768                        let reference_limit = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3769                            .saturating_sub(decoded_references);
3770                        decode_engine_prediction_v2_with_measurement_schema(
3771                            raw.get(),
3772                            facet_limit,
3773                            reference_limit,
3774                            expected_measurement_schema,
3775                        )
3776                        .map_err(|error| {
3777                            let source = match error {
3778                                PredictionDecodeError::Shape(source) => {
3779                                    MeasurementFileError::InvalidPredictionShape {
3780                                        check_index,
3781                                        reason: source.to_string(),
3782                                    }
3783                                }
3784                                PredictionDecodeError::Semantic(source) => {
3785                                    MeasurementFileError::InvalidPrediction {
3786                                        check_index,
3787                                        source,
3788                                    }
3789                                }
3790                                PredictionDecodeError::TooManyFileFacets => {
3791                                    MeasurementFileError::TooManyPredictionFacets {
3792                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3793                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3794                                    }
3795                                }
3796                                PredictionDecodeError::TooManyFileBasisReferences => {
3797                                    MeasurementFileError::TooManyPredictionBasisReferences {
3798                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3799                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3800                                    }
3801                                }
3802                            };
3803                            prediction_file_error(file_index, source)
3804                        })
3805                    })
3806                    .transpose()?;
3807                let check = PredictionCheckInput {
3808                    check_id: wire.check_id,
3809                    selection: wire.selection,
3810                    configuration: wire.configuration,
3811                    applicability: wire.applicability,
3812                    evaluation: wire.evaluation,
3813                    findings: wire.findings,
3814                    evaluated_scopes: wire.evaluated_scopes,
3815                    gaps: wire.gaps,
3816                    prediction,
3817                };
3818                check
3819                    .validate(
3820                        check_index,
3821                        provenance_for_checks,
3822                        expected_measurement_schema,
3823                    )
3824                    .map_err(|source| prediction_file_error(file_index, source))?;
3825                if let Some(prediction) = &check.prediction {
3826                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
3827                    decoded_facets = decoded_facets
3828                        .checked_add(prediction.facets().len())
3829                        .ok_or_else(|| {
3830                            prediction_file_error(
3831                                file_index,
3832                                MeasurementFileError::PredictionAccountingOverflow,
3833                            )
3834                        })?;
3835                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3836                        return Err(prediction_file_error(
3837                            file_index,
3838                            MeasurementFileError::TooManyPredictionFacets {
3839                                found: decoded_facets,
3840                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3841                            },
3842                        ));
3843                    }
3844                    decoded_references = decoded_references
3845                        .checked_add(prediction.basis_reference_count())
3846                        .ok_or_else(|| {
3847                            prediction_file_error(
3848                                file_index,
3849                                MeasurementFileError::PredictionAccountingOverflow,
3850                            )
3851                        })?;
3852                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3853                        return Err(prediction_file_error(
3854                            file_index,
3855                            MeasurementFileError::TooManyPredictionBasisReferences {
3856                                found: decoded_references,
3857                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3858                            },
3859                        ));
3860                    }
3861                    decoded_text = decoded_text
3862                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3863                            prediction_file_error(
3864                                file_index,
3865                                MeasurementFileError::InvalidPrediction {
3866                                    check_index,
3867                                    source,
3868                                },
3869                            )
3870                        })?)
3871                        .ok_or_else(|| {
3872                            prediction_file_error(
3873                                file_index,
3874                                MeasurementFileError::PredictionAccountingOverflow,
3875                            )
3876                        })?;
3877                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3878                        return Err(prediction_file_error(
3879                            file_index,
3880                            MeasurementFileError::TooMuchPredictionText {
3881                                found: decoded_text,
3882                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3883                            },
3884                        ));
3885                    }
3886                }
3887                checks.push(check);
3888            }
3889            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
3890                return Err(prediction_file_error(
3891                    file_index,
3892                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
3893                        found: decoded_facets,
3894                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3895                    },
3896                ));
3897            }
3898            Ok(checks)
3899        })
3900        .transpose()?;
3901    Ok(MeasurementFileInput {
3902        path: wire.path,
3903        input: wire.input,
3904        rig_v17: None,
3905        measurements: wire.measurements,
3906        prediction_provenance,
3907        checks,
3908        legacy_prediction_provenance: RequiredNullable::Missing,
3909        legacy_checks: None,
3910        prediction_provenance_v3: RequiredNullable::Missing,
3911        checks_v3: None,
3912        prediction_provenance_v4: RequiredNullable::Missing,
3913        checks_v4: None,
3914        prediction_provenance_v5: RequiredNullable::Missing,
3915        checks_v5: None,
3916        prediction_provenance_v6: RequiredNullable::Missing,
3917        checks_v6: None,
3918    })
3919}
3920
3921fn decode_prediction_phase_file_v14(
3922    command: &str,
3923    file_index: usize,
3924    raw: &RawValue,
3925) -> Result<MeasurementFileInput, MeasurementReportError> {
3926    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3927        prediction_file_error(
3928            file_index,
3929            MeasurementFileError::InvalidFileShape {
3930                reason: source.to_string(),
3931            },
3932        )
3933    })?;
3934    if command == "measure" {
3935        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3936            return Err(prediction_file_error(
3937                file_index,
3938                MeasurementFileError::UnexpectedPredictionProvenance,
3939            ));
3940        }
3941        if wire.checks.is_some() {
3942            return Err(prediction_file_error(
3943                file_index,
3944                MeasurementFileError::UnexpectedChecks,
3945            ));
3946        }
3947        return Ok(MeasurementFileInput {
3948            path: wire.path,
3949            input: wire.input,
3950            rig_v17: None,
3951            measurements: wire.measurements,
3952            prediction_provenance: RequiredNullable::Missing,
3953            checks: None,
3954            legacy_prediction_provenance: RequiredNullable::Missing,
3955            legacy_checks: None,
3956            prediction_provenance_v3: RequiredNullable::Missing,
3957            checks_v3: None,
3958            prediction_provenance_v4: RequiredNullable::Missing,
3959            checks_v4: None,
3960            prediction_provenance_v5: RequiredNullable::Missing,
3961            checks_v5: None,
3962            prediction_provenance_v6: RequiredNullable::Missing,
3963            checks_v6: None,
3964        });
3965    }
3966    if wire
3967        .checks
3968        .as_ref()
3969        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3970    {
3971        return Err(prediction_file_error(
3972            file_index,
3973            MeasurementFileError::TooManyChecks {
3974                found: wire.checks.as_ref().map_or(0, Vec::len),
3975                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3976            },
3977        ));
3978    }
3979    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3980        return Err(prediction_file_error(
3981            file_index,
3982            MeasurementFileError::MissingPredictionProvenance,
3983        ));
3984    }
3985    let prediction_provenance_v3 = match wire.prediction_provenance {
3986        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3987        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3988        RequiredNullable::Present(Some(raw)) => {
3989            let provenance = decode_prediction_provenance_v3(raw.get()).map_err(|error| {
3990                let source = match error {
3991                    PredictionDecodeError::Shape(source) => {
3992                        MeasurementFileError::InvalidPredictionProvenanceShape {
3993                            reason: source.to_string(),
3994                        }
3995                    }
3996                    PredictionDecodeError::Semantic(source) => {
3997                        MeasurementFileError::InvalidPredictionProvenance { source }
3998                    }
3999                    PredictionDecodeError::TooManyFileFacets
4000                    | PredictionDecodeError::TooManyFileBasisReferences => {
4001                        unreachable!("provenance never consumes prediction budgets")
4002                    }
4003                };
4004                prediction_file_error(file_index, source)
4005            })?;
4006            RequiredNullable::Present(Some(provenance))
4007        }
4008    };
4009    let provenance_for_checks = match &prediction_provenance_v3 {
4010        RequiredNullable::Present(provenance) => provenance.as_ref(),
4011        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4012    };
4013    let mut decoded_facets = 0usize;
4014    let mut decoded_references = 0usize;
4015    let mut has_facet_budget_summary = false;
4016    let mut decoded_text = provenance_for_checks
4017        .map(PredictionProvenanceV3::retained_text_bytes)
4018        .transpose()
4019        .map_err(|source| {
4020            prediction_file_error(
4021                file_index,
4022                MeasurementFileError::InvalidPredictionProvenance { source },
4023            )
4024        })?
4025        .unwrap_or(0);
4026    let checks_v3 = wire
4027        .checks
4028        .map(|raw_checks| {
4029            let mut checks = Vec::with_capacity(raw_checks.len());
4030            for (check_index, raw) in raw_checks.into_iter().enumerate() {
4031                let wire: PredictionCheckWireInput =
4032                    serde_json::from_str(raw.get()).map_err(|source| {
4033                        prediction_file_error(
4034                            file_index,
4035                            MeasurementFileError::InvalidPredictionShape {
4036                                check_index,
4037                                reason: source.to_string(),
4038                            },
4039                        )
4040                    })?;
4041                if provenance_for_checks.is_none() && wire.prediction.is_some() {
4042                    return Err(prediction_file_error(
4043                        file_index,
4044                        MeasurementFileError::PredictionWithoutProvenance { check_index },
4045                    ));
4046                }
4047                if (wire.selection == SelectionState::Unselected
4048                    || wire.configuration == ConfigurationState::Disabled
4049                    || wire.applicability == Applicability::NotApplicable)
4050                    && wire.prediction.is_some()
4051                {
4052                    return Err(prediction_file_error(
4053                        file_index,
4054                        MeasurementFileError::InvalidPredictionLifecycle {
4055                            check_index,
4056                            reason: "inactive check must have empty output",
4057                        },
4058                    ));
4059                }
4060                let prediction = wire
4061                    .prediction
4062                    .map(|raw| {
4063                        decode_engine_prediction_v3(
4064                            raw.get(),
4065                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4066                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4067                                .saturating_sub(decoded_references),
4068                        )
4069                        .map_err(|error| {
4070                            let source = match error {
4071                                PredictionDecodeError::Shape(source) => {
4072                                    MeasurementFileError::InvalidPredictionShape {
4073                                        check_index,
4074                                        reason: source.to_string(),
4075                                    }
4076                                }
4077                                PredictionDecodeError::Semantic(source) => {
4078                                    MeasurementFileError::InvalidPrediction {
4079                                        check_index,
4080                                        source,
4081                                    }
4082                                }
4083                                PredictionDecodeError::TooManyFileFacets => {
4084                                    MeasurementFileError::TooManyPredictionFacets {
4085                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4086                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4087                                    }
4088                                }
4089                                PredictionDecodeError::TooManyFileBasisReferences => {
4090                                    MeasurementFileError::TooManyPredictionBasisReferences {
4091                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4092                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4093                                    }
4094                                }
4095                            };
4096                            prediction_file_error(file_index, source)
4097                        })
4098                    })
4099                    .transpose()?;
4100                let check = PredictionCheckInputV3 {
4101                    check_id: wire.check_id,
4102                    selection: wire.selection,
4103                    configuration: wire.configuration,
4104                    applicability: wire.applicability,
4105                    evaluation: wire.evaluation,
4106                    findings: wire.findings,
4107                    evaluated_scopes: wire.evaluated_scopes,
4108                    gaps: wire.gaps,
4109                    prediction,
4110                };
4111                check
4112                    .validate(check_index, provenance_for_checks)
4113                    .map_err(|source| prediction_file_error(file_index, source))?;
4114                if let Some(prediction) = &check.prediction {
4115                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
4116                    decoded_facets = decoded_facets
4117                        .checked_add(prediction.facets().len())
4118                        .ok_or_else(|| {
4119                            prediction_file_error(
4120                                file_index,
4121                                MeasurementFileError::PredictionAccountingOverflow,
4122                            )
4123                        })?;
4124                    decoded_references = decoded_references
4125                        .checked_add(prediction.basis_reference_count())
4126                        .ok_or_else(|| {
4127                            prediction_file_error(
4128                                file_index,
4129                                MeasurementFileError::PredictionAccountingOverflow,
4130                            )
4131                        })?;
4132                    decoded_text = decoded_text
4133                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4134                            prediction_file_error(
4135                                file_index,
4136                                MeasurementFileError::InvalidPrediction {
4137                                    check_index,
4138                                    source,
4139                                },
4140                            )
4141                        })?)
4142                        .ok_or_else(|| {
4143                            prediction_file_error(
4144                                file_index,
4145                                MeasurementFileError::PredictionAccountingOverflow,
4146                            )
4147                        })?;
4148                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4149                        return Err(prediction_file_error(
4150                            file_index,
4151                            MeasurementFileError::TooMuchPredictionText {
4152                                found: decoded_text,
4153                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4154                            },
4155                        ));
4156                    }
4157                }
4158                checks.push(check);
4159            }
4160            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
4161                return Err(prediction_file_error(
4162                    file_index,
4163                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
4164                        found: decoded_facets,
4165                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4166                    },
4167                ));
4168            }
4169            Ok(checks)
4170        })
4171        .transpose()?;
4172    Ok(MeasurementFileInput {
4173        path: wire.path,
4174        input: wire.input,
4175        rig_v17: None,
4176        measurements: wire.measurements,
4177        prediction_provenance: RequiredNullable::Missing,
4178        checks: None,
4179        legacy_prediction_provenance: RequiredNullable::Missing,
4180        legacy_checks: None,
4181        prediction_provenance_v3,
4182        checks_v3,
4183        prediction_provenance_v4: RequiredNullable::Missing,
4184        checks_v4: None,
4185        prediction_provenance_v5: RequiredNullable::Missing,
4186        checks_v5: None,
4187        prediction_provenance_v6: RequiredNullable::Missing,
4188        checks_v6: None,
4189    })
4190}
4191
4192fn decode_prediction_phase_file_v15(
4193    command: &str,
4194    file_index: usize,
4195    raw: &RawValue,
4196) -> Result<MeasurementFileInput, MeasurementReportError> {
4197    #[derive(Deserialize)]
4198    struct SchemaProbe {
4199        schema: String,
4200    }
4201
4202    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4203        prediction_file_error(
4204            file_index,
4205            MeasurementFileError::InvalidFileShape {
4206                reason: source.to_string(),
4207            },
4208        )
4209    })?;
4210    if command == "measure"
4211        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4212    {
4213        return decode_prediction_phase_file_v14(command, file_index, raw);
4214    }
4215    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4216        return decode_prediction_phase_file_v14(command, file_index, raw);
4217    };
4218    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4219        prediction_file_error(
4220            file_index,
4221            MeasurementFileError::InvalidPredictionProvenanceShape {
4222                reason: source.to_string(),
4223            },
4224        )
4225    })?;
4226    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4227        return decode_prediction_phase_file_v14(command, file_index, raw);
4228    }
4229    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V4_ID {
4230        return Err(prediction_file_error(
4231            file_index,
4232            MeasurementFileError::InvalidPredictionProvenance {
4233                source: PredictionContractError::InvalidSchema {
4234                    field: "prediction provenance.schema",
4235                    expected: crate::prediction::PREDICTION_PROVENANCE_V4_ID,
4236                    found: schema.schema,
4237                },
4238            },
4239        ));
4240    }
4241
4242    let MeasurementFileWireInput {
4243        path,
4244        input,
4245        measurements,
4246        prediction_provenance,
4247        checks,
4248        ..
4249    } = probe;
4250    if checks
4251        .as_ref()
4252        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4253    {
4254        return Err(prediction_file_error(
4255            file_index,
4256            MeasurementFileError::TooManyChecks {
4257                found: checks.as_ref().map_or(0, Vec::len),
4258                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4259            },
4260        ));
4261    }
4262    let RequiredNullable::Present(Some(provenance_raw)) = prediction_provenance else {
4263        unreachable!("V4 provenance was probed above")
4264    };
4265    let provenance = decode_prediction_provenance_v4(provenance_raw.get()).map_err(|error| {
4266        let source = match error {
4267            PredictionDecodeError::Shape(source) => {
4268                MeasurementFileError::InvalidPredictionProvenanceShape {
4269                    reason: source.to_string(),
4270                }
4271            }
4272            PredictionDecodeError::Semantic(source) => {
4273                MeasurementFileError::InvalidPredictionProvenance { source }
4274            }
4275            PredictionDecodeError::TooManyFileFacets
4276            | PredictionDecodeError::TooManyFileBasisReferences => unreachable!(),
4277        };
4278        prediction_file_error(file_index, source)
4279    })?;
4280    let mut decoded_facets = 0usize;
4281    let mut decoded_references = 0usize;
4282    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4283        prediction_file_error(
4284            file_index,
4285            MeasurementFileError::InvalidPredictionProvenance { source },
4286        )
4287    })?;
4288    let checks_v4 = checks
4289        .map(|raw_checks| {
4290            let mut decoded = Vec::with_capacity(raw_checks.len());
4291            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4292                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4293                    .map_err(|source| {
4294                        prediction_file_error(
4295                            file_index,
4296                            MeasurementFileError::InvalidPredictionShape {
4297                                check_index,
4298                                reason: source.to_string(),
4299                            },
4300                        )
4301                    })?;
4302                if (wire.selection == SelectionState::Unselected
4303                    || wire.configuration == ConfigurationState::Disabled
4304                    || wire.applicability == Applicability::NotApplicable)
4305                    && wire.prediction.is_some()
4306                {
4307                    return Err(prediction_file_error(
4308                        file_index,
4309                        MeasurementFileError::InvalidPredictionLifecycle {
4310                            check_index,
4311                            reason: "inactive check must have empty output",
4312                        },
4313                    ));
4314                }
4315                let prediction = wire
4316                    .prediction
4317                    .map(|prediction_raw| {
4318                        decode_engine_prediction_v4(
4319                            prediction_raw.get(),
4320                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4321                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4322                                .saturating_sub(decoded_references),
4323                        )
4324                        .map_err(|error| {
4325                            let source = match error {
4326                                PredictionDecodeError::Shape(source) => {
4327                                    MeasurementFileError::InvalidPredictionShape {
4328                                        check_index,
4329                                        reason: source.to_string(),
4330                                    }
4331                                }
4332                                PredictionDecodeError::Semantic(source) => {
4333                                    MeasurementFileError::InvalidPrediction {
4334                                        check_index,
4335                                        source,
4336                                    }
4337                                }
4338                                PredictionDecodeError::TooManyFileFacets => {
4339                                    MeasurementFileError::TooManyPredictionFacets {
4340                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4341                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4342                                    }
4343                                }
4344                                PredictionDecodeError::TooManyFileBasisReferences => {
4345                                    MeasurementFileError::TooManyPredictionBasisReferences {
4346                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4347                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4348                                    }
4349                                }
4350                            };
4351                            prediction_file_error(file_index, source)
4352                        })
4353                    })
4354                    .transpose()?;
4355                let check = PredictionCheckInputV4 {
4356                    check_id: wire.check_id,
4357                    selection: wire.selection,
4358                    configuration: wire.configuration,
4359                    applicability: wire.applicability,
4360                    evaluation: wire.evaluation,
4361                    findings: wire.findings,
4362                    evaluated_scopes: wire.evaluated_scopes,
4363                    gaps: wire.gaps,
4364                    prediction,
4365                };
4366                check
4367                    .validate(check_index, Some(&provenance))
4368                    .map_err(|source| prediction_file_error(file_index, source))?;
4369                if let Some(prediction) = &check.prediction {
4370                    decoded_facets = decoded_facets
4371                        .checked_add(prediction.facets().len())
4372                        .ok_or_else(|| {
4373                            prediction_file_error(
4374                                file_index,
4375                                MeasurementFileError::PredictionAccountingOverflow,
4376                            )
4377                        })?;
4378                    decoded_references = decoded_references
4379                        .checked_add(prediction.basis_reference_count())
4380                        .ok_or_else(|| {
4381                            prediction_file_error(
4382                                file_index,
4383                                MeasurementFileError::PredictionAccountingOverflow,
4384                            )
4385                        })?;
4386                    decoded_text = decoded_text
4387                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4388                            prediction_file_error(
4389                                file_index,
4390                                MeasurementFileError::InvalidPrediction {
4391                                    check_index,
4392                                    source,
4393                                },
4394                            )
4395                        })?)
4396                        .ok_or_else(|| {
4397                            prediction_file_error(
4398                                file_index,
4399                                MeasurementFileError::PredictionAccountingOverflow,
4400                            )
4401                        })?;
4402                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4403                        return Err(prediction_file_error(
4404                            file_index,
4405                            MeasurementFileError::TooMuchPredictionText {
4406                                found: decoded_text,
4407                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4408                            },
4409                        ));
4410                    }
4411                }
4412                decoded.push(check);
4413            }
4414            Ok(decoded)
4415        })
4416        .transpose()?;
4417    Ok(MeasurementFileInput {
4418        path,
4419        input,
4420        rig_v17: None,
4421        measurements,
4422        prediction_provenance: RequiredNullable::Missing,
4423        checks: None,
4424        legacy_prediction_provenance: RequiredNullable::Missing,
4425        legacy_checks: None,
4426        prediction_provenance_v3: RequiredNullable::Missing,
4427        checks_v3: None,
4428        prediction_provenance_v4: RequiredNullable::Present(Some(provenance)),
4429        checks_v4,
4430        prediction_provenance_v5: RequiredNullable::Missing,
4431        checks_v5: None,
4432        prediction_provenance_v6: RequiredNullable::Missing,
4433        checks_v6: None,
4434    })
4435}
4436
4437fn decode_prediction_phase_file_v16(
4438    command: &str,
4439    file_index: usize,
4440    raw: &RawValue,
4441) -> Result<MeasurementFileInput, MeasurementReportError> {
4442    #[derive(Deserialize)]
4443    struct SchemaProbe {
4444        schema: String,
4445    }
4446
4447    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4448        prediction_file_error(
4449            file_index,
4450            MeasurementFileError::InvalidFileShape {
4451                reason: source.to_string(),
4452            },
4453        )
4454    })?;
4455    if command == "measure"
4456        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4457    {
4458        return decode_prediction_phase_file_v15(command, file_index, raw);
4459    }
4460    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4461        return decode_prediction_phase_file_v15(command, file_index, raw);
4462    };
4463    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4464        prediction_file_error(
4465            file_index,
4466            MeasurementFileError::InvalidPredictionProvenanceShape {
4467                reason: source.to_string(),
4468            },
4469        )
4470    })?;
4471    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4472        return decode_prediction_phase_file_v15(command, file_index, raw);
4473    }
4474    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V5_ID {
4475        return Err(prediction_file_error(
4476            file_index,
4477            MeasurementFileError::InvalidPredictionProvenance {
4478                source: PredictionContractError::InvalidSchema {
4479                    field: "prediction provenance.schema",
4480                    expected: crate::prediction::PREDICTION_PROVENANCE_V5_ID,
4481                    found: schema.schema,
4482                },
4483            },
4484        ));
4485    }
4486    let MeasurementFileWireInput {
4487        path,
4488        input,
4489        measurements,
4490        prediction_provenance,
4491        checks,
4492        ..
4493    } = probe;
4494    if checks
4495        .as_ref()
4496        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4497    {
4498        return Err(prediction_file_error(
4499            file_index,
4500            MeasurementFileError::TooManyChecks {
4501                found: checks.as_ref().map_or(0, Vec::len),
4502                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4503            },
4504        ));
4505    }
4506    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4507        unreachable!()
4508    };
4509    let provenance: PredictionProvenanceV5 =
4510        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4511            prediction_file_error(
4512                file_index,
4513                MeasurementFileError::InvalidPredictionProvenanceShape {
4514                    reason: source.to_string(),
4515                },
4516            )
4517        })?;
4518    provenance.validate().map_err(|source| {
4519        prediction_file_error(
4520            file_index,
4521            MeasurementFileError::InvalidPredictionProvenance { source },
4522        )
4523    })?;
4524    let mut decoded_facets = 0usize;
4525    let mut decoded_references = 0usize;
4526    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4527        prediction_file_error(
4528            file_index,
4529            MeasurementFileError::InvalidPredictionProvenance { source },
4530        )
4531    })?;
4532    let checks_v5 = checks
4533        .map(|raw_checks| {
4534            let mut decoded = Vec::with_capacity(raw_checks.len());
4535            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4536                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4537                    .map_err(|source| {
4538                        prediction_file_error(
4539                            file_index,
4540                            MeasurementFileError::InvalidPredictionShape {
4541                                check_index,
4542                                reason: source.to_string(),
4543                            },
4544                        )
4545                    })?;
4546                if (wire.selection == SelectionState::Unselected
4547                    || wire.configuration == ConfigurationState::Disabled
4548                    || wire.applicability == Applicability::NotApplicable)
4549                    && wire.prediction.is_some()
4550                {
4551                    return Err(prediction_file_error(
4552                        file_index,
4553                        MeasurementFileError::InvalidPredictionLifecycle {
4554                            check_index,
4555                            reason: "inactive check must have empty output",
4556                        },
4557                    ));
4558                }
4559                let prediction =
4560                    wire.prediction
4561                        .map(|raw_prediction| {
4562                            serde_json::from_str::<EnginePredictionV5>(raw_prediction.get())
4563                                .map_err(|source| {
4564                                    prediction_file_error(
4565                                        file_index,
4566                                        MeasurementFileError::InvalidPredictionShape {
4567                                            check_index,
4568                                            reason: source.to_string(),
4569                                        },
4570                                    )
4571                                })
4572                        })
4573                        .transpose()?;
4574                let check = PredictionCheckInputV5 {
4575                    check_id: wire.check_id,
4576                    selection: wire.selection,
4577                    configuration: wire.configuration,
4578                    applicability: wire.applicability,
4579                    evaluation: wire.evaluation,
4580                    findings: wire.findings,
4581                    evaluated_scopes: wire.evaluated_scopes,
4582                    gaps: wire.gaps,
4583                    prediction,
4584                };
4585                check
4586                    .validate(check_index, Some(&provenance))
4587                    .map_err(|source| prediction_file_error(file_index, source))?;
4588                if let Some(prediction) = &check.prediction {
4589                    decoded_facets = decoded_facets
4590                        .checked_add(prediction.facets().len())
4591                        .ok_or_else(|| {
4592                            prediction_file_error(
4593                                file_index,
4594                                MeasurementFileError::PredictionAccountingOverflow,
4595                            )
4596                        })?;
4597                    decoded_references = decoded_references
4598                        .checked_add(prediction.basis_reference_count())
4599                        .ok_or_else(|| {
4600                            prediction_file_error(
4601                                file_index,
4602                                MeasurementFileError::PredictionAccountingOverflow,
4603                            )
4604                        })?;
4605                    decoded_text = decoded_text
4606                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4607                            prediction_file_error(
4608                                file_index,
4609                                MeasurementFileError::InvalidPrediction {
4610                                    check_index,
4611                                    source,
4612                                },
4613                            )
4614                        })?)
4615                        .ok_or_else(|| {
4616                            prediction_file_error(
4617                                file_index,
4618                                MeasurementFileError::PredictionAccountingOverflow,
4619                            )
4620                        })?;
4621                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4622                        return Err(prediction_file_error(
4623                            file_index,
4624                            MeasurementFileError::TooManyPredictionFacets {
4625                                found: decoded_facets,
4626                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4627                            },
4628                        ));
4629                    }
4630                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4631                        return Err(prediction_file_error(
4632                            file_index,
4633                            MeasurementFileError::TooManyPredictionBasisReferences {
4634                                found: decoded_references,
4635                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4636                            },
4637                        ));
4638                    }
4639                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4640                        return Err(prediction_file_error(
4641                            file_index,
4642                            MeasurementFileError::TooMuchPredictionText {
4643                                found: decoded_text,
4644                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4645                            },
4646                        ));
4647                    }
4648                }
4649                decoded.push(check);
4650            }
4651            Ok(decoded)
4652        })
4653        .transpose()?;
4654    Ok(MeasurementFileInput {
4655        path,
4656        input,
4657        rig_v17: None,
4658        measurements,
4659        prediction_provenance: RequiredNullable::Missing,
4660        checks: None,
4661        legacy_prediction_provenance: RequiredNullable::Missing,
4662        legacy_checks: None,
4663        prediction_provenance_v3: RequiredNullable::Missing,
4664        checks_v3: None,
4665        prediction_provenance_v4: RequiredNullable::Missing,
4666        checks_v4: None,
4667        prediction_provenance_v5: RequiredNullable::Present(Some(provenance)),
4668        checks_v5,
4669        prediction_provenance_v6: RequiredNullable::Missing,
4670        checks_v6: None,
4671    })
4672}
4673
4674fn decode_prediction_phase_file_v17(
4675    command: &str,
4676    file_index: usize,
4677    raw: &RawValue,
4678) -> Result<MeasurementFileInput, MeasurementReportError> {
4679    #[derive(Deserialize)]
4680    struct SchemaProbe {
4681        schema: String,
4682    }
4683    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4684        prediction_file_error(
4685            file_index,
4686            MeasurementFileError::InvalidFileShape {
4687                reason: source.to_string(),
4688            },
4689        )
4690    })?;
4691    if command == "measure"
4692        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4693    {
4694        return decode_prediction_phase_file_v16(command, file_index, raw);
4695    }
4696    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4697        return decode_prediction_phase_file_v16(command, file_index, raw);
4698    };
4699    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4700        prediction_file_error(
4701            file_index,
4702            MeasurementFileError::InvalidPredictionProvenanceShape {
4703                reason: source.to_string(),
4704            },
4705        )
4706    })?;
4707    if matches!(
4708        schema.schema.as_str(),
4709        crate::prediction::PREDICTION_PROVENANCE_V3_ID
4710            | crate::prediction::PREDICTION_PROVENANCE_V5_ID
4711    ) {
4712        return decode_prediction_phase_file_v16(command, file_index, raw);
4713    }
4714    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V6_ID {
4715        return Err(prediction_file_error(
4716            file_index,
4717            MeasurementFileError::InvalidPredictionProvenance {
4718                source: PredictionContractError::InvalidSchema {
4719                    field: "prediction provenance.schema",
4720                    expected: crate::prediction::PREDICTION_PROVENANCE_V6_ID,
4721                    found: schema.schema,
4722                },
4723            },
4724        ));
4725    }
4726    let MeasurementFileWireInput {
4727        path,
4728        input,
4729        rig,
4730        measurements,
4731        prediction_provenance,
4732        checks,
4733    } = probe;
4734    let rig_v17 = serde_json::from_str::<RigInfo>(rig.get()).map_err(|source| {
4735        prediction_file_error(
4736            file_index,
4737            MeasurementFileError::InvalidFileShape {
4738                reason: format!("invalid output-v17 rig evidence: {source}"),
4739            },
4740        )
4741    })?;
4742    if checks
4743        .as_ref()
4744        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4745    {
4746        return Err(prediction_file_error(
4747            file_index,
4748            MeasurementFileError::TooManyChecks {
4749                found: checks.as_ref().map_or(0, Vec::len),
4750                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4751            },
4752        ));
4753    }
4754    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4755        unreachable!()
4756    };
4757    let provenance: PredictionProvenanceV6 =
4758        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4759            prediction_file_error(
4760                file_index,
4761                MeasurementFileError::InvalidPredictionProvenanceShape {
4762                    reason: source.to_string(),
4763                },
4764            )
4765        })?;
4766    provenance.validate().map_err(|source| {
4767        prediction_file_error(
4768            file_index,
4769            MeasurementFileError::InvalidPredictionProvenance { source },
4770        )
4771    })?;
4772    let mut decoded_facets = 0usize;
4773    let mut decoded_references = 0usize;
4774    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4775        prediction_file_error(
4776            file_index,
4777            MeasurementFileError::InvalidPredictionProvenance { source },
4778        )
4779    })?;
4780    let checks_v6 = checks
4781        .map(|raw_checks| {
4782            let mut decoded = Vec::with_capacity(raw_checks.len());
4783            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4784                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4785                    .map_err(|source| {
4786                        prediction_file_error(
4787                            file_index,
4788                            MeasurementFileError::InvalidPredictionShape {
4789                                check_index,
4790                                reason: source.to_string(),
4791                            },
4792                        )
4793                    })?;
4794                if (wire.selection == SelectionState::Unselected
4795                    || wire.configuration == ConfigurationState::Disabled
4796                    || wire.applicability == Applicability::NotApplicable)
4797                    && wire.prediction.is_some()
4798                {
4799                    return Err(prediction_file_error(
4800                        file_index,
4801                        MeasurementFileError::InvalidPredictionLifecycle {
4802                            check_index,
4803                            reason: "inactive check must have empty output",
4804                        },
4805                    ));
4806                }
4807                let prediction =
4808                    wire.prediction
4809                        .map(|raw_prediction| {
4810                            serde_json::from_str::<EnginePredictionV6>(raw_prediction.get())
4811                                .map_err(|source| {
4812                                    prediction_file_error(
4813                                        file_index,
4814                                        MeasurementFileError::InvalidPredictionShape {
4815                                            check_index,
4816                                            reason: source.to_string(),
4817                                        },
4818                                    )
4819                                })
4820                        })
4821                        .transpose()?;
4822                let check = PredictionCheckInputV6 {
4823                    check_id: wire.check_id,
4824                    selection: wire.selection,
4825                    configuration: wire.configuration,
4826                    applicability: wire.applicability,
4827                    evaluation: wire.evaluation,
4828                    findings: wire.findings,
4829                    evaluated_scopes: wire.evaluated_scopes,
4830                    gaps: wire.gaps,
4831                    prediction,
4832                };
4833                check
4834                    .validate(check_index, Some(&provenance))
4835                    .map_err(|source| prediction_file_error(file_index, source))?;
4836                if let Some(prediction) = &check.prediction {
4837                    decoded_facets = decoded_facets
4838                        .checked_add(prediction.facets().len())
4839                        .ok_or_else(|| {
4840                            prediction_file_error(
4841                                file_index,
4842                                MeasurementFileError::PredictionAccountingOverflow,
4843                            )
4844                        })?;
4845                    decoded_references = decoded_references
4846                        .checked_add(prediction.basis_reference_count())
4847                        .ok_or_else(|| {
4848                            prediction_file_error(
4849                                file_index,
4850                                MeasurementFileError::PredictionAccountingOverflow,
4851                            )
4852                        })?;
4853                    decoded_text = decoded_text
4854                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4855                            prediction_file_error(
4856                                file_index,
4857                                MeasurementFileError::InvalidPrediction {
4858                                    check_index,
4859                                    source,
4860                                },
4861                            )
4862                        })?)
4863                        .ok_or_else(|| {
4864                            prediction_file_error(
4865                                file_index,
4866                                MeasurementFileError::PredictionAccountingOverflow,
4867                            )
4868                        })?;
4869                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4870                        return Err(prediction_file_error(
4871                            file_index,
4872                            MeasurementFileError::TooManyPredictionFacets {
4873                                found: decoded_facets,
4874                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4875                            },
4876                        ));
4877                    }
4878                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4879                        return Err(prediction_file_error(
4880                            file_index,
4881                            MeasurementFileError::TooManyPredictionBasisReferences {
4882                                found: decoded_references,
4883                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4884                            },
4885                        ));
4886                    }
4887                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4888                        return Err(prediction_file_error(
4889                            file_index,
4890                            MeasurementFileError::TooMuchPredictionText {
4891                                found: decoded_text,
4892                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4893                            },
4894                        ));
4895                    }
4896                }
4897                decoded.push(check);
4898            }
4899            Ok(decoded)
4900        })
4901        .transpose()?;
4902    Ok(MeasurementFileInput {
4903        path,
4904        input,
4905        rig_v17: Some(rig_v17),
4906        measurements,
4907        prediction_provenance: RequiredNullable::Missing,
4908        checks: None,
4909        legacy_prediction_provenance: RequiredNullable::Missing,
4910        legacy_checks: None,
4911        prediction_provenance_v3: RequiredNullable::Missing,
4912        checks_v3: None,
4913        prediction_provenance_v4: RequiredNullable::Missing,
4914        checks_v4: None,
4915        prediction_provenance_v5: RequiredNullable::Missing,
4916        checks_v5: None,
4917        prediction_provenance_v6: RequiredNullable::Present(Some(provenance)),
4918        checks_v6,
4919    })
4920}
4921
4922/// Decode the immutable output-v11 file envelope with its original V1 staged
4923/// reader.  Historical evidence remains V1 all the way through validation;
4924/// accepting it must be neither weaker nor a reinterpretation as V2.
4925fn decode_legacy_v11_file(
4926    command: &str,
4927    file_index: usize,
4928    raw: &RawValue,
4929) -> Result<MeasurementFileInput, MeasurementReportError> {
4930    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4931        prediction_file_error(
4932            file_index,
4933            MeasurementFileError::InvalidFileShape {
4934                reason: source.to_string(),
4935            },
4936        )
4937    })?;
4938    if command == "measure" {
4939        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4940            return Err(prediction_file_error(
4941                file_index,
4942                MeasurementFileError::UnexpectedPredictionProvenance,
4943            ));
4944        }
4945        if wire.checks.is_some() {
4946            return Err(prediction_file_error(
4947                file_index,
4948                MeasurementFileError::UnexpectedChecks,
4949            ));
4950        }
4951        return Ok(MeasurementFileInput {
4952            path: wire.path,
4953            input: wire.input,
4954            rig_v17: None,
4955            measurements: wire.measurements,
4956            prediction_provenance: RequiredNullable::Missing,
4957            checks: None,
4958            legacy_prediction_provenance: RequiredNullable::Missing,
4959            legacy_checks: None,
4960            prediction_provenance_v3: RequiredNullable::Missing,
4961            checks_v3: None,
4962            prediction_provenance_v4: RequiredNullable::Missing,
4963            checks_v4: None,
4964            prediction_provenance_v5: RequiredNullable::Missing,
4965            checks_v5: None,
4966            prediction_provenance_v6: RequiredNullable::Missing,
4967            checks_v6: None,
4968        });
4969    }
4970
4971    if wire
4972        .checks
4973        .as_ref()
4974        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4975    {
4976        return Err(prediction_file_error(
4977            file_index,
4978            MeasurementFileError::TooManyChecks {
4979                found: wire.checks.as_ref().map_or(0, Vec::len),
4980                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4981            },
4982        ));
4983    }
4984    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4985        return Err(prediction_file_error(
4986            file_index,
4987            MeasurementFileError::MissingPredictionProvenance,
4988        ));
4989    }
4990    let legacy_prediction_provenance = match wire.prediction_provenance {
4991        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4992        RequiredNullable::Present(None) => RequiredNullable::Present(None),
4993        RequiredNullable::Present(Some(raw)) => RequiredNullable::Present(Some(
4994            decode_prediction_provenance_v1_with_measurement_schema(
4995                raw.get(),
4996                MEASUREMENTS_V15_SCHEMA_ID,
4997            )
4998            .map_err(|error| {
4999                prediction_file_error(
5000                    file_index,
5001                    match error {
5002                        PredictionDecodeError::Shape(source) => {
5003                            MeasurementFileError::InvalidPredictionProvenanceShape {
5004                                reason: source.to_string(),
5005                            }
5006                        }
5007                        PredictionDecodeError::Semantic(source) => {
5008                            MeasurementFileError::InvalidPredictionProvenance { source }
5009                        }
5010                        PredictionDecodeError::TooManyFileFacets
5011                        | PredictionDecodeError::TooManyFileBasisReferences => {
5012                            unreachable!("provenance decoding cannot consume prediction budgets")
5013                        }
5014                    },
5015                )
5016            })?,
5017        )),
5018    };
5019    let mut decoded_facets = 0usize;
5020    let mut decoded_references = 0usize;
5021    let mut decoded_text = legacy_prediction_provenance
5022        .as_present()
5023        .map(PredictionProvenanceV1::retained_text_bytes)
5024        .transpose()
5025        .map_err(|source| {
5026            prediction_file_error(
5027                file_index,
5028                MeasurementFileError::InvalidPredictionProvenance { source },
5029            )
5030        })?
5031        .unwrap_or(0);
5032    let provenance_for_checks = legacy_prediction_provenance.as_present();
5033    let legacy_checks = wire
5034        .checks
5035        .map(|raw_checks| {
5036            let mut checks = Vec::with_capacity(raw_checks.len());
5037            for (check_index, raw) in raw_checks.into_iter().enumerate() {
5038                let wire: LegacyPredictionCheckWireV11 =
5039                    serde_json::from_str(raw.get()).map_err(|source| {
5040                        prediction_file_error(
5041                            file_index,
5042                            MeasurementFileError::InvalidPredictionShape {
5043                                check_index,
5044                                reason: source.to_string(),
5045                            },
5046                        )
5047                    })?;
5048                // Preserve the released V11 reader's precedence: these
5049                // lifecycle violations are decided from the raw attachment
5050                // presence before a malformed prediction can be decoded.
5051                if provenance_for_checks.is_none() && wire.prediction.is_some() {
5052                    return Err(prediction_file_error(
5053                        file_index,
5054                        MeasurementFileError::PredictionWithoutProvenance { check_index },
5055                    ));
5056                }
5057                if (wire.selection == SelectionState::Unselected
5058                    || wire.configuration == ConfigurationState::Disabled
5059                    || wire.applicability == Applicability::NotApplicable)
5060                    && wire.prediction.is_some()
5061                {
5062                    return Err(prediction_file_error(
5063                        file_index,
5064                        MeasurementFileError::InvalidPredictionLifecycle {
5065                            check_index,
5066                            reason: "inactive check must have empty output",
5067                        },
5068                    ));
5069                }
5070                let prediction = wire
5071                    .prediction
5072                    .map(|raw| {
5073                        decode_engine_prediction_v1_with_measurement_schema(
5074                            raw.get(),
5075                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
5076                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
5077                                .saturating_sub(decoded_references),
5078                            MEASUREMENTS_V15_SCHEMA_ID,
5079                        )
5080                        .map_err(|error| {
5081                            prediction_file_error(
5082                                file_index,
5083                                match error {
5084                                    PredictionDecodeError::Shape(source) => {
5085                                        MeasurementFileError::InvalidPredictionShape {
5086                                            check_index,
5087                                            reason: source.to_string(),
5088                                        }
5089                                    }
5090                                    PredictionDecodeError::Semantic(source) => {
5091                                        MeasurementFileError::InvalidPrediction {
5092                                            check_index,
5093                                            source,
5094                                        }
5095                                    }
5096                                    PredictionDecodeError::TooManyFileFacets => {
5097                                        MeasurementFileError::TooManyPredictionFacets {
5098                                            found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
5099                                            limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5100                                        }
5101                                    }
5102                                    PredictionDecodeError::TooManyFileBasisReferences => {
5103                                        MeasurementFileError::TooManyPredictionBasisReferences {
5104                                            found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
5105                                            limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5106                                        }
5107                                    }
5108                                },
5109                            )
5110                        })
5111                    })
5112                    .transpose()?;
5113                let check = LegacyPredictionCheckInput {
5114                    check_id: wire.check_id,
5115                    selection: wire.selection,
5116                    configuration: wire.configuration,
5117                    applicability: wire.applicability,
5118                    evaluation: wire.evaluation,
5119                    findings: wire.findings,
5120                    evaluated_scopes: wire.evaluated_scopes,
5121                    gaps: wire.gaps,
5122                    prediction,
5123                };
5124                check
5125                    .validate(
5126                        check_index,
5127                        provenance_for_checks,
5128                        MEASUREMENTS_V15_SCHEMA_ID,
5129                    )
5130                    .map_err(|source| prediction_file_error(file_index, source))?;
5131                if let Some(prediction) = &check.prediction {
5132                    decoded_facets = decoded_facets
5133                        .checked_add(prediction.facets().len())
5134                        .ok_or_else(|| {
5135                            prediction_file_error(
5136                                file_index,
5137                                MeasurementFileError::PredictionAccountingOverflow,
5138                            )
5139                        })?;
5140                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5141                        return Err(prediction_file_error(
5142                            file_index,
5143                            MeasurementFileError::TooManyPredictionFacets {
5144                                found: decoded_facets,
5145                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5146                            },
5147                        ));
5148                    }
5149                    decoded_references = decoded_references
5150                        .checked_add(prediction.basis_reference_count())
5151                        .ok_or_else(|| {
5152                            prediction_file_error(
5153                                file_index,
5154                                MeasurementFileError::PredictionAccountingOverflow,
5155                            )
5156                        })?;
5157                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5158                        return Err(prediction_file_error(
5159                            file_index,
5160                            MeasurementFileError::TooManyPredictionBasisReferences {
5161                                found: decoded_references,
5162                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5163                            },
5164                        ));
5165                    }
5166                    decoded_text = decoded_text
5167                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5168                            prediction_file_error(
5169                                file_index,
5170                                MeasurementFileError::InvalidPrediction {
5171                                    check_index,
5172                                    source,
5173                                },
5174                            )
5175                        })?)
5176                        .ok_or_else(|| {
5177                            prediction_file_error(
5178                                file_index,
5179                                MeasurementFileError::PredictionAccountingOverflow,
5180                            )
5181                        })?;
5182                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5183                        return Err(prediction_file_error(
5184                            file_index,
5185                            MeasurementFileError::TooMuchPredictionText {
5186                                found: decoded_text,
5187                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5188                            },
5189                        ));
5190                    }
5191                }
5192                checks.push(check);
5193            }
5194            Ok(checks)
5195        })
5196        .transpose()?;
5197    Ok(MeasurementFileInput {
5198        path: wire.path,
5199        input: wire.input,
5200        rig_v17: None,
5201        measurements: wire.measurements,
5202        prediction_provenance: RequiredNullable::Missing,
5203        checks: None,
5204        legacy_prediction_provenance,
5205        legacy_checks,
5206        prediction_provenance_v3: RequiredNullable::Missing,
5207        checks_v3: None,
5208        prediction_provenance_v4: RequiredNullable::Missing,
5209        checks_v4: None,
5210        prediction_provenance_v5: RequiredNullable::Missing,
5211        checks_v5: None,
5212        prediction_provenance_v6: RequiredNullable::Missing,
5213        checks_v6: None,
5214    })
5215}
5216
5217fn validate_legacy_v11_prediction_phase_file(
5218    command: &str,
5219    file_index: usize,
5220    file: &MeasurementFileInput,
5221) -> Result<(usize, usize), MeasurementReportError> {
5222    match command {
5223        "measure" => return Ok((0, 0)),
5224        "lint" => {}
5225        _ => unreachable!("command was validated before prediction phase"),
5226    }
5227    let provenance = match &file.legacy_prediction_provenance {
5228        RequiredNullable::Missing => {
5229            return Err(prediction_file_error(
5230                file_index,
5231                MeasurementFileError::MissingPredictionProvenance,
5232            ));
5233        }
5234        RequiredNullable::Present(provenance) => provenance.as_ref(),
5235    };
5236    let checks = file
5237        .legacy_checks
5238        .as_ref()
5239        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5240    if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5241        return Err(prediction_file_error(
5242            file_index,
5243            MeasurementFileError::TooManyChecks {
5244                found: checks.len(),
5245                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5246            },
5247        ));
5248    }
5249    if let Some(provenance) = provenance {
5250        provenance
5251            .validate_with_measurement_schema(MEASUREMENTS_V15_SCHEMA_ID)
5252            .map_err(|source| {
5253                prediction_file_error(
5254                    file_index,
5255                    MeasurementFileError::InvalidPredictionProvenance { source },
5256                )
5257            })?;
5258        let input = file
5259            .input
5260            .as_ref()
5261            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5262        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5263            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5264        {
5265            return Err(prediction_file_error(
5266                file_index,
5267                MeasurementFileError::PredictionPrimaryInputMismatch,
5268            ));
5269        }
5270    }
5271    let mut facets = 0usize;
5272    let mut references = 0usize;
5273    let mut text = provenance
5274        .map(PredictionProvenanceV1::retained_text_bytes)
5275        .transpose()
5276        .map_err(|source| {
5277            prediction_file_error(
5278                file_index,
5279                MeasurementFileError::InvalidPredictionProvenance { source },
5280            )
5281        })?
5282        .unwrap_or(0);
5283    let mut available = 0usize;
5284    let mut unavailable = 0usize;
5285    for (check_index, check) in checks.iter().enumerate() {
5286        if let Some(prediction) = &check.prediction {
5287            facets = facets
5288                .checked_add(prediction.facets().len())
5289                .ok_or_else(|| {
5290                    prediction_file_error(
5291                        file_index,
5292                        MeasurementFileError::PredictionAccountingOverflow,
5293                    )
5294                })?;
5295            references = references
5296                .checked_add(prediction.basis_reference_count())
5297                .ok_or_else(|| {
5298                    prediction_file_error(
5299                        file_index,
5300                        MeasurementFileError::PredictionAccountingOverflow,
5301                    )
5302                })?;
5303            text = text
5304                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5305                    prediction_file_error(
5306                        file_index,
5307                        MeasurementFileError::InvalidPrediction {
5308                            check_index,
5309                            source,
5310                        },
5311                    )
5312                })?)
5313                .ok_or_else(|| {
5314                    prediction_file_error(
5315                        file_index,
5316                        MeasurementFileError::PredictionAccountingOverflow,
5317                    )
5318                })?;
5319            for facet in prediction.facets() {
5320                match facet.state() {
5321                    EnginePredictionFacetStateV1::Available => {
5322                        available = available
5323                            .checked_add(1)
5324                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5325                    }
5326                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5327                        unavailable = unavailable
5328                            .checked_add(1)
5329                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5330                    }
5331                }
5332            }
5333        }
5334    }
5335    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5336        return Err(prediction_file_error(
5337            file_index,
5338            MeasurementFileError::TooManyPredictionFacets {
5339                found: facets,
5340                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5341            },
5342        ));
5343    }
5344    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5345        return Err(prediction_file_error(
5346            file_index,
5347            MeasurementFileError::TooManyPredictionBasisReferences {
5348                found: references,
5349                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5350            },
5351        ));
5352    }
5353    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5354        return Err(prediction_file_error(
5355            file_index,
5356            MeasurementFileError::TooMuchPredictionText {
5357                found: text,
5358                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5359            },
5360        ));
5361    }
5362    Ok((available, unavailable))
5363}
5364
5365impl LegacyPredictionCheckInput {
5366    fn validate(
5367        &self,
5368        check_index: usize,
5369        provenance: Option<&PredictionProvenanceV1>,
5370        expected_measurement_schema: &'static str,
5371    ) -> Result<(), MeasurementFileError> {
5372        let gap_refs = self
5373            .gaps
5374            .iter()
5375            .map(|gap| CheckEvaluationGapRef {
5376                code: &gap.code,
5377                scope: gap.scope.as_ref(),
5378            })
5379            .collect::<Vec<_>>();
5380        let finding_check_ids = self
5381            .findings
5382            .iter()
5383            .map(|finding| finding.check_id.as_str())
5384            .collect::<Vec<_>>();
5385        let prediction_scopes = self
5386            .prediction
5387            .as_ref()
5388            .into_iter()
5389            .flat_map(EnginePredictionV1::facets)
5390            .map(|facet| facet.scope())
5391            .collect::<Vec<_>>();
5392        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
5393            check_id: &self.check_id,
5394            selection: self.selection,
5395            configuration: self.configuration,
5396            applicability: self.applicability,
5397            finding_check_ids: &finding_check_ids,
5398            evaluated_scopes: &self.evaluated_scopes,
5399            gaps: &gap_refs,
5400            prediction_scopes: &prediction_scopes,
5401            has_prediction: self.prediction.is_some(),
5402            prediction_has_required_unavailable: self
5403                .prediction
5404                .as_ref()
5405                .is_some_and(EnginePredictionV1::has_required_unavailable),
5406        })
5407        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
5408            check_index,
5409            reason: error.reason(),
5410        })?;
5411        if self.evaluation != derived {
5412            return Err(MeasurementFileError::InvalidPredictionLifecycle {
5413                check_index,
5414                reason: "evaluation does not match completed and missing prediction work",
5415            });
5416        }
5417        let Some(prediction) = &self.prediction else {
5418            if self
5419                .findings
5420                .iter()
5421                .any(|finding| finding.prediction_scope.is_some())
5422            {
5423                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5424                    check_index,
5425                    reason: "finding has prediction_scope without prediction",
5426                });
5427            }
5428            return Ok(());
5429        };
5430        let provenance =
5431            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
5432        prediction
5433            .validate_against_provenance_with_measurement_schema(
5434                provenance,
5435                expected_measurement_schema,
5436            )
5437            .map_err(|source| MeasurementFileError::InvalidPrediction {
5438                check_index,
5439                source,
5440            })?;
5441        for facet in prediction.facets() {
5442            let evaluated = self
5443                .evaluated_scopes
5444                .iter()
5445                .filter(|scope| *scope == facet.scope())
5446                .count();
5447            let duplicated_gap = self
5448                .gaps
5449                .iter()
5450                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
5451            match facet.state() {
5452                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
5453                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5454                        check_index,
5455                        reason: "available facet scope must occur exactly once in evaluated_scopes",
5456                    });
5457                }
5458                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
5459                    if evaluated != 0 || duplicated_gap =>
5460                {
5461                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5462                        check_index,
5463                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
5464                    });
5465                }
5466                _ => {}
5467            }
5468        }
5469        for finding in &self.findings {
5470            let Some(scope) = &finding.prediction_scope else {
5471                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5472                    check_index,
5473                    reason: "prediction-backed finding must carry prediction_scope",
5474                });
5475            };
5476            if prediction
5477                .facets()
5478                .iter()
5479                .filter(|facet| {
5480                    facet.scope() == scope
5481                        && facet.state() == EnginePredictionFacetStateV1::Available
5482                })
5483                .count()
5484                != 1
5485            {
5486                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5487                    check_index,
5488                    reason: "finding prediction_scope must name one available facet",
5489                });
5490            }
5491        }
5492        Ok(())
5493    }
5494}
5495
5496fn validate_prediction_phase_file(
5497    command: &str,
5498    file_index: usize,
5499    file: &MeasurementFileInput,
5500    expected_measurement_schema: &'static str,
5501) -> Result<(usize, usize), MeasurementReportError> {
5502    let mut available = 0usize;
5503    let mut unavailable = 0usize;
5504    match command {
5505        "measure" => {
5506            if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
5507                return Err(prediction_file_error(
5508                    file_index,
5509                    MeasurementFileError::UnexpectedPredictionProvenance,
5510                ));
5511            }
5512            if file.checks.is_some() {
5513                return Err(prediction_file_error(
5514                    file_index,
5515                    MeasurementFileError::UnexpectedChecks,
5516                ));
5517            }
5518        }
5519        "lint" => {
5520            let provenance = match &file.prediction_provenance {
5521                RequiredNullable::Missing => {
5522                    return Err(prediction_file_error(
5523                        file_index,
5524                        MeasurementFileError::MissingPredictionProvenance,
5525                    ));
5526                }
5527                RequiredNullable::Present(provenance) => provenance.as_ref(),
5528            };
5529            let checks = file.checks.as_ref().ok_or_else(|| {
5530                prediction_file_error(file_index, MeasurementFileError::MissingChecks)
5531            })?;
5532            if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5533                return Err(prediction_file_error(
5534                    file_index,
5535                    MeasurementFileError::TooManyChecks {
5536                        found: checks.len(),
5537                        limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5538                    },
5539                ));
5540            }
5541            if let Some(provenance) = provenance {
5542                provenance
5543                    .validate_with_measurement_schema(expected_measurement_schema)
5544                    .map_err(|source| {
5545                        prediction_file_error(
5546                            file_index,
5547                            MeasurementFileError::InvalidPredictionProvenance { source },
5548                        )
5549                    })?;
5550                let input = file.input.as_ref().ok_or_else(|| {
5551                    prediction_file_error(file_index, MeasurementFileError::MissingInput)
5552                })?;
5553                if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5554                    || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5555                {
5556                    return Err(prediction_file_error(
5557                        file_index,
5558                        MeasurementFileError::PredictionPrimaryInputMismatch,
5559                    ));
5560                }
5561            }
5562
5563            let mut facets = 0usize;
5564            let mut references = 0usize;
5565            let mut text = provenance
5566                .map(PredictionProvenanceV2::retained_text_bytes)
5567                .transpose()
5568                .map_err(|source| {
5569                    prediction_file_error(
5570                        file_index,
5571                        MeasurementFileError::InvalidPredictionProvenance { source },
5572                    )
5573                })?
5574                .unwrap_or(0);
5575            for (check_index, check) in checks.iter().enumerate() {
5576                if let Some(prediction) = &check.prediction {
5577                    facets = facets
5578                        .checked_add(prediction.facets().len())
5579                        .ok_or_else(|| {
5580                            prediction_file_error(
5581                                file_index,
5582                                MeasurementFileError::PredictionAccountingOverflow,
5583                            )
5584                        })?;
5585                    references = references
5586                        .checked_add(prediction.basis_reference_count())
5587                        .ok_or_else(|| {
5588                            prediction_file_error(
5589                                file_index,
5590                                MeasurementFileError::PredictionAccountingOverflow,
5591                            )
5592                        })?;
5593                    text = text
5594                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5595                            prediction_file_error(
5596                                file_index,
5597                                MeasurementFileError::InvalidPrediction {
5598                                    check_index,
5599                                    source,
5600                                },
5601                            )
5602                        })?)
5603                        .ok_or_else(|| {
5604                            prediction_file_error(
5605                                file_index,
5606                                MeasurementFileError::PredictionAccountingOverflow,
5607                            )
5608                        })?;
5609                    for facet in prediction.facets() {
5610                        match facet.state() {
5611                            EnginePredictionFacetStateV1::Available => {
5612                                available = available.checked_add(1).ok_or(
5613                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5614                                )?;
5615                            }
5616                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5617                                unavailable = unavailable.checked_add(1).ok_or(
5618                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5619                                )?;
5620                            }
5621                        }
5622                    }
5623                }
5624            }
5625            if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5626                return Err(prediction_file_error(
5627                    file_index,
5628                    MeasurementFileError::TooManyPredictionFacets {
5629                        found: facets,
5630                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5631                    },
5632                ));
5633            }
5634            if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5635                return Err(prediction_file_error(
5636                    file_index,
5637                    MeasurementFileError::TooManyPredictionBasisReferences {
5638                        found: references,
5639                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5640                    },
5641                ));
5642            }
5643            if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5644                return Err(prediction_file_error(
5645                    file_index,
5646                    MeasurementFileError::TooMuchPredictionText {
5647                        found: text,
5648                        limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5649                    },
5650                ));
5651            }
5652        }
5653        _ => unreachable!("command was validated before prediction phase"),
5654    }
5655    Ok((available, unavailable))
5656}
5657
5658fn validate_prediction_phase_file_v14(
5659    command: &str,
5660    file_index: usize,
5661    file: &MeasurementFileInput,
5662) -> Result<(usize, usize), MeasurementReportError> {
5663    if command == "measure" {
5664        if !matches!(file.prediction_provenance_v3, RequiredNullable::Missing) {
5665            return Err(prediction_file_error(
5666                file_index,
5667                MeasurementFileError::UnexpectedPredictionProvenance,
5668            ));
5669        }
5670        if file.checks_v3.is_some() {
5671            return Err(prediction_file_error(
5672                file_index,
5673                MeasurementFileError::UnexpectedChecks,
5674            ));
5675        }
5676        return Ok((0, 0));
5677    }
5678    let provenance = match &file.prediction_provenance_v3 {
5679        RequiredNullable::Missing => {
5680            return Err(prediction_file_error(
5681                file_index,
5682                MeasurementFileError::MissingPredictionProvenance,
5683            ));
5684        }
5685        RequiredNullable::Present(provenance) => provenance.as_ref(),
5686    };
5687    let checks = file
5688        .checks_v3
5689        .as_ref()
5690        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5691    if let Some(provenance) = provenance {
5692        provenance.validate().map_err(|source| {
5693            prediction_file_error(
5694                file_index,
5695                MeasurementFileError::InvalidPredictionProvenance { source },
5696            )
5697        })?;
5698        let input = file
5699            .input
5700            .as_ref()
5701            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5702        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5703            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5704        {
5705            return Err(prediction_file_error(
5706                file_index,
5707                MeasurementFileError::PredictionPrimaryInputMismatch,
5708            ));
5709        }
5710    }
5711    let mut available = 0usize;
5712    let mut unavailable = 0usize;
5713    let mut facets = 0usize;
5714    let mut references = 0usize;
5715    let mut text = provenance
5716        .map(PredictionProvenanceV3::retained_text_bytes)
5717        .transpose()
5718        .map_err(|source| {
5719            prediction_file_error(
5720                file_index,
5721                MeasurementFileError::InvalidPredictionProvenance { source },
5722            )
5723        })?
5724        .unwrap_or(0);
5725    for (check_index, check) in checks.iter().enumerate() {
5726        check
5727            .validate(check_index, provenance)
5728            .map_err(|source| prediction_file_error(file_index, source))?;
5729        if let Some(prediction) = &check.prediction {
5730            facets = facets
5731                .checked_add(prediction.facets().len())
5732                .ok_or_else(|| {
5733                    prediction_file_error(
5734                        file_index,
5735                        MeasurementFileError::PredictionAccountingOverflow,
5736                    )
5737                })?;
5738            references = references
5739                .checked_add(prediction.basis_reference_count())
5740                .ok_or_else(|| {
5741                    prediction_file_error(
5742                        file_index,
5743                        MeasurementFileError::PredictionAccountingOverflow,
5744                    )
5745                })?;
5746            text = text
5747                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5748                    prediction_file_error(
5749                        file_index,
5750                        MeasurementFileError::InvalidPrediction {
5751                            check_index,
5752                            source,
5753                        },
5754                    )
5755                })?)
5756                .ok_or_else(|| {
5757                    prediction_file_error(
5758                        file_index,
5759                        MeasurementFileError::PredictionAccountingOverflow,
5760                    )
5761                })?;
5762            for facet in prediction.facets() {
5763                match facet.state() {
5764                    EnginePredictionFacetStateV1::Available => available += 1,
5765                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5766                }
5767            }
5768        }
5769    }
5770    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5771        return Err(prediction_file_error(
5772            file_index,
5773            MeasurementFileError::TooManyPredictionFacets {
5774                found: facets,
5775                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5776            },
5777        ));
5778    }
5779    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5780        return Err(prediction_file_error(
5781            file_index,
5782            MeasurementFileError::TooManyPredictionBasisReferences {
5783                found: references,
5784                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5785            },
5786        ));
5787    }
5788    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5789        return Err(prediction_file_error(
5790            file_index,
5791            MeasurementFileError::TooMuchPredictionText {
5792                found: text,
5793                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5794            },
5795        ));
5796    }
5797    Ok((available, unavailable))
5798}
5799
5800fn validate_prediction_phase_file_v15(
5801    command: &str,
5802    file_index: usize,
5803    file: &MeasurementFileInput,
5804) -> Result<(usize, usize), MeasurementReportError> {
5805    if matches!(file.prediction_provenance_v4, RequiredNullable::Missing) {
5806        return validate_prediction_phase_file_v14(command, file_index, file);
5807    }
5808    if command == "measure" {
5809        return Err(prediction_file_error(
5810            file_index,
5811            MeasurementFileError::UnexpectedPredictionProvenance,
5812        ));
5813    }
5814    let provenance = match &file.prediction_provenance_v4 {
5815        RequiredNullable::Present(provenance) => provenance.as_ref(),
5816        RequiredNullable::Missing => unreachable!(),
5817    };
5818    let checks = file
5819        .checks_v4
5820        .as_ref()
5821        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5822    if let Some(provenance) = provenance {
5823        provenance.validate().map_err(|source| {
5824            prediction_file_error(
5825                file_index,
5826                MeasurementFileError::InvalidPredictionProvenance { source },
5827            )
5828        })?;
5829        let input = file
5830            .input
5831            .as_ref()
5832            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5833        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5834            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5835        {
5836            return Err(prediction_file_error(
5837                file_index,
5838                MeasurementFileError::PredictionPrimaryInputMismatch,
5839            ));
5840        }
5841    }
5842    let mut available = 0usize;
5843    let mut unavailable = 0usize;
5844    let mut has_facet_budget_summary = false;
5845    let mut facets = 0usize;
5846    let mut references = 0usize;
5847    let mut text = provenance
5848        .map(PredictionProvenanceV4::retained_text_bytes)
5849        .transpose()
5850        .map_err(|source| {
5851            prediction_file_error(
5852                file_index,
5853                MeasurementFileError::InvalidPredictionProvenance { source },
5854            )
5855        })?
5856        .unwrap_or(0);
5857    for (check_index, check) in checks.iter().enumerate() {
5858        check
5859            .validate(check_index, provenance)
5860            .map_err(|source| prediction_file_error(file_index, source))?;
5861        if let Some(prediction) = &check.prediction {
5862            has_facet_budget_summary |= prediction.has_facet_budget_summary();
5863            facets = facets
5864                .checked_add(prediction.facets().len())
5865                .ok_or_else(|| {
5866                    prediction_file_error(
5867                        file_index,
5868                        MeasurementFileError::PredictionAccountingOverflow,
5869                    )
5870                })?;
5871            references = references
5872                .checked_add(prediction.basis_reference_count())
5873                .ok_or_else(|| {
5874                    prediction_file_error(
5875                        file_index,
5876                        MeasurementFileError::PredictionAccountingOverflow,
5877                    )
5878                })?;
5879            text = text
5880                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5881                    prediction_file_error(
5882                        file_index,
5883                        MeasurementFileError::InvalidPrediction {
5884                            check_index,
5885                            source,
5886                        },
5887                    )
5888                })?)
5889                .ok_or_else(|| {
5890                    prediction_file_error(
5891                        file_index,
5892                        MeasurementFileError::PredictionAccountingOverflow,
5893                    )
5894                })?;
5895            for facet in prediction.facets() {
5896                match facet.state() {
5897                    EnginePredictionFacetStateV1::Available => available += 1,
5898                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5899                }
5900            }
5901        }
5902    }
5903    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5904        return Err(prediction_file_error(
5905            file_index,
5906            MeasurementFileError::TooManyPredictionFacets {
5907                found: facets,
5908                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5909            },
5910        ));
5911    }
5912    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5913        return Err(prediction_file_error(
5914            file_index,
5915            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5916                found: facets,
5917                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5918            },
5919        ));
5920    }
5921    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5922        return Err(prediction_file_error(
5923            file_index,
5924            MeasurementFileError::TooManyPredictionBasisReferences {
5925                found: references,
5926                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5927            },
5928        ));
5929    }
5930    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5931        return Err(prediction_file_error(
5932            file_index,
5933            MeasurementFileError::TooMuchPredictionText {
5934                found: text,
5935                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5936            },
5937        ));
5938    }
5939    Ok((available, unavailable))
5940}
5941
5942fn validate_prediction_phase_file_v16(
5943    command: &str,
5944    file_index: usize,
5945    file: &MeasurementFileInput,
5946) -> Result<(usize, usize), MeasurementReportError> {
5947    if matches!(file.prediction_provenance_v5, RequiredNullable::Missing) {
5948        return validate_prediction_phase_file_v15(command, file_index, file);
5949    }
5950    if command == "measure" {
5951        return Err(prediction_file_error(
5952            file_index,
5953            MeasurementFileError::UnexpectedPredictionProvenance,
5954        ));
5955    }
5956    let provenance = match &file.prediction_provenance_v5 {
5957        RequiredNullable::Present(provenance) => provenance.as_ref(),
5958        RequiredNullable::Missing => unreachable!(),
5959    };
5960    let checks = file
5961        .checks_v5
5962        .as_ref()
5963        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5964    if let Some(provenance) = provenance {
5965        provenance.validate().map_err(|source| {
5966            prediction_file_error(
5967                file_index,
5968                MeasurementFileError::InvalidPredictionProvenance { source },
5969            )
5970        })?;
5971        let input = file
5972            .input
5973            .as_ref()
5974            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5975        if input.sha256.as_deref()
5976            != Some(provenance.raw_animation_channels().primary_input().sha256())
5977            || input.bytes != Some(provenance.raw_animation_channels().primary_input().bytes())
5978        {
5979            return Err(prediction_file_error(
5980                file_index,
5981                MeasurementFileError::PredictionPrimaryInputMismatch,
5982            ));
5983        }
5984    }
5985    let mut available = 0usize;
5986    let mut unavailable = 0usize;
5987    let mut facets = 0usize;
5988    let mut references = 0usize;
5989    let mut has_facet_budget_summary = false;
5990    let mut text = provenance
5991        .map(PredictionProvenanceV5::retained_text_bytes)
5992        .transpose()
5993        .map_err(|source| {
5994            prediction_file_error(
5995                file_index,
5996                MeasurementFileError::InvalidPredictionProvenance { source },
5997            )
5998        })?
5999        .unwrap_or(0);
6000    for (check_index, check) in checks.iter().enumerate() {
6001        check
6002            .validate(check_index, provenance)
6003            .map_err(|source| prediction_file_error(file_index, source))?;
6004        validate_current_engine_track_support_prediction_v5(
6005            &check.check_id,
6006            check.selection,
6007            check.configuration,
6008            check.applicability,
6009            check.prediction.as_ref(),
6010            provenance,
6011            check.findings.is_empty(),
6012        )
6013        .map_err(|source| {
6014            prediction_file_error(
6015                file_index,
6016                MeasurementFileError::InvalidPrediction {
6017                    check_index,
6018                    source,
6019                },
6020            )
6021        })?;
6022        if let Some(prediction) = &check.prediction {
6023            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
6024            facets = facets
6025                .checked_add(prediction.facets().len())
6026                .ok_or_else(|| {
6027                    prediction_file_error(
6028                        file_index,
6029                        MeasurementFileError::PredictionAccountingOverflow,
6030                    )
6031                })?;
6032            references = references
6033                .checked_add(prediction.basis_reference_count())
6034                .ok_or_else(|| {
6035                    prediction_file_error(
6036                        file_index,
6037                        MeasurementFileError::PredictionAccountingOverflow,
6038                    )
6039                })?;
6040            text = text
6041                .checked_add(prediction.retained_text_bytes().map_err(|source| {
6042                    prediction_file_error(
6043                        file_index,
6044                        MeasurementFileError::InvalidPrediction {
6045                            check_index,
6046                            source,
6047                        },
6048                    )
6049                })?)
6050                .ok_or_else(|| {
6051                    prediction_file_error(
6052                        file_index,
6053                        MeasurementFileError::PredictionAccountingOverflow,
6054                    )
6055                })?;
6056            for facet in prediction.facets() {
6057                match facet.state() {
6058                    EnginePredictionFacetStateV1::Available => available += 1,
6059                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6060                }
6061            }
6062        }
6063    }
6064    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6065        return Err(prediction_file_error(
6066            file_index,
6067            MeasurementFileError::TooManyPredictionFacets {
6068                found: facets,
6069                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6070            },
6071        ));
6072    }
6073    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6074        return Err(prediction_file_error(
6075            file_index,
6076            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6077                found: facets,
6078                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6079            },
6080        ));
6081    }
6082    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6083        return Err(prediction_file_error(
6084            file_index,
6085            MeasurementFileError::TooManyPredictionBasisReferences {
6086                found: references,
6087                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6088            },
6089        ));
6090    }
6091    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6092        return Err(prediction_file_error(
6093            file_index,
6094            MeasurementFileError::TooMuchPredictionText {
6095                found: text,
6096                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6097            },
6098        ));
6099    }
6100    Ok((available, unavailable))
6101}
6102
6103fn validate_prediction_phase_file_v17(
6104    command: &str,
6105    file_index: usize,
6106    file: &MeasurementFileInput,
6107) -> Result<(usize, usize), MeasurementReportError> {
6108    if matches!(file.prediction_provenance_v6, RequiredNullable::Missing) {
6109        return validate_prediction_phase_file_v16(command, file_index, file);
6110    }
6111    if command == "measure" {
6112        return Err(prediction_file_error(
6113            file_index,
6114            MeasurementFileError::UnexpectedPredictionProvenance,
6115        ));
6116    }
6117    let provenance = match &file.prediction_provenance_v6 {
6118        RequiredNullable::Present(provenance) => provenance.as_ref(),
6119        RequiredNullable::Missing => unreachable!(),
6120    };
6121    let checks = file
6122        .checks_v6
6123        .as_ref()
6124        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
6125    if let Some(provenance) = provenance {
6126        provenance.validate().map_err(|source| {
6127            prediction_file_error(
6128                file_index,
6129                MeasurementFileError::InvalidPredictionProvenance { source },
6130            )
6131        })?;
6132        let input = file
6133            .input
6134            .as_ref()
6135            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
6136        let primary = provenance.raw_transform_paths().primary_input();
6137        if input.sha256.as_deref() != Some(primary.sha256()) || input.bytes != Some(primary.bytes())
6138        {
6139            return Err(prediction_file_error(
6140                file_index,
6141                MeasurementFileError::PredictionPrimaryInputMismatch,
6142            ));
6143        }
6144    }
6145    let mut available = 0usize;
6146    let mut unavailable = 0usize;
6147    let mut facets = 0usize;
6148    let mut references = 0usize;
6149    let mut has_facet_budget_summary = false;
6150    let mut text = provenance
6151        .map(PredictionProvenanceV6::retained_text_bytes)
6152        .transpose()
6153        .map_err(|source| {
6154            prediction_file_error(
6155                file_index,
6156                MeasurementFileError::InvalidPredictionProvenance { source },
6157            )
6158        })?
6159        .unwrap_or(0);
6160    for (check_index, check) in checks.iter().enumerate() {
6161        check
6162            .validate(check_index, provenance)
6163            .map_err(|source| prediction_file_error(file_index, source))?;
6164        if let Some(prediction) = &check.prediction {
6165            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
6166            facets = facets
6167                .checked_add(prediction.facets().len())
6168                .ok_or_else(|| {
6169                    prediction_file_error(
6170                        file_index,
6171                        MeasurementFileError::PredictionAccountingOverflow,
6172                    )
6173                })?;
6174            references = references
6175                .checked_add(prediction.basis_reference_count())
6176                .ok_or_else(|| {
6177                    prediction_file_error(
6178                        file_index,
6179                        MeasurementFileError::PredictionAccountingOverflow,
6180                    )
6181                })?;
6182            text = text
6183                .checked_add(prediction.retained_text_bytes().map_err(|source| {
6184                    prediction_file_error(
6185                        file_index,
6186                        MeasurementFileError::InvalidPrediction {
6187                            check_index,
6188                            source,
6189                        },
6190                    )
6191                })?)
6192                .ok_or_else(|| {
6193                    prediction_file_error(
6194                        file_index,
6195                        MeasurementFileError::PredictionAccountingOverflow,
6196                    )
6197                })?;
6198            for facet in prediction.facets() {
6199                match facet.state() {
6200                    EnginePredictionFacetStateV1::Available => available += 1,
6201                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6202                }
6203            }
6204        }
6205    }
6206    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6207        return Err(prediction_file_error(
6208            file_index,
6209            MeasurementFileError::TooManyPredictionFacets {
6210                found: facets,
6211                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6212            },
6213        ));
6214    }
6215    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6216        return Err(prediction_file_error(
6217            file_index,
6218            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6219                found: facets,
6220                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6221            },
6222        ));
6223    }
6224    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6225        return Err(prediction_file_error(
6226            file_index,
6227            MeasurementFileError::TooManyPredictionBasisReferences {
6228                found: references,
6229                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6230            },
6231        ));
6232    }
6233    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6234        return Err(prediction_file_error(
6235            file_index,
6236            MeasurementFileError::TooMuchPredictionText {
6237                found: text,
6238                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6239            },
6240        ));
6241    }
6242    Ok((available, unavailable))
6243}
6244
6245fn validate_prediction_summary(
6246    command: &str,
6247    summary: Option<&MeasurementReportSummaryInput>,
6248    available: usize,
6249    unavailable: usize,
6250) -> Result<(), MeasurementReportError> {
6251    let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
6252    match (command, summary) {
6253        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6254        ("measure", None) => Ok(()),
6255        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6256        ("lint", Some(summary))
6257            if summary.available != available
6258                || summary.required_prediction_unavailable != unavailable =>
6259        {
6260            Err(MeasurementReportError::PredictionFacetSummaryMismatch)
6261        }
6262        ("lint", Some(_)) => Ok(()),
6263        _ => unreachable!("command was validated before prediction summary"),
6264    }
6265}
6266
6267fn validate_prediction_summary_presence(
6268    command: &str,
6269    summary: Option<&MeasurementReportSummaryInput>,
6270) -> Result<(), MeasurementReportError> {
6271    match (
6272        command,
6273        summary.and_then(|summary| summary.prediction_facets.as_ref()),
6274    ) {
6275        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6276        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6277        ("measure", None) | ("lint", Some(_)) => Ok(()),
6278        _ => unreachable!("command was validated before prediction summary"),
6279    }
6280}
6281
6282impl PredictionCheckInput {
6283    fn validate(
6284        &self,
6285        check_index: usize,
6286        provenance: Option<&PredictionProvenanceV2>,
6287        expected_measurement_schema: &'static str,
6288    ) -> Result<(), MeasurementFileError> {
6289        let gap_refs = self
6290            .gaps
6291            .iter()
6292            .map(|gap| CheckEvaluationGapRef {
6293                code: &gap.code,
6294                scope: gap.scope.as_ref(),
6295            })
6296            .collect::<Vec<_>>();
6297        let finding_check_ids = self
6298            .findings
6299            .iter()
6300            .map(|finding| finding.check_id.as_str())
6301            .collect::<Vec<_>>();
6302        let prediction_scopes = self
6303            .prediction
6304            .as_ref()
6305            .into_iter()
6306            .flat_map(EnginePredictionV2::facets)
6307            .map(|facet| facet.scope())
6308            .collect::<Vec<_>>();
6309        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6310            check_id: &self.check_id,
6311            selection: self.selection,
6312            configuration: self.configuration,
6313            applicability: self.applicability,
6314            finding_check_ids: &finding_check_ids,
6315            evaluated_scopes: &self.evaluated_scopes,
6316            gaps: &gap_refs,
6317            prediction_scopes: &prediction_scopes,
6318            has_prediction: self.prediction.is_some(),
6319            prediction_has_required_unavailable: self
6320                .prediction
6321                .as_ref()
6322                .is_some_and(EnginePredictionV2::has_required_unavailable),
6323        })
6324        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6325            check_index,
6326            reason: error.reason(),
6327        })?;
6328        if self.evaluation != derived {
6329            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6330                check_index,
6331                reason: "evaluation does not match completed and missing prediction work",
6332            });
6333        }
6334
6335        let Some(prediction) = &self.prediction else {
6336            if self
6337                .findings
6338                .iter()
6339                .any(|finding| finding.prediction_scope.is_some())
6340            {
6341                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6342                    check_index,
6343                    reason: "finding has prediction_scope without prediction",
6344                });
6345            }
6346            return Ok(());
6347        };
6348        let provenance =
6349            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6350        prediction
6351            .validate_against_provenance_with_measurement_schema(
6352                provenance,
6353                expected_measurement_schema,
6354            )
6355            .map_err(|source| MeasurementFileError::InvalidPrediction {
6356                check_index,
6357                source,
6358            })?;
6359        prediction
6360            .validate_facet_budget_summary_for_check(&self.check_id)
6361            .map_err(|source| MeasurementFileError::InvalidPrediction {
6362                check_index,
6363                source,
6364            })?;
6365        validate_current_engine_addressability_prediction_v2(
6366            &self.check_id,
6367            prediction,
6368            provenance,
6369        )
6370        .map_err(|source| MeasurementFileError::InvalidPrediction {
6371            check_index,
6372            source,
6373        })?;
6374        for facet in prediction.facets() {
6375            let evaluated = self
6376                .evaluated_scopes
6377                .iter()
6378                .filter(|scope| *scope == facet.scope())
6379                .count();
6380            let duplicated_gap = self
6381                .gaps
6382                .iter()
6383                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6384            match facet.state() {
6385                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6386                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6387                        check_index,
6388                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6389                    });
6390                }
6391                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6392                    if evaluated != 0 || duplicated_gap =>
6393                {
6394                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6395                        check_index,
6396                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6397                    });
6398                }
6399                _ => {}
6400            }
6401        }
6402        for finding in &self.findings {
6403            let Some(scope) = &finding.prediction_scope else {
6404                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6405                    check_index,
6406                    reason: "prediction-backed finding must carry prediction_scope",
6407                });
6408            };
6409            if prediction
6410                .facets()
6411                .iter()
6412                .filter(|facet| {
6413                    facet.scope() == scope
6414                        && facet.state() == EnginePredictionFacetStateV1::Available
6415                })
6416                .count()
6417                != 1
6418            {
6419                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6420                    check_index,
6421                    reason: "finding prediction_scope must name one available facet",
6422                });
6423            }
6424        }
6425        Ok(())
6426    }
6427}
6428
6429impl PredictionCheckInputV3 {
6430    fn validate(
6431        &self,
6432        check_index: usize,
6433        provenance: Option<&PredictionProvenanceV3>,
6434    ) -> Result<(), MeasurementFileError> {
6435        let gap_refs = self
6436            .gaps
6437            .iter()
6438            .map(|gap| CheckEvaluationGapRef {
6439                code: &gap.code,
6440                scope: gap.scope.as_ref(),
6441            })
6442            .collect::<Vec<_>>();
6443        let finding_check_ids = self
6444            .findings
6445            .iter()
6446            .map(|finding| finding.check_id.as_str())
6447            .collect::<Vec<_>>();
6448        let prediction_scopes = self
6449            .prediction
6450            .as_ref()
6451            .into_iter()
6452            .flat_map(EnginePredictionV3::facets)
6453            .map(|facet| facet.scope())
6454            .collect::<Vec<_>>();
6455        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6456            check_id: &self.check_id,
6457            selection: self.selection,
6458            configuration: self.configuration,
6459            applicability: self.applicability,
6460            finding_check_ids: &finding_check_ids,
6461            evaluated_scopes: &self.evaluated_scopes,
6462            gaps: &gap_refs,
6463            prediction_scopes: &prediction_scopes,
6464            has_prediction: self.prediction.is_some(),
6465            prediction_has_required_unavailable: self
6466                .prediction
6467                .as_ref()
6468                .is_some_and(EnginePredictionV3::has_required_unavailable),
6469        })
6470        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6471            check_index,
6472            reason: error.reason(),
6473        })?;
6474        if self.evaluation != derived {
6475            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6476                check_index,
6477                reason: "evaluation does not match completed and missing prediction work",
6478            });
6479        }
6480        validate_current_engine_clip_boundary_applicability_v3(
6481            &self.check_id,
6482            self.applicability,
6483            provenance,
6484        )
6485        .map_err(|source| MeasurementFileError::InvalidPrediction {
6486            check_index,
6487            source,
6488        })?;
6489        let Some(prediction) = &self.prediction else {
6490            if self
6491                .findings
6492                .iter()
6493                .any(|finding| finding.prediction_scope.is_some())
6494            {
6495                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6496                    check_index,
6497                    reason: "finding has prediction_scope without prediction",
6498                });
6499            }
6500            if self.check_id == "engine-clip-boundary"
6501                && self.selection == SelectionState::Selected
6502                && self.configuration == ConfigurationState::Enabled
6503                && self.applicability == Applicability::Applicable
6504            {
6505                return Err(MeasurementFileError::InvalidPrediction {
6506                    check_index,
6507                    source: PredictionContractError::EngineClipBoundaryFacetMismatch,
6508                });
6509            }
6510            return Ok(());
6511        };
6512        let provenance =
6513            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6514        prediction
6515            .validate_against_provenance(provenance)
6516            .map_err(|source| MeasurementFileError::InvalidPrediction {
6517                check_index,
6518                source,
6519            })?;
6520        prediction
6521            .validate_facet_budget_summary_for_check(&self.check_id)
6522            .map_err(|source| MeasurementFileError::InvalidPrediction {
6523                check_index,
6524                source,
6525            })?;
6526        validate_current_engine_addressability_prediction_v3(
6527            &self.check_id,
6528            prediction,
6529            provenance,
6530        )
6531        .map_err(|source| MeasurementFileError::InvalidPrediction {
6532            check_index,
6533            source,
6534        })?;
6535        for facet in prediction.facets() {
6536            let evaluated = self
6537                .evaluated_scopes
6538                .iter()
6539                .filter(|scope| *scope == facet.scope())
6540                .count();
6541            let duplicated_gap = self
6542                .gaps
6543                .iter()
6544                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6545            match facet.state() {
6546                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6547                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6548                        check_index,
6549                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6550                    });
6551                }
6552                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6553                    if evaluated != 0 || duplicated_gap =>
6554                {
6555                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6556                        check_index,
6557                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6558                    });
6559                }
6560                _ => {}
6561            }
6562        }
6563        for finding in &self.findings {
6564            let Some(scope) = &finding.prediction_scope else {
6565                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6566                    check_index,
6567                    reason: "prediction-backed finding must carry prediction_scope",
6568                });
6569            };
6570            if prediction
6571                .facets()
6572                .iter()
6573                .filter(|facet| {
6574                    facet.scope() == scope
6575                        && facet.state() == EnginePredictionFacetStateV1::Available
6576                })
6577                .count()
6578                != 1
6579            {
6580                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6581                    check_index,
6582                    reason: "finding prediction_scope must name one available facet",
6583                });
6584            }
6585        }
6586        let finding_scopes = self
6587            .findings
6588            .iter()
6589            .filter_map(|finding| finding.prediction_scope.as_ref())
6590            .collect::<Vec<_>>();
6591        validate_current_engine_clip_boundary_prediction_v3(
6592            &self.check_id,
6593            prediction,
6594            provenance,
6595            &self.evaluated_scopes,
6596            &finding_scopes,
6597        )
6598        .map_err(|source| MeasurementFileError::InvalidPrediction {
6599            check_index,
6600            source,
6601        })?;
6602        Ok(())
6603    }
6604}
6605
6606impl PredictionCheckInputV4 {
6607    fn validate(
6608        &self,
6609        check_index: usize,
6610        provenance: Option<&PredictionProvenanceV4>,
6611    ) -> Result<(), MeasurementFileError> {
6612        let gap_refs = self
6613            .gaps
6614            .iter()
6615            .map(|gap| CheckEvaluationGapRef {
6616                code: &gap.code,
6617                scope: gap.scope.as_ref(),
6618            })
6619            .collect::<Vec<_>>();
6620        let finding_check_ids = self
6621            .findings
6622            .iter()
6623            .map(|finding| finding.check_id.as_str())
6624            .collect::<Vec<_>>();
6625        let prediction_scopes = self
6626            .prediction
6627            .as_ref()
6628            .into_iter()
6629            .flat_map(EnginePredictionV4::facets)
6630            .map(|facet| facet.scope())
6631            .collect::<Vec<_>>();
6632        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6633            check_id: &self.check_id,
6634            selection: self.selection,
6635            configuration: self.configuration,
6636            applicability: self.applicability,
6637            finding_check_ids: &finding_check_ids,
6638            evaluated_scopes: &self.evaluated_scopes,
6639            gaps: &gap_refs,
6640            prediction_scopes: &prediction_scopes,
6641            has_prediction: self.prediction.is_some(),
6642            prediction_has_required_unavailable: self
6643                .prediction
6644                .as_ref()
6645                .is_some_and(EnginePredictionV4::has_required_unavailable),
6646        })
6647        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6648            check_index,
6649            reason: error.reason(),
6650        })?;
6651        if self.evaluation != derived {
6652            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6653                check_index,
6654                reason: "evaluation does not match completed and missing prediction work",
6655            });
6656        }
6657        let Some(prediction) = &self.prediction else {
6658            if self
6659                .findings
6660                .iter()
6661                .any(|finding| finding.prediction_scope.is_some())
6662            {
6663                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6664                    check_index,
6665                    reason: "finding has prediction_scope without prediction",
6666                });
6667            }
6668            return Ok(());
6669        };
6670        let provenance =
6671            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6672        prediction
6673            .validate_against_provenance(provenance)
6674            .map_err(|source| MeasurementFileError::InvalidPrediction {
6675                check_index,
6676                source,
6677            })?;
6678        prediction
6679            .validate_facet_budget_summary_for_check(&self.check_id)
6680            .map_err(|source| MeasurementFileError::InvalidPrediction {
6681                check_index,
6682                source,
6683            })?;
6684        for facet in prediction.facets() {
6685            let evaluated = self
6686                .evaluated_scopes
6687                .iter()
6688                .filter(|scope| *scope == facet.scope())
6689                .count();
6690            let duplicated_gap = self
6691                .gaps
6692                .iter()
6693                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6694            match facet.state() {
6695                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6696                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6697                        check_index,
6698                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6699                    });
6700                }
6701                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6702                    if evaluated != 0 || duplicated_gap =>
6703                {
6704                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6705                        check_index,
6706                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6707                    });
6708                }
6709                _ => {}
6710            }
6711        }
6712        for finding in &self.findings {
6713            let Some(scope) = &finding.prediction_scope else {
6714                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6715                    check_index,
6716                    reason: "prediction-backed finding must carry prediction_scope",
6717                });
6718            };
6719            if prediction
6720                .facets()
6721                .iter()
6722                .filter(|facet| {
6723                    facet.scope() == scope
6724                        && facet.state() == EnginePredictionFacetStateV1::Available
6725                })
6726                .count()
6727                != 1
6728            {
6729                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6730                    check_index,
6731                    reason: "finding prediction_scope must name one available facet",
6732                });
6733            }
6734        }
6735        Ok(())
6736    }
6737}
6738
6739impl PredictionCheckInputV5 {
6740    fn validate(
6741        &self,
6742        check_index: usize,
6743        provenance: Option<&PredictionProvenanceV5>,
6744    ) -> Result<(), MeasurementFileError> {
6745        let gap_refs = self
6746            .gaps
6747            .iter()
6748            .map(|gap| CheckEvaluationGapRef {
6749                code: &gap.code,
6750                scope: gap.scope.as_ref(),
6751            })
6752            .collect::<Vec<_>>();
6753        let finding_check_ids = self
6754            .findings
6755            .iter()
6756            .map(|finding| finding.check_id.as_str())
6757            .collect::<Vec<_>>();
6758        let prediction_scopes = self
6759            .prediction
6760            .as_ref()
6761            .into_iter()
6762            .flat_map(EnginePredictionV5::facets)
6763            .map(|facet| facet.scope())
6764            .collect::<Vec<_>>();
6765        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6766            check_id: &self.check_id,
6767            selection: self.selection,
6768            configuration: self.configuration,
6769            applicability: self.applicability,
6770            finding_check_ids: &finding_check_ids,
6771            evaluated_scopes: &self.evaluated_scopes,
6772            gaps: &gap_refs,
6773            prediction_scopes: &prediction_scopes,
6774            has_prediction: self.prediction.is_some(),
6775            prediction_has_required_unavailable: self
6776                .prediction
6777                .as_ref()
6778                .is_some_and(EnginePredictionV5::has_required_unavailable),
6779        })
6780        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6781            check_index,
6782            reason: error.reason(),
6783        })?;
6784        if self.evaluation != derived {
6785            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6786                check_index,
6787                reason: "evaluation does not match completed and missing prediction work",
6788            });
6789        }
6790        let Some(prediction) = &self.prediction else {
6791            if self
6792                .findings
6793                .iter()
6794                .any(|finding| finding.prediction_scope.is_some())
6795            {
6796                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6797                    check_index,
6798                    reason: "finding has prediction_scope without prediction",
6799                });
6800            }
6801            return Ok(());
6802        };
6803        let provenance =
6804            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6805        prediction
6806            .validate_against_provenance(provenance)
6807            .map_err(|source| MeasurementFileError::InvalidPrediction {
6808                check_index,
6809                source,
6810            })?;
6811        prediction
6812            .base_prediction()
6813            .validate_facet_budget_summary_for_check(&self.check_id)
6814            .map_err(|source| MeasurementFileError::InvalidPrediction {
6815                check_index,
6816                source,
6817            })?;
6818        for facet in prediction.facets() {
6819            let evaluated = self
6820                .evaluated_scopes
6821                .iter()
6822                .filter(|scope| *scope == facet.scope())
6823                .count();
6824            let duplicated_gap = self
6825                .gaps
6826                .iter()
6827                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6828            match facet.state() {
6829                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6830                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6831                        check_index,
6832                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6833                    });
6834                }
6835                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6836                    if evaluated != 0 || duplicated_gap =>
6837                {
6838                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6839                        check_index,
6840                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6841                    });
6842                }
6843                _ => {}
6844            }
6845        }
6846        for finding in &self.findings {
6847            let Some(scope) = &finding.prediction_scope else {
6848                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6849                    check_index,
6850                    reason: "prediction-backed finding must carry prediction_scope",
6851                });
6852            };
6853            if prediction
6854                .facets()
6855                .iter()
6856                .filter(|facet| {
6857                    facet.scope() == scope
6858                        && facet.state() == EnginePredictionFacetStateV1::Available
6859                })
6860                .count()
6861                != 1
6862            {
6863                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6864                    check_index,
6865                    reason: "finding prediction_scope must name one available facet",
6866                });
6867            }
6868        }
6869        Ok(())
6870    }
6871}
6872
6873impl PredictionCheckInputV6 {
6874    fn validate(
6875        &self,
6876        check_index: usize,
6877        provenance: Option<&PredictionProvenanceV6>,
6878    ) -> Result<(), MeasurementFileError> {
6879        let gap_refs = self
6880            .gaps
6881            .iter()
6882            .map(|gap| CheckEvaluationGapRef {
6883                code: &gap.code,
6884                scope: gap.scope.as_ref(),
6885            })
6886            .collect::<Vec<_>>();
6887        let finding_check_ids = self
6888            .findings
6889            .iter()
6890            .map(|finding| finding.check_id.as_str())
6891            .collect::<Vec<_>>();
6892        let prediction_scopes = self
6893            .prediction
6894            .as_ref()
6895            .into_iter()
6896            .flat_map(EnginePredictionV6::facets)
6897            .map(|facet| facet.scope())
6898            .collect::<Vec<_>>();
6899        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6900            check_id: &self.check_id,
6901            selection: self.selection,
6902            configuration: self.configuration,
6903            applicability: self.applicability,
6904            finding_check_ids: &finding_check_ids,
6905            evaluated_scopes: &self.evaluated_scopes,
6906            gaps: &gap_refs,
6907            prediction_scopes: &prediction_scopes,
6908            has_prediction: self.prediction.is_some(),
6909            prediction_has_required_unavailable: self
6910                .prediction
6911                .as_ref()
6912                .is_some_and(EnginePredictionV6::has_required_unavailable),
6913        })
6914        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6915            check_index,
6916            reason: error.reason(),
6917        })?;
6918        if self.evaluation != derived {
6919            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6920                check_index,
6921                reason: "evaluation does not match completed and missing prediction work",
6922            });
6923        }
6924        let Some(prediction) = &self.prediction else {
6925            if self
6926                .findings
6927                .iter()
6928                .any(|finding| finding.prediction_scope.is_some())
6929            {
6930                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6931                    check_index,
6932                    reason: "finding has prediction_scope without prediction",
6933                });
6934            }
6935            return Ok(());
6936        };
6937        let provenance =
6938            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6939        prediction
6940            .validate_against_provenance(provenance)
6941            .map_err(|source| MeasurementFileError::InvalidPrediction {
6942                check_index,
6943                source,
6944            })?;
6945        prediction
6946            .base_prediction()
6947            .validate_facet_budget_summary_for_check(&self.check_id)
6948            .map_err(|source| MeasurementFileError::InvalidPrediction {
6949                check_index,
6950                source,
6951            })?;
6952        for facet in prediction.facets() {
6953            let evaluated = self
6954                .evaluated_scopes
6955                .iter()
6956                .filter(|scope| *scope == facet.scope())
6957                .count();
6958            let duplicated_gap = self
6959                .gaps
6960                .iter()
6961                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6962            match facet.state() {
6963                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6964                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6965                        check_index,
6966                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6967                    });
6968                }
6969                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6970                    if evaluated != 0 || duplicated_gap =>
6971                {
6972                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6973                        check_index,
6974                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6975                    });
6976                }
6977                _ => {}
6978            }
6979        }
6980        for finding in &self.findings {
6981            let Some(scope) = &finding.prediction_scope else {
6982                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6983                    check_index,
6984                    reason: "prediction-backed finding must carry prediction_scope",
6985                });
6986            };
6987            if prediction
6988                .facets()
6989                .iter()
6990                .filter(|facet| {
6991                    facet.scope() == scope
6992                        && facet.state() == EnginePredictionFacetStateV1::Available
6993                })
6994                .count()
6995                != 1
6996            {
6997                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6998                    check_index,
6999                    reason: "finding prediction_scope must name one available facet",
7000                });
7001            }
7002        }
7003        Ok(())
7004    }
7005}
7006
7007/// Validate the frozen current-lint addressability inventory contract.  This
7008/// is deliberately output-facing: standalone V1 engine artifacts retain their
7009/// historic provenance and reason vocabulary.
7010fn validate_current_engine_addressability_prediction_v2(
7011    check_id: &str,
7012    prediction: &EnginePredictionV2,
7013    provenance: &PredictionProvenanceV2,
7014) -> Result<(), PredictionContractError> {
7015    if check_id != "engine-addressability" {
7016        return Ok(());
7017    }
7018    let raw_partial =
7019        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
7020    let settings_partial = matches!(
7021        provenance.settings().clip_coverage().state(),
7022        ResolvedEngineSettingsCoverageStateV2::Partial
7023    );
7024    let inventories = prediction
7025        .facets()
7026        .iter()
7027        .filter(|facet| {
7028            facet.scope().code.as_str() == "animation_asset_label_inventory"
7029                && facet.scope().subject.is_none()
7030        })
7031        .collect::<Vec<_>>();
7032    if !raw_partial && !settings_partial {
7033        if prediction.facets().iter().any(|facet| {
7034            facet
7035                .reasons()
7036                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
7037        }) {
7038            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7039        }
7040        let available = prediction
7041            .facets()
7042            .iter()
7043            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
7044            .collect::<Vec<_>>();
7045        let expected_rows = provenance.settings().clips().len();
7046        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
7047            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
7048        {
7049            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7050        }
7051        let mut seen = vec![false; available.len()];
7052        for facet in available {
7053            let Some(ordinal) = facet
7054                .scope()
7055                .subject
7056                .as_deref()
7057                .and_then(|subject| subject.strip_prefix("Animation"))
7058                .and_then(|ordinal| ordinal.parse::<usize>().ok())
7059            else {
7060                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7061            };
7062            if facet.scope().code.as_str() != "animation_asset_label"
7063                || ordinal >= seen.len()
7064                || std::mem::replace(&mut seen[ordinal], true)
7065                || !facet.basis().references().iter().any(|reference| {
7066                    matches!(
7067                        reference,
7068                        PredictionBasisReferenceV1::RawSource { reference }
7069                            if reference.domain() == RawSourceDomainV1::Clip
7070                                && matches!(
7071                                    reference.key(),
7072                                    RawSourceKeyV1::Clip { source_clip_index }
7073                                        if *source_clip_index == ordinal as u64
7074                                )
7075                                && reference.field().as_str() == "source_name.state"
7076                    )
7077                })
7078            {
7079                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7080            }
7081        }
7082        if seen.iter().any(|seen| !seen) {
7083            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7084        }
7085        return Ok(());
7086    }
7087
7088    let mut expected = Vec::new();
7089    if raw_partial {
7090        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7091    }
7092    if settings_partial {
7093        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7094    }
7095    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7096    {
7097        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7098    }
7099    if let Some(inventory) = inventories.first()
7100        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7101            || inventory.reasons() != expected)
7102    {
7103        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7104    }
7105    if inventories.is_empty() {
7106        // The allocator may replace this sole incomplete-inventory candidate
7107        // with its canonical budget summary.  The enclosing file validator
7108        // separately proves that the shared 4,096-slot budget was exhausted.
7109        return Ok(());
7110    }
7111    Ok(())
7112}
7113
7114/// Validate the frozen V14/V3 addressability inventory without retargeting
7115/// the immutable V13/V2 contract.
7116fn validate_current_engine_addressability_prediction_v3(
7117    check_id: &str,
7118    prediction: &EnginePredictionV3,
7119    provenance: &PredictionProvenanceV3,
7120) -> Result<(), PredictionContractError> {
7121    if check_id != "engine-addressability" {
7122        return Ok(());
7123    }
7124    let raw_partial =
7125        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
7126    let settings_partial = matches!(
7127        provenance.settings().clip_coverage().state(),
7128        ResolvedEngineSettingsCoverageStateV2::Partial
7129    );
7130    let inventories = prediction
7131        .facets()
7132        .iter()
7133        .filter(|facet| {
7134            facet.scope().code.as_str() == "animation_asset_label_inventory"
7135                && facet.scope().subject.is_none()
7136        })
7137        .collect::<Vec<_>>();
7138    if !raw_partial && !settings_partial {
7139        if prediction.facets().iter().any(|facet| {
7140            facet
7141                .reasons()
7142                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
7143        }) {
7144            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7145        }
7146        let available = prediction
7147            .facets()
7148            .iter()
7149            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
7150            .collect::<Vec<_>>();
7151        let expected_rows = provenance.settings().clips().len();
7152        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
7153            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
7154        {
7155            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7156        }
7157        let mut seen = vec![false; available.len()];
7158        for facet in available {
7159            let Some(ordinal) = facet
7160                .scope()
7161                .subject
7162                .as_deref()
7163                .and_then(|subject| subject.strip_prefix("Animation"))
7164                .and_then(|ordinal| ordinal.parse::<usize>().ok())
7165            else {
7166                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7167            };
7168            if facet.scope().code.as_str() != "animation_asset_label"
7169                || ordinal >= seen.len()
7170                || std::mem::replace(&mut seen[ordinal], true)
7171                || !facet.basis().references().iter().any(|reference| {
7172                    matches!(
7173                        reference,
7174                        PredictionBasisReferenceV2::V1(PredictionBasisReferenceV1::RawSource { reference })
7175                            if reference.domain() == RawSourceDomainV1::Clip
7176                                && matches!(
7177                                    reference.key(),
7178                                    RawSourceKeyV1::Clip { source_clip_index }
7179                                        if *source_clip_index == ordinal as u64
7180                                )
7181                                && reference.field().as_str() == "source_name.state"
7182                    )
7183                })
7184            {
7185                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7186            }
7187        }
7188        if seen.iter().any(|seen| !seen) {
7189            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7190        }
7191        return Ok(());
7192    }
7193
7194    let mut expected = Vec::new();
7195    if raw_partial {
7196        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7197    }
7198    if settings_partial {
7199        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7200    }
7201    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7202    {
7203        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7204    }
7205    if let Some(inventory) = inventories.first()
7206        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7207            || inventory.reasons() != expected)
7208    {
7209        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7210    }
7211    Ok(())
7212}
7213
7214const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
7215const ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE: &str = "engine-track-support:animation";
7216const ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
7217const ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE: &str = "engine-track-support:inventory";
7218const ENGINE_TRACK_SUPPORT_BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
7219
7220fn validate_current_engine_track_support_prediction_v5(
7221    check_id: &str,
7222    selection_state: SelectionState,
7223    configuration: ConfigurationState,
7224    applicability: Applicability,
7225    prediction: Option<&EnginePredictionV5>,
7226    provenance: Option<&PredictionProvenanceV5>,
7227    findings_empty: bool,
7228) -> Result<(), PredictionContractError> {
7229    if check_id != ENGINE_TRACK_SUPPORT_CHECK_ID {
7230        if prediction.is_some_and(|prediction| {
7231            prediction.facets().iter().any(|facet| {
7232                matches!(
7233                    facet.result(),
7234                    Some(EngineMachineResultV1::SourceImportDisposition(_))
7235                )
7236            })
7237        }) {
7238            return Err(PredictionContractError::InvalidMachineResult(
7239                "source-import disposition is confined to engine-track-support",
7240            ));
7241        }
7242        return Ok(());
7243    }
7244    let exact_profile = provenance.is_some_and(|provenance| {
7245        let base = provenance.base();
7246        let selected = base.profile().selection();
7247        selected.family() == "bevy"
7248            && selected.profile_revision() == 3
7249            && selected.engine_version() == "0.19.0"
7250            && selected.importer() == "gltf-asset-loader"
7251            && base.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
7252            && base.profile().facts_identity().sha256()
7253                == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
7254            && base.profile().facts_identity().bytes() == 4_849
7255            && matches!(
7256                base.source_format(),
7257                SourceFormatV1::GltfJson | SourceFormatV1::Glb
7258            )
7259            && !provenance.raw_animation_channels().is_complete_empty()
7260    });
7261    let expected_applicability = if exact_profile {
7262        Applicability::Applicable
7263    } else {
7264        Applicability::NotApplicable
7265    };
7266    if applicability != expected_applicability {
7267        return Err(PredictionContractError::InvalidMachineResult(
7268            "engine-track-support applicability mismatch",
7269        ));
7270    }
7271    if !exact_profile
7272        || selection_state != SelectionState::Selected
7273        || configuration != ConfigurationState::Enabled
7274    {
7275        return if prediction.is_none() {
7276            Ok(())
7277        } else {
7278            Err(PredictionContractError::InvalidMachineResult(
7279                "inactive engine-track-support carried prediction",
7280            ))
7281        };
7282    }
7283    if !findings_empty {
7284        return Err(PredictionContractError::InvalidMachineResult(
7285            "engine-track-support must not emit findings",
7286        ));
7287    }
7288    let provenance = provenance.ok_or(PredictionContractError::ProvenanceIdentityMismatch)?;
7289    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7290        "applicable engine-track-support check requires prediction",
7291    ))?;
7292    let inventory = provenance.raw_animation_channels();
7293    let mut expected = Vec::new();
7294    let has_summary = prediction.base_prediction().has_facet_budget_summary();
7295    let expected_demand = if !inventory.source_coverage_complete() {
7296        1
7297    } else {
7298        inventory.rows().len()
7299    };
7300    if inventory.source_coverage_complete() && inventory.candidate_overflow() && !has_summary {
7301        return Err(PredictionContractError::InvalidMachineResult(
7302            "saturated engine-track-support demand requires facet-budget summary",
7303        ));
7304    }
7305    let candidate_capacity = if has_summary {
7306        prediction.facets().len().saturating_sub(1)
7307    } else {
7308        expected_demand
7309    };
7310    if !inventory.is_complete_empty() && candidate_capacity != 0 {
7311        if !inventory.source_coverage_complete() {
7312            expected.push(track_unavailable(
7313                EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7314                    ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE,
7315                )),
7316                track_inventory_basis(inventory),
7317                PredictionUnavailableReasonV2::RawSourceIncomplete,
7318            ));
7319        } else {
7320            for row in inventory.rows().iter().take(candidate_capacity) {
7321                let animation = row.source_animation_index();
7322                if let Some(channel) = row.source_channel_index() {
7323                    expected.push(track_subject_facet(
7324                        track_scope(
7325                            ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE,
7326                            format!("source_animation:{animation}:source_channel:{channel}"),
7327                        ),
7328                        track_row_basis(inventory, animation, Some(channel)),
7329                        SourceImportSubjectKindV1::AnimationChannel,
7330                        track_gate(provenance),
7331                    ));
7332                } else {
7333                    expected.push(track_subject_facet(
7334                        track_scope(
7335                            ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE,
7336                            format!("source_animation:{animation}"),
7337                        ),
7338                        track_row_basis(inventory, animation, None),
7339                        SourceImportSubjectKindV1::Animation,
7340                        track_gate(provenance),
7341                    ));
7342                }
7343            }
7344        }
7345    }
7346    if has_summary {
7347        expected.push(track_unavailable(
7348            EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7349                ENGINE_TRACK_SUPPORT_BUDGET_SCOPE,
7350            )),
7351            track_static_basis(),
7352            PredictionUnavailableReasonV2::FacetBudgetExceeded,
7353        ));
7354    }
7355    let expected = EnginePredictionV4::new(provenance.base().identity().clone(), expected)?
7356        .facets()
7357        .to_vec();
7358    if prediction.facets() != expected {
7359        return Err(PredictionContractError::InvalidMachineResult(
7360            "engine-track-support facets do not reconstruct from V5 provenance",
7361        ));
7362    }
7363    Ok(())
7364}
7365
7366/// Shared producer/readback hook for the immutable engine-root-motion rule.
7367///
7368/// The exact Unity revision-2 reconstruction is filled beside the production
7369/// rule once its final profile identity and facet grammar are frozen. Keeping
7370/// this hook at the common boundary prevents the writer and strict reader from
7371/// acquiring separate acceptance paths in the meantime.
7372#[allow(clippy::too_many_arguments)]
7373fn validate_current_engine_root_motion_prediction_v6<F: RootMotionFindingEvidence>(
7374    check_id: &str,
7375    selection: SelectionState,
7376    configuration: ConfigurationState,
7377    applicability: Applicability,
7378    prediction: Option<&EnginePredictionV6>,
7379    provenance: Option<&PredictionProvenanceV6>,
7380    findings: &[F],
7381    rig: &RigInfo,
7382    measurements: &MeasurementContract,
7383) -> Result<(), PredictionContractError> {
7384    const CHECK_ID: &str = "engine-root-motion";
7385    if check_id != CHECK_ID
7386        && prediction.is_some_and(|prediction| {
7387            prediction.facets().iter().any(|facet| {
7388                matches!(
7389                    facet.result(),
7390                    Some(EngineMachineResultV1::RootMotionRouting(_))
7391                )
7392            })
7393        })
7394    {
7395        return Err(PredictionContractError::InvalidMachineResult(
7396            "root-motion routing is confined to engine-root-motion",
7397        ));
7398    }
7399    if check_id != CHECK_ID {
7400        return Ok(());
7401    }
7402    let active =
7403        selection == SelectionState::Selected && configuration == ConfigurationState::Enabled;
7404    let exact = provenance.is_some_and(root_motion_is_exact_unity_v2);
7405    let has_work = provenance.is_some_and(root_motion_has_work);
7406    let expected_applicability = if exact && has_work {
7407        Applicability::Applicable
7408    } else {
7409        Applicability::NotApplicable
7410    };
7411    if applicability != expected_applicability {
7412        return Err(PredictionContractError::InvalidMachineResult(
7413            "engine-root-motion applicability does not reconstruct from V6 provenance",
7414        ));
7415    }
7416    if !active || expected_applicability == Applicability::NotApplicable {
7417        if prediction.is_some() || !findings.is_empty() {
7418            return Err(PredictionContractError::InvalidMachineResult(
7419                "inactive or inapplicable engine-root-motion must carry no prediction or findings",
7420            ));
7421        }
7422        return Ok(());
7423    }
7424    let provenance = provenance.ok_or(PredictionContractError::InvalidMachineResult(
7425        "applicable engine-root-motion has no V6 provenance",
7426    ))?;
7427    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7428        "applicable engine-root-motion has no V6 prediction",
7429    ))?;
7430    validate_root_motion_facets_v6(prediction, provenance, findings, rig, measurements)
7431}
7432
7433/// An exact profile remains applicable while declaration evidence needed to
7434/// prove complete-empty intent is incomplete. This keeps the strict reader
7435/// aligned with the producer: an unvisited ownerless tail is unavailable work,
7436/// not N/A.
7437fn root_motion_has_work(provenance: &PredictionProvenanceV6) -> bool {
7438    let intent = provenance.root_motion_project_intent();
7439    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete
7440        || provenance.base().base().settings().clip_coverage().state()
7441            != ResolvedEngineSettingsCoverageStateV2::Complete
7442    {
7443        return true;
7444    }
7445    match intent.declared_axis_candidates() {
7446        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
7447        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => true,
7448    }
7449}
7450
7451fn root_motion_is_exact_unity_v2(provenance: &PredictionProvenanceV6) -> bool {
7452    let profile = provenance.base().base().profile();
7453    let selection = profile.selection();
7454    selection.family() == "unity-generic"
7455        && selection.profile_revision() == 2
7456        && selection.engine_version() == "6000.3"
7457        && selection.importer() == "fbx-model-importer"
7458        && profile.fact_bundle_urn() == "urn:animsmith:engine-profile:unity-generic:2"
7459        && profile.facts_identity().sha256()
7460            == "740e1c324a7a5b13efa2d9980fe255a6245d858adec55fb3387614a3ff45274c"
7461        && profile.facts_identity().bytes() == 2_776
7462        && provenance.base().base().source_format() == SourceFormatV1::Fbx
7463        && profile.setting_descriptors().len() == 7
7464        && profile.primary_sources().len() == 3
7465        && [
7466            "unity-fbx-animation-clip-6000.3",
7467            "unity-fbx-model-importer-6000.3",
7468            "unity-fbx-motion-node-6000.3",
7469        ]
7470        .into_iter()
7471        .all(|id| profile.source(id).is_some())
7472}
7473
7474fn validate_root_motion_facets_v6<F: RootMotionFindingEvidence>(
7475    prediction: &EnginePredictionV6,
7476    provenance: &PredictionProvenanceV6,
7477    findings: &[F],
7478    rig: &RigInfo,
7479    measurements: &MeasurementContract,
7480) -> Result<(), PredictionContractError> {
7481    const INVENTORY_SCOPE: &str = "engine-root-motion:inventory";
7482    const AXIS_SCOPE: &str = "engine-root-motion:clip-axis";
7483    const BUDGET_SCOPE: &str = "engine-root-motion:facet-budget";
7484    let intent = provenance.root_motion_project_intent();
7485    let mut atomic = Vec::new();
7486    if provenance
7487        .base()
7488        .base()
7489        .raw_source()
7490        .clips_coverage()
7491        .state()
7492        != RawSourceSetCoverageStateV1::Complete
7493        || !matches!(
7494            provenance.raw_transform_paths().coverage(),
7495            crate::RawTransformPathCoverageV1::Complete
7496        )
7497    {
7498        atomic.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7499    }
7500    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete {
7501        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7502    }
7503    if intent.declared_axis_candidates().overflowed()
7504        || intent.unmapped_declared_axis_candidates().overflowed()
7505    {
7506        atomic.push(PredictionUnavailableReasonV2::custom(
7507            "animsmith:root_motion_intent_work_budget_exceeded",
7508        )?);
7509    }
7510    if !matches!(
7511        intent.unmapped_declared_axis_candidates(),
7512        crate::EngineRootMotionProjectIntentCountV1::Exact { count: 0 }
7513    ) {
7514        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7515    }
7516    if provenance.base().base().settings().clip_coverage().state()
7517        != ResolvedEngineSettingsCoverageStateV2::Complete
7518    {
7519        atomic.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7520    }
7521    atomic.sort_by(|left, right| left.as_str().cmp(right.as_str()));
7522    atomic.dedup();
7523    if !atomic.is_empty() {
7524        let facets = prediction.facets();
7525        let valid_inventory = facets.len() == 1
7526            && facets[0].scope().code.as_str() == INVENTORY_SCOPE
7527            && facets[0].scope().subject.is_none()
7528            && facets[0].state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7529            && facets[0].reasons() == atomic
7530            && facets[0].basis() == &root_motion_inventory_basis(provenance)?;
7531        if !valid_inventory || !findings.is_empty() {
7532            return Err(PredictionContractError::InvalidMachineResult(
7533                "engine-root-motion atomic unavailable summary is not canonical",
7534            ));
7535        }
7536        return Ok(());
7537    }
7538
7539    let configured_path = provenance
7540        .base()
7541        .base()
7542        .settings()
7543        .document_setting(EngineSettingIdV2::RootMotionSource)
7544        .and_then(|row| match row.value() {
7545            EngineSettingValueV2::SourceTransformPath(path) => {
7546                crate::RawTransformPathV1::parse(path).ok()
7547            }
7548            _ => None,
7549        });
7550    let path_resolution = configured_path
7551        .as_ref()
7552        .map(|path| provenance.raw_transform_paths().resolve(path));
7553    let root_name = rig.resolved_roles.get("root").map(String::as_str);
7554    let mut name_counts = BTreeMap::new();
7555    for clip in intent.clips() {
7556        if let Some(name) = clip.normalized_clip_name() {
7557            *name_counts.entry(name).or_insert(0usize) += 1;
7558        }
7559    }
7560    let mut expected = Vec::new();
7561    for clip in intent.clips() {
7562        let Some(name) = clip.normalized_clip_name() else {
7563            continue;
7564        };
7565        for (axis, owner) in [
7566            (
7567                crate::RootMotionAxisV1::HorizontalXz,
7568                clip.movement_owner_xz(),
7569            ),
7570            (crate::RootMotionAxisV1::VerticalY, clip.movement_owner_y()),
7571            (crate::RootMotionAxisV1::Yaw, clip.movement_owner_yaw()),
7572        ]
7573        .into_iter()
7574        .filter_map(|(axis, owner)| owner.map(|owner| (axis, owner)))
7575        {
7576            let axis_name = match axis {
7577                crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7578                crate::RootMotionAxisV1::VerticalY => "vertical_y",
7579                crate::RootMotionAxisV1::Yaw => "yaw",
7580            };
7581            let scope = EvaluationScope::new(crate::EvaluationScopeCode::custom(AXIS_SCOPE))
7582                .subject(format!(
7583                    "source_clip:{:020}:axis:{axis_name}",
7584                    clip.source_clip_index()
7585                ));
7586            let setting_id = match axis {
7587                crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7588                crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7589                crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7590            };
7591            let setting = provenance
7592                .base()
7593                .base()
7594                .settings()
7595                .clip_row(
7596                    clip.normalized_clip_index().ok_or(
7597                        PredictionContractError::InvalidMachineResult(
7598                            "mapped root-motion intent is missing normalized clip index",
7599                        ),
7600                    )?,
7601                    name,
7602                )
7603                .and_then(|row| row.setting(setting_id));
7604            let measurement = measurements.clips().get(name);
7605            let reason = if name_counts.get(name).copied().unwrap_or(0) > 1 {
7606                Some(PredictionUnavailableReasonV2::MeasurementUnavailable)
7607            } else {
7608                root_motion_unavailable_reason(
7609                    path_resolution.as_ref(),
7610                    intent.resolved_root_bone_index(),
7611                    root_name,
7612                    measurement,
7613                    setting.map(|row| row.value()),
7614                    axis,
7615                )?
7616            };
7617            let basis = root_motion_candidate_basis(
7618                provenance,
7619                clip,
7620                name,
7621                name_counts.get(name).copied().unwrap_or(0),
7622                axis,
7623                owner,
7624                configured_path.as_ref(),
7625                path_resolution.as_ref(),
7626                setting.map(|row| row.value_origin()),
7627                measurement,
7628            )?;
7629            expected.push((
7630                scope.clone(),
7631                name.to_owned(),
7632                axis,
7633                owner,
7634                setting.map(|row| row.value()).cloned(),
7635                reason,
7636                basis,
7637            ));
7638        }
7639    }
7640    let facets = prediction.facets();
7641    let has_budget = facets
7642        .last()
7643        .is_some_and(|facet| facet.scope().code.as_str() == BUDGET_SCOPE);
7644    let candidates = if has_budget {
7645        &facets[..facets.len() - 1]
7646    } else {
7647        facets
7648    };
7649    if candidates.len() > expected.len() || (candidates.len() < expected.len() && !has_budget) {
7650        return Err(PredictionContractError::InvalidMachineResult(
7651            "engine-root-motion facet allocation is not a canonical prefix",
7652        ));
7653    }
7654    if has_budget {
7655        let facet = facets.last().unwrap();
7656        if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7657            || facet.scope().subject.is_some()
7658            || facet.reasons() != [PredictionUnavailableReasonV2::FacetBudgetExceeded]
7659            || facet.basis() != &root_motion_static_basis()?
7660        {
7661            return Err(PredictionContractError::InvalidMachineResult(
7662                "engine-root-motion facet-budget summary is invalid",
7663            ));
7664        }
7665    }
7666    let mut retained_conflicts = Vec::new();
7667    for (facet, (scope, clip_name, axis, owner, setting, reason, basis)) in
7668        candidates.iter().zip(expected.iter())
7669    {
7670        if facet.scope() != scope || facet.basis() != basis {
7671            return Err(PredictionContractError::InvalidMachineResult(
7672                "engine-root-motion scope or basis is not canonical",
7673            ));
7674        }
7675        match reason {
7676            Some(reason)
7677                if facet.state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7678                    && facet.reasons() == std::slice::from_ref(reason) => {}
7679            None => {
7680                let disposition = root_motion_disposition(setting.as_ref().unwrap()).unwrap();
7681                let compatible = matches!(
7682                    (owner, disposition),
7683                    (
7684                        crate::RootMotionProjectOwnerV1::Gameplay,
7685                        crate::RootMotionImporterDispositionV1::BakedIntoPose
7686                    ) | (
7687                        crate::RootMotionProjectOwnerV1::Animation,
7688                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion
7689                    )
7690                );
7691                let expected_result = crate::RootMotionRoutingResultV1 {
7692                    axis: *axis,
7693                    project_owner: *owner,
7694                    importer_disposition: disposition,
7695                    compatibility: if compatible {
7696                        crate::RootMotionCompatibilityV1::Compatible
7697                    } else {
7698                        crate::RootMotionCompatibilityV1::Conflict
7699                    },
7700                };
7701                if !matches!(facet.result(), Some(EngineMachineResultV1::RootMotionRouting(result)) if result == &expected_result)
7702                {
7703                    return Err(PredictionContractError::InvalidMachineResult(
7704                        "engine-root-motion result is not canonical",
7705                    ));
7706                }
7707                if !compatible {
7708                    let axis_label = match axis {
7709                        crate::RootMotionAxisV1::HorizontalXz => "horizontal XZ",
7710                        crate::RootMotionAxisV1::VerticalY => "vertical Y",
7711                        crate::RootMotionAxisV1::Yaw => "yaw",
7712                    };
7713                    let owner_label = match owner {
7714                        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7715                        crate::RootMotionProjectOwnerV1::Animation => "animation",
7716                    };
7717                    let disposition_label = match disposition {
7718                        crate::RootMotionImporterDispositionV1::BakedIntoPose => {
7719                            "baked into the pose"
7720                        }
7721                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion => {
7722                            "stored as root motion"
7723                        }
7724                    };
7725                    retained_conflicts.push(serde_json::json!({
7726                        "check_id": "engine-root-motion",
7727                        "severity": "error",
7728                        "clip": clip_name,
7729                        "prediction_scope": scope,
7730                        "message": format!("clip {:?} assigns {} movement to {}, but Unity imports that axis as {}", clip_name, axis_label, owner_label, disposition_label),
7731                    }));
7732                }
7733            }
7734            _ => {
7735                return Err(PredictionContractError::InvalidMachineResult(
7736                    "engine-root-motion unavailable facet is not canonical",
7737                ));
7738            }
7739        }
7740    }
7741    let actual_findings = findings
7742        .iter()
7743        .map(RootMotionFindingEvidence::root_motion_wire_value)
7744        .collect::<Vec<_>>();
7745    if actual_findings != retained_conflicts {
7746        return Err(PredictionContractError::InvalidMachineResult(
7747            "engine-root-motion conflict findings are not canonical",
7748        ));
7749    }
7750    Ok(())
7751}
7752
7753fn root_motion_lift(reference: PredictionBasisReferenceV1) -> PredictionBasisReferenceV4 {
7754    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference))
7755}
7756
7757fn root_motion_static_basis() -> Result<EnginePredictionBasisV4, PredictionContractError> {
7758    let mut references = vec![root_motion_lift(PredictionBasisReferenceV1::profile_fact(
7759        "root_motion_addressability",
7760    )?)];
7761    for source in [
7762        "unity-fbx-model-importer-6000.3",
7763        "unity-fbx-animation-clip-6000.3",
7764        "unity-fbx-motion-node-6000.3",
7765    ] {
7766        references.push(root_motion_lift(
7767            PredictionBasisReferenceV1::primary_source(source)?,
7768        ));
7769    }
7770    for setting in [
7771        EngineSettingIdV2::AnimationType,
7772        EngineSettingIdV2::AvatarSetup,
7773        EngineSettingIdV2::ImportAnimation,
7774        EngineSettingIdV2::RootMotionSource,
7775    ] {
7776        references.push(root_motion_lift(
7777            PredictionBasisReferenceV1::resolved_setting(
7778                ResolvedSettingLocationV1::Document,
7779                setting.as_str(),
7780            )?,
7781        ));
7782    }
7783    EnginePredictionBasisV4::new(references)
7784}
7785
7786fn root_motion_inventory_basis(
7787    provenance: &PredictionProvenanceV6,
7788) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7789    let mut references = root_motion_static_basis()?.references().to_vec();
7790    let path_coverage = match provenance.raw_transform_paths().coverage() {
7791        crate::RawTransformPathCoverageV1::Complete => "complete",
7792        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7793        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7794    };
7795    let clip_coverage = match provenance.root_motion_project_intent().clip_coverage() {
7796        crate::EngineRootMotionProjectIntentCoverageV1::Complete => "complete",
7797        crate::EngineRootMotionProjectIntentCoverageV1::PartialProjectionBudgetExceeded => {
7798            "partial_projection_budget_exceeded"
7799        }
7800    };
7801    let count = match provenance
7802        .root_motion_project_intent()
7803        .declared_axis_candidates()
7804    {
7805        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7806            PredictionScalarV1::UnsignedInteger { value: count }
7807        }
7808        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7809            PredictionScalarV1::token("n_plus_one")?
7810        }
7811    };
7812    let unmapped_count = match provenance
7813        .root_motion_project_intent()
7814        .unmapped_declared_axis_candidates()
7815    {
7816        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7817            PredictionScalarV1::UnsignedInteger { value: count }
7818        }
7819        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7820            PredictionScalarV1::token("n_plus_one")?
7821        }
7822    };
7823    let raw_clip_coverage = match provenance
7824        .base()
7825        .base()
7826        .raw_source()
7827        .clips_coverage()
7828        .state()
7829    {
7830        RawSourceSetCoverageStateV1::Complete => "complete",
7831        RawSourceSetCoverageStateV1::Partial => "partial",
7832        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7833    };
7834    let settings_coverage = match provenance.base().base().settings().clip_coverage().state() {
7835        ResolvedEngineSettingsCoverageStateV2::Complete => "complete",
7836        ResolvedEngineSettingsCoverageStateV2::Partial => "partial",
7837    };
7838    for (field, value) in [
7839        (
7840            "raw_source.clips.coverage",
7841            PredictionScalarV1::token(raw_clip_coverage)?,
7842        ),
7843        (
7844            "raw_transform_path_inventory.coverage",
7845            PredictionScalarV1::token(path_coverage)?,
7846        ),
7847        (
7848            "root_motion_project_intent.clip_coverage",
7849            PredictionScalarV1::token(clip_coverage)?,
7850        ),
7851        ("root_motion_project_intent.declared_axis_candidates", count),
7852        (
7853            "root_motion_project_intent.unmapped_declared_axis_candidates",
7854            unmapped_count,
7855        ),
7856        (
7857            "resolved_settings.clips.coverage",
7858            PredictionScalarV1::token(settings_coverage)?,
7859        ),
7860    ] {
7861        references.push(root_motion_lift(PredictionBasisReferenceV1::project_field(
7862            field, value,
7863        )?));
7864    }
7865    EnginePredictionBasisV4::new(references)
7866}
7867
7868fn root_motion_project_reference(
7869    field: &'static str,
7870    value: PredictionScalarV1,
7871) -> Result<PredictionBasisReferenceV4, PredictionContractError> {
7872    Ok(root_motion_lift(PredictionBasisReferenceV1::project_field(
7873        field, value,
7874    )?))
7875}
7876
7877fn root_motion_token(value: &'static str) -> Result<PredictionScalarV1, PredictionContractError> {
7878    PredictionScalarV1::token(value)
7879}
7880
7881#[allow(clippy::too_many_arguments)]
7882fn root_motion_candidate_basis(
7883    provenance: &PredictionProvenanceV6,
7884    clip: &crate::EngineRootMotionClipIntentV1,
7885    name: &str,
7886    duplicate_count: usize,
7887    axis: crate::RootMotionAxisV1,
7888    owner: crate::RootMotionProjectOwnerV1,
7889    configured_path: Option<&crate::RawTransformPathV1>,
7890    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
7891    setting_origin: Option<crate::EngineSettingValueOriginV3>,
7892    measurement: Option<&ClipMeasurements>,
7893) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7894    let mut refs = root_motion_static_basis()?.references().to_vec();
7895    let settings = provenance.base().base().settings();
7896    for (id, field) in [
7897        (
7898            EngineSettingIdV2::AnimationType,
7899            "resolved_setting.document.animation_type.value_origin",
7900        ),
7901        (
7902            EngineSettingIdV2::AvatarSetup,
7903            "resolved_setting.document.avatar_setup.value_origin",
7904        ),
7905        (
7906            EngineSettingIdV2::ImportAnimation,
7907            "resolved_setting.document.import_animation.value_origin",
7908        ),
7909        (
7910            EngineSettingIdV2::RootMotionSource,
7911            "resolved_setting.document.root_motion_source.value_origin",
7912        ),
7913    ] {
7914        let value = settings
7915            .document_setting(id)
7916            .map_or(PredictionScalarV1::Null, |row| {
7917                root_motion_token(match row.value_origin() {
7918                    crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7919                    crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7920                })
7921                .expect("static origin token")
7922            });
7923        refs.push(root_motion_project_reference(field, value)?);
7924    }
7925    let setting_id = match axis {
7926        crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7927        crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7928        crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7929    };
7930    if let Some(index) = clip.normalized_clip_index() {
7931        refs.push(root_motion_lift(
7932            PredictionBasisReferenceV1::resolved_setting(
7933                ResolvedSettingLocationV1::Clip {
7934                    clip_ordinal: index,
7935                    clip_name: name.to_owned(),
7936                },
7937                setting_id.as_str(),
7938            )?,
7939        ));
7940    }
7941    refs.push(root_motion_project_reference(
7942        "root_motion_project_intent.source_clip_index",
7943        PredictionScalarV1::UnsignedInteger {
7944            value: clip.source_clip_index(),
7945        },
7946    )?);
7947    let mapping_state = match clip.normalized_clip_mapping_state() {
7948        crate::EngineRootMotionClipMappingStateV1::Observed => "observed",
7949        crate::EngineRootMotionClipMappingStateV1::ProvenAbsent => "proven_absent",
7950        crate::EngineRootMotionClipMappingStateV1::Unavailable => "unavailable",
7951    };
7952    for field in ["normalized_clip_index.state", "normalized_clip_index.value"] {
7953        if field.ends_with(".value") && clip.normalized_clip_index().is_none() {
7954            continue;
7955        }
7956        let value = if field.ends_with(".state") {
7957            root_motion_token(mapping_state)?
7958        } else {
7959            PredictionScalarV1::UnsignedInteger {
7960                value: clip.normalized_clip_index().unwrap(),
7961            }
7962        };
7963        let raw: RawSourceBasisReferenceV1 = serde_json::from_value(serde_json::json!({
7964            "domain": "clip", "key": {"kind": "clip", "source_clip_index": clip.source_clip_index()}, "field": field, "value": value
7965        })).map_err(|_| PredictionContractError::InvalidMachineResult("failed to reconstruct root-motion raw clip reference"))?;
7966        refs.push(root_motion_lift(PredictionBasisReferenceV1::raw_source(
7967            raw,
7968        )));
7969    }
7970    let axis_name = match axis {
7971        crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7972        crate::RootMotionAxisV1::VerticalY => "vertical_y",
7973        crate::RootMotionAxisV1::Yaw => "yaw",
7974    };
7975    let owner_name = match owner {
7976        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7977        crate::RootMotionProjectOwnerV1::Animation => "animation",
7978    };
7979    let origin = setting_origin.map_or(PredictionScalarV1::Null, |origin| {
7980        root_motion_token(match origin {
7981            crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7982            crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7983        })
7984        .expect("origin token")
7985    });
7986    let path_coverage = match provenance.raw_transform_paths().coverage() {
7987        crate::RawTransformPathCoverageV1::Complete => "complete",
7988        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7989        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7990    };
7991    let raw_coverage = match provenance
7992        .base()
7993        .base()
7994        .raw_source()
7995        .clips_coverage()
7996        .state()
7997    {
7998        RawSourceSetCoverageStateV1::Complete => "complete",
7999        RawSourceSetCoverageStateV1::Partial => "partial",
8000        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8001    };
8002    let resolution = match path_resolution {
8003        Some(crate::RawTransformPathResolutionV1::Exact(_)) => "exact",
8004        Some(crate::RawTransformPathResolutionV1::NoMatch) => "no_match",
8005        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => "ambiguous",
8006        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8007            "coverage_incomplete"
8008        }
8009        None => "invalid_configured_path",
8010    };
8011    for (field, value) in [
8012        (
8013            "root_motion_project_intent.clip_mapping_state",
8014            root_motion_token(mapping_state)?,
8015        ),
8016        (
8017            "root_motion_project_intent.normalized_clip_index",
8018            clip.normalized_clip_index()
8019                .map_or(PredictionScalarV1::Null, |value| {
8020                    PredictionScalarV1::UnsignedInteger { value }
8021                }),
8022        ),
8023        (
8024            "root_motion_project_intent.normalized_clip_name",
8025            PredictionScalarV1::text(name)?,
8026        ),
8027        (
8028            "measurement.clip_name_match_count",
8029            PredictionScalarV1::UnsignedInteger {
8030                value: duplicate_count as u64,
8031            },
8032        ),
8033        (
8034            "measurement.clip_identity_state",
8035            root_motion_token(if duplicate_count > 1 {
8036                "duplicate"
8037            } else {
8038                "unique"
8039            })?,
8040        ),
8041        (
8042            "root_motion_project_intent.axis",
8043            root_motion_token(axis_name)?,
8044        ),
8045        (
8046            "root_motion_project_intent.owner",
8047            root_motion_token(owner_name)?,
8048        ),
8049        ("resolved_setting.clip.value_origin", origin),
8050        (
8051            "raw_source.clips.coverage",
8052            root_motion_token(raw_coverage)?,
8053        ),
8054        (
8055            "raw_transform_path_inventory.coverage",
8056            root_motion_token(path_coverage)?,
8057        ),
8058        (
8059            "resolved_role.root.bone_index",
8060            provenance
8061                .root_motion_project_intent()
8062                .resolved_root_bone_index()
8063                .map_or(PredictionScalarV1::Null, |value| {
8064                    PredictionScalarV1::UnsignedInteger { value }
8065                }),
8066        ),
8067        (
8068            "root_motion_source.configured_path",
8069            configured_path.map_or(PredictionScalarV1::Null, |path| {
8070                PredictionScalarV1::text(path.as_str()).expect("path text")
8071            }),
8072        ),
8073        (
8074            "root_motion_source.resolution_state",
8075            root_motion_token(resolution)?,
8076        ),
8077    ] {
8078        refs.push(root_motion_project_reference(field, value)?);
8079    }
8080    if let Some(crate::RawTransformPathResolutionV1::Ambiguous { matches }) = path_resolution {
8081        refs.push(root_motion_project_reference(
8082            "root_motion_source.match_count",
8083            PredictionScalarV1::UnsignedInteger {
8084                value: matches.len() as u64,
8085            },
8086        )?);
8087    }
8088    if let Some(crate::RawTransformPathResolutionV1::Exact(path_match)) = path_resolution {
8089        let digest = crate::sha256_hex(
8090            &serde_json::to_vec(path_match.parent_chain()).expect("parent chain serializes"),
8091        );
8092        for (field, value) in [
8093            (
8094                "root_motion_source.source_node_index",
8095                PredictionScalarV1::UnsignedInteger {
8096                    value: path_match.source_node_index(),
8097                },
8098            ),
8099            (
8100                "root_motion_source.projected_bone_index",
8101                path_match
8102                    .projected_bone_index()
8103                    .map_or(PredictionScalarV1::Null, |value| {
8104                        PredictionScalarV1::UnsignedInteger { value }
8105                    }),
8106            ),
8107            (
8108                "root_motion_source.path",
8109                PredictionScalarV1::text(path_match.path().as_str())?,
8110            ),
8111            ("root_motion_source.node_kind", root_motion_token("source")?),
8112            (
8113                "root_motion_source.parent_chain_count",
8114                PredictionScalarV1::UnsignedInteger {
8115                    value: path_match.parent_chain().len() as u64,
8116                },
8117            ),
8118            (
8119                "root_motion_source.parent_chain_sha256",
8120                PredictionScalarV1::text(digest)?,
8121            ),
8122        ] {
8123            refs.push(root_motion_project_reference(field, value)?);
8124        }
8125    }
8126    if duplicate_count == 1
8127        && let Some(measurement) = measurement
8128    {
8129        let escaped = name.replace('~', "~0").replace('/', "~1");
8130        let prefix = format!("/measurements/clips/{escaped}/root_trajectory");
8131        let availability = |value| {
8132            root_motion_token(match value {
8133                MeasurementAvailability::Measured => "measured",
8134                MeasurementAvailability::NotApplicable => "not_applicable",
8135                MeasurementAvailability::Unavailable => "unavailable",
8136            })
8137        };
8138        let mut add_measurement = |pointer: String, value| -> Result<(), PredictionContractError> {
8139            refs.push(root_motion_lift(
8140                PredictionBasisReferenceV1::measurement_v16(
8141                    crate::MeasurementPointerV1::new(pointer)?,
8142                    value,
8143                ),
8144            ));
8145            Ok(())
8146        };
8147        add_measurement(
8148            format!("{prefix}_availability"),
8149            availability(measurement.root_trajectory_availability)?,
8150        )?;
8151        if let Some(trajectory) = measurement.root_trajectory.as_ref() {
8152            add_measurement(
8153                format!("{prefix}/bone_index"),
8154                PredictionScalarV1::UnsignedInteger {
8155                    value: u64::from(trajectory.bone_index),
8156                },
8157            )?;
8158            add_measurement(
8159                format!("{prefix}/source_role"),
8160                root_motion_token(trajectory.source_role.as_str())?,
8161            )?;
8162            let (suffix, present_field, availability_value, present) = match axis {
8163                crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => (
8164                    "translation_availability",
8165                    "measurement.root_translation_present",
8166                    trajectory.translation_availability,
8167                    trajectory.translation.is_some(),
8168                ),
8169                crate::RootMotionAxisV1::Yaw => (
8170                    "yaw_availability",
8171                    "measurement.root_yaw_present",
8172                    trajectory.yaw_availability,
8173                    trajectory.yaw.is_some(),
8174                ),
8175            };
8176            add_measurement(
8177                format!("{prefix}/{suffix}"),
8178                availability(availability_value)?,
8179            )?;
8180            refs.push(root_motion_project_reference(
8181                present_field,
8182                PredictionScalarV1::Boolean { value: present },
8183            )?);
8184        }
8185    }
8186    EnginePredictionBasisV4::new(refs)
8187}
8188
8189trait RootMotionFindingEvidence {
8190    fn root_motion_wire_value(&self) -> serde_json::Value;
8191}
8192
8193impl RootMotionFindingEvidence for crate::Finding {
8194    fn root_motion_wire_value(&self) -> serde_json::Value {
8195        serde_json::to_value(self).expect("Finding serializes")
8196    }
8197}
8198
8199impl RootMotionFindingEvidence for PredictionFindingInput {
8200    fn root_motion_wire_value(&self) -> serde_json::Value {
8201        serde_json::to_value(self).expect("finding input serializes")
8202    }
8203}
8204
8205fn root_motion_disposition(
8206    setting: &EngineSettingValueV2,
8207) -> Option<crate::RootMotionImporterDispositionV1> {
8208    match setting {
8209        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Bake) => {
8210            Some(crate::RootMotionImporterDispositionV1::BakedIntoPose)
8211        }
8212        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Extract) => {
8213            Some(crate::RootMotionImporterDispositionV1::StoredAsRootMotion)
8214        }
8215        _ => None,
8216    }
8217}
8218
8219fn root_motion_unavailable_reason(
8220    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
8221    resolved_root_bone_index: Option<u64>,
8222    root_name: Option<&str>,
8223    measurement: Option<&ClipMeasurements>,
8224    setting: Option<&EngineSettingValueV2>,
8225    axis: crate::RootMotionAxisV1,
8226) -> Result<Option<PredictionUnavailableReasonV2>, PredictionContractError> {
8227    let path_match = match path_resolution {
8228        Some(crate::RawTransformPathResolutionV1::Exact(path_match)) => path_match,
8229        Some(crate::RawTransformPathResolutionV1::NoMatch) | None => {
8230            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch));
8231        }
8232        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => {
8233            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous));
8234        }
8235        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8236            return Ok(Some(PredictionUnavailableReasonV2::RawSourceIncomplete));
8237        }
8238    };
8239    if resolved_root_bone_index.is_none()
8240        || path_match.projected_bone_index() != resolved_root_bone_index
8241    {
8242        return Ok(Some(PredictionUnavailableReasonV2::custom(
8243            "animsmith:root_motion_source_not_explicit_root",
8244        )?));
8245    }
8246    let Some(trajectory) = measurement
8247        .filter(|measurement| {
8248            measurement.root_trajectory_availability == MeasurementAvailability::Measured
8249        })
8250        .and_then(|measurement| measurement.root_trajectory.as_ref())
8251    else {
8252        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8253    };
8254    if trajectory.source_role != crate::measure::RootTrajectorySourceRole::Root
8255        || root_name != Some(trajectory.bone_name.as_str())
8256        || resolved_root_bone_index != Some(u64::from(trajectory.bone_index))
8257    {
8258        return Ok(Some(PredictionUnavailableReasonV2::custom(
8259            "animsmith:root_motion_source_not_explicit_root",
8260        )?));
8261    }
8262    let measured = match axis {
8263        crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => {
8264            trajectory.translation_availability == MeasurementAvailability::Measured
8265                && trajectory.translation.is_some()
8266        }
8267        crate::RootMotionAxisV1::Yaw => {
8268            trajectory.yaw_availability == MeasurementAvailability::Measured
8269                && trajectory.yaw.is_some()
8270        }
8271    };
8272    if !measured {
8273        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8274    }
8275    if setting.and_then(root_motion_disposition).is_none() {
8276        return Ok(Some(
8277            PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
8278        ));
8279    }
8280    Ok(None)
8281}
8282
8283fn track_scope(code: &'static str, subject: String) -> EvaluationScope {
8284    EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code)).subject(subject)
8285}
8286
8287fn track_subject_facet(
8288    scope: EvaluationScope,
8289    basis: EnginePredictionBasisV4,
8290    subject_kind: SourceImportSubjectKindV1,
8291    gate: Option<EngineSettingIdV2>,
8292) -> crate::EnginePredictionFacetV4 {
8293    match gate {
8294        None => track_unavailable(
8295            scope,
8296            basis,
8297            PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
8298        ),
8299        Some(controlling_gate) => crate::EnginePredictionFacetV4::available(
8300            scope,
8301            basis,
8302            EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
8303                subject_kind,
8304                disposition: SourceImportDispositionV1::Dropped,
8305                controlling_gate: Some(controlling_gate),
8306            }),
8307        )
8308        .expect("reconstructed track result is valid"),
8309    }
8310}
8311
8312fn track_unavailable(
8313    scope: EvaluationScope,
8314    basis: EnginePredictionBasisV4,
8315    reason: PredictionUnavailableReasonV2,
8316) -> crate::EnginePredictionFacetV4 {
8317    crate::EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
8318        .expect("reconstructed track unavailable facet is valid")
8319}
8320
8321fn track_gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
8322    let settings = provenance.base().settings();
8323    if matches!(
8324        settings
8325            .document_setting(EngineSettingIdV2::BevyAnimationFeature)
8326            .map(|row| row.value()),
8327        Some(EngineSettingValueV2::Boolean(false))
8328    ) {
8329        Some(EngineSettingIdV2::BevyAnimationFeature)
8330    } else if matches!(
8331        settings
8332            .document_setting(EngineSettingIdV2::LoadAnimations)
8333            .map(|row| row.value()),
8334        Some(EngineSettingValueV2::Boolean(false))
8335    ) {
8336        Some(EngineSettingIdV2::LoadAnimations)
8337    } else {
8338        None
8339    }
8340}
8341
8342fn track_static_basis() -> EnginePredictionBasisV4 {
8343    let v1 = |reference| PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference));
8344    EnginePredictionBasisV4::new(vec![
8345        v1(PredictionBasisReferenceV1::profile_fact("source_import_disposition").unwrap()),
8346        v1(PredictionBasisReferenceV1::primary_source("bevy-gltf-loader-0.19.0-c6f634ca").unwrap()),
8347        v1(
8348            PredictionBasisReferenceV1::primary_source("bevy-feature-manifest-0.19.0-c6f634ca")
8349                .unwrap(),
8350        ),
8351        v1(PredictionBasisReferenceV1::resolved_setting(
8352            ResolvedSettingLocationV1::Document,
8353            EngineSettingIdV2::BevyAnimationFeature.as_str(),
8354        )
8355        .unwrap()),
8356        v1(PredictionBasisReferenceV1::resolved_setting(
8357            ResolvedSettingLocationV1::Document,
8358            EngineSettingIdV2::LoadAnimations.as_str(),
8359        )
8360        .unwrap()),
8361    ])
8362    .unwrap()
8363}
8364
8365fn track_inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
8366    let mut references = track_static_basis().references().to_vec();
8367    references.push(PredictionBasisReferenceV4::v2(
8368        PredictionBasisReferenceV2::v1(
8369            PredictionBasisReferenceV1::project_field(
8370                "raw_animation_channel_inventory.animation_coverage",
8371                PredictionScalarV1::text(match inventory.animation_coverage().state() {
8372                    RawSourceSetCoverageStateV1::Complete => "complete",
8373                    RawSourceSetCoverageStateV1::Partial => "partial",
8374                    RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8375                })
8376                .unwrap(),
8377            )
8378            .unwrap(),
8379        ),
8380    ));
8381    references.push(PredictionBasisReferenceV4::v2(
8382        PredictionBasisReferenceV2::v1(
8383            PredictionBasisReferenceV1::project_field(
8384                "raw_animation_channel_inventory.source_coverage_complete",
8385                PredictionScalarV1::Boolean {
8386                    value: inventory.source_coverage_complete(),
8387                },
8388            )
8389            .unwrap(),
8390        ),
8391    ));
8392    if let Some(row) = inventory.rows().iter().find(|row| {
8393        row.channel_coverage()
8394            .is_some_and(|coverage| coverage.state() != RawSourceSetCoverageStateV1::Complete)
8395    }) {
8396        let coverage = row.channel_coverage().unwrap();
8397        let state = match coverage.state() {
8398            RawSourceSetCoverageStateV1::Complete => "complete",
8399            RawSourceSetCoverageStateV1::Partial => "partial",
8400            RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8401        };
8402        references.push(PredictionBasisReferenceV4::v2(
8403            PredictionBasisReferenceV2::v1(
8404                PredictionBasisReferenceV1::project_field(
8405                    "raw_animation_channel_inventory.incomplete_channel_animation_row",
8406                    PredictionScalarV1::UnsignedInteger {
8407                        value: row.source_animation_index(),
8408                    },
8409                )
8410                .unwrap(),
8411            ),
8412        ));
8413        references.push(PredictionBasisReferenceV4::v2(
8414            PredictionBasisReferenceV2::v1(
8415                PredictionBasisReferenceV1::project_field(
8416                    "raw_animation_channel_inventory.incomplete_channel_coverage",
8417                    PredictionScalarV1::text(state).unwrap(),
8418                )
8419                .unwrap(),
8420            ),
8421        ));
8422        if let Some(reason) = coverage.reason() {
8423            let reason = serde_json::to_value(reason).unwrap();
8424            references.push(PredictionBasisReferenceV4::v2(
8425                PredictionBasisReferenceV2::v1(
8426                    PredictionBasisReferenceV1::project_field(
8427                        "raw_animation_channel_inventory.incomplete_channel_reason",
8428                        PredictionScalarV1::text(reason.as_str().unwrap()).unwrap(),
8429                    )
8430                    .unwrap(),
8431                ),
8432            ));
8433        }
8434    }
8435    EnginePredictionBasisV4::new(references).unwrap()
8436}
8437
8438fn track_row_basis(
8439    inventory: &RawAnimationChannelInventoryV1,
8440    animation: u64,
8441    channel: Option<u64>,
8442) -> EnginePredictionBasisV4 {
8443    let mut references = track_inventory_basis(inventory).references().to_vec();
8444    references.push(PredictionBasisReferenceV4::v2(
8445        PredictionBasisReferenceV2::v1(
8446            PredictionBasisReferenceV1::project_field(
8447                "raw_animation_channel_inventory.animation_row",
8448                PredictionScalarV1::UnsignedInteger { value: animation },
8449            )
8450            .unwrap(),
8451        ),
8452    ));
8453    if let Some(channel) = channel {
8454        references.push(PredictionBasisReferenceV4::v2(
8455            PredictionBasisReferenceV2::v1(
8456                PredictionBasisReferenceV1::project_field(
8457                    "raw_animation_channel_inventory.channel_row",
8458                    PredictionScalarV1::UnsignedInteger { value: channel },
8459                )
8460                .unwrap(),
8461            ),
8462        ));
8463    }
8464    EnginePredictionBasisV4::new(references).unwrap()
8465}
8466
8467const ENGINE_UNIT_SCALE_CHECK_ID: &str = "engine-unit-scale";
8468const ENGINE_UNIT_SCALE_FILE_SCOPE: &str = "engine-unit-scale:file-unit";
8469const ENGINE_UNIT_SCALE_SCENE_SCOPE: &str = "engine-unit-scale:loader-scene-root";
8470const ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE: &str = "engine-unit-scale:scene-inventory";
8471const ENGINE_UNIT_SCALE_MESH_SCOPE: &str = "engine-unit-scale:loader-mesh-primitive";
8472const ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE: &str = "engine-unit-scale:mesh-inventory";
8473const ENGINE_UNIT_SCALE_SELECTED_SCOPE: &str = "engine-unit-scale:selected-source-node";
8474const ENGINE_UNIT_SCALE_BUDGET_SCOPE: &str = "engine-unit-scale:facet-budget";
8475const ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON: &str =
8476    "animsmith:selected_node_scene_reachability_unavailable";
8477const ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES: usize = 128;
8478
8479#[derive(Clone, PartialEq, Eq)]
8480struct ExpectedUnitScaleFacet {
8481    scope: EvaluationScope,
8482    result: Option<EngineMachineResultV1>,
8483    reasons: Vec<PredictionUnavailableReasonV2>,
8484}
8485
8486fn unit_scale_scope(code: &'static str, subject: Option<String>) -> EvaluationScope {
8487    let scope = EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code));
8488    subject.map_or(scope.clone(), |subject| scope.subject(subject))
8489}
8490
8491fn unit_scale_unavailable_reasons(
8492    reason: PredictionUnavailableReasonV2,
8493    dependency_complete: bool,
8494) -> Vec<PredictionUnavailableReasonV2> {
8495    let mut reasons = vec![reason];
8496    if !dependency_complete {
8497        reasons.push(PredictionUnavailableReasonV2::DependencyClosureIncomplete);
8498    }
8499    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
8500    reasons
8501}
8502
8503#[derive(Clone, Copy, PartialEq, Eq)]
8504enum UnitScaleSelectedReachability {
8505    Reachable(u64, u64),
8506    Unreachable,
8507    Unavailable,
8508    WorkBudgetExceeded,
8509}
8510
8511enum UnitScaleSelectedReachabilityPlan {
8512    Complete {
8513        node_index: u64,
8514        scene_witnesses: BTreeMap<u64, (u64, u64)>,
8515    },
8516    Refused {
8517        node_index: u64,
8518    },
8519}
8520
8521enum UnitScaleSelectedReachabilityPlans {
8522    Complete {
8523        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8524        selected_facet_count: usize,
8525    },
8526    Overflow {
8527        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8528    },
8529}
8530
8531impl UnitScaleSelectedReachabilityPlans {
8532    fn get(&self, selector: &str) -> Option<&UnitScaleSelectedReachabilityPlan> {
8533        match self {
8534            Self::Complete { plans, .. } | Self::Overflow { plans } => plans.get(selector),
8535        }
8536    }
8537
8538    const fn selected_facet_count(&self) -> usize {
8539        match self {
8540            Self::Complete {
8541                selected_facet_count,
8542                ..
8543            } => *selected_facet_count,
8544            Self::Overflow { .. } => PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE + 1,
8545        }
8546    }
8547}
8548
8549impl UnitScaleSelectedReachabilityPlan {
8550    const fn node_index(&self) -> u64 {
8551        match self {
8552            Self::Complete { node_index, .. } | Self::Refused { node_index } => *node_index,
8553        }
8554    }
8555}
8556
8557fn unit_scale_selected_reachability_budget_exceeded(work: &mut usize) -> bool {
8558    *work = work.saturating_add(1);
8559    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8560}
8561
8562fn unit_scale_selected_reachability(
8563    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8564    start: u64,
8565    roots: &[u64],
8566    work: &mut usize,
8567) -> UnitScaleSelectedReachability {
8568    let Ok(start) = usize::try_from(start) else {
8569        return UnitScaleSelectedReachability::Unavailable;
8570    };
8571    if *work >= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8572        return UnitScaleSelectedReachability::WorkBudgetExceeded;
8573    }
8574    let roots = roots
8575        .iter()
8576        .copied()
8577        .enumerate()
8578        .map(|(ordinal, node)| (node, ordinal as u64))
8579        .collect::<BTreeMap<_, _>>();
8580    let mut seen = BTreeSet::new();
8581    let mut current = Some(start);
8582    for _ in 0..ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES {
8583        let Some(index) = current else {
8584            return UnitScaleSelectedReachability::Unreachable;
8585        };
8586        if unit_scale_selected_reachability_budget_exceeded(work) {
8587            return UnitScaleSelectedReachability::WorkBudgetExceeded;
8588        }
8589        if let Some(ordinal) = roots.get(&(index as u64)) {
8590            return UnitScaleSelectedReachability::Reachable(*ordinal, index as u64);
8591        }
8592        if !seen.insert(index) {
8593            return UnitScaleSelectedReachability::Unavailable;
8594        }
8595        current = match nodes.get(&index) {
8596            Some(node) => node.parent_node_index,
8597            None => return UnitScaleSelectedReachability::Unavailable,
8598        };
8599    }
8600    match current {
8601        Some(_) => UnitScaleSelectedReachability::Unavailable,
8602        None => UnitScaleSelectedReachability::Unreachable,
8603    }
8604}
8605
8606fn unit_scale_selected_reachability_plans(
8607    provenance: &PredictionProvenanceV4,
8608    measurements: &MeasurementContract,
8609) -> UnitScaleSelectedReachabilityPlans {
8610    let assets = measurements.assets();
8611    let inventory = provenance
8612        .raw_scene_attachment()
8613        .inventory()
8614        .filter(|inventory| {
8615            assets.skeleton_source_coverage == SourceSkeletonCoverage::Complete
8616                && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8617                && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8618        });
8619    let nodes = assets
8620        .skeleton_nodes
8621        .iter()
8622        .map(|node| (node.node_index, node))
8623        .collect::<BTreeMap<_, _>>();
8624    let mut plans = BTreeMap::new();
8625    let mut selected_facet_count = 0usize;
8626    for selector in provenance.rule_inputs().runtime_node_selectors() {
8627        let Some(inventory) = inventory else {
8628            selected_facet_count = selected_facet_count.saturating_add(1);
8629            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8630                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8631            }
8632            continue;
8633        };
8634        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8635            node.name
8636                .as_deref()
8637                .is_some_and(|name| crate::config::glob_match(selector, name))
8638        });
8639        let Some(node) = matches.next() else {
8640            selected_facet_count = selected_facet_count.saturating_add(1);
8641            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8642                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8643            }
8644            continue;
8645        };
8646        if matches.next().is_some() {
8647            selected_facet_count = selected_facet_count.saturating_add(1);
8648            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8649                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8650            }
8651            continue;
8652        }
8653        let node_index = node.node_index as u64;
8654        let mut work = 0usize;
8655        let remaining_capacity =
8656            PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(selected_facet_count);
8657        let mut would_overflow = false;
8658        let mut plan = UnitScaleSelectedReachabilityPlan::Complete {
8659            node_index,
8660            scene_witnesses: BTreeMap::new(),
8661        };
8662        for scene in inventory.scenes().rows() {
8663            match unit_scale_selected_reachability(
8664                &nodes,
8665                node_index,
8666                scene.root_node_indices(),
8667                &mut work,
8668            ) {
8669                UnitScaleSelectedReachability::Reachable(root_ordinal, root_node_index) => {
8670                    let UnitScaleSelectedReachabilityPlan::Complete {
8671                        scene_witnesses, ..
8672                    } = &mut plan
8673                    else {
8674                        unreachable!("a reachable scene cannot follow a refusal");
8675                    };
8676                    if scene_witnesses.len() < remaining_capacity {
8677                        scene_witnesses
8678                            .insert(scene.source_scene_index(), (root_ordinal, root_node_index));
8679                    } else {
8680                        would_overflow = true;
8681                    }
8682                }
8683                UnitScaleSelectedReachability::Unreachable => {}
8684                UnitScaleSelectedReachability::Unavailable
8685                | UnitScaleSelectedReachability::WorkBudgetExceeded => {
8686                    plan = UnitScaleSelectedReachabilityPlan::Refused { node_index };
8687                    break;
8688                }
8689            }
8690        }
8691        if matches!(&plan, UnitScaleSelectedReachabilityPlan::Complete { .. }) && would_overflow {
8692            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8693        }
8694        let facets = match &plan {
8695            UnitScaleSelectedReachabilityPlan::Complete {
8696                scene_witnesses, ..
8697            } => scene_witnesses.len().max(1),
8698            UnitScaleSelectedReachabilityPlan::Refused { .. } => 1,
8699        };
8700        selected_facet_count = selected_facet_count.saturating_add(facets);
8701        if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8702            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8703        }
8704        plans.insert(selector.clone(), plan);
8705    }
8706    UnitScaleSelectedReachabilityPlans::Complete {
8707        plans,
8708        selected_facet_count,
8709    }
8710}
8711
8712fn unit_scale_selected_ancestry_reason(
8713    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8714    start: usize,
8715) -> Option<PredictionUnavailableReasonV2> {
8716    let mut current = start;
8717    let mut seen = BTreeSet::new();
8718    for _ in 0..128 {
8719        if !seen.insert(current) {
8720            return Some(
8721                PredictionUnavailableReasonV2::custom(
8722                    "animsmith:selected_node_ancestry_unavailable",
8723                )
8724                .expect("static reason is valid"),
8725            );
8726        }
8727        let Some(node) = nodes.get(&current) else {
8728            return Some(
8729                PredictionUnavailableReasonV2::custom(
8730                    "animsmith:selected_node_ancestry_unavailable",
8731                )
8732                .expect("static reason is valid"),
8733            );
8734        };
8735        match node.local_rest {
8736            SkeletonNodeLocalRestMeasurements::Matrix { .. } => {
8737                return Some(
8738                    PredictionUnavailableReasonV2::custom(
8739                        "animsmith:matrix_authored_selected_node_or_ancestry",
8740                    )
8741                    .expect("static reason is valid"),
8742                );
8743            }
8744            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {
8745                return Some(
8746                    PredictionUnavailableReasonV2::custom(
8747                        "animsmith:selected_node_ancestry_unavailable",
8748                    )
8749                    .expect("static reason is valid"),
8750                );
8751            }
8752            SkeletonNodeLocalRestMeasurements::Trs { .. } => {}
8753        }
8754        let parent = node.parent_node_index?;
8755        current = parent;
8756    }
8757    Some(
8758        PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
8759            .expect("static reason is valid"),
8760    )
8761}
8762
8763#[derive(Clone, Copy)]
8764struct CurrentUnitScaleMeshRow {
8765    source_scene_index: u64,
8766    source_root_ordinal: u64,
8767    root_node_index: u64,
8768    source_node_index: u64,
8769    source_mesh_index: u64,
8770    source_primitive_index: u64,
8771}
8772
8773enum CurrentUnitScaleMeshPlan {
8774    Detailed(Vec<CurrentUnitScaleMeshRow>),
8775    CompleteEmpty,
8776    Incomplete,
8777    JoinOverflow,
8778}
8779
8780fn unit_scale_join_budget_exceeded(work: &mut usize) -> bool {
8781    *work = work.saturating_add(1);
8782    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8783}
8784
8785fn unit_scale_reachable_root_indexed(
8786    start: u64,
8787    roots: &BTreeMap<u64, u64>,
8788    parents: &BTreeMap<u64, Option<u64>>,
8789    work: &mut usize,
8790) -> Result<Option<(u64, u64)>, ()> {
8791    let mut seen = BTreeSet::new();
8792    let mut current = Some(start);
8793    while let Some(index) = current {
8794        if unit_scale_join_budget_exceeded(work) {
8795            return Err(());
8796        }
8797        if let Some(ordinal) = roots.get(&index) {
8798            return Ok(Some((*ordinal, index)));
8799        }
8800        if !seen.insert(index) {
8801            return Ok(None);
8802        }
8803        current = match parents.get(&index) {
8804            Some(parent) => *parent,
8805            None => return Ok(None),
8806        };
8807    }
8808    Ok(None)
8809}
8810
8811fn current_unit_scale_mesh_plan(
8812    provenance: &PredictionProvenanceV4,
8813    measurements: &MeasurementContract,
8814) -> CurrentUnitScaleMeshPlan {
8815    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
8816        return CurrentUnitScaleMeshPlan::Incomplete;
8817    };
8818    if inventory.scenes().coverage() != RawSceneAttachmentCoverageV1::Complete
8819        || inventory.node_mesh_attachments().coverage() != RawSceneAttachmentCoverageV1::Complete
8820        || inventory.mesh_primitives().coverage() != RawSceneAttachmentCoverageV1::Complete
8821        || inventory.source_skeleton().coverage() != RawSceneAttachmentCoverageV1::Complete
8822        || measurements.assets().skeleton_source_coverage != SourceSkeletonCoverage::Complete
8823    {
8824        return CurrentUnitScaleMeshPlan::Incomplete;
8825    }
8826    let parents = measurements
8827        .assets()
8828        .skeleton_nodes
8829        .iter()
8830        .map(|node| {
8831            (
8832                node.node_index as u64,
8833                node.parent_node_index.map(|parent| parent as u64),
8834            )
8835        })
8836        .collect::<BTreeMap<_, _>>();
8837    let scene_roots = inventory
8838        .scenes()
8839        .rows()
8840        .iter()
8841        .map(|scene| {
8842            (
8843                scene.source_scene_index(),
8844                scene
8845                    .root_node_indices()
8846                    .iter()
8847                    .copied()
8848                    .enumerate()
8849                    .map(|(ordinal, node)| (node, ordinal as u64))
8850                    .collect::<BTreeMap<_, _>>(),
8851            )
8852        })
8853        .collect::<Vec<_>>();
8854    let primitives_by_mesh = inventory.mesh_primitives().rows().iter().fold(
8855        BTreeMap::<u64, Vec<u64>>::new(),
8856        |mut grouped, primitive| {
8857            grouped
8858                .entry(primitive.source_mesh_index())
8859                .or_default()
8860                .push(primitive.source_primitive_index());
8861            grouped
8862        },
8863    );
8864    let mut work = 0usize;
8865    let mut rows = Vec::new();
8866    for (source_scene_index, roots) in scene_roots {
8867        for attachment in inventory.node_mesh_attachments().rows() {
8868            if unit_scale_join_budget_exceeded(&mut work) {
8869                return CurrentUnitScaleMeshPlan::JoinOverflow;
8870            }
8871            let Ok(reachable) = unit_scale_reachable_root_indexed(
8872                attachment.source_node_index(),
8873                &roots,
8874                &parents,
8875                &mut work,
8876            ) else {
8877                return CurrentUnitScaleMeshPlan::JoinOverflow;
8878            };
8879            let Some((source_root_ordinal, root_node_index)) = reachable else {
8880                continue;
8881            };
8882            for &source_primitive_index in primitives_by_mesh
8883                .get(&attachment.source_mesh_index())
8884                .into_iter()
8885                .flatten()
8886            {
8887                if unit_scale_join_budget_exceeded(&mut work) {
8888                    return CurrentUnitScaleMeshPlan::JoinOverflow;
8889                }
8890                rows.push(CurrentUnitScaleMeshRow {
8891                    source_scene_index,
8892                    source_root_ordinal,
8893                    root_node_index,
8894                    source_node_index: attachment.source_node_index(),
8895                    source_mesh_index: attachment.source_mesh_index(),
8896                    source_primitive_index,
8897                });
8898            }
8899        }
8900    }
8901    if rows.is_empty() {
8902        CurrentUnitScaleMeshPlan::CompleteEmpty
8903    } else {
8904        CurrentUnitScaleMeshPlan::Detailed(rows)
8905    }
8906}
8907
8908fn current_unit_scale_selected_facet_count(
8909    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8910) -> usize {
8911    reachability_plans.selected_facet_count()
8912}
8913
8914fn expected_current_engine_unit_scale_facets(
8915    provenance: &PredictionProvenanceV4,
8916    measurements: &MeasurementContract,
8917    mesh_plan: &CurrentUnitScaleMeshPlan,
8918    candidate_capacity: usize,
8919    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8920) -> Option<Vec<ExpectedUnitScaleFacet>> {
8921    let dependency_complete = matches!(
8922        provenance.dependency_closure().coverage(),
8923        DependencyClosureCoverageV1::Complete
8924    );
8925    let available = |scope, result| ExpectedUnitScaleFacet {
8926        scope,
8927        result: Some(result),
8928        reasons: vec![],
8929    };
8930    let unavailable = |scope, reasons| ExpectedUnitScaleFacet {
8931        scope,
8932        result: None,
8933        reasons,
8934    };
8935    let mut expected = Vec::with_capacity(candidate_capacity);
8936    if expected.len() < candidate_capacity {
8937        expected.push(if dependency_complete {
8938            available(
8939                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8940                EngineMachineResultV1::UnitMapping(
8941                    UnitMappingResultV1::gltf_to_engine_world_length_unit(),
8942                ),
8943            )
8944        } else {
8945            unavailable(
8946                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8947                vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8948            )
8949        });
8950    }
8951
8952    let inventory = provenance.raw_scene_attachment().inventory();
8953    match inventory
8954        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
8955    {
8956        Some(inventory) => {
8957            for scene in inventory.scenes().rows() {
8958                if expected.len() >= candidate_capacity {
8959                    break;
8960                }
8961                let scope = unit_scale_scope(
8962                    ENGINE_UNIT_SCALE_SCENE_SCOPE,
8963                    Some(format!("source_scene:{}", scene.source_scene_index())),
8964                );
8965                expected.push(if dependency_complete {
8966                    available(
8967                        scope,
8968                        EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8969                            subject_kind: TransformScaleSubjectKindV1::LoaderSceneEntity,
8970                            creation: ImporterSubjectCreationV1::Created,
8971                            domain: TransformScaleDomainV1::Local,
8972                            classification: Some(LinearTransformClassification::UnitOrthonormal),
8973                        }),
8974                    )
8975                } else {
8976                    unavailable(
8977                        scope,
8978                        vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8979                    )
8980                });
8981            }
8982        }
8983        None if expected.len() < candidate_capacity => expected.push(unavailable(
8984            unit_scale_scope(ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE, None),
8985            unit_scale_unavailable_reasons(
8986                PredictionUnavailableReasonV2::RawSourceIncomplete,
8987                dependency_complete,
8988            ),
8989        )),
8990        None => {}
8991    }
8992
8993    let assets = measurements.assets();
8994    let nodes = assets
8995        .skeleton_nodes
8996        .iter()
8997        .map(|node| (node.node_index, node))
8998        .collect::<BTreeMap<_, _>>();
8999    if expected.len() < candidate_capacity {
9000        match mesh_plan {
9001            CurrentUnitScaleMeshPlan::Incomplete => expected.push(unavailable(
9002                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9003                unit_scale_unavailable_reasons(
9004                    PredictionUnavailableReasonV2::RawSourceIncomplete,
9005                    dependency_complete,
9006                ),
9007            )),
9008            CurrentUnitScaleMeshPlan::JoinOverflow => expected.push(unavailable(
9009                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9010                vec![
9011                    PredictionUnavailableReasonV2::custom(
9012                        "animsmith:mesh_join_work_budget_exceeded",
9013                    )
9014                    .expect("static reason is valid"),
9015                ],
9016            )),
9017            CurrentUnitScaleMeshPlan::CompleteEmpty => expected.push(if dependency_complete {
9018                available(
9019                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9020                    EngineMachineResultV1::InventoryCoverage(InventoryCoverageResultV1 {
9021                        domain: PredictionInventoryDomainV1::LoaderMeshPrimitiveSubjects,
9022                        coverage: PredictionInventoryCoverageStateV1::Complete,
9023                        retained_rows: 0,
9024                    }),
9025                )
9026            } else {
9027                unavailable(
9028                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9029                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9030                )
9031            }),
9032            CurrentUnitScaleMeshPlan::Detailed(rows) => {
9033                let load_meshes = matches!(
9034                    provenance
9035                        .settings()
9036                        .document_setting(EngineSettingIdV2::LoadMeshes)
9037                        .map(|setting| setting.value()),
9038                    Some(EngineSettingValueV2::Token(value)) if value == "nonempty"
9039                );
9040                for row in rows {
9041                    if expected.len() >= candidate_capacity {
9042                        break;
9043                    }
9044                    let scope = unit_scale_scope(
9045                        ENGINE_UNIT_SCALE_MESH_SCOPE,
9046                        Some(format!(
9047                            "source_scene:{}:source_node:{}:source_mesh:{}:source_primitive:{}",
9048                            row.source_scene_index,
9049                            row.source_node_index,
9050                            row.source_mesh_index,
9051                            row.source_primitive_index,
9052                        )),
9053                    );
9054                    expected.push(if dependency_complete {
9055                        available(
9056                            scope,
9057                            EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9058                                subject_kind:
9059                                    TransformScaleSubjectKindV1::LoaderMeshPrimitiveEntity,
9060                                creation: if load_meshes {
9061                                    ImporterSubjectCreationV1::Created
9062                                } else {
9063                                    ImporterSubjectCreationV1::SuppressedBySetting
9064                                },
9065                                domain: TransformScaleDomainV1::Local,
9066                                classification: load_meshes
9067                                    .then_some(LinearTransformClassification::UnitOrthonormal),
9068                            }),
9069                        )
9070                    } else {
9071                        unavailable(
9072                            scope,
9073                            vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9074                        )
9075                    });
9076                }
9077            }
9078        }
9079    }
9080
9081    for selector in provenance.rule_inputs().runtime_node_selectors() {
9082        if expected.len() >= candidate_capacity {
9083            break;
9084        }
9085        if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete
9086            || !inventory.is_some_and(|inventory| {
9087                inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9088                    && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9089            })
9090        {
9091            expected.push(unavailable(
9092                unit_scale_scope(
9093                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9094                    Some(format!("selector:{selector}")),
9095                ),
9096                unit_scale_unavailable_reasons(
9097                    PredictionUnavailableReasonV2::RawSourceIncomplete,
9098                    dependency_complete,
9099                ),
9100            ));
9101            continue;
9102        }
9103        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9104            node.name
9105                .as_deref()
9106                .is_some_and(|name| crate::config::glob_match(selector, name))
9107        });
9108        let first = matches.next();
9109        let second = matches.next();
9110        if first.is_none() || second.is_some() {
9111            expected.push(unavailable(
9112                unit_scale_scope(
9113                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9114                    Some(format!("selector:{selector}")),
9115                ),
9116                unit_scale_unavailable_reasons(
9117                    if first.is_none() {
9118                        PredictionUnavailableReasonV2::SourceSelectorNoMatch
9119                    } else {
9120                        PredictionUnavailableReasonV2::SourceSelectorAmbiguous
9121                    },
9122                    dependency_complete,
9123                ),
9124            ));
9125            continue;
9126        }
9127        let node = first.expect("one selected source node was established");
9128        let inventory = inventory.expect("selected inventory was proven complete");
9129        let mut reachable_scenes = Vec::new();
9130        let plan = reachability_plans.get(selector)?;
9131        let reachability_unavailable =
9132            match plan {
9133                UnitScaleSelectedReachabilityPlan::Refused {
9134                    node_index: cached_node_index,
9135                } if *cached_node_index == node.node_index as u64 => true,
9136                UnitScaleSelectedReachabilityPlan::Complete {
9137                    node_index: cached_node_index,
9138                    scene_witnesses,
9139                } if *cached_node_index == node.node_index as u64 => {
9140                    reachable_scenes.extend(
9141                        inventory.scenes().rows().iter().filter(|scene| {
9142                            scene_witnesses.contains_key(&scene.source_scene_index())
9143                        }),
9144                    );
9145                    false
9146                }
9147                UnitScaleSelectedReachabilityPlan::Refused { .. }
9148                | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9149            };
9150        if reachability_unavailable {
9151            expected.push(unavailable(
9152                unit_scale_scope(
9153                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9154                    Some(format!("selector:{selector}")),
9155                ),
9156                unit_scale_unavailable_reasons(
9157                    PredictionUnavailableReasonV2::custom(
9158                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9159                    )
9160                    .expect("static reason is valid"),
9161                    dependency_complete,
9162                ),
9163            ));
9164            continue;
9165        }
9166        let reason = unit_scale_selected_ancestry_reason(&nodes, node.node_index).or_else(|| {
9167            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9168                || node.rest_world_matrix.is_none())
9169            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9170        });
9171        let mut reachable_found = false;
9172        for scene in reachable_scenes {
9173            reachable_found = true;
9174            if expected.len() >= candidate_capacity {
9175                break;
9176            }
9177            let scope = unit_scale_scope(
9178                ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9179                Some(format!(
9180                    "selector:{selector}:source_scene:{}:source_node:{}",
9181                    scene.source_scene_index(),
9182                    node.node_index,
9183                )),
9184            );
9185            expected.push(if let Some(reason) = reason.clone() {
9186                unavailable(
9187                    scope,
9188                    unit_scale_unavailable_reasons(reason, dependency_complete),
9189                )
9190            } else if dependency_complete {
9191                available(
9192                    scope,
9193                    EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9194                        subject_kind: TransformScaleSubjectKindV1::SelectedSourceNode,
9195                        creation: ImporterSubjectCreationV1::Created,
9196                        domain: TransformScaleDomainV1::LoaderRootToSubject,
9197                        classification: Some(node.rest_world_linear.classification),
9198                    }),
9199                )
9200            } else {
9201                unavailable(
9202                    scope,
9203                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9204                )
9205            });
9206        }
9207        if !reachable_found && expected.len() < candidate_capacity {
9208            expected.push(unavailable(
9209                unit_scale_scope(
9210                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9211                    Some(format!("selector:{selector}")),
9212                ),
9213                unit_scale_unavailable_reasons(
9214                    PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9215                        .expect("static reason is valid"),
9216                    dependency_complete,
9217                ),
9218            ));
9219        }
9220    }
9221    Some(expected)
9222}
9223
9224fn unit_scale_exact_raw_row_references(
9225    basis: &EnginePredictionBasisV4,
9226    expected: &[RawSceneAttachmentBasisReferenceV1],
9227) -> bool {
9228    let actual = basis
9229        .references()
9230        .iter()
9231        .filter_map(|reference| match reference {
9232            PredictionBasisReferenceV4::RawSceneAttachment(reference)
9233                if !matches!(
9234                    reference,
9235                    RawSceneAttachmentBasisReferenceV1::Coverage { .. }
9236                ) =>
9237            {
9238                Some(reference)
9239            }
9240            _ => None,
9241        })
9242        .collect::<Vec<_>>();
9243    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9244}
9245
9246fn unit_scale_mesh_scope_keys(subject: &str) -> Option<(u64, u64, u64, u64)> {
9247    let values = subject
9248        .strip_prefix("source_scene:")?
9249        .split(':')
9250        .collect::<Vec<_>>();
9251    if values.len() != 7
9252        || values[1] != "source_node"
9253        || values[3] != "source_mesh"
9254        || values[5] != "source_primitive"
9255    {
9256        return None;
9257    }
9258    Some((
9259        values[0].parse().ok()?,
9260        values[2].parse().ok()?,
9261        values[4].parse().ok()?,
9262        values[6].parse().ok()?,
9263    ))
9264}
9265
9266fn unit_scale_expected_mesh_raw_rows(
9267    subject: &str,
9268    mesh_plan: &CurrentUnitScaleMeshPlan,
9269) -> Option<Vec<RawSceneAttachmentBasisReferenceV1>> {
9270    let (source_scene_index, source_node_index, source_mesh_index, source_primitive_index) =
9271        unit_scale_mesh_scope_keys(subject)?;
9272    let CurrentUnitScaleMeshPlan::Detailed(rows) = mesh_plan else {
9273        return None;
9274    };
9275    let row = rows.iter().find(|row| {
9276        row.source_scene_index == source_scene_index
9277            && row.source_node_index == source_node_index
9278            && row.source_mesh_index == source_mesh_index
9279            && row.source_primitive_index == source_primitive_index
9280    })?;
9281    Some(vec![
9282        RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9283        RawSceneAttachmentBasisReferenceV1::SceneRoot {
9284            source_scene_index,
9285            source_root_ordinal: row.source_root_ordinal,
9286            source_node_index: row.root_node_index,
9287        },
9288        RawSceneAttachmentBasisReferenceV1::NodeMeshAttachmentRow {
9289            source_node_index,
9290            source_mesh_index,
9291        },
9292        RawSceneAttachmentBasisReferenceV1::MeshPrimitiveRow {
9293            source_mesh_index,
9294            source_primitive_index,
9295        },
9296    ])
9297}
9298
9299fn unit_scale_selected_scope_keys<'a>(
9300    subject: &str,
9301    selectors: &'a [String],
9302) -> Option<(&'a str, Option<(u64, u64)>)> {
9303    for selector in selectors {
9304        let prefix = format!("selector:{selector}");
9305        if subject == prefix {
9306            return Some((selector, None));
9307        }
9308        let Some(values) = subject
9309            .strip_prefix(&prefix)
9310            .and_then(|suffix| suffix.strip_prefix(":source_scene:"))
9311        else {
9312            continue;
9313        };
9314        let values = values.split(':').collect::<Vec<_>>();
9315        if values.len() == 3 && values[1] == "source_node" {
9316            return Some((
9317                selector,
9318                Some((values[0].parse().ok()?, values[2].parse().ok()?)),
9319            ));
9320        }
9321    }
9322    None
9323}
9324
9325fn unit_scale_classification_name(value: LinearTransformClassification) -> &'static str {
9326    match value {
9327        LinearTransformClassification::UnitOrthonormal => "unit_orthonormal",
9328        LinearTransformClassification::UniformScaled => "uniform_scaled",
9329        LinearTransformClassification::NonUniform => "non_uniform",
9330        LinearTransformClassification::Sheared => "sheared",
9331        LinearTransformClassification::Reflected => "reflected",
9332        LinearTransformClassification::Singular => "singular",
9333        LinearTransformClassification::NonFinite => "non_finite",
9334    }
9335}
9336
9337fn unit_scale_raw_source_node_reference(
9338    source_index: u64,
9339    field: &str,
9340    value: PredictionScalarV1,
9341) -> Option<RawSourceBasisReferenceV1> {
9342    RawSourceBasisReferenceV1::from_wire(
9343        RawSourceDomainV1::SourceNode,
9344        RawSourceKeyV1::SourceSkeleton {
9345            row_kind: SourceSkeletonRowKindV1::SourceNode,
9346            source_index,
9347        },
9348        RawSourceFieldIdV1::new(field).ok()?,
9349        value,
9350    )
9351    .ok()
9352}
9353
9354fn unit_scale_exact_raw_source_references(
9355    basis: &EnginePredictionBasisV4,
9356    expected: &[RawSourceBasisReferenceV1],
9357) -> bool {
9358    let actual = basis
9359        .references()
9360        .iter()
9361        .filter_map(|reference| match reference {
9362            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9363                PredictionBasisReferenceV1::RawSource { reference },
9364            )) => Some(reference),
9365            _ => None,
9366        })
9367        .collect::<Vec<_>>();
9368    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9369}
9370
9371fn unit_scale_selected_authored_kind(
9372    basis: &EnginePredictionBasisV4,
9373    source_index: u64,
9374) -> Option<&str> {
9375    let mut values = basis.references().iter().filter_map(|reference| {
9376        let PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9377            PredictionBasisReferenceV1::RawSource { reference },
9378        )) = reference
9379        else {
9380            return None;
9381        };
9382        if reference.domain() != RawSourceDomainV1::SourceNode
9383            || reference.key()
9384                != &(RawSourceKeyV1::SourceSkeleton {
9385                    row_kind: SourceSkeletonRowKindV1::SourceNode,
9386                    source_index,
9387                })
9388            || reference.field().as_str() != "local_rest.kind"
9389        {
9390            return None;
9391        }
9392        match reference.value() {
9393            PredictionScalarV1::Token { value } if matches!(value.as_str(), "trs" | "matrix") => {
9394                Some(value.as_str())
9395            }
9396            _ => None,
9397        }
9398    });
9399    let value = values.next()?;
9400    values.next().is_none().then_some(value)
9401}
9402
9403fn unit_scale_selected_expected_reasons(
9404    primary: Option<PredictionUnavailableReasonV2>,
9405    dependency_complete: bool,
9406) -> Vec<PredictionUnavailableReasonV2> {
9407    match primary {
9408        Some(reason) => unit_scale_unavailable_reasons(reason, dependency_complete),
9409        None if dependency_complete => Vec::new(),
9410        None => vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9411    }
9412}
9413
9414struct CurrentUnitScaleSelectedEvidence {
9415    raw_source_references: Vec<RawSourceBasisReferenceV1>,
9416    reasons: Vec<PredictionUnavailableReasonV2>,
9417}
9418
9419fn unit_scale_expected_selected_raw_source_references(
9420    selector: &str,
9421    keys: Option<(u64, u64)>,
9422    basis: &EnginePredictionBasisV4,
9423    provenance: &PredictionProvenanceV4,
9424    measurements: &MeasurementContract,
9425    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9426) -> Option<CurrentUnitScaleSelectedEvidence> {
9427    let assets = measurements.assets();
9428    let dependency_complete = matches!(
9429        provenance.dependency_closure().coverage(),
9430        DependencyClosureCoverageV1::Complete
9431    );
9432    let inventory = provenance.raw_scene_attachment().inventory();
9433    let inventory_complete = inventory.is_some_and(|inventory| {
9434        inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9435            && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9436    });
9437    if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete || !inventory_complete {
9438        return Some(CurrentUnitScaleSelectedEvidence {
9439            raw_source_references: Vec::new(),
9440            reasons: unit_scale_selected_expected_reasons(
9441                Some(PredictionUnavailableReasonV2::RawSourceIncomplete),
9442                dependency_complete,
9443            ),
9444        });
9445    }
9446    let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9447        node.name
9448            .as_deref()
9449            .is_some_and(|name| crate::config::glob_match(selector, name))
9450    });
9451    let first = matches.next();
9452    let second = matches.next();
9453    let name_reference = |node: &crate::measure::SkeletonNodeMeasurements| {
9454        unit_scale_raw_source_node_reference(
9455            node.node_index as u64,
9456            "name",
9457            node.name.as_ref().map_or(PredictionScalarV1::Null, |name| {
9458                PredictionScalarV1::text(name).expect("retained measurement text is bounded")
9459            }),
9460        )
9461    };
9462    if first.is_none() {
9463        return keys.is_none().then(|| CurrentUnitScaleSelectedEvidence {
9464            raw_source_references: Vec::new(),
9465            reasons: unit_scale_selected_expected_reasons(
9466                Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch),
9467                dependency_complete,
9468            ),
9469        });
9470    }
9471    if let Some(second) = second {
9472        if keys.is_some() {
9473            return None;
9474        }
9475        return Some(CurrentUnitScaleSelectedEvidence {
9476            raw_source_references: vec![name_reference(first?)?, name_reference(second)?],
9477            reasons: unit_scale_selected_expected_reasons(
9478                Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous),
9479                dependency_complete,
9480            ),
9481        });
9482    }
9483    let node = first?;
9484    if keys.is_some_and(|(_, source_node_index)| source_node_index != node.node_index as u64) {
9485        return None;
9486    }
9487    let mut expected = vec![name_reference(node)?];
9488    let nodes = assets
9489        .skeleton_nodes
9490        .iter()
9491        .map(|node| (node.node_index, node))
9492        .collect::<BTreeMap<_, _>>();
9493    let plan = reachability_plans.get(selector)?;
9494    let reachability_unavailable = match plan {
9495        UnitScaleSelectedReachabilityPlan::Refused {
9496            node_index: cached_node_index,
9497        } if *cached_node_index == node.node_index as u64 => true,
9498        UnitScaleSelectedReachabilityPlan::Complete {
9499            node_index: cached_node_index,
9500            ..
9501        } if *cached_node_index == node.node_index as u64 => false,
9502        UnitScaleSelectedReachabilityPlan::Refused { .. }
9503        | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9504    };
9505    if reachability_unavailable {
9506        if keys.is_some() {
9507            return None;
9508        }
9509        return Some(CurrentUnitScaleSelectedEvidence {
9510            raw_source_references: expected,
9511            reasons: unit_scale_selected_expected_reasons(
9512                Some(
9513                    PredictionUnavailableReasonV2::custom(
9514                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9515                    )
9516                    .expect("static reason is valid"),
9517                ),
9518                dependency_complete,
9519            ),
9520        });
9521    }
9522    let mut current = node.node_index;
9523    let mut seen = BTreeSet::new();
9524    let mut ancestry_reason = None;
9525    let mut ancestry_complete = false;
9526    for _ in 0..128 {
9527        if !seen.insert(current) {
9528            ancestry_reason = Some(
9529                PredictionUnavailableReasonV2::custom(
9530                    "animsmith:selected_node_ancestry_unavailable",
9531                )
9532                .expect("static reason is valid"),
9533            );
9534            break;
9535        }
9536        let Some(ancestry_node) = nodes.get(&current).copied() else {
9537            ancestry_reason = Some(
9538                PredictionUnavailableReasonV2::custom(
9539                    "animsmith:selected_node_ancestry_unavailable",
9540                )
9541                .expect("static reason is valid"),
9542            );
9543            break;
9544        };
9545        // The normalized measurement deliberately erases whether a non-finite
9546        // local rest was authored as TRS or as a matrix. The exact same-load
9547        // raw-source scalar in the basis retains that distinction per row.
9548        let retained_kind = unit_scale_selected_authored_kind(basis, current as u64)?;
9549        let local_kind = match ancestry_node.local_rest {
9550            SkeletonNodeLocalRestMeasurements::Trs { .. } if retained_kind == "trs" => "trs",
9551            SkeletonNodeLocalRestMeasurements::Matrix { .. } if retained_kind == "matrix" => {
9552                "matrix"
9553            }
9554            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => retained_kind,
9555            _ => return None,
9556        };
9557        expected.push(unit_scale_raw_source_node_reference(
9558            current as u64,
9559            "local_rest.kind",
9560            PredictionScalarV1::token(local_kind).ok()?,
9561        )?);
9562        expected.push(unit_scale_raw_source_node_reference(
9563            current as u64,
9564            "parent_source_node_index",
9565            ancestry_node
9566                .parent_node_index
9567                .map_or(PredictionScalarV1::Null, |parent| {
9568                    PredictionScalarV1::UnsignedInteger {
9569                        value: parent as u64,
9570                    }
9571                }),
9572        )?);
9573        if local_kind == "matrix" {
9574            ancestry_reason = Some(
9575                PredictionUnavailableReasonV2::custom(
9576                    "animsmith:matrix_authored_selected_node_or_ancestry",
9577                )
9578                .expect("static reason is valid"),
9579            );
9580            break;
9581        }
9582        let Some(parent) = ancestry_node.parent_node_index else {
9583            ancestry_complete = true;
9584            break;
9585        };
9586        current = parent;
9587    }
9588    if ancestry_reason.is_none() && !ancestry_complete {
9589        ancestry_reason = Some(
9590            PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
9591                .expect("static reason is valid"),
9592        );
9593    }
9594    let primary_reason = if keys.is_none() {
9595        Some(
9596            PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9597                .expect("static reason is valid"),
9598        )
9599    } else {
9600        ancestry_reason.or_else(|| {
9601            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9602                || node.rest_world_matrix.is_none())
9603            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9604        })
9605    };
9606    Some(CurrentUnitScaleSelectedEvidence {
9607        raw_source_references: expected,
9608        reasons: unit_scale_selected_expected_reasons(primary_reason, dependency_complete),
9609    })
9610}
9611
9612fn unit_scale_exact_selected_evidence(
9613    selector: &str,
9614    keys: Option<(u64, u64)>,
9615    reasons: &[PredictionUnavailableReasonV2],
9616    basis: &EnginePredictionBasisV4,
9617    provenance: &PredictionProvenanceV4,
9618    measurements: &MeasurementContract,
9619    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9620) -> bool {
9621    let Some(expected) = unit_scale_expected_selected_raw_source_references(
9622        selector,
9623        keys,
9624        basis,
9625        provenance,
9626        measurements,
9627        reachability_plans,
9628    ) else {
9629        return false;
9630    };
9631    if reasons != expected.reasons
9632        || !unit_scale_exact_raw_source_references(basis, &expected.raw_source_references)
9633    {
9634        return false;
9635    }
9636    let measurement_references = basis
9637        .references()
9638        .iter()
9639        .filter_map(|reference| match reference {
9640            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9641                PredictionBasisReferenceV1::Measurement {
9642                    schema,
9643                    pointer,
9644                    value,
9645                },
9646            )) => Some((*schema, pointer.as_str(), value)),
9647            _ => None,
9648        })
9649        .collect::<Vec<_>>();
9650    let Some((source_scene_index, source_node_index)) = keys else {
9651        return unit_scale_exact_raw_row_references(basis, &[])
9652            && measurement_references.is_empty();
9653    };
9654    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
9655        return false;
9656    };
9657    let Some(_scene) = inventory
9658        .scenes()
9659        .rows()
9660        .iter()
9661        .find(|scene| scene.source_scene_index() == source_scene_index)
9662    else {
9663        return false;
9664    };
9665    let witness = reachability_plans
9666        .get(selector)
9667        .filter(|plan| plan.node_index() == source_node_index)
9668        .and_then(|plan| match plan {
9669            UnitScaleSelectedReachabilityPlan::Complete {
9670                scene_witnesses, ..
9671            } => scene_witnesses.get(&source_scene_index).copied(),
9672            UnitScaleSelectedReachabilityPlan::Refused { .. } => None,
9673        });
9674    let Some((source_root_ordinal, source_node_index_at_root)) = witness else {
9675        return false;
9676    };
9677    if !unit_scale_exact_raw_row_references(
9678        basis,
9679        &[
9680            RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9681            RawSceneAttachmentBasisReferenceV1::SceneRoot {
9682                source_scene_index,
9683                source_root_ordinal,
9684                source_node_index: source_node_index_at_root,
9685            },
9686        ],
9687    ) {
9688        return false;
9689    }
9690    let Some((ordinal, node)) = measurements
9691        .assets()
9692        .skeleton_nodes
9693        .iter()
9694        .enumerate()
9695        .find(|(_, node)| node.node_index as u64 == source_node_index)
9696    else {
9697        return measurement_references.is_empty();
9698    };
9699    let pointer =
9700        format!("/measurements/skeleton_nodes/{ordinal}/rest_world_linear/classification");
9701    matches!(
9702        measurement_references.as_slice(),
9703        [(
9704            MEASUREMENTS_V16_SCHEMA_ID,
9705            actual_pointer,
9706            PredictionScalarV1::Token { value },
9707        )] if *actual_pointer == pointer
9708            && value == unit_scale_classification_name(node.rest_world_linear.classification)
9709    )
9710}
9711
9712fn validate_current_engine_unit_scale_basis(
9713    scope: &EvaluationScope,
9714    basis: &EnginePredictionBasisV4,
9715    reasons: &[PredictionUnavailableReasonV2],
9716    provenance: &PredictionProvenanceV4,
9717    measurements: &MeasurementContract,
9718    mesh_plan: &CurrentUnitScaleMeshPlan,
9719    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9720) -> bool {
9721    let v1 = |predicate: &dyn Fn(&PredictionBasisReferenceV1) -> bool| {
9722        basis.references().iter().any(|reference| {
9723            matches!(
9724                reference,
9725                PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(reference))
9726                    if predicate(reference)
9727            )
9728        })
9729    };
9730    let fact = |expected: &str| {
9731        v1(
9732            &|reference| matches!(reference, PredictionBasisReferenceV1::ProfileFact { fact_id } if fact_id == expected),
9733        )
9734    };
9735    let setting = |expected: &str| {
9736        v1(
9737            &|reference| matches!(reference, PredictionBasisReferenceV1::ResolvedSetting { setting_id, .. } if setting_id == expected),
9738        )
9739    };
9740    let source = |expected: &str| {
9741        v1(
9742            &|reference| matches!(reference, PredictionBasisReferenceV1::PrimarySource { source_id } if source_id == expected),
9743        )
9744    };
9745    let raw_coverage = |expected: RawSceneAttachmentBasisDomainV1| {
9746        basis.references().iter().any(|reference| {
9747            matches!(
9748                reference,
9749                PredictionBasisReferenceV4::RawSceneAttachment(
9750                    RawSceneAttachmentBasisReferenceV1::Coverage { domain }
9751                ) if *domain == expected
9752            )
9753        })
9754    };
9755    let common_transform = source("bevy-gltf-loader-0.19.0-c6f634ca")
9756        && source("bevy-gltf-coordinate-conversion-0.19.0-c6f634ca")
9757        && fact("resulting_transform_scale")
9758        && setting("extension_handler_environment");
9759    match scope.code.as_str() {
9760        ENGINE_UNIT_SCALE_FILE_SCOPE => {
9761            unit_scale_exact_raw_row_references(basis, &[])
9762                && [
9763                    "application_world_unit_policy",
9764                    "importer_scale_conversion",
9765                    "physical_dimensions_preserved",
9766                    "source_to_target_unit_mapping",
9767                    "target_linear_unit",
9768                ]
9769                .into_iter()
9770                .all(fact)
9771                && [
9772                    "bevy-gltf-loader-0.19.0-c6f634ca",
9773                    "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
9774                    "khronos-gltf-2.0-coordinate-units",
9775                ]
9776                .into_iter()
9777                .all(source)
9778        }
9779        ENGINE_UNIT_SCALE_SCENE_SCOPE | ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE => {
9780            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE {
9781                Some(Vec::new())
9782            } else {
9783                scope
9784                    .subject
9785                    .as_deref()
9786                    .and_then(|subject| subject.strip_prefix("source_scene:"))
9787                    .and_then(|index| index.parse::<u64>().ok())
9788                    .filter(|index| {
9789                        scope.subject.as_deref() == Some(&format!("source_scene:{index}"))
9790                    })
9791                    .map(|source_scene_index| {
9792                        vec![RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index }]
9793                    })
9794            };
9795            expected_rows.as_deref().is_some_and(|expected_rows| {
9796                unit_scale_exact_raw_row_references(basis, expected_rows)
9797            }) && common_transform
9798                && setting("rotate_scene_entity")
9799                && (provenance.raw_scene_attachment().inventory().is_none()
9800                    || raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes))
9801        }
9802        ENGINE_UNIT_SCALE_MESH_SCOPE | ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE => {
9803            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE {
9804                Some(Vec::new())
9805            } else {
9806                scope
9807                    .subject
9808                    .as_deref()
9809                    .and_then(|subject| unit_scale_expected_mesh_raw_rows(subject, mesh_plan))
9810            };
9811            expected_rows.as_deref().is_some_and(|expected_rows| {
9812                unit_scale_exact_raw_row_references(basis, expected_rows)
9813            }) && common_transform
9814                && source("bevy-render-asset-usages-0.19.0-c6f634ca")
9815                && setting("load_meshes")
9816                && setting("rotate_meshes")
9817                && (provenance.raw_scene_attachment().inventory().is_none()
9818                    || [
9819                        RawSceneAttachmentBasisDomainV1::SourceSkeleton,
9820                        RawSceneAttachmentBasisDomainV1::Scenes,
9821                        RawSceneAttachmentBasisDomainV1::NodeMeshAttachments,
9822                        RawSceneAttachmentBasisDomainV1::MeshPrimitives,
9823                    ]
9824                    .into_iter()
9825                    .all(raw_coverage))
9826        }
9827        ENGINE_UNIT_SCALE_SELECTED_SCOPE => {
9828            let selected = scope.subject.as_deref().and_then(|subject| {
9829                unit_scale_selected_scope_keys(
9830                    subject,
9831                    provenance.rule_inputs().runtime_node_selectors(),
9832                )
9833            });
9834            let selector = selected.map(|(selector, _)| selector);
9835            common_transform
9836                && setting("rotate_scene_entity")
9837                && selected.is_some_and(|(selector, keys)| {
9838                    unit_scale_exact_selected_evidence(
9839                        selector,
9840                        keys,
9841                        reasons,
9842                        basis,
9843                        provenance,
9844                        measurements,
9845                        reachability_plans,
9846                    )
9847                })
9848                && selector.is_some_and(|selector| {
9849                    v1(&|reference| {
9850                        matches!(
9851                            reference,
9852                            PredictionBasisReferenceV1::ProjectField {
9853                                field_id,
9854                                value: PredictionScalarV1::Text { value }
9855                            } if field_id == "runtime_nodes.selector" && value == selector
9856                        )
9857                    })
9858                })
9859                && (provenance.raw_scene_attachment().inventory().is_none()
9860                    || (raw_coverage(RawSceneAttachmentBasisDomainV1::SourceSkeleton)
9861                        && raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes)))
9862        }
9863        ENGINE_UNIT_SCALE_BUDGET_SCOPE => {
9864            unit_scale_exact_raw_row_references(basis, &[])
9865                && source("bevy-gltf-loader-0.19.0-c6f634ca")
9866        }
9867        _ => false,
9868    }
9869}
9870
9871fn validate_current_engine_unit_scale_prediction_v4(
9872    check_id: &str,
9873    selection: SelectionState,
9874    configuration: ConfigurationState,
9875    applicability: Applicability,
9876    prediction: Option<&EnginePredictionV4>,
9877    provenance: Option<&PredictionProvenanceV4>,
9878    measurements: &MeasurementContract,
9879) -> Result<(), PredictionContractError> {
9880    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9881        return Ok(());
9882    }
9883    let exact_profile = provenance.is_some_and(|provenance| {
9884        let selection = provenance.profile().selection();
9885        selection.family() == "bevy"
9886            && selection.profile_revision() == 2
9887            && selection.engine_version() == "0.19.0"
9888            && selection.importer() == "gltf-asset-loader"
9889            && provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9890            && provenance.profile().facts_identity().sha256()
9891                == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9892            && provenance.profile().facts_identity().bytes() == 4_783
9893            && matches!(
9894                provenance.source_format(),
9895                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9896            )
9897    });
9898    validate_current_engine_unit_scale_prediction_common(
9899        check_id,
9900        selection,
9901        configuration,
9902        applicability,
9903        prediction,
9904        provenance,
9905        measurements,
9906        exact_profile,
9907    )
9908}
9909
9910fn validate_current_engine_unit_scale_prediction_v5(
9911    check_id: &str,
9912    selection: SelectionState,
9913    configuration: ConfigurationState,
9914    applicability: Applicability,
9915    prediction: Option<&EnginePredictionV5>,
9916    provenance: Option<&PredictionProvenanceV5>,
9917    measurements: &MeasurementContract,
9918) -> Result<(), PredictionContractError> {
9919    let base = provenance.map(PredictionProvenanceV5::base);
9920    let exact_profile = base.is_some_and(|provenance| {
9921        let selection = provenance.profile().selection();
9922        let exact_identity = match selection.profile_revision() {
9923            2 => {
9924                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9925                    && provenance.profile().facts_identity().sha256()
9926                        == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9927                    && provenance.profile().facts_identity().bytes() == 4_783
9928            }
9929            3 => {
9930                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
9931                    && provenance.profile().facts_identity().sha256()
9932                        == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
9933                    && provenance.profile().facts_identity().bytes() == 4_849
9934            }
9935            _ => false,
9936        };
9937        selection.family() == "bevy"
9938            && selection.engine_version() == "0.19.0"
9939            && selection.importer() == "gltf-asset-loader"
9940            && exact_identity
9941            && matches!(
9942                provenance.source_format(),
9943                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9944            )
9945    });
9946    validate_current_engine_unit_scale_prediction_common(
9947        check_id,
9948        selection,
9949        configuration,
9950        applicability,
9951        prediction.map(EnginePredictionV5::base_prediction),
9952        base,
9953        measurements,
9954        exact_profile,
9955    )
9956}
9957
9958#[allow(clippy::too_many_arguments)]
9959fn validate_current_engine_unit_scale_prediction_common(
9960    check_id: &str,
9961    selection: SelectionState,
9962    configuration: ConfigurationState,
9963    applicability: Applicability,
9964    prediction: Option<&EnginePredictionV4>,
9965    provenance: Option<&PredictionProvenanceV4>,
9966    measurements: &MeasurementContract,
9967    exact_profile: bool,
9968) -> Result<(), PredictionContractError> {
9969    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9970        return Ok(());
9971    }
9972    if applicability
9973        != if exact_profile {
9974            Applicability::Applicable
9975        } else {
9976            Applicability::NotApplicable
9977        }
9978    {
9979        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
9980    }
9981    if !exact_profile {
9982        return if prediction.is_none() {
9983            Ok(())
9984        } else {
9985            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9986        };
9987    }
9988    if selection != SelectionState::Selected || configuration != ConfigurationState::Enabled {
9989        return if prediction.is_none() {
9990            Ok(())
9991        } else {
9992            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
9993        };
9994    }
9995    let provenance = provenance.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9996    let prediction = prediction.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
9997    let mesh_plan = current_unit_scale_mesh_plan(provenance, measurements);
9998    let reachability_plans = unit_scale_selected_reachability_plans(provenance, measurements);
9999    if prediction.facets().iter().any(|facet| {
10000        !validate_current_engine_unit_scale_basis(
10001            facet.scope(),
10002            facet.basis(),
10003            facet.reasons(),
10004            provenance,
10005            measurements,
10006            &mesh_plan,
10007            &reachability_plans,
10008        )
10009    }) {
10010        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10011    }
10012    let has_summary = prediction.facets().iter().any(|facet| {
10013        facet.scope().code.as_str() == ENGINE_UNIT_SCALE_BUDGET_SCOPE
10014            && facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded]
10015    });
10016    let retained = prediction
10017        .facets()
10018        .iter()
10019        .filter(|facet| facet.scope().code.as_str() != ENGINE_UNIT_SCALE_BUDGET_SCOPE)
10020        .collect::<Vec<_>>();
10021    let scene_facets = provenance
10022        .raw_scene_attachment()
10023        .inventory()
10024        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
10025        .map_or(1, |inventory| inventory.scenes().rows().len());
10026    let mesh_facets = match &mesh_plan {
10027        CurrentUnitScaleMeshPlan::Detailed(rows) => rows.len(),
10028        CurrentUnitScaleMeshPlan::CompleteEmpty
10029        | CurrentUnitScaleMeshPlan::Incomplete
10030        | CurrentUnitScaleMeshPlan::JoinOverflow => 1,
10031    };
10032    let selected_facets = current_unit_scale_selected_facet_count(&reachability_plans);
10033    let expected_count = 1usize
10034        .checked_add(scene_facets)
10035        .and_then(|count| count.checked_add(mesh_facets))
10036        .and_then(|count| count.checked_add(selected_facets))
10037        .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10038    if (!has_summary && expected_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
10039        || (has_summary && retained.len() >= expected_count)
10040    {
10041        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10042    }
10043    let candidate_capacity = if has_summary {
10044        retained.len()
10045    } else {
10046        expected_count
10047    };
10048    let mut expected_retained = expected_current_engine_unit_scale_facets(
10049        provenance,
10050        measurements,
10051        &mesh_plan,
10052        candidate_capacity,
10053        &reachability_plans,
10054    )
10055    .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10056    if expected_retained.len() != candidate_capacity {
10057        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10058    }
10059    expected_retained.sort_by(|left, right| {
10060        left.scope
10061            .code
10062            .as_str()
10063            .cmp(right.scope.code.as_str())
10064            .then_with(|| left.scope.subject.cmp(&right.scope.subject))
10065    });
10066    if retained.len() != expected_retained.len()
10067        || retained
10068            .iter()
10069            .zip(&expected_retained)
10070            .any(|(actual, expected)| {
10071                actual.scope() != &expected.scope
10072                    || actual.result() != expected.result.as_ref()
10073                    || (actual.scope().code.as_str() != ENGINE_UNIT_SCALE_SELECTED_SCOPE
10074                        && actual.reasons() != expected.reasons)
10075            })
10076    {
10077        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10078    }
10079    Ok(())
10080}
10081
10082const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
10083const ENGINE_CLIP_BOUNDARY_SOURCE_ID: &str = "unreal-animation-sequences-5.8";
10084const ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY: &str = "unreal";
10085const ENGINE_CLIP_BOUNDARY_PROFILE_REVISION: u32 = 1;
10086const ENGINE_CLIP_BOUNDARY_ENGINE_VERSION: &str = "5.8";
10087const ENGINE_CLIP_BOUNDARY_IMPORTER: &str = "fbx-importer";
10088const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256: &str =
10089    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759";
10090const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES: u64 = 2_169;
10091
10092fn current_engine_clip_boundary_profile_matches_v3(provenance: &PredictionProvenanceV3) -> bool {
10093    let selection = provenance.profile().selection();
10094    provenance.source_format() == SourceFormatV1::Fbx
10095        && provenance.raw_source().source_format() == SourceFormatV1::Fbx
10096        && selection.family() == ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY
10097        && selection.profile_revision() == ENGINE_CLIP_BOUNDARY_PROFILE_REVISION
10098        && selection.engine_version() == ENGINE_CLIP_BOUNDARY_ENGINE_VERSION
10099        && selection.importer() == ENGINE_CLIP_BOUNDARY_IMPORTER
10100        && provenance.profile().facts_identity().sha256()
10101            == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
10102        && provenance.profile().facts_identity().bytes() == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
10103        && matches!(
10104            provenance
10105                .profile()
10106                .fact(EngineFactIdV1::WholeEndFrameRequired)
10107                .map(|fact| fact.state()),
10108            Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
10109        )
10110        && provenance
10111            .profile()
10112            .source(ENGINE_CLIP_BOUNDARY_SOURCE_ID)
10113            .is_some()
10114}
10115
10116fn validate_current_engine_clip_boundary_applicability_v3(
10117    check_id: &str,
10118    applicability: Applicability,
10119    provenance: Option<&PredictionProvenanceV3>,
10120) -> Result<(), PredictionContractError> {
10121    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10122        return Ok(());
10123    }
10124    let expected = match provenance {
10125        Some(provenance)
10126            if current_engine_clip_boundary_profile_matches_v3(provenance)
10127                && !(provenance.raw_source().clips_coverage().state()
10128                    == RawSourceSetCoverageStateV1::Complete
10129                    && provenance
10130                        .raw_source()
10131                        .exact_source_timing()
10132                        .is_some_and(|timing| timing.clips().is_empty())) =>
10133        {
10134            Applicability::Applicable
10135        }
10136        _ => Applicability::NotApplicable,
10137    };
10138    if applicability != expected {
10139        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10140    }
10141    Ok(())
10142}
10143
10144/// Re-derive the frozen output-v14 clip-boundary rule from embedded V3
10145/// provenance. This keeps readback and producer construction from accepting a
10146/// merely well-shaped facet whose scope, basis, availability, reason, or
10147/// finding disagrees with the retained exact source timing.
10148fn validate_current_engine_clip_boundary_prediction_v3(
10149    check_id: &str,
10150    prediction: &EnginePredictionV3,
10151    provenance: &PredictionProvenanceV3,
10152    evaluated_scopes: &[EvaluationScope],
10153    finding_scopes: &[&EvaluationScope],
10154) -> Result<(), PredictionContractError> {
10155    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10156        return Ok(());
10157    }
10158
10159    if !current_engine_clip_boundary_profile_matches_v3(provenance) {
10160        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10161    }
10162
10163    let expected_rows = provenance.settings().clips().len();
10164    let timing = provenance.raw_source().exact_source_timing();
10165    if timing.is_some_and(|timing| timing.clips().len() != expected_rows) {
10166        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10167    }
10168    let inventory_incomplete =
10169        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
10170    let has_budget_summary = prediction.has_facet_budget_summary();
10171    let mut seen_rows = vec![false; expected_rows];
10172    let mut row_facets = 0usize;
10173    let mut inventory_facets = 0usize;
10174    let mut available_scopes = Vec::new();
10175    let mut expected_finding_scopes = Vec::new();
10176
10177    for facet in prediction.facets() {
10178        if facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded] {
10179            if facet.basis() != &engine_clip_boundary_inventory_basis(timing)? {
10180                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10181            }
10182            continue;
10183        }
10184        if facet.scope().code.as_str() == "engine_clip_boundary" {
10185            let Some(source_clip_index) = facet
10186                .scope()
10187                .subject
10188                .as_deref()
10189                .and_then(|subject| subject.strip_prefix("source_stack:"))
10190                .and_then(|index| index.parse::<usize>().ok())
10191            else {
10192                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10193            };
10194            if source_clip_index >= expected_rows
10195                || std::mem::replace(&mut seen_rows[source_clip_index], true)
10196            {
10197                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10198            }
10199            row_facets += 1;
10200            let expected_basis = engine_clip_boundary_stack_basis(timing, source_clip_index)?;
10201            if facet.basis() != &expected_basis {
10202                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10203            }
10204
10205            let exact = timing.and_then(|timing| {
10206                let declared = matches!(
10207                    timing.declared_time_mode().state(),
10208                    ExactSourceTimingObservationStateWireV1::Observed(_)
10209                );
10210                let period = match timing.frame_period().state() {
10211                    ExactSourceTimingObservationStateWireV1::Observed(period) => {
10212                        Some(period.units_per_frame())
10213                    }
10214                    _ => None,
10215                };
10216                let end = match timing.clips()[source_clip_index]
10217                    .source_time_range()
10218                    .state()
10219                {
10220                    ExactSourceTimingObservationStateWireV1::Observed(range) => {
10221                        Some(range.end_units())
10222                    }
10223                    _ => None,
10224                };
10225                declared.then_some(())?;
10226                Some((period?, end?))
10227            });
10228            match exact {
10229                Some((period, end)) => {
10230                    if facet.state() != EnginePredictionFacetStateV1::Available
10231                        || !facet.reasons().is_empty()
10232                    {
10233                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10234                    }
10235                    available_scopes.push(facet.scope());
10236                    if end.rem_euclid(period) != 0 {
10237                        expected_finding_scopes.push(facet.scope());
10238                    }
10239                }
10240                None => {
10241                    let expected_reasons =
10242                        engine_clip_boundary_unavailable_reasons(timing, source_clip_index)?;
10243                    if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10244                        || facet.reasons() != expected_reasons
10245                    {
10246                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10247                    }
10248                }
10249            }
10250        } else if facet.scope().code.as_str() == "engine_clip_boundary_inventory"
10251            && facet.scope().subject.is_none()
10252        {
10253            inventory_facets += 1;
10254            if inventory_facets != 1
10255                || !inventory_incomplete
10256                || facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10257                || facet.reasons() != [PredictionUnavailableReasonV2::RawSourceIncomplete]
10258                || facet.basis() != &engine_clip_boundary_inventory_basis(timing)?
10259            {
10260                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10261            }
10262        } else {
10263            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10264        }
10265    }
10266
10267    if seen_rows[..row_facets].iter().any(|seen| !seen)
10268        || seen_rows[row_facets..].iter().any(|seen| *seen)
10269    {
10270        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10271    }
10272    let candidate_facets = row_facets + inventory_facets;
10273    let expected_demand = expected_rows + usize::from(inventory_incomplete);
10274    if has_budget_summary {
10275        if candidate_facets >= expected_demand
10276            || inventory_facets != usize::from(inventory_incomplete && candidate_facets != 0)
10277        {
10278            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10279        }
10280    } else if row_facets != expected_rows || inventory_facets != usize::from(inventory_incomplete) {
10281        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10282    }
10283    if evaluated_scopes.len() != available_scopes.len()
10284        || evaluated_scopes
10285            .iter()
10286            .any(|scope| !available_scopes.contains(&scope))
10287    {
10288        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10289    }
10290    if finding_scopes.len() != expected_finding_scopes.len()
10291        || expected_finding_scopes.iter().any(|expected| {
10292            finding_scopes
10293                .iter()
10294                .filter(|actual| **actual == *expected)
10295                .count()
10296                != 1
10297        })
10298    {
10299        return Err(PredictionContractError::EngineClipBoundaryFindingMismatch);
10300    }
10301    Ok(())
10302}
10303
10304fn engine_clip_boundary_common_basis()
10305-> Result<Vec<PredictionBasisReferenceV2>, PredictionContractError> {
10306    Ok(vec![
10307        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::profile_fact(
10308            "whole_end_frame_required",
10309        )?),
10310        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::primary_source(
10311            ENGINE_CLIP_BOUNDARY_SOURCE_ID,
10312        )?),
10313    ])
10314}
10315
10316fn engine_clip_boundary_exact_reference(
10317    binding: &ExactSourceTimingBindingV1,
10318    domain: ExactSourceTimingDomainV1,
10319    key: ExactSourceTimingKeyV1,
10320    field: &'static str,
10321) -> Result<PredictionBasisReferenceV2, PredictionContractError> {
10322    Ok(PredictionBasisReferenceV2::exact_source_timing(
10323        ExactSourceTimingBasisReferenceV1::from_binding(
10324            domain,
10325            key,
10326            RawSourceFieldIdV1::new(field)?,
10327            binding,
10328        )?,
10329    ))
10330}
10331
10332fn engine_clip_boundary_stack_basis(
10333    timing: Option<&ExactSourceTimingBindingV1>,
10334    source_clip_index: usize,
10335) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10336    let mut references = engine_clip_boundary_common_basis()?;
10337    let Some(timing) = timing else {
10338        return EnginePredictionBasisV2::new(references);
10339    };
10340    let stack_key = ExactSourceTimingKeyV1::Clip {
10341        source_clip_index: source_clip_index as u64,
10342    };
10343    for (domain, key, field) in [
10344        (
10345            ExactSourceTimingDomainV1::Document,
10346            ExactSourceTimingKeyV1::Document,
10347            "declared_time_mode.state",
10348        ),
10349        (
10350            ExactSourceTimingDomainV1::Document,
10351            ExactSourceTimingKeyV1::Document,
10352            "frame_period.state",
10353        ),
10354        (
10355            ExactSourceTimingDomainV1::Clip,
10356            stack_key.clone(),
10357            "source_time_range.state",
10358        ),
10359    ] {
10360        references.push(engine_clip_boundary_exact_reference(
10361            timing, domain, key, field,
10362        )?);
10363    }
10364    if matches!(
10365        timing.declared_time_mode().state(),
10366        ExactSourceTimingObservationStateWireV1::Observed(_)
10367    ) {
10368        references.push(engine_clip_boundary_exact_reference(
10369            timing,
10370            ExactSourceTimingDomainV1::Document,
10371            ExactSourceTimingKeyV1::Document,
10372            "declared_time_mode.value.time_mode",
10373        )?);
10374    }
10375    if matches!(
10376        timing.frame_period().state(),
10377        ExactSourceTimingObservationStateWireV1::Observed(_)
10378    ) {
10379        references.push(engine_clip_boundary_exact_reference(
10380            timing,
10381            ExactSourceTimingDomainV1::Document,
10382            ExactSourceTimingKeyV1::Document,
10383            "frame_period.value.units_per_frame",
10384        )?);
10385    }
10386    if matches!(
10387        timing.clips()[source_clip_index]
10388            .source_time_range()
10389            .state(),
10390        ExactSourceTimingObservationStateWireV1::Observed(_)
10391    ) {
10392        references.push(engine_clip_boundary_exact_reference(
10393            timing,
10394            ExactSourceTimingDomainV1::Clip,
10395            stack_key,
10396            "source_time_range.value.end_units",
10397        )?);
10398    }
10399    EnginePredictionBasisV2::new(references)
10400}
10401
10402fn engine_clip_boundary_inventory_basis(
10403    timing: Option<&ExactSourceTimingBindingV1>,
10404) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10405    let mut references = engine_clip_boundary_common_basis()?;
10406    if let Some(timing) = timing {
10407        for field in ["clip_coverage.state", "clip_coverage.reason"] {
10408            references.push(engine_clip_boundary_exact_reference(
10409                timing,
10410                ExactSourceTimingDomainV1::Document,
10411                ExactSourceTimingKeyV1::Document,
10412                field,
10413            )?);
10414        }
10415    }
10416    EnginePredictionBasisV2::new(references)
10417}
10418
10419fn engine_clip_boundary_unavailable_reasons(
10420    timing: Option<&ExactSourceTimingBindingV1>,
10421    source_clip_index: usize,
10422) -> Result<Vec<PredictionUnavailableReasonV2>, PredictionContractError> {
10423    let Some(timing) = timing else {
10424        return Ok(vec![PredictionUnavailableReasonV2::custom(
10425            "animsmith:exact_source_timing_unavailable",
10426        )?]);
10427    };
10428    let mut reasons = Vec::new();
10429    if !matches!(
10430        timing.declared_time_mode().state(),
10431        ExactSourceTimingObservationStateWireV1::Observed(_)
10432    ) {
10433        reasons.push(PredictionUnavailableReasonV2::custom(
10434            "animsmith:source_declared_time_mode_unavailable",
10435        )?);
10436    }
10437    if !matches!(
10438        timing.frame_period().state(),
10439        ExactSourceTimingObservationStateWireV1::Observed(_)
10440    ) {
10441        reasons.push(PredictionUnavailableReasonV2::custom(
10442            "animsmith:source_frame_period_unavailable",
10443        )?);
10444    }
10445    if !matches!(
10446        timing.clips()[source_clip_index]
10447            .source_time_range()
10448            .state(),
10449        ExactSourceTimingObservationStateWireV1::Observed(_)
10450    ) {
10451        reasons.push(PredictionUnavailableReasonV2::custom(
10452            "animsmith:source_clip_time_range_unavailable",
10453        )?);
10454    }
10455    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
10456    Ok(reasons)
10457}
10458
10459impl MeasurementReportInput {
10460    /// Read one report through the immutable output-v11 byte bound before
10461    /// UTF-8 or JSON parsing.
10462    ///
10463    /// The JSON parser receives at most [`OUTPUT_V11_MAX_REPORT_BYTES`] bytes
10464    /// and retains its recursion limit. This function never performs an
10465    /// unbounded `read_to_end` or constructs a generic JSON value.
10466    ///
10467    /// # Errors
10468    ///
10469    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic contract
10470    /// validation remains in [`Self::into_files`].
10471    pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
10472        Self::read_from_with_limit(reader, OUTPUT_V11_MAX_REPORT_BYTES)
10473    }
10474
10475    fn read_from_with_limit(
10476        reader: impl Read,
10477        limit: u64,
10478    ) -> Result<Self, MeasurementReportReadError> {
10479        let mut bounded = reader.take(limit + 1);
10480        let mut bytes = Vec::new();
10481        bounded
10482            .read_to_end(&mut bytes)
10483            .map_err(|source| MeasurementReportReadError::Io { source })?;
10484        if bytes.len() as u64 > limit {
10485            return Err(MeasurementReportReadError::ReportTooLarge { limit });
10486        }
10487        serde_json::from_slice(&bytes)
10488            .map_err(|source| MeasurementReportReadError::InvalidJson { source })
10489    }
10490
10491    /// Number of file records present before nested record validation.
10492    ///
10493    /// Returns `None` when the report omitted its file array. Consumers can
10494    /// retain this count while [`MeasurementReportInput::into_files`] performs
10495    /// full validation, then apply their own cardinality and error policy.
10496    pub fn file_count(&self) -> Option<usize> {
10497        self.files.as_ref().map(Vec::len)
10498    }
10499
10500    /// Validate current output/measurement identities and recover every file's
10501    /// complete measurement record from a `measure` or `lint` report.
10502    ///
10503    /// File order is preserved. Empty and multi-file reports are accepted so
10504    /// callers can apply their own cardinality policy.
10505    ///
10506    /// # Errors
10507    ///
10508    /// Returns a typed error for a missing or unsupported identity, command,
10509    /// file shape, nested measurement contract, or measurement payload.
10510    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
10511        #[derive(Clone, Copy, PartialEq, Eq)]
10512        enum ReaderRevision {
10513            V11,
10514            V12,
10515            V13,
10516            V14,
10517            V15,
10518            V16,
10519            V17,
10520            V18,
10521            V19,
10522        }
10523
10524        let revision = match self.schema_version {
10525            Some(OUTPUT_V11_SCHEMA_VERSION) => ReaderRevision::V11,
10526            Some(OUTPUT_V12_SCHEMA_VERSION) => ReaderRevision::V12,
10527            Some(OUTPUT_V13_SCHEMA_VERSION) => ReaderRevision::V13,
10528            Some(OUTPUT_V14_SCHEMA_VERSION) => ReaderRevision::V14,
10529            Some(OUTPUT_V15_SCHEMA_VERSION) => ReaderRevision::V15,
10530            Some(OUTPUT_V16_SCHEMA_VERSION) => ReaderRevision::V16,
10531            Some(OUTPUT_V17_SCHEMA_VERSION) => ReaderRevision::V17,
10532            Some(OUTPUT_V18_SCHEMA_VERSION) => ReaderRevision::V18,
10533            Some(OUTPUT_SCHEMA_VERSION) => ReaderRevision::V19,
10534            Some(found) => {
10535                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
10536            }
10537            None => return Err(MeasurementReportError::MissingOutputVersion),
10538        };
10539        let expected_schema = match revision {
10540            ReaderRevision::V11 => OUTPUT_V11_SCHEMA_ID,
10541            ReaderRevision::V12 => OUTPUT_V12_SCHEMA_ID,
10542            ReaderRevision::V13 => OUTPUT_V13_SCHEMA_ID,
10543            ReaderRevision::V14 => OUTPUT_V14_SCHEMA_ID,
10544            ReaderRevision::V15 => OUTPUT_V15_SCHEMA_ID,
10545            ReaderRevision::V16 => OUTPUT_V16_SCHEMA_ID,
10546            ReaderRevision::V17 => OUTPUT_V17_SCHEMA_ID,
10547            ReaderRevision::V18 => OUTPUT_V18_SCHEMA_ID,
10548            ReaderRevision::V19 => OUTPUT_SCHEMA_ID,
10549        };
10550        if self.schema.as_deref() != Some(expected_schema) {
10551            return Err(MeasurementReportError::WrongOutputIdentity);
10552        }
10553        let command = match self.command.as_deref() {
10554            Some(command @ ("measure" | "lint")) => command,
10555            Some(command) => {
10556                return Err(MeasurementReportError::UnsupportedCommand {
10557                    command: command.to_owned(),
10558                });
10559            }
10560            None => return Err(MeasurementReportError::MissingCommand),
10561        };
10562        if let Some(field) = self.extra.keys().next() {
10563            return Err(MeasurementReportError::UnknownOutputField {
10564                field: field.clone(),
10565            });
10566        }
10567        if self._tool.is_none() {
10568            return Err(MeasurementReportError::MissingTool);
10569        }
10570        // The V11 reader retains the same summary obligation as the released
10571        // V1 contract; only its prediction attachment identity differs.
10572        validate_prediction_summary_presence(command, self.summary.as_ref())?;
10573        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
10574        if files.len() > OUTPUT_V11_MAX_FILES {
10575            return Err(MeasurementReportError::TooManyFiles {
10576                found: files.len(),
10577                limit: OUTPUT_V11_MAX_FILES,
10578            });
10579        }
10580        let mut available = 0usize;
10581        let mut unavailable = 0usize;
10582        let mut decoded_files = Vec::with_capacity(files.len());
10583        for (file_index, raw) in files.into_iter().enumerate() {
10584            let file = if revision == ReaderRevision::V11 {
10585                let file = decode_legacy_v11_file(command, file_index, &raw)?;
10586                let (file_available, file_unavailable) =
10587                    validate_legacy_v11_prediction_phase_file(command, file_index, &file)?;
10588                available = available
10589                    .checked_add(file_available)
10590                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10591                unavailable = unavailable
10592                    .checked_add(file_unavailable)
10593                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10594                file
10595            } else if revision == ReaderRevision::V14 {
10596                let file = decode_prediction_phase_file_v14(command, file_index, &raw)?;
10597                let (file_available, file_unavailable) =
10598                    validate_prediction_phase_file_v14(command, file_index, &file)?;
10599                available = available
10600                    .checked_add(file_available)
10601                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10602                unavailable = unavailable
10603                    .checked_add(file_unavailable)
10604                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10605                file
10606            } else if revision == ReaderRevision::V15 {
10607                let file = decode_prediction_phase_file_v15(command, file_index, &raw)?;
10608                let (file_available, file_unavailable) =
10609                    validate_prediction_phase_file_v15(command, file_index, &file)?;
10610                available = available
10611                    .checked_add(file_available)
10612                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10613                unavailable = unavailable
10614                    .checked_add(file_unavailable)
10615                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10616                file
10617            } else if revision == ReaderRevision::V16 {
10618                let file = decode_prediction_phase_file_v16(command, file_index, &raw)?;
10619                let (file_available, file_unavailable) =
10620                    validate_prediction_phase_file_v16(command, file_index, &file)?;
10621                available = available
10622                    .checked_add(file_available)
10623                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10624                unavailable = unavailable
10625                    .checked_add(file_unavailable)
10626                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10627                file
10628            } else if matches!(
10629                revision,
10630                ReaderRevision::V17 | ReaderRevision::V18 | ReaderRevision::V19
10631            ) {
10632                let file = decode_prediction_phase_file_v17(command, file_index, &raw)?;
10633                let (file_available, file_unavailable) =
10634                    validate_prediction_phase_file_v17(command, file_index, &file)?;
10635                available = available
10636                    .checked_add(file_available)
10637                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10638                unavailable = unavailable
10639                    .checked_add(file_unavailable)
10640                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10641                file
10642            } else {
10643                let expected_measurement_schema = if revision == ReaderRevision::V12 {
10644                    MEASUREMENTS_V15_SCHEMA_ID
10645                } else {
10646                    MEASUREMENTS_V16_SCHEMA_ID
10647                };
10648                let file = decode_prediction_phase_file(
10649                    command,
10650                    file_index,
10651                    &raw,
10652                    expected_measurement_schema,
10653                )?;
10654                let (file_available, file_unavailable) = validate_prediction_phase_file(
10655                    command,
10656                    file_index,
10657                    &file,
10658                    expected_measurement_schema,
10659                )?;
10660                available = available
10661                    .checked_add(file_available)
10662                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10663                unavailable = unavailable
10664                    .checked_add(file_unavailable)
10665                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10666                file
10667            };
10668            decoded_files.push(file);
10669        }
10670        validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
10671        let parsed = decoded_files
10672            .into_iter()
10673            .enumerate()
10674            .map(|(file_index, file)| {
10675                let path = file.path.ok_or_else(|| {
10676                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
10677                })?;
10678                let input = file.input.ok_or_else(|| {
10679                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
10680                })?;
10681                let sha256 = input.sha256.ok_or_else(|| {
10682                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
10683                })?;
10684                if sha256.len() != 64
10685                    || !sha256
10686                        .bytes()
10687                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
10688                {
10689                    return Err(MeasurementReportError::file(
10690                        file_index,
10691                        MeasurementFileError::InvalidSha256,
10692                    ));
10693                }
10694                let bytes = input.bytes.ok_or_else(|| {
10695                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
10696                })?;
10697                let measurements = file.measurements.ok_or_else(|| {
10698                    MeasurementReportError::file(
10699                        file_index,
10700                        MeasurementFileError::MissingMeasurements,
10701                    )
10702                })?;
10703                let measurements = decode_measurement_payload(
10704                    &measurements,
10705                    matches!(
10706                        revision,
10707                        ReaderRevision::V13
10708                            | ReaderRevision::V14
10709                            | ReaderRevision::V15
10710                            | ReaderRevision::V16
10711                            | ReaderRevision::V17
10712                            | ReaderRevision::V18
10713                            | ReaderRevision::V19
10714                    ),
10715                )
10716                .map_err(|source| {
10717                    MeasurementReportError::file(
10718                        file_index,
10719                        MeasurementFileError::InvalidMeasurementsShape {
10720                            reason: source.to_string(),
10721                        },
10722                    )
10723                })?;
10724                let (expected_measurement_version, expected_measurement_schema) = match revision {
10725                    ReaderRevision::V11 | ReaderRevision::V12 => {
10726                        (MEASUREMENTS_V15_SCHEMA_VERSION, MEASUREMENTS_V15_SCHEMA_ID)
10727                    }
10728                    ReaderRevision::V13
10729                    | ReaderRevision::V14
10730                    | ReaderRevision::V15
10731                    | ReaderRevision::V16
10732                    | ReaderRevision::V17 => {
10733                        (MEASUREMENTS_V16_SCHEMA_VERSION, MEASUREMENTS_V16_SCHEMA_ID)
10734                    }
10735                    ReaderRevision::V18 => {
10736                        (MEASUREMENTS_V17_SCHEMA_VERSION, MEASUREMENTS_V17_SCHEMA_ID)
10737                    }
10738                    ReaderRevision::V19 => (MEASUREMENTS_SCHEMA_VERSION, MEASUREMENTS_SCHEMA_ID),
10739                };
10740                match measurements.schema_version {
10741                    Some(found) if found == expected_measurement_version => {}
10742                    Some(found) => {
10743                        return Err(MeasurementReportError::file(
10744                            file_index,
10745                            MeasurementFileError::UnsupportedMeasurementVersion {
10746                                found,
10747                                expected: expected_measurement_version,
10748                            },
10749                        ));
10750                    }
10751                    None => {
10752                        return Err(MeasurementReportError::file(
10753                            file_index,
10754                            MeasurementFileError::MissingMeasurementVersion,
10755                        ));
10756                    }
10757                }
10758                if measurements.schema.as_deref() != Some(expected_measurement_schema) {
10759                    return Err(MeasurementReportError::file(
10760                        file_index,
10761                        MeasurementFileError::WrongMeasurementIdentity {
10762                            expected: expected_measurement_schema,
10763                        },
10764                    ));
10765                }
10766                let clips = measurements.clips.ok_or_else(|| {
10767                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
10768                })?;
10769                let material_resource_coverage =
10770                    measurements.material_resource_coverage.ok_or_else(|| {
10771                        MeasurementReportError::file(
10772                            file_index,
10773                            MeasurementFileError::MissingMaterialResourceCoverage,
10774                        )
10775                    })?;
10776                let material_definitions = measurements.material_definitions.ok_or_else(|| {
10777                    MeasurementReportError::file(
10778                        file_index,
10779                        MeasurementFileError::MissingMaterialDefinitions,
10780                    )
10781                })?;
10782                let textures = measurements.textures.ok_or_else(|| {
10783                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
10784                })?;
10785                let images = measurements.images.ok_or_else(|| {
10786                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
10787                })?;
10788                let skeleton_source_coverage =
10789                    measurements.skeleton_source_coverage.ok_or_else(|| {
10790                        MeasurementReportError::file(
10791                            file_index,
10792                            MeasurementFileError::MissingSkeletonSourceCoverage,
10793                        )
10794                    })?;
10795                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
10796                    MeasurementReportError::file(
10797                        file_index,
10798                        MeasurementFileError::MissingSkeletonNodes,
10799                    )
10800                })?;
10801                let skeleton_nodes = skeleton_nodes
10802                    .into_iter()
10803                    .enumerate()
10804                    .map(|(offset, node)| match node {
10805                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
10806                        SkeletonNodeMeasurementInput::Earlier { .. } => {
10807                            Err(MeasurementReportError::file(
10808                                file_index,
10809                                MeasurementFileError::InvalidMeasurements {
10810                                    source: MeasurementContractError::InvalidStructure {
10811                                        path: format!("skeleton_nodes[{offset}]"),
10812                                        reason: "uses a shape from an earlier measurement contract"
10813                                            .into(),
10814                                    },
10815                                },
10816                            ))
10817                        }
10818                    })
10819                    .collect::<Result<Vec<_>, _>>()?;
10820                let skins = measurements.skins.ok_or_else(|| {
10821                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
10822                })?;
10823                let skins = skins
10824                    .into_iter()
10825                    .enumerate()
10826                    .map(|(offset, skin)| match skin {
10827                        SkinMeasurementInput::Current(skin) => Ok(*skin),
10828                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
10829                            file_index,
10830                            MeasurementFileError::InvalidMeasurements {
10831                                source: MeasurementContractError::InvalidStructure {
10832                                    path: format!("skins[{offset}]"),
10833                                    reason: "uses a shape from an earlier measurement contract"
10834                                        .into(),
10835                                },
10836                            },
10837                        )),
10838                    })
10839                    .collect::<Result<Vec<_>, _>>()?;
10840                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
10841                    MeasurementReportError::file(
10842                        file_index,
10843                        MeasurementFileError::MissingMeshDefinitions,
10844                    )
10845                })?;
10846                let node_instances = measurements.node_instances.ok_or_else(|| {
10847                    MeasurementReportError::file(
10848                        file_index,
10849                        MeasurementFileError::MissingNodeInstances,
10850                    )
10851                })?;
10852                let scenes = measurements.scenes.ok_or_else(|| {
10853                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
10854                })?;
10855                let assets = AssetMeasurements {
10856                    material_resource_coverage,
10857                    material_definitions,
10858                    textures,
10859                    images,
10860                    skeleton_source_coverage,
10861                    skeleton_nodes,
10862                    skins,
10863                    mesh_definitions,
10864                    node_instances,
10865                    scenes,
10866                    default_scene_index: measurements.default_scene_index,
10867                };
10868                let measurements = match revision {
10869                    ReaderRevision::V11 | ReaderRevision::V12 => {
10870                        MeasurementContract::historical_v15(clips, assets)
10871                    }
10872                    ReaderRevision::V13
10873                    | ReaderRevision::V14
10874                    | ReaderRevision::V15
10875                    | ReaderRevision::V16
10876                    | ReaderRevision::V17 => MeasurementContract::historical_v16(clips, assets),
10877                    ReaderRevision::V18 => MeasurementContract::historical_v17(clips, assets),
10878                    ReaderRevision::V19 => MeasurementContract::new(clips, assets),
10879                }
10880                .map_err(|source| {
10881                    MeasurementReportError::file(
10882                        file_index,
10883                        MeasurementFileError::InvalidMeasurements { source },
10884                    )
10885                })?;
10886                let prediction_measurements =
10887                    if matches!(revision, ReaderRevision::V18 | ReaderRevision::V19) {
10888                        measurements.prediction_v16_projection().map_err(|source| {
10889                            MeasurementReportError::file(
10890                                file_index,
10891                                MeasurementFileError::InvalidMeasurements { source },
10892                            )
10893                        })?
10894                    } else {
10895                        measurements.clone()
10896                    };
10897                if revision == ReaderRevision::V15 {
10898                    let provenance = file.prediction_provenance_v4.as_present();
10899                    for (check_index, check) in file
10900                        .checks_v3
10901                        .as_deref()
10902                        .unwrap_or_default()
10903                        .iter()
10904                        .enumerate()
10905                    {
10906                        validate_current_engine_unit_scale_prediction_v4(
10907                            &check.check_id,
10908                            check.selection,
10909                            check.configuration,
10910                            check.applicability,
10911                            None,
10912                            None,
10913                            &prediction_measurements,
10914                        )
10915                        .map_err(|source| {
10916                            MeasurementReportError::file(
10917                                file_index,
10918                                MeasurementFileError::InvalidPrediction {
10919                                    check_index,
10920                                    source,
10921                                },
10922                            )
10923                        })?;
10924                    }
10925                    for (check_index, check) in file
10926                        .checks_v4
10927                        .as_deref()
10928                        .unwrap_or_default()
10929                        .iter()
10930                        .enumerate()
10931                    {
10932                        validate_current_engine_unit_scale_prediction_v4(
10933                            &check.check_id,
10934                            check.selection,
10935                            check.configuration,
10936                            check.applicability,
10937                            check.prediction.as_ref(),
10938                            provenance,
10939                            &prediction_measurements,
10940                        )
10941                        .map_err(|source| {
10942                            MeasurementReportError::file(
10943                                file_index,
10944                                MeasurementFileError::InvalidPrediction {
10945                                    check_index,
10946                                    source,
10947                                },
10948                            )
10949                        })?;
10950                    }
10951                }
10952                if matches!(
10953                    revision,
10954                    ReaderRevision::V16
10955                        | ReaderRevision::V17
10956                        | ReaderRevision::V18
10957                        | ReaderRevision::V19
10958                ) && !matches!(file.prediction_provenance_v5, RequiredNullable::Missing)
10959                {
10960                    let provenance = file.prediction_provenance_v5.as_present();
10961                    for (check_index, check) in file
10962                        .checks_v5
10963                        .as_deref()
10964                        .unwrap_or_default()
10965                        .iter()
10966                        .enumerate()
10967                    {
10968                        validate_current_engine_unit_scale_prediction_v5(
10969                            &check.check_id,
10970                            check.selection,
10971                            check.configuration,
10972                            check.applicability,
10973                            check.prediction.as_ref(),
10974                            provenance,
10975                            &prediction_measurements,
10976                        )
10977                        .map_err(|source| {
10978                            MeasurementReportError::file(
10979                                file_index,
10980                                MeasurementFileError::InvalidPrediction {
10981                                    check_index,
10982                                    source,
10983                                },
10984                            )
10985                        })?;
10986                    }
10987                }
10988                if matches!(
10989                    revision,
10990                    ReaderRevision::V17 | ReaderRevision::V18 | ReaderRevision::V19
10991                ) && !matches!(file.prediction_provenance_v6, RequiredNullable::Missing)
10992                {
10993                    let provenance = file.prediction_provenance_v6.as_present();
10994                    let rig = file.rig_v17.as_ref().ok_or_else(|| {
10995                        MeasurementReportError::file(
10996                            file_index,
10997                            MeasurementFileError::InvalidFileShape {
10998                                reason: "current role-policy rig evidence was not retained".into(),
10999                            },
11000                        )
11001                    })?;
11002                    for (check_index, check) in file
11003                        .checks_v6
11004                        .as_deref()
11005                        .unwrap_or_default()
11006                        .iter()
11007                        .enumerate()
11008                    {
11009                        validate_current_engine_root_motion_prediction_v6(
11010                            &check.check_id,
11011                            check.selection,
11012                            check.configuration,
11013                            check.applicability,
11014                            check.prediction.as_ref(),
11015                            provenance,
11016                            &check.findings,
11017                            rig,
11018                            &prediction_measurements,
11019                        )
11020                        .map_err(|source| {
11021                            MeasurementReportError::file(
11022                                file_index,
11023                                MeasurementFileError::InvalidPrediction {
11024                                    check_index,
11025                                    source,
11026                                },
11027                            )
11028                        })?;
11029                    }
11030                }
11031                Ok((
11032                    MeasurementReportFile {
11033                        path,
11034                        input: InputIdentity { sha256, bytes },
11035                        measurements,
11036                    },
11037                    (
11038                        file.checks.unwrap_or_default(),
11039                        file.legacy_checks.unwrap_or_default(),
11040                        file.checks_v3.unwrap_or_default(),
11041                        file.checks_v4.unwrap_or_default(),
11042                        file.checks_v5.unwrap_or_default(),
11043                        file.checks_v6.unwrap_or_default(),
11044                    ),
11045                ))
11046            })
11047            .collect::<Result<Vec<_>, _>>()?;
11048
11049        // Measurement-dependent basis pointers are deliberately resolved only
11050        // after every file's complete, version-routed measurements contract has passed.
11051        for (
11052            file_index,
11053            (file, (checks, legacy_checks, checks_v3, checks_v4, checks_v5, checks_v6)),
11054        ) in parsed.iter().enumerate()
11055        {
11056            let prediction_measurements =
11057                if matches!(revision, ReaderRevision::V18 | ReaderRevision::V19) {
11058                    file.measurements
11059                        .prediction_v16_projection()
11060                        .map_err(|source| {
11061                            MeasurementReportError::file(
11062                                file_index,
11063                                MeasurementFileError::InvalidMeasurements { source },
11064                            )
11065                        })?
11066                } else {
11067                    file.measurements.clone()
11068                };
11069            validate_measurement_references_batch_v4(
11070                &prediction_measurements,
11071                checks_v6
11072                    .iter()
11073                    .enumerate()
11074                    .filter_map(|(check_index, check)| {
11075                        check
11076                            .prediction
11077                            .as_ref()
11078                            .map(|prediction| (check_index, prediction.base_prediction()))
11079                    }),
11080            )
11081            .map_err(|error| {
11082                MeasurementReportError::file(
11083                    file_index,
11084                    MeasurementFileError::InvalidPrediction {
11085                        check_index: error.prediction_index,
11086                        source: error.source,
11087                    },
11088                )
11089            })?;
11090            validate_measurement_references_batch_v4(
11091                &prediction_measurements,
11092                checks_v5
11093                    .iter()
11094                    .enumerate()
11095                    .filter_map(|(check_index, check)| {
11096                        check
11097                            .prediction
11098                            .as_ref()
11099                            .map(|prediction| (check_index, prediction.base_prediction()))
11100                    }),
11101            )
11102            .map_err(|error| {
11103                MeasurementReportError::file(
11104                    file_index,
11105                    MeasurementFileError::InvalidPrediction {
11106                        check_index: error.prediction_index,
11107                        source: error.source,
11108                    },
11109                )
11110            })?;
11111            validate_measurement_references_batch_v4(
11112                &prediction_measurements,
11113                checks_v4
11114                    .iter()
11115                    .enumerate()
11116                    .filter_map(|(check_index, check)| {
11117                        check
11118                            .prediction
11119                            .as_ref()
11120                            .map(|prediction| (check_index, prediction))
11121                    }),
11122            )
11123            .map_err(|error| {
11124                MeasurementReportError::file(
11125                    file_index,
11126                    MeasurementFileError::InvalidPrediction {
11127                        check_index: error.prediction_index,
11128                        source: error.source,
11129                    },
11130                )
11131            })?;
11132            validate_measurement_references_batch_v3(
11133                &prediction_measurements,
11134                checks_v3
11135                    .iter()
11136                    .enumerate()
11137                    .filter_map(|(check_index, check)| {
11138                        check
11139                            .prediction
11140                            .as_ref()
11141                            .map(|prediction| (check_index, prediction))
11142                    }),
11143            )
11144            .map_err(|error| {
11145                MeasurementReportError::file(
11146                    file_index,
11147                    MeasurementFileError::InvalidPrediction {
11148                        check_index: error.prediction_index,
11149                        source: error.source,
11150                    },
11151                )
11152            })?;
11153            validate_measurement_references_batch_v2(
11154                &prediction_measurements,
11155                checks
11156                    .iter()
11157                    .enumerate()
11158                    .filter_map(|(check_index, check)| {
11159                        check
11160                            .prediction
11161                            .as_ref()
11162                            .map(|prediction| (check_index, prediction))
11163                    }),
11164            )
11165            .map_err(|error| {
11166                MeasurementReportError::file(
11167                    file_index,
11168                    MeasurementFileError::InvalidPrediction {
11169                        check_index: error.prediction_index,
11170                        source: error.source,
11171                    },
11172                )
11173            })?;
11174            validate_measurement_references_batch(
11175                &prediction_measurements,
11176                legacy_checks
11177                    .iter()
11178                    .enumerate()
11179                    .filter_map(|(check_index, check)| {
11180                        check
11181                            .prediction
11182                            .as_ref()
11183                            .map(|prediction| (check_index, prediction))
11184                    }),
11185            )
11186            .map_err(|error| {
11187                MeasurementReportError::file(
11188                    file_index,
11189                    MeasurementFileError::InvalidPrediction {
11190                        check_index: error.prediction_index,
11191                        source: error.source,
11192                    },
11193                )
11194            })?;
11195        }
11196        Ok(parsed.into_iter().map(|(file, _)| file).collect())
11197    }
11198}
11199
11200#[cfg(test)]
11201mod measurement_report_input_tests {
11202    use std::collections::BTreeMap;
11203
11204    use super::*;
11205    use crate::engine_contract::{
11206        EngineClipSettingsV1, EngineConversionControlV1, EngineCoordinateBasisV1, EngineFactIdV1,
11207        EngineFactStateV1, EngineFactValueV1, EngineForwardAxisV1, EngineHandednessV1,
11208        EngineLinearUnitV1, EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1,
11209        EngineUpAxisV1, ResolvedEngineProfileV1, ResolvedEngineSettingsCoverageV2,
11210        ResolvedEngineSettingsV1, ResolvedEngineSettingsV2, ResolvedEngineSettingsWorkV2,
11211    };
11212    use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
11213    use crate::measure::{
11214        AssetMeasurements, ImageMeasurements, MeshDefinitionMeasurements, PrimitiveMeasurements,
11215    };
11216    use crate::prediction::{
11217        EngineMachineResultV1, EnginePredictionBasisV1, EnginePredictionBasisV2,
11218        EnginePredictionBasisV4, EnginePredictionFacetV1, EnginePredictionFacetV2,
11219        EnginePredictionFacetV3, EnginePredictionFacetV4, EnginePredictionV1, EnginePredictionV2,
11220        EnginePredictionV3, EnginePredictionV4, PredictionBasisReferenceV1,
11221        PredictionBasisReferenceV2, PredictionBasisReferenceV4, PredictionProvenanceIdentityV4,
11222        PredictionScalarV1, PredictionUnavailableReasonV1, PredictionUnavailableReasonV2,
11223        RawSourceBindingV1, RawSourceBindingV2, UnitMappingResultV1,
11224    };
11225    use crate::source_facts::SourceFormatV1;
11226    use crate::{
11227        DependencyClosureV1, Document, Finding, ImageSourceKind, ImageUnavailableReason,
11228        MaterialResourceCoverage, ResolvedRoles,
11229    };
11230
11231    fn prediction_test_profile() -> ResolvedEngineProfileV1 {
11232        let all_fact_ids = [
11233            EngineFactIdV1::AcceptedInputs,
11234            EngineFactIdV1::AnimationAddressability,
11235            EngineFactIdV1::AnimationChannelHandling,
11236            EngineFactIdV1::AnimationTargetAddressability,
11237            EngineFactIdV1::AxisConversionControl,
11238            EngineFactIdV1::ConstructHandling,
11239            EngineFactIdV1::ExactAxisConversion,
11240            EngineFactIdV1::ExtensionHandling,
11241            EngineFactIdV1::ResultingHierarchyScale,
11242            EngineFactIdV1::RootMotionAddressability,
11243            EngineFactIdV1::TargetCoordinateBasis,
11244            EngineFactIdV1::TargetLinearUnit,
11245            EngineFactIdV1::UnitConversionControl,
11246            EngineFactIdV1::WholeEndFrameRequired,
11247        ];
11248        let facts = all_fact_ids
11249            .into_iter()
11250            .map(|id| {
11251                let state = if id == EngineFactIdV1::AcceptedInputs {
11252                    EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11253                        SourceFormatV1::Glb,
11254                    ]))
11255                } else {
11256                    EngineFactStateV1::Unknown
11257                };
11258                EngineProfileFactV1::new(id, state)
11259            })
11260            .collect();
11261        ResolvedEngineProfileV1::new(
11262            EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
11263            "urn:animsmith:engine-profile:test:1",
11264            facts,
11265            vec![],
11266            vec![
11267                EnginePrimarySourceV1::new(
11268                    "test-source",
11269                    "1",
11270                    "https://example.invalid/test",
11271                    "2026-08-20",
11272                    vec![EngineFactIdV1::AcceptedInputs],
11273                    vec![],
11274                )
11275                .unwrap(),
11276            ],
11277        )
11278        .unwrap()
11279    }
11280
11281    fn prediction_test_provenance_v2() -> PredictionProvenanceV2 {
11282        let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
11283            "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11284            "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11285            "source_format": "glb",
11286            "linear_unit": {
11287                "state": "observed", "value": 1.0, "disposition": "preserved",
11288                "provenance": {"kind": "format_defined"}
11289            },
11290            "coordinate_basis": {
11291                "state": "observed",
11292                "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11293                "disposition": "preserved", "provenance": {"kind": "format_defined"}
11294            },
11295            "frames_per_second": {
11296                "state": "observed", "value": 30.0, "disposition": "preserved",
11297                "provenance": {"kind": "format_defined"}
11298            },
11299            "clips_coverage": {"state": "complete"},
11300            "constructs_coverage": {"state": "complete"},
11301            "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11302            "source_skeleton_coverage": "unavailable",
11303            "work": {
11304                "inspected_rows": 0, "retained_rows": 0,
11305                "retained_text_bytes": 0, "max_traversal_depth": 0
11306            }
11307        }))
11308        .unwrap();
11309        let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
11310        let profile = prediction_test_profile();
11311        let settings = ResolvedEngineSettingsV2::new(
11312            &profile,
11313            vec![],
11314            vec![],
11315            ResolvedEngineSettingsCoverageV2::complete(),
11316            ResolvedEngineSettingsWorkV2::new(0, 0, 0),
11317        )
11318        .unwrap();
11319        PredictionProvenanceV2::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
11320    }
11321
11322    fn prediction_test_provenance() -> PredictionProvenanceV3 {
11323        let prior = prediction_test_provenance_v2();
11324        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11325            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11326            "source_facts": prior.raw_source(),
11327            "exact_source_timing": null
11328        }))
11329        .unwrap();
11330        PredictionProvenanceV3::new(
11331            prior.profile().clone(),
11332            prior.source_format(),
11333            prior.settings().clone(),
11334            raw,
11335            prior.dependency_closure().clone(),
11336        )
11337        .unwrap()
11338    }
11339
11340    fn partial_engine_provenance() -> PredictionProvenanceV3 {
11341        let complete = prediction_test_provenance();
11342        let mut raw_wire = serde_json::to_value(complete.raw_source().source_facts()).unwrap();
11343        raw_wire["clips_coverage"] = serde_json::json!({
11344            "state": "partial",
11345            "reason": "projection_budget_exceeded"
11346        });
11347        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11348            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11349            "source_facts": raw_wire,
11350            "exact_source_timing": null
11351        }))
11352        .unwrap();
11353        let clips = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
11354            .map(|index| EngineClipSettingsV1::new(format!("clip-{index:04}"), Vec::new()).unwrap())
11355            .collect();
11356        let settings = ResolvedEngineSettingsV2::new(
11357            complete.profile(),
11358            Vec::new(),
11359            clips,
11360            ResolvedEngineSettingsCoverageV2::actual_clip_rows_exceeded(),
11361            ResolvedEngineSettingsWorkV2::new(4_097, 4_096, 4_096),
11362        )
11363        .unwrap();
11364        PredictionProvenanceV3::new(
11365            complete.profile().clone(),
11366            complete.source_format(),
11367            settings,
11368            raw,
11369            complete.dependency_closure().clone(),
11370        )
11371        .unwrap()
11372    }
11373
11374    fn prediction_test_measurements() -> MeasurementContract {
11375        MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11376    }
11377
11378    fn prediction_measurements_with_rotation_facts() -> MeasurementContract {
11379        let assets: AssetMeasurements = serde_json::from_value(serde_json::json!({
11380            "material_resource_coverage": "unavailable",
11381            "material_definitions": [], "textures": [], "images": [],
11382            "skeleton_source_coverage": "complete",
11383            "skeleton_nodes": [{
11384                "node_index": 0,
11385                "scene_root_indices": [],
11386                "local_rest": {
11387                    "kind": "trs",
11388                    "translation_parent_space_m": [0.0, 0.0, 0.0],
11389                    "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
11390                    "scale": [1.0, 1.0, 1.0]
11391                },
11392                "rest_world_matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
11393                "rest_world_translation_m": [0.0, 0.0, 0.0],
11394                "rest_world_linear": {
11395                    "classification": "unit_orthonormal",
11396                    "axis_lengths": [1.0, 1.0, 1.0],
11397                    "determinant": 1.0,
11398                    "orientation": "positive",
11399                    "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
11400                    "uniform_scale": 1.0
11401                }
11402            }],
11403            "skins": [{
11404                "skin_index": 0,
11405                "joints": [{
11406                    "joint_index": 0, "node_index": 0,
11407                    "joint_bind_to_mesh": {
11408                        "source_inverse_bind_matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
11409                        "inversion_quality": { "reciprocal_condition_number_inf": 1.0 },
11410                        "matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
11411                        "linear": { "classification": "unit_orthonormal", "axis_lengths": [1.0, 1.0, 1.0], "determinant": 1.0, "orientation": "positive", "rotation_xyzw": [0.0, 0.0, 0.0, 1.0], "uniform_scale": 1.0 }
11412                    },
11413                    "mesh_bind_world": {
11414                        "source_inverse_bind_matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
11415                        "matrix": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
11416                        "linear": { "classification": "unit_orthonormal", "axis_lengths": [1.0, 1.0, 1.0], "determinant": 1.0, "orientation": "positive", "rotation_xyzw": [0.0, 0.0, 0.0, 1.0], "uniform_scale": 1.0 }
11417                    }
11418                }],
11419                "joint_bind_linear_summary": { "classification": "consistent_uniform", "joint_count": 1, "available_joint_count": 1, "unavailable_joint_count": 0, "consistent_uniform_scale": 1.0 },
11420                "inverse_bind_accessor": {
11421                    "status": "available", "declared_count": 1,
11422                    "matrices": [[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]]
11423                },
11424                "attachments": [{ "node_index": 0 }]
11425            }],
11426            "mesh_definitions": [], "node_instances": [], "scenes": []
11427        }))
11428        .expect("complete skeleton and skin fixture");
11429        MeasurementContract::new(BTreeMap::new(), assets).expect("current rotation facts")
11430    }
11431
11432    fn prediction_test_measurements_v16() -> MeasurementContract {
11433        MeasurementContract::historical_v16(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11434    }
11435
11436    fn loop_projection_measurements(
11437        availability: &[MeasurementAvailability],
11438    ) -> MeasurementContract {
11439        let bones = availability
11440            .iter()
11441            .enumerate()
11442            .map(|(index, availability)| match availability {
11443                MeasurementAvailability::Measured => serde_json::json!({
11444                    "bone_index": index,
11445                    "bone_name": format!("bone-{index}"),
11446                    "availability": "measured",
11447                    "position_delta_m": index as f64,
11448                    "rotation_delta_deg": index as f64,
11449                    "seam_velocity_delta_mps": index as f64,
11450                    "seam_angular_velocity_delta_degps": index as f64,
11451                }),
11452                MeasurementAvailability::Unavailable => serde_json::json!({
11453                    "bone_index": index,
11454                    "bone_name": format!("bone-{index}"),
11455                    "availability": "unavailable",
11456                }),
11457                MeasurementAvailability::NotApplicable => {
11458                    panic!("loop-continuity rows are always applicable")
11459                }
11460            })
11461            .collect::<Vec<_>>();
11462        let clip: ClipMeasurements = serde_json::from_value(serde_json::json!({
11463            "duration_s": 1.0,
11464            "frame_count": 3,
11465            "animated_bones": [],
11466            "bone_channels": [],
11467            "bone_rotation_range_deg": {},
11468            "loop_continuity": { "bones": bones },
11469            "loop_continuity_availability": "measured",
11470            "loop_endpoint_mode_availability": "not_applicable",
11471            "frame_grid_availability": "not_applicable",
11472            "loop_seam_ratio_availability": "not_applicable",
11473            "gait_availability": "not_applicable",
11474            "root_trajectory_availability": "not_applicable",
11475            "speed_mps_availability": "not_applicable"
11476        }))
11477        .unwrap();
11478        MeasurementContract::new(
11479            BTreeMap::from([("loop".into(), clip)]),
11480            AssetMeasurements::default(),
11481        )
11482        .unwrap()
11483    }
11484
11485    fn measure_wire(measurements: MeasurementContract) -> serde_json::Value {
11486        let file = MeasureFileReport::new(
11487            "test.glb",
11488            InputIdentity::from_bytes(&[]),
11489            prediction_test_rig(),
11490            measurements,
11491        )
11492        .unwrap();
11493        let envelope =
11494            MeasureEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11495                .unwrap();
11496        let wire = serde_json::to_value(envelope).unwrap();
11497        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11498            .unwrap()
11499            .into_files()
11500            .expect("current measurement fixture reads back");
11501        wire
11502    }
11503
11504    fn primitive_measurement_contract() -> MeasurementContract {
11505        let mut assets = AssetMeasurements::default();
11506        assets.mesh_definitions.push(MeshDefinitionMeasurements {
11507            mesh_index: 0,
11508            name: "mesh".into(),
11509            primitives: Some(vec![
11510                PrimitiveMeasurements {
11511                    primitive_index: 1,
11512                    material_index: Some(7),
11513                    vertex_count: 2,
11514                    finite_vertex_count: 1,
11515                    geometry_aabb: Some(Aabb {
11516                        min: [-2.0, 1.0, 0.0],
11517                        max: [-2.0, 1.0, 0.0],
11518                    }),
11519                    geometry_centroid: Some([-2.0, 1.0, 0.0]),
11520                },
11521                PrimitiveMeasurements {
11522                    primitive_index: 3,
11523                    material_index: None,
11524                    vertex_count: 2,
11525                    finite_vertex_count: 2,
11526                    geometry_aabb: Some(Aabb {
11527                        min: [4.0, 3.0, 0.0],
11528                        max: [6.0, 3.0, 0.0],
11529                    }),
11530                    geometry_centroid: Some([5.0, 3.0, 0.0]),
11531                },
11532            ]),
11533            vertex_count: 4,
11534            geometry_aabb: Some(Aabb {
11535                min: [-2.0, 1.0, 0.0],
11536                max: [6.0, 3.0, 0.0],
11537            }),
11538            geometry_centroid: Some([8.0 / 3.0, 7.0 / 3.0, 0.0]),
11539            max_joints_per_vertex: 0,
11540            weight_sum_min: None,
11541            weight_sum_max: None,
11542            additional_influence_sets: Vec::new(),
11543        });
11544        MeasurementContract::new(BTreeMap::new(), assets).unwrap()
11545    }
11546
11547    fn prediction_test_rig() -> RigInfo {
11548        RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
11549    }
11550
11551    fn basis_v2(basis: EnginePredictionBasisV1) -> EnginePredictionBasisV2 {
11552        EnginePredictionBasisV2::new(
11553            basis
11554                .references()
11555                .iter()
11556                .cloned()
11557                .map(PredictionBasisReferenceV2::v1)
11558                .collect(),
11559        )
11560        .unwrap()
11561    }
11562
11563    fn unavailable_facet(
11564        subject: String,
11565        basis: EnginePredictionBasisV1,
11566    ) -> EnginePredictionFacetV3 {
11567        EnginePredictionFacetV3::required_unavailable(
11568            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
11569                .subject(subject),
11570            basis_v2(basis),
11571            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
11572        )
11573        .unwrap()
11574    }
11575
11576    fn unavailable_check(
11577        check_id: &'static str,
11578        provenance: &PredictionProvenanceV3,
11579        facets: Vec<EnginePredictionFacetV3>,
11580    ) -> CheckEvaluation {
11581        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11582        CheckEvaluation::evaluated(
11583            check_id,
11584            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11585                .with_engine_prediction_v3(prediction),
11586        )
11587        .unwrap()
11588    }
11589
11590    fn unavailable_check_v2(
11591        check_id: &'static str,
11592        provenance: &PredictionProvenanceV2,
11593        facets: Vec<EnginePredictionFacetV2>,
11594    ) -> CheckEvaluation {
11595        let prediction = EnginePredictionV2::new(provenance.identity().clone(), facets).unwrap();
11596        CheckEvaluation::evaluated(
11597            check_id,
11598            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11599                .with_engine_prediction_v2(prediction),
11600        )
11601        .unwrap()
11602    }
11603
11604    fn lint_file(
11605        provenance: &PredictionProvenanceV3,
11606        checks: Vec<CheckEvaluation>,
11607    ) -> Result<LintFileReport, OutputContractError> {
11608        LintFileReport::new(
11609            "limit.glb",
11610            provenance.raw_source().primary_input().clone(),
11611            prediction_test_rig(),
11612            Some(provenance.clone()),
11613            checks,
11614            prediction_test_measurements_v16(),
11615        )
11616    }
11617
11618    #[test]
11619    fn output_v15_rejects_mixed_v3_provenance_and_v4_predictions() {
11620        let provenance = prediction_test_provenance();
11621        let identity: PredictionProvenanceIdentityV4 =
11622            serde_json::from_value(serde_json::to_value(provenance.identity()).unwrap()).unwrap();
11623        let basis = EnginePredictionBasisV4::new(vec![PredictionBasisReferenceV4::v2(
11624            PredictionBasisReferenceV2::v1(
11625                PredictionBasisReferenceV1::profile_fact("accepted_inputs").unwrap(),
11626            ),
11627        )])
11628        .unwrap();
11629        let scope = EvaluationScope::new(EvaluationScopeCode::custom("acme:v4-mixed"));
11630        let facet = EnginePredictionFacetV4::available(
11631            scope.clone(),
11632            basis,
11633            EngineMachineResultV1::UnitMapping(
11634                UnitMappingResultV1::gltf_to_engine_world_length_unit(),
11635            ),
11636        )
11637        .unwrap();
11638        let prediction = EnginePredictionV4::new(identity, vec![facet]).unwrap();
11639        let check = CheckEvaluation::evaluated(
11640            "acme-v4-mixed",
11641            CheckOutput::from_coverage(vec![], vec![scope], vec![])
11642                .with_engine_prediction_v4(prediction),
11643        )
11644        .unwrap();
11645        assert!(matches!(
11646            lint_file(&provenance, vec![check]),
11647            Err(OutputContractError::PredictionRevisionMismatch)
11648        ));
11649    }
11650
11651    #[test]
11652    fn output_v15_preserves_prediction_without_provenance_error_precedence() {
11653        let provenance = prediction_test_provenance();
11654        let check = unavailable_check(
11655            "acme-v3-without-provenance",
11656            &provenance,
11657            vec![unavailable_facet(
11658                "row".to_owned(),
11659                EnginePredictionBasisV1::new(Vec::new()).unwrap(),
11660            )],
11661        );
11662        let error = LintFileReport::new(
11663            "without-provenance.glb",
11664            provenance.raw_source().primary_input().clone(),
11665            prediction_test_rig(),
11666            None,
11667            vec![check],
11668            prediction_test_measurements_v16(),
11669        )
11670        .unwrap_err();
11671        assert_eq!(error, OutputContractError::PredictionWithoutProvenance);
11672    }
11673
11674    #[test]
11675    fn output_v15_rejects_active_unit_scale_without_v4_provenance() {
11676        let check = CheckEvaluation::evaluated(
11677            ENGINE_UNIT_SCALE_CHECK_ID,
11678            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
11679        )
11680        .unwrap();
11681        let error = LintFileReport::new_v4(
11682            "unit-scale-without-provenance.glb",
11683            InputIdentity::from_bytes(&[]),
11684            prediction_test_rig(),
11685            None,
11686            vec![check],
11687            prediction_test_measurements_v16(),
11688        )
11689        .unwrap_err();
11690        assert_eq!(
11691            error,
11692            OutputContractError::InvalidPrediction(
11693                PredictionContractError::EngineUnitScaleFacetMismatch
11694            )
11695        );
11696    }
11697
11698    fn validated_lint_wire(
11699        provenance: &PredictionProvenanceV3,
11700        checks: Vec<CheckEvaluation>,
11701    ) -> serde_json::Value {
11702        let file = lint_file(provenance, checks).expect("producer accepts exact N");
11703        let envelope =
11704            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11705                .unwrap();
11706        let wire = serde_json::to_value(envelope).unwrap();
11707        let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
11708        read.into_files().expect("reader accepts exact N");
11709        wire
11710    }
11711
11712    fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
11713        let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
11714        read.into_files().expect_err("reader must reject N+1")
11715    }
11716
11717    fn clip_boundary_profile() -> ResolvedEngineProfileV1 {
11718        let all_fact_ids = [
11719            EngineFactIdV1::AcceptedInputs,
11720            EngineFactIdV1::AnimationAddressability,
11721            EngineFactIdV1::AnimationChannelHandling,
11722            EngineFactIdV1::AnimationTargetAddressability,
11723            EngineFactIdV1::AxisConversionControl,
11724            EngineFactIdV1::ConstructHandling,
11725            EngineFactIdV1::ExactAxisConversion,
11726            EngineFactIdV1::ExtensionHandling,
11727            EngineFactIdV1::ResultingHierarchyScale,
11728            EngineFactIdV1::RootMotionAddressability,
11729            EngineFactIdV1::TargetCoordinateBasis,
11730            EngineFactIdV1::TargetLinearUnit,
11731            EngineFactIdV1::UnitConversionControl,
11732            EngineFactIdV1::WholeEndFrameRequired,
11733        ];
11734        let facts = all_fact_ids
11735            .into_iter()
11736            .map(|id| {
11737                let state = match id {
11738                    EngineFactIdV1::AcceptedInputs => {
11739                        EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11740                            SourceFormatV1::Fbx,
11741                        ]))
11742                    }
11743                    EngineFactIdV1::TargetCoordinateBasis => EngineFactStateV1::Known(
11744                        EngineFactValueV1::CoordinateBasis(EngineCoordinateBasisV1 {
11745                            handedness: EngineHandednessV1::Left,
11746                            up_axis: EngineUpAxisV1::Z,
11747                            forward_axis: EngineForwardAxisV1::PositiveX,
11748                        }),
11749                    ),
11750                    EngineFactIdV1::TargetLinearUnit => EngineFactStateV1::Known(
11751                        EngineFactValueV1::LinearUnit(EngineLinearUnitV1::Centimetre),
11752                    ),
11753                    EngineFactIdV1::UnitConversionControl
11754                    | EngineFactIdV1::AxisConversionControl => {
11755                        EngineFactStateV1::Known(EngineFactValueV1::ConversionControl(
11756                            EngineConversionControlV1::ImporterOption,
11757                        ))
11758                    }
11759                    EngineFactIdV1::WholeEndFrameRequired => {
11760                        EngineFactStateV1::Known(EngineFactValueV1::Boolean(true))
11761                    }
11762                    _ => EngineFactStateV1::Unknown,
11763                };
11764                EngineProfileFactV1::new(id, state)
11765            })
11766            .collect();
11767        ResolvedEngineProfileV1::new(
11768            EngineProfileSelectionV1::new("unreal", 1, "5.8", "fbx-importer").unwrap(),
11769            "urn:animsmith:engine-profile:unreal:1",
11770            facts,
11771            vec![],
11772            vec![
11773                EnginePrimarySourceV1::new(
11774                    ENGINE_CLIP_BOUNDARY_SOURCE_ID,
11775                    "5.8",
11776                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
11777                    "2026-08-20",
11778                    vec![EngineFactIdV1::WholeEndFrameRequired],
11779                    vec![],
11780                )
11781                .unwrap(),
11782                EnginePrimarySourceV1::new(
11783                    "unreal-coordinate-system-5.8",
11784                    "5.8",
11785                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
11786                    "2026-08-20",
11787                    vec![EngineFactIdV1::TargetCoordinateBasis],
11788                    vec![],
11789                )
11790                .unwrap(),
11791                EnginePrimarySourceV1::new(
11792                    "unreal-fbx-import-options-5.8",
11793                    "5.8",
11794                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
11795                    "2026-08-20",
11796                    vec![
11797                        EngineFactIdV1::AcceptedInputs,
11798                        EngineFactIdV1::UnitConversionControl,
11799                        EngineFactIdV1::AxisConversionControl,
11800                    ],
11801                    vec![],
11802                )
11803                .unwrap(),
11804                EnginePrimarySourceV1::new(
11805                    "unreal-units-5.8",
11806                    "5.8",
11807                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
11808                    "2026-08-20",
11809                    vec![EngineFactIdV1::TargetLinearUnit],
11810                    vec![],
11811                )
11812                .unwrap(),
11813            ],
11814        )
11815        .unwrap()
11816    }
11817
11818    fn exact_observed(value: serde_json::Value, kind: &'static str) -> serde_json::Value {
11819        serde_json::json!({
11820            "state": {"kind": "observed", "value": value},
11821            "disposition": "preserved",
11822            "provenance": {"kind": kind}
11823        })
11824    }
11825
11826    fn clip_boundary_raw_wire(unavailable_last: bool) -> serde_json::Value {
11827        let last_range = if unavailable_last {
11828            serde_json::json!({
11829                "state": {"kind": "unavailable", "value": "malformed"},
11830                "disposition": "baked",
11831                "provenance": null
11832            })
11833        } else {
11834            exact_observed(
11835                serde_json::json!({
11836                    "selection": "primary", "begin_units": 0, "end_units": 9_408_000
11837                }),
11838                "parser_projected",
11839            )
11840        };
11841        serde_json::json!({
11842            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11843            "source_facts": {
11844                "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11845                "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11846                "source_format": "fbx",
11847                "linear_unit": {
11848                    "state": "observed", "value": 0.01, "disposition": "preserved",
11849                    "provenance": {"kind": "format_defined"}
11850                },
11851                "coordinate_basis": {
11852                    "state": "observed",
11853                    "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11854                    "disposition": "preserved", "provenance": {"kind": "format_defined"}
11855                },
11856                "frames_per_second": {
11857                    "state": "observed", "value": 30.0, "disposition": "preserved",
11858                    "provenance": {"kind": "format_defined"}
11859                },
11860                "clips_coverage": {"state": "complete"},
11861                "constructs_coverage": {"state": "complete"},
11862                "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11863                "source_skeleton_coverage": "unavailable",
11864                "work": {
11865                    "inspected_rows": 3, "retained_rows": 3,
11866                    "retained_text_bytes": 0, "max_traversal_depth": 0
11867                }
11868            },
11869            "exact_source_timing": {
11870                "schema": crate::EXACT_SOURCE_TIMING_V1_ID,
11871                "time_basis": exact_observed(
11872                    serde_json::json!({"units_per_second": 141_120_000}),
11873                    "format_defined"
11874                ),
11875                "declared_time_mode": exact_observed(
11876                    serde_json::json!("fps30"), "source_declared"
11877                ),
11878                "effective_time_mode": exact_observed(
11879                    serde_json::json!("fps30"), "parser_projected"
11880                ),
11881                "declared_custom_frame_rate": exact_observed(
11882                    serde_json::json!({"binary64_bits": 30.0_f64.to_bits()}),
11883                    "source_declared"
11884                ),
11885                "frame_period": exact_observed(
11886                    serde_json::json!({"units_per_frame": 4_704_000}),
11887                    "derived_from_source"
11888                ),
11889                "declared_time_protocol": exact_observed(
11890                    serde_json::json!("default"), "source_declared"
11891                ),
11892                "effective_time_protocol": exact_observed(
11893                    serde_json::json!("default"), "parser_projected"
11894                ),
11895                "clip_coverage": {"state": "complete"},
11896                "clips": [
11897                    {
11898                        "source_clip_index": 0,
11899                        "source_time_range": exact_observed(
11900                            serde_json::json!({
11901                                "selection": "primary", "begin_units": 0,
11902                                "end_units": 4_704_000
11903                            }),
11904                            "parser_projected"
11905                        )
11906                    },
11907                    {
11908                        "source_clip_index": 1,
11909                        "source_time_range": exact_observed(
11910                            serde_json::json!({
11911                                "selection": "primary", "begin_units": 0,
11912                                "end_units": 4_704_001
11913                            }),
11914                            "parser_projected"
11915                        )
11916                    },
11917                    {"source_clip_index": 2, "source_time_range": last_range}
11918                ]
11919            }
11920        })
11921    }
11922
11923    fn clip_boundary_provenance(unavailable_last: bool) -> PredictionProvenanceV3 {
11924        let raw: RawSourceBindingV2 =
11925            serde_json::from_value(clip_boundary_raw_wire(unavailable_last)).unwrap();
11926        let profile = clip_boundary_profile();
11927        let clips = (0..3)
11928            .map(|index| EngineClipSettingsV1::new(format!("stack-{index}"), vec![]).unwrap())
11929            .collect();
11930        let settings = ResolvedEngineSettingsV2::new(
11931            &profile,
11932            vec![],
11933            clips,
11934            ResolvedEngineSettingsCoverageV2::complete(),
11935            ResolvedEngineSettingsWorkV2::new(3, 3, 3),
11936        )
11937        .unwrap();
11938        PredictionProvenanceV3::new(
11939            profile,
11940            SourceFormatV1::Fbx,
11941            settings,
11942            raw.clone(),
11943            DependencyClosureV1::unavailable(raw.primary_input().clone()),
11944        )
11945        .unwrap()
11946    }
11947
11948    fn clip_boundary_provenance_with_settings(
11949        provenance: &PredictionProvenanceV3,
11950        profile: ResolvedEngineProfileV1,
11951        clips: Vec<EngineClipSettingsV1>,
11952    ) -> PredictionProvenanceV3 {
11953        let retained = clips.len();
11954        let settings = ResolvedEngineSettingsV2::new(
11955            &profile,
11956            vec![],
11957            clips,
11958            ResolvedEngineSettingsCoverageV2::complete(),
11959            ResolvedEngineSettingsWorkV2::new(retained, retained, retained),
11960        )
11961        .unwrap();
11962        PredictionProvenanceV3::new(
11963            profile,
11964            provenance.source_format(),
11965            settings,
11966            provenance.raw_source().clone(),
11967            provenance.dependency_closure().clone(),
11968        )
11969        .unwrap()
11970    }
11971
11972    fn altered_clip_boundary_source_profile(
11973        provenance: &PredictionProvenanceV3,
11974    ) -> ResolvedEngineProfileV1 {
11975        let sources = provenance
11976            .profile()
11977            .primary_sources()
11978            .iter()
11979            .map(|source| {
11980                let url = if source.id() == ENGINE_CLIP_BOUNDARY_SOURCE_ID {
11981                    format!("{}#altered", source.url())
11982                } else {
11983                    source.url().to_owned()
11984                };
11985                EnginePrimarySourceV1::new(
11986                    source.id(),
11987                    source.target_version(),
11988                    url,
11989                    source.verified_on(),
11990                    source.supported_fact_ids().to_vec(),
11991                    source.supported_setting_ids().to_vec(),
11992                )
11993                .unwrap()
11994            })
11995            .collect();
11996        ResolvedEngineProfileV1::new(
11997            provenance.profile().selection().clone(),
11998            provenance.profile().fact_bundle_urn(),
11999            provenance.profile().facts().to_vec(),
12000            provenance.profile().setting_descriptors().to_vec(),
12001            sources,
12002        )
12003        .unwrap()
12004    }
12005
12006    fn clip_boundary_scope(source_clip_index: usize) -> EvaluationScope {
12007        EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
12008            .subject(format!("source_stack:{source_clip_index}"))
12009    }
12010
12011    struct InapplicableClipBoundaryCheck;
12012
12013    impl crate::Check for InapplicableClipBoundaryCheck {
12014        fn id(&self) -> &'static str {
12015            ENGINE_CLIP_BOUNDARY_CHECK_ID
12016        }
12017
12018        fn applicability(&self, _ctx: &crate::CheckCtx<'_>) -> Applicability {
12019            Applicability::NotApplicable
12020        }
12021
12022        fn evaluate(&self, _ctx: &crate::CheckCtx<'_>) -> CheckOutput {
12023            panic!("an inapplicable check must not be evaluated")
12024        }
12025    }
12026
12027    fn inapplicable_clip_boundary_check() -> CheckEvaluation {
12028        let document = Document::default();
12029        let grids = crate::MetricGrids::new(&document);
12030        let roles = ResolvedRoles::default();
12031        let config = crate::Config::default();
12032        let context = crate::CheckCtx::new(&grids, &roles, &config);
12033        let checks: Vec<Box<dyn crate::Check>> = vec![Box::new(InapplicableClipBoundaryCheck)];
12034        crate::evaluate_checks(&context, &checks, crate::CheckSelection::All)
12035            .unwrap()
12036            .pop()
12037            .unwrap()
12038    }
12039
12040    fn clip_boundary_check(
12041        provenance: &PredictionProvenanceV3,
12042        unavailable_last: bool,
12043        first_basis: Option<EnginePredictionBasisV2>,
12044    ) -> CheckEvaluation {
12045        let timing = provenance.raw_source().exact_source_timing();
12046        let scopes = (0..3).map(clip_boundary_scope).collect::<Vec<_>>();
12047        let mut facets = Vec::new();
12048        for (index, scope) in scopes.iter().cloned().enumerate() {
12049            let basis = if index == 0 {
12050                first_basis
12051                    .clone()
12052                    .unwrap_or_else(|| engine_clip_boundary_stack_basis(timing, index).unwrap())
12053            } else {
12054                engine_clip_boundary_stack_basis(timing, index).unwrap()
12055            };
12056            if unavailable_last && index == 2 {
12057                facets.push(
12058                    EnginePredictionFacetV3::required_unavailable(
12059                        scope,
12060                        basis,
12061                        engine_clip_boundary_unavailable_reasons(timing, index).unwrap(),
12062                    )
12063                    .unwrap(),
12064                );
12065            } else {
12066                facets.push(EnginePredictionFacetV3::available(scope, basis).unwrap());
12067            }
12068        }
12069        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
12070        let finding = Finding::new(
12071            ENGINE_CLIP_BOUNDARY_CHECK_ID,
12072            Severity::Warning,
12073            "fractional exact source clip end",
12074        )
12075        .prediction_scope(scopes[1].clone());
12076        let evaluated_scopes = if unavailable_last {
12077            scopes[..2].to_vec()
12078        } else {
12079            scopes
12080        };
12081        CheckEvaluation::evaluated(
12082            ENGINE_CLIP_BOUNDARY_CHECK_ID,
12083            CheckOutput::from_coverage(vec![finding], evaluated_scopes, vec![])
12084                .with_engine_prediction_v3(prediction),
12085        )
12086        .unwrap()
12087    }
12088
12089    fn clip_boundary_lint_wire(unavailable_last: bool) -> serde_json::Value {
12090        let provenance = clip_boundary_provenance(unavailable_last);
12091        let check = clip_boundary_check(&provenance, unavailable_last, None);
12092        let file = LintFileReport::new(
12093            "test.fbx",
12094            provenance.raw_source().primary_input().clone(),
12095            prediction_test_rig(),
12096            Some(provenance),
12097            vec![check],
12098            prediction_test_measurements_v16(),
12099        )
12100        .unwrap();
12101        let envelope =
12102            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
12103                .unwrap();
12104        let wire = serde_json::to_value(envelope).unwrap();
12105        serde_json::from_value::<MeasurementReportInput>(wire.clone())
12106            .unwrap()
12107            .into_files()
12108            .unwrap();
12109        wire
12110    }
12111
12112    fn assert_clip_boundary_read_error(wire: serde_json::Value, expected: PredictionContractError) {
12113        assert_eq!(
12114            lint_read_error(wire),
12115            MeasurementReportError::File {
12116                file_index: 0,
12117                source: MeasurementFileError::InvalidPrediction {
12118                    check_index: 0,
12119                    source: expected,
12120                },
12121            }
12122        );
12123    }
12124
12125    #[test]
12126    fn exact_source_raw_source_v2_observed_values_round_trip_and_reject_hostile_mutations() {
12127        let wire = clip_boundary_raw_wire(false);
12128        let binding: RawSourceBindingV2 = serde_json::from_value(wire.clone()).unwrap();
12129        assert_eq!(serde_json::to_value(binding).unwrap(), wire);
12130
12131        let mut invalid_value = wire.clone();
12132        invalid_value["exact_source_timing"]["frame_period"]["state"]["value"]["units_per_frame"] =
12133            serde_json::json!(0);
12134        assert_eq!(
12135            serde_json::from_value::<RawSourceBindingV2>(invalid_value)
12136                .unwrap_err()
12137                .to_string(),
12138            PredictionContractError::ExactSourceTimingValueMismatch.to_string()
12139        );
12140
12141        let mut invalid_coverage = wire.clone();
12142        invalid_coverage["exact_source_timing"]["clip_coverage"] = serde_json::json!({
12143            "state": "partial", "reason": "projection_budget_exceeded"
12144        });
12145        assert_eq!(
12146            serde_json::from_value::<RawSourceBindingV2>(invalid_coverage)
12147                .unwrap_err()
12148                .to_string(),
12149            PredictionContractError::ExactSourceTimingCoverageMismatch.to_string()
12150        );
12151
12152        let mut invalid_prefix = wire;
12153        invalid_prefix["exact_source_timing"]["clips"][1]["source_clip_index"] =
12154            serde_json::json!(2);
12155        assert_eq!(
12156            serde_json::from_value::<RawSourceBindingV2>(invalid_prefix)
12157                .unwrap_err()
12158                .to_string(),
12159            PredictionContractError::ExactSourceTimingClipPrefixMismatch.to_string()
12160        );
12161    }
12162
12163    #[test]
12164    fn clip_boundary_v3_readback_rejects_scope_basis_reason_and_finding_mutations() {
12165        let wire = clip_boundary_lint_wire(false);
12166
12167        let mut wrong_scope = wire.clone();
12168        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["subject"] =
12169            serde_json::json!("source_stack:9");
12170        wrong_scope["files"][0]["checks"][0]["evaluated_scopes"][0]["subject"] =
12171            serde_json::json!("source_stack:9");
12172        assert_clip_boundary_read_error(
12173            wrong_scope,
12174            PredictionContractError::EngineClipBoundaryFacetMismatch,
12175        );
12176
12177        let mut wrong_basis = wire.clone();
12178        wrong_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"] =
12179            serde_json::to_value(
12180                EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap(),
12181            )
12182            .unwrap();
12183        assert_clip_boundary_read_error(
12184            wrong_basis,
12185            PredictionContractError::EngineClipBoundaryFacetMismatch,
12186        );
12187
12188        let mut missing_finding = wire;
12189        missing_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([]);
12190        assert_clip_boundary_read_error(
12191            missing_finding,
12192            PredictionContractError::EngineClipBoundaryFindingMismatch,
12193        );
12194
12195        let mut wrong_reason = clip_boundary_lint_wire(true);
12196        wrong_reason["files"][0]["checks"][0]["prediction"]["facets"][2]["reasons"] =
12197            serde_json::json!(["animsmith:source_frame_period_unavailable"]);
12198        assert_clip_boundary_read_error(
12199            wrong_reason,
12200            PredictionContractError::EngineClipBoundaryFacetMismatch,
12201        );
12202    }
12203
12204    #[test]
12205    fn clip_boundary_v3_rederives_applicability_for_producer_and_readback() {
12206        let provenance = clip_boundary_provenance(false);
12207        assert!(matches!(
12208            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12209            Err(OutputContractError::InvalidPrediction(
12210                PredictionContractError::EngineClipBoundaryFacetMismatch
12211            ))
12212        ));
12213
12214        let mut wire = clip_boundary_lint_wire(false);
12215        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12216        check.insert(
12217            "applicability".to_owned(),
12218            serde_json::json!("not_applicable"),
12219        );
12220        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12221        check.insert("findings".to_owned(), serde_json::json!([]));
12222        check.remove("evaluated_scopes");
12223        check.remove("gaps");
12224        check.remove("prediction");
12225        assert_clip_boundary_read_error(
12226            wire,
12227            PredictionContractError::EngineClipBoundaryFacetMismatch,
12228        );
12229    }
12230
12231    #[test]
12232    fn clip_boundary_v3_applicability_uses_raw_exact_stack_inventory() {
12233        let original = clip_boundary_provenance(false);
12234        let provenance = clip_boundary_provenance_with_settings(
12235            &original,
12236            original.profile().clone(),
12237            Vec::new(),
12238        );
12239        assert_eq!(
12240            provenance
12241                .raw_source()
12242                .exact_source_timing()
12243                .unwrap()
12244                .clips()
12245                .len(),
12246            3
12247        );
12248        assert!(provenance.settings().clips().is_empty());
12249        assert!(matches!(
12250            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12251            Err(OutputContractError::InvalidPrediction(
12252                PredictionContractError::EngineClipBoundaryFacetMismatch
12253            ))
12254        ));
12255
12256        let mut wire = clip_boundary_lint_wire(false);
12257        wire["files"][0]["prediction_provenance"] = serde_json::to_value(provenance).unwrap();
12258        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12259        check.insert(
12260            "applicability".to_owned(),
12261            serde_json::json!("not_applicable"),
12262        );
12263        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12264        check.insert("findings".to_owned(), serde_json::json!([]));
12265        check.remove("evaluated_scopes");
12266        check.remove("gaps");
12267        check.remove("prediction");
12268        assert_clip_boundary_read_error(
12269            wire,
12270            PredictionContractError::EngineClipBoundaryFacetMismatch,
12271        );
12272    }
12273
12274    #[test]
12275    fn clip_boundary_v3_binds_the_frozen_unreal_profile_identity() {
12276        let original = clip_boundary_provenance(false);
12277        assert_eq!(
12278            original.profile().facts_identity().sha256(),
12279            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
12280        );
12281        assert_eq!(
12282            original.profile().facts_identity().bytes(),
12283            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
12284        );
12285        let altered_profile = altered_clip_boundary_source_profile(&original);
12286        assert_ne!(
12287            altered_profile.facts_identity(),
12288            original.profile().facts_identity()
12289        );
12290        let altered = clip_boundary_provenance_with_settings(
12291            &original,
12292            altered_profile,
12293            original.settings().clips().to_vec(),
12294        );
12295        let altered_check = clip_boundary_check(&altered, false, None);
12296        assert!(matches!(
12297            lint_file(&altered, vec![altered_check.clone()]),
12298            Err(OutputContractError::InvalidPrediction(
12299                PredictionContractError::EngineClipBoundaryFacetMismatch
12300            ))
12301        ));
12302
12303        let mut wire = clip_boundary_lint_wire(false);
12304        wire["files"][0]["prediction_provenance"] = serde_json::to_value(altered).unwrap();
12305        wire["files"][0]["checks"][0] = serde_json::to_value(altered_check).unwrap();
12306        assert_clip_boundary_read_error(
12307            wire,
12308            PredictionContractError::EngineClipBoundaryFacetMismatch,
12309        );
12310    }
12311
12312    #[test]
12313    fn clip_boundary_v3_producer_rejects_incomplete_exact_basis() {
12314        let provenance = clip_boundary_provenance(false);
12315        let incomplete_basis =
12316            EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap();
12317        let check = clip_boundary_check(&provenance, false, Some(incomplete_basis));
12318        assert!(matches!(
12319            lint_file(&provenance, vec![check]),
12320            Err(OutputContractError::InvalidPrediction(
12321                PredictionContractError::EngineClipBoundaryFacetMismatch
12322            ))
12323        ));
12324    }
12325
12326    fn prediction_with_retained_text(
12327        provenance: &PredictionProvenanceV3,
12328        retained_text: usize,
12329    ) -> EnginePredictionV3 {
12330        const FIELD_ID_BYTES: usize = 16;
12331        const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
12332        let fixed = "test:prediction-limit".len()
12333            + PredictionUnavailableReasonV2::ProjectIntentUnavailable
12334                .as_str()
12335                .len();
12336        let remaining = retained_text.checked_sub(fixed).unwrap();
12337        let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
12338        let full_rows = remaining / full_row;
12339        let remainder = remaining % full_row;
12340        let (full_rows, tail_lengths) = if remainder == 0 {
12341            (full_rows, Vec::new())
12342        } else if remainder >= FIELD_ID_BYTES {
12343            (full_rows, vec![remainder - FIELD_ID_BYTES])
12344        } else {
12345            (
12346                full_rows - 1,
12347                vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
12348            )
12349        };
12350        let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
12351        for index in 0..full_rows {
12352            references.push(
12353                PredictionBasisReferenceV1::project_field(
12354                    format!("f{index:015}"),
12355                    PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
12356                )
12357                .unwrap(),
12358            );
12359        }
12360        for length in tail_lengths {
12361            let index = references.len();
12362            references.push(
12363                PredictionBasisReferenceV1::project_field(
12364                    format!("f{index:015}"),
12365                    PredictionScalarV1::text("x".repeat(length)).unwrap(),
12366                )
12367                .unwrap(),
12368            );
12369        }
12370        let basis = EnginePredictionBasisV1::new(references).unwrap();
12371        let facet = EnginePredictionFacetV3::required_unavailable(
12372            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
12373            basis_v2(basis),
12374            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12375        )
12376        .unwrap();
12377        let prediction =
12378            EnginePredictionV3::new(provenance.identity().clone(), vec![facet]).unwrap();
12379        assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
12380        prediction
12381    }
12382
12383    #[test]
12384    fn report_reader_enforces_the_byte_cap_before_json_parsing() {
12385        let bytes = br#"{"schema_version":10,"tool":{}}"#;
12386        let report =
12387            MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
12388                .expect("exact N must parse");
12389        assert_eq!(report.schema_version, Some(10));
12390
12391        assert!(matches!(
12392            MeasurementReportInput::read_from_with_limit(
12393                bytes.as_slice(),
12394                bytes.len() as u64 - 1,
12395            ),
12396            Err(MeasurementReportReadError::ReportTooLarge { limit })
12397                if limit == bytes.len() as u64 - 1
12398        ));
12399    }
12400
12401    #[test]
12402    fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12403        let provenance = prediction_test_provenance();
12404        let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12405        let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
12406            .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
12407            .collect();
12408        let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
12409        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12410        let extra = unavailable_check(
12411            "test:facet-extra",
12412            &provenance,
12413            vec![unavailable_facet("facet-extra".into(), empty_basis)],
12414        );
12415
12416        assert_eq!(
12417            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12418            OutputContractError::TooManyPredictionFacets {
12419                found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12420                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12421            }
12422        );
12423
12424        wire["files"][0]["checks"]
12425            .as_array_mut()
12426            .unwrap()
12427            .push(serde_json::to_value(extra).unwrap());
12428        assert_eq!(
12429            lint_read_error(wire),
12430            MeasurementReportError::File {
12431                file_index: 0,
12432                source: MeasurementFileError::TooManyPredictionFacets {
12433                    found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12434                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12435                },
12436            }
12437        );
12438    }
12439
12440    #[test]
12441    fn v2_budget_summary_is_canonical_and_requires_an_exhausted_file_budget() {
12442        let provenance = prediction_test_provenance();
12443        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12444        let mut facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12445            .map(|index| unavailable_facet(format!("facet-{index:04}"), basis.clone()))
12446            .collect::<Vec<_>>();
12447        facets.push(
12448            EnginePredictionFacetV3::required_unavailable(
12449                EvaluationScope::new(EvaluationScopeCode::custom("test:budget:facet-budget")),
12450                basis_v2(basis),
12451                vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12452            )
12453            .unwrap(),
12454        );
12455        let check = unavailable_check("test:budget", &provenance, facets);
12456        let wire = validated_lint_wire(&provenance, vec![check]);
12457        let facets = wire["files"][0]["checks"][0]["prediction"]["facets"]
12458            .as_array()
12459            .unwrap();
12460        let summary_index = facets
12461            .iter()
12462            .position(|facet| facet["reasons"] == serde_json::json!(["facet_budget_exceeded"]))
12463            .unwrap();
12464
12465        let mut wrong_scope = wire.clone();
12466        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["code"] =
12467            serde_json::json!("test:wrong:facet-budget");
12468        assert!(matches!(
12469            lint_read_error(wrong_scope),
12470            MeasurementReportError::File {
12471                source: MeasurementFileError::InvalidPrediction {
12472                    source: PredictionContractError::InvalidFacetBudgetSummary,
12473                    ..
12474                },
12475                ..
12476            }
12477        ));
12478
12479        let mut subject = wire.clone();
12480        subject["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["subject"] =
12481            serde_json::json!("forged");
12482        assert!(matches!(
12483            lint_read_error(subject),
12484            MeasurementReportError::File {
12485                source: MeasurementFileError::InvalidPrediction {
12486                    source: PredictionContractError::InvalidFacetBudgetSummary,
12487                    ..
12488                },
12489                ..
12490            }
12491        ));
12492
12493        let mut available = wire.clone();
12494        available["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["state"] =
12495            serde_json::json!("available");
12496        let available_error = lint_read_error(available);
12497        assert!(
12498            matches!(
12499                available_error,
12500                MeasurementReportError::File {
12501                    source: MeasurementFileError::InvalidPrediction {
12502                        source: PredictionContractError::AvailableBasisEmpty,
12503                        ..
12504                    },
12505                    ..
12506                }
12507            ),
12508            "unexpected available mutation: {available_error:?}"
12509        );
12510
12511        let mut duplicate = wire.clone();
12512        let duplicate_summary =
12513            duplicate["files"][0]["checks"][0]["prediction"]["facets"][summary_index].clone();
12514        duplicate["files"][0]["checks"][0]["prediction"]["facets"]
12515            [if summary_index == 0 { 1 } else { 0 }] = duplicate_summary;
12516        assert!(matches!(
12517            lint_read_error(duplicate),
12518            MeasurementReportError::File {
12519                source: MeasurementFileError::InvalidPrediction {
12520                    source: PredictionContractError::DuplicateFacetScope,
12521                    ..
12522                },
12523                ..
12524            }
12525        ));
12526
12527        let mut under_full = wire;
12528        under_full["files"][0]["checks"][0]["prediction"]["facets"]
12529            .as_array_mut()
12530            .unwrap()
12531            .remove(if summary_index == 0 { 1 } else { 0 });
12532        under_full["summary"]["prediction_facets"]["required_prediction_unavailable"] =
12533            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE - 1);
12534        assert_eq!(
12535            lint_read_error(under_full),
12536            MeasurementReportError::File {
12537                file_index: 0,
12538                source: MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
12539                    found: PREDICTION_V1_MAX_FACETS_PER_FILE - 1,
12540                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12541                },
12542            }
12543        );
12544    }
12545
12546    #[test]
12547    fn partial_engine_inventory_can_be_replaced_by_its_budget_summary() {
12548        let provenance = partial_engine_provenance();
12549        let summary = EnginePredictionFacetV3::required_unavailable(
12550            EvaluationScope::new(EvaluationScopeCode::custom(
12551                "engine-addressability:facet-budget",
12552            )),
12553            basis_v2(EnginePredictionBasisV1::new(Vec::new()).unwrap()),
12554            vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12555        )
12556        .unwrap();
12557        let engine = unavailable_check("engine-addressability", &provenance, vec![summary]);
12558        let filler_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12559        let filler = unavailable_check(
12560            "test:filler",
12561            &provenance,
12562            (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12563                .map(|index| unavailable_facet(format!("filler-{index:04}"), filler_basis.clone()))
12564                .collect(),
12565        );
12566        // A capacity-zero incomplete inventory is represented by the one
12567        // canonical summary while other rules consume the retained slots.
12568        let wire = validated_lint_wire(&provenance, vec![engine, filler]);
12569        assert_eq!(
12570            wire["summary"]["prediction_facets"]["required_prediction_unavailable"],
12571            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE)
12572        );
12573    }
12574
12575    #[test]
12576    fn engine_addressability_inventory_reasons_follow_raw_and_settings_coverage() {
12577        let partial = partial_engine_provenance();
12578        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12579        let inventory = EnginePredictionFacetV3::required_unavailable(
12580            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12581            basis_v2(basis.clone()),
12582            vec![
12583                PredictionUnavailableReasonV2::RawSourceIncomplete,
12584                PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
12585            ],
12586        )
12587        .unwrap();
12588        let check = unavailable_check("engine-addressability", &partial, vec![inventory]);
12589        let mut wire = validated_lint_wire(&partial, vec![check]);
12590        wire["files"][0]["checks"][0]["prediction"]["facets"][0]["reasons"] =
12591            serde_json::json!(["raw_source_incomplete"]);
12592        assert!(matches!(
12593            lint_read_error(wire),
12594            MeasurementReportError::File {
12595                source: MeasurementFileError::InvalidPrediction {
12596                    source: PredictionContractError::EngineAddressabilityInventoryReasonsMismatch,
12597                    ..
12598                },
12599                ..
12600            }
12601        ));
12602
12603        let complete = prediction_test_provenance();
12604        let forged = EnginePredictionFacetV3::required_unavailable(
12605            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12606            basis_v2(basis),
12607            vec![PredictionUnavailableReasonV2::ResolvedSettingsOverflow],
12608        )
12609        .unwrap();
12610        assert!(matches!(
12611            lint_file(
12612                &complete,
12613                vec![unavailable_check(
12614                    "engine-addressability",
12615                    &complete,
12616                    vec![forged]
12617                )],
12618            ),
12619            Err(OutputContractError::InvalidPrediction(
12620                PredictionContractError::EngineAddressabilityInventoryReasonsMismatch
12621            ))
12622        ));
12623    }
12624
12625    #[test]
12626    fn engine_addressability_rejects_a_non_addressability_available_facet_prefix() {
12627        let mut wire = clip_boundary_lint_wire(false);
12628        let check = &mut wire["files"][0]["checks"][0];
12629        check["check_id"] = serde_json::json!("engine-addressability");
12630        for (index, facet) in check["prediction"]["facets"]
12631            .as_array_mut()
12632            .unwrap()
12633            .iter_mut()
12634            .enumerate()
12635        {
12636            facet["scope"]["code"] = serde_json::json!("animation_asset_label");
12637            facet["scope"]["subject"] = serde_json::json!(format!("Animation{index}"));
12638        }
12639        for (index, scope) in check["evaluated_scopes"]
12640            .as_array_mut()
12641            .unwrap()
12642            .iter_mut()
12643            .enumerate()
12644        {
12645            scope["code"] = serde_json::json!("animation_asset_label");
12646            scope["subject"] = serde_json::json!(format!("Animation{index}"));
12647        }
12648        check["findings"][0]["check_id"] = serde_json::json!("engine-addressability");
12649        check["findings"][0]["prediction_scope"]["code"] =
12650            serde_json::json!("animation_asset_label");
12651        check["findings"][0]["prediction_scope"]["subject"] = serde_json::json!("Animation1");
12652
12653        assert!(matches!(
12654            lint_read_error(wire),
12655            MeasurementReportError::File {
12656                source: MeasurementFileError::InvalidPrediction {
12657                    source: PredictionContractError::EngineAddressabilityFacetPrefixMismatch,
12658                    ..
12659                },
12660                ..
12661            }
12662        ));
12663    }
12664
12665    #[test]
12666    fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12667        let provenance = prediction_test_provenance();
12668        let basis = EnginePredictionBasisV1::new(
12669            (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
12670                .map(|index| {
12671                    PredictionBasisReferenceV1::project_field(
12672                        format!("project.field.{index:04}"),
12673                        PredictionScalarV1::Null,
12674                    )
12675                    .unwrap()
12676                })
12677                .collect(),
12678        )
12679        .unwrap();
12680        let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
12681            / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
12682        let facets = (0..facet_count)
12683            .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
12684            .collect();
12685        let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
12686        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12687        let extra_basis = EnginePredictionBasisV1::new(vec![
12688            PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
12689                .unwrap(),
12690        ])
12691        .unwrap();
12692        let extra = unavailable_check(
12693            "test:basis-extra",
12694            &provenance,
12695            vec![unavailable_facet("basis-extra".into(), extra_basis)],
12696        );
12697
12698        assert_eq!(
12699            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12700            OutputContractError::TooManyPredictionBasisReferences {
12701                found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
12702                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
12703            }
12704        );
12705
12706        wire["files"][0]["checks"]
12707            .as_array_mut()
12708            .unwrap()
12709            .push(serde_json::to_value(extra).unwrap());
12710        *wire["files"][0]["checks"]
12711            .as_array_mut()
12712            .unwrap()
12713            .last_mut()
12714            .unwrap()
12715            .get_mut("prediction")
12716            .unwrap()
12717            .get_mut("facets")
12718            .and_then(serde_json::Value::as_array_mut)
12719            .and_then(|facets| facets.first_mut())
12720            .and_then(|facet| facet.get_mut("basis"))
12721            .and_then(|basis| basis.get_mut("references"))
12722            .and_then(serde_json::Value::as_array_mut)
12723            .and_then(|references| references.first_mut())
12724            .unwrap() = serde_json::Value::Null;
12725        assert!(matches!(
12726            lint_read_error(wire),
12727            MeasurementReportError::File {
12728                file_index: 0,
12729                source: MeasurementFileError::TooManyPredictionBasisReferences { .. },
12730            }
12731        ));
12732    }
12733
12734    #[test]
12735    fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12736        let provenance = prediction_test_provenance();
12737        let provenance_text = provenance.retained_text_bytes().unwrap();
12738        let at_limit_prediction = prediction_with_retained_text(
12739            &provenance,
12740            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
12741        );
12742        let at_limit = CheckEvaluation::evaluated(
12743            "test:text-limit",
12744            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12745                .with_engine_prediction_v3(at_limit_prediction),
12746        )
12747        .unwrap();
12748        let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
12749
12750        let above_limit_prediction = prediction_with_retained_text(
12751            &provenance,
12752            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
12753        );
12754        let above_limit = CheckEvaluation::evaluated(
12755            "test:text-limit",
12756            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12757                .with_engine_prediction_v3(above_limit_prediction),
12758        )
12759        .unwrap();
12760        let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
12761        assert_eq!(
12762            lint_file(&provenance, vec![above_limit]).unwrap_err(),
12763            OutputContractError::TooMuchPredictionText {
12764                found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12765                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12766            }
12767        );
12768
12769        wire["files"][0]["checks"][0] = above_limit_wire;
12770        assert_eq!(
12771            lint_read_error(wire),
12772            MeasurementReportError::File {
12773                file_index: 0,
12774                source: MeasurementFileError::TooMuchPredictionText {
12775                    found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12776                    limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12777                },
12778            }
12779        );
12780    }
12781
12782    fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
12783        serde_json::from_value::<MeasurementReportInput>(wire)
12784            .expect("outer v11 shape remains valid")
12785            .into_files()
12786            .expect_err("mutated report must fail")
12787    }
12788
12789    fn empty_check(check_id: &'static str) -> CheckEvaluation {
12790        CheckEvaluation::evaluated(
12791            check_id,
12792            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
12793        )
12794        .unwrap()
12795    }
12796
12797    #[test]
12798    fn staged_reader_rejects_unknown_root_file_and_check_fields() {
12799        let provenance = prediction_test_provenance();
12800        let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12801
12802        let mut root = wire.clone();
12803        root["unknown_root"] = serde_json::json!(true);
12804        let bytes = serde_json::to_vec(&root).unwrap();
12805        assert_eq!(
12806            MeasurementReportInput::read_from(bytes.as_slice())
12807                .expect("unknown root fields are retained through the staged read")
12808                .into_files()
12809                .unwrap_err(),
12810            MeasurementReportError::UnknownOutputField {
12811                field: "unknown_root".into(),
12812            }
12813        );
12814
12815        let mut missing_tool = wire.clone();
12816        missing_tool.as_object_mut().unwrap().remove("tool");
12817        assert_eq!(
12818            reader_error(missing_tool),
12819            MeasurementReportError::MissingTool
12820        );
12821
12822        let bare = br#"{"walk":true}"#;
12823        assert_eq!(
12824            MeasurementReportInput::read_from(bare.as_slice())
12825                .expect("unknown root fields remain staged until header validation")
12826                .into_files()
12827                .unwrap_err(),
12828            MeasurementReportError::MissingOutputVersion,
12829        );
12830
12831        let unsupported = br#"{"schema_version":9,"walk":true}"#;
12832        assert_eq!(
12833            MeasurementReportInput::read_from(unsupported.as_slice())
12834                .expect("unknown root fields remain staged until header validation")
12835                .into_files()
12836                .unwrap_err(),
12837            MeasurementReportError::UnsupportedOutputVersion { found: 9 },
12838        );
12839
12840        let mut file = wire.clone();
12841        file["files"][0]["unknown_file"] = serde_json::json!(true);
12842        assert!(matches!(
12843            reader_error(file),
12844            MeasurementReportError::File {
12845                file_index: 0,
12846                source: MeasurementFileError::InvalidFileShape { reason },
12847            } if reason.contains("unknown field `unknown_file`")
12848        ));
12849
12850        let mut check = wire;
12851        check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
12852        assert!(matches!(
12853            reader_error(check),
12854            MeasurementReportError::File {
12855                file_index: 0,
12856                source: MeasurementFileError::InvalidPredictionShape {
12857                    check_index: 0,
12858                    reason,
12859                },
12860            } if reason.contains("unknown field `unknown_check`")
12861        ));
12862
12863        let provenance = prediction_test_provenance();
12864        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12865        summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
12866        let bytes = serde_json::to_vec(&summary).unwrap();
12867        assert!(matches!(
12868            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12869            MeasurementReportReadError::InvalidJson { source }
12870                if source.to_string().contains("unknown field `unknown_prediction_total`")
12871        ));
12872
12873        let provenance = prediction_test_provenance();
12874        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12875        summary["summary"]["unknown_summary"] = serde_json::json!(0);
12876        let bytes = serde_json::to_vec(&summary).unwrap();
12877        assert!(matches!(
12878            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12879            MeasurementReportReadError::InvalidJson { source }
12880                if source.to_string().contains("unknown field `unknown_summary`")
12881        ));
12882    }
12883
12884    #[test]
12885    fn staged_reader_preserves_typed_prediction_semantic_errors() {
12886        let provenance = prediction_test_provenance();
12887        let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
12888        provenance_wire["files"][0]["prediction_provenance"]["schema"] =
12889            serde_json::json!("urn:changed");
12890        assert!(matches!(
12891            reader_error(provenance_wire),
12892            MeasurementReportError::File {
12893                file_index: 0,
12894                source: MeasurementFileError::InvalidPredictionProvenance { .. },
12895            }
12896        ));
12897
12898        let basis = EnginePredictionBasisV1::new(vec![
12899            PredictionBasisReferenceV1::project_field(
12900                "test:project",
12901                PredictionScalarV1::Boolean { value: true },
12902            )
12903            .unwrap(),
12904        ])
12905        .unwrap();
12906        let facet = EnginePredictionFacetV3::required_unavailable(
12907            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12908            basis_v2(basis),
12909            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12910        )
12911        .unwrap();
12912        let prediction_wire = validated_lint_wire(
12913            &provenance,
12914            vec![unavailable_check("test:reader", &provenance, vec![facet])],
12915        );
12916
12917        let mut wrong_emitter = prediction_wire.clone();
12918        wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12919            serde_json::json!("member_existence");
12920        assert!(matches!(
12921            reader_error(wrong_emitter),
12922            MeasurementReportError::File {
12923                file_index: 0,
12924                source: MeasurementFileError::InvalidPredictionLifecycle {
12925                    check_index: 0,
12926                    reason: "prediction facet scope code is invalid for its parent check",
12927                },
12928            }
12929        ));
12930
12931        let mut empty_scope = prediction_wire.clone();
12932        empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12933            serde_json::json!("");
12934        assert!(matches!(
12935            reader_error(empty_scope),
12936            MeasurementReportError::File {
12937                file_index: 0,
12938                source: MeasurementFileError::InvalidPrediction { check_index: 0, .. },
12939            }
12940        ));
12941
12942        let mut prediction_wire = prediction_wire;
12943        prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
12944            ["bytes"] = serde_json::json!(0);
12945        assert!(matches!(
12946            reader_error(prediction_wire),
12947            MeasurementReportError::File {
12948                file_index: 0,
12949                source: MeasurementFileError::InvalidPrediction {
12950                    check_index: 0,
12951                    source: PredictionContractError::IdentityMismatch {
12952                        contract: "engine prediction basis v2",
12953                    },
12954                },
12955            }
12956        ));
12957    }
12958
12959    #[test]
12960    fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
12961        let provenance = prediction_test_provenance();
12962        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12963
12964        for (field, state) in [
12965            ("selection", "unselected"),
12966            ("configuration", "disabled"),
12967            ("applicability", "not_applicable"),
12968        ] {
12969            let mut inactive = base.clone();
12970            inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
12971            assert!(matches!(
12972                reader_error(inactive),
12973                MeasurementReportError::File {
12974                    file_index: 0,
12975                    source: MeasurementFileError::InvalidPredictionLifecycle {
12976                        check_index: 0,
12977                        reason: "evaluation does not match completed and missing prediction work",
12978                    },
12979                }
12980            ));
12981        }
12982
12983        let mut inactive = base.clone();
12984        inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
12985        inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12986        serde_json::from_value::<MeasurementReportInput>(inactive)
12987            .unwrap()
12988            .into_files()
12989            .expect("empty inactive record is valid");
12990
12991        let mut not_evaluated = base.clone();
12992        not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
12993            "code": "test:missing",
12994            "message": "missing",
12995        }]);
12996        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
12997        serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
12998            .unwrap()
12999            .into_files()
13000            .expect("missing-only active record derives not_evaluated");
13001        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
13002        assert!(matches!(
13003            reader_error(not_evaluated),
13004            MeasurementReportError::File {
13005                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13006                ..
13007            }
13008        ));
13009
13010        let mut partial = base.clone();
13011        partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
13012            "code": "test:missing",
13013            "message": "missing",
13014        }]);
13015        partial["files"][0]["checks"][0]["evaluated_scopes"] =
13016            serde_json::json!([{ "code": "test:completed" }]);
13017        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13018        serde_json::from_value::<MeasurementReportInput>(partial.clone())
13019            .unwrap()
13020            .into_files()
13021            .expect("mixed active record derives partial");
13022        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
13023        assert!(matches!(
13024            reader_error(partial),
13025            MeasurementReportError::File {
13026                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13027                ..
13028            }
13029        ));
13030
13031        let mut wrong_complete = base;
13032        wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13033        assert!(matches!(
13034            reader_error(wrong_complete),
13035            MeasurementReportError::File {
13036                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13037                ..
13038            }
13039        ));
13040    }
13041
13042    #[test]
13043    fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
13044        let provenance = prediction_test_provenance();
13045        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
13046
13047        let mut empty_scope = base.clone();
13048        empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
13049            serde_json::json!([{ "code": "" }]);
13050        assert!(matches!(
13051            reader_error(empty_scope),
13052            MeasurementReportError::File {
13053                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13054                ..
13055            }
13056        ));
13057
13058        let mut malformed_gap = base.clone();
13059        malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
13060            "code": "",
13061            "message": "missing",
13062        }]);
13063        malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
13064        assert!(matches!(
13065            reader_error(malformed_gap),
13066            MeasurementReportError::File {
13067                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13068                ..
13069            }
13070        ));
13071
13072        let mut incomplete_finding = base;
13073        incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
13074            "check_id": "test:reader",
13075        }]);
13076        assert!(matches!(
13077            reader_error(incomplete_finding),
13078            MeasurementReportError::File {
13079                source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
13080                ..
13081            }
13082        ));
13083    }
13084
13085    #[test]
13086    fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
13087        let provenance = prediction_test_provenance();
13088        let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
13089        let mut later_file = wire["files"][0].clone();
13090        later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
13091        wire["files"].as_array_mut().unwrap().push(later_file);
13092        wire["summary"]["files"] = serde_json::json!(2);
13093        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13094
13095        assert!(matches!(
13096            reader_error(wire),
13097            MeasurementReportError::File {
13098                file_index: 0,
13099                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13100            }
13101        ));
13102    }
13103
13104    #[test]
13105    fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
13106        let provenance = prediction_test_provenance();
13107        let basis = EnginePredictionBasisV1::new(vec![
13108            PredictionBasisReferenceV1::project_field(
13109                "test:project",
13110                PredictionScalarV1::Boolean { value: true },
13111            )
13112            .unwrap(),
13113        ])
13114        .unwrap();
13115        let facet = EnginePredictionFacetV3::required_unavailable(
13116            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
13117            basis_v2(basis),
13118            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
13119        )
13120        .unwrap();
13121        let mut wire = validated_lint_wire(
13122            &provenance,
13123            vec![
13124                empty_check("test:first"),
13125                unavailable_check("test:second", &provenance, vec![facet]),
13126            ],
13127        );
13128        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13129        wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
13130
13131        assert!(matches!(
13132            reader_error(wire),
13133            MeasurementReportError::File {
13134                file_index: 0,
13135                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13136            }
13137        ));
13138    }
13139
13140    #[test]
13141    fn v11_nested_version_is_rejected_before_current_shape_decode() {
13142        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
13143            "schema_version": OUTPUT_SCHEMA_VERSION,
13144            "schema": OUTPUT_SCHEMA_ID,
13145            "tool": {},
13146            "command": "measure",
13147            "files": [{
13148                "path": "measurements-v11.json",
13149                "input": { "sha256": "0".repeat(64), "bytes": 0 },
13150                "rig": {},
13151                "measurements": {
13152                    "schema_version": 11,
13153                    "schema": "urn:animsmith:schema:measurements:11",
13154                    "skeleton_nodes": [{
13155                        "node_index": 0,
13156                        "scene_root_indices": [],
13157                        "local_rest": {
13158                            "kind": "trs",
13159                            "translation_m": [0.0, 0.0, 0.0],
13160                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
13161                            "scale": [1.0, 1.0, 1.0]
13162                        },
13163                        "rest_world_matrix": [
13164                            1.0, 0.0, 0.0, 0.0,
13165                            0.0, 1.0, 0.0, 0.0,
13166                            0.0, 0.0, 1.0, 0.0,
13167                            0.0, 0.0, 0.0, 1.0
13168                        ]
13169                    }],
13170                    "skins": [{ "skin_index": 0 }]
13171                }
13172            }]
13173        }))
13174        .expect("unsupported payload shapes remain decodable for version rejection");
13175
13176        assert!(matches!(
13177            report.into_files(),
13178            Err(MeasurementReportError::File {
13179                file_index: 0,
13180                source: MeasurementFileError::UnsupportedMeasurementVersion {
13181                    found: 11,
13182                    expected: MEASUREMENTS_SCHEMA_VERSION,
13183                },
13184            })
13185        ));
13186    }
13187
13188    #[test]
13189    fn current_v18_primitive_mutations_fail_closed_on_readback() {
13190        let wire = measure_wire(primitive_measurement_contract());
13191        let primitive = &wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0];
13192        assert_eq!(primitive["primitive_index"], serde_json::json!(1));
13193        assert_eq!(primitive["material_index"], serde_json::json!(7));
13194        assert_eq!(
13195            wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["material_index"],
13196            serde_json::Value::Null
13197        );
13198
13199        let mut missing_material = wire.clone();
13200        missing_material["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]
13201            .as_object_mut()
13202            .unwrap()
13203            .remove("material_index");
13204        assert!(matches!(
13205            reader_error(missing_material),
13206            MeasurementReportError::File {
13207                source: MeasurementFileError::InvalidMeasurementsShape { .. },
13208                ..
13209            }
13210        ));
13211
13212        let mut mutations = Vec::new();
13213        let mut missing = wire.clone();
13214        missing["files"][0]["measurements"]["mesh_definitions"][0]
13215            .as_object_mut()
13216            .unwrap()
13217            .remove("primitives");
13218        mutations.push(missing);
13219
13220        let mut duplicate_index = wire.clone();
13221        duplicate_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13222            serde_json::json!(1);
13223        mutations.push(duplicate_index);
13224
13225        let mut decreasing_index = wire.clone();
13226        decreasing_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13227            serde_json::json!(0);
13228        mutations.push(decreasing_index);
13229
13230        let mut finite_over = wire.clone();
13231        finite_over["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13232            serde_json::json!(3);
13233        mutations.push(finite_over);
13234
13235        let mut zero_with_facts = wire.clone();
13236        zero_with_facts["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13237            serde_json::json!(0);
13238        mutations.push(zero_with_facts);
13239
13240        let mut positive_without_centroid = wire.clone();
13241        positive_without_centroid["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13242            [0]
13243        .as_object_mut()
13244        .unwrap()
13245        .remove("geometry_centroid");
13246        mutations.push(positive_without_centroid);
13247
13248        let mut centroid_outside_aabb = wire.clone();
13249        centroid_outside_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13250            ["geometry_centroid"] = serde_json::json!([-3.0, 1.0, 0.0]);
13251        mutations.push(centroid_outside_aabb);
13252
13253        let mut missing_mesh_geometry = wire.clone();
13254        missing_mesh_geometry["files"][0]["measurements"]["mesh_definitions"][0]
13255            .as_object_mut()
13256            .unwrap()
13257            .remove("geometry_centroid");
13258        mutations.push(missing_mesh_geometry);
13259
13260        let mut wrong_mesh_aabb = wire.clone();
13261        wrong_mesh_aabb["files"][0]["measurements"]["mesh_definitions"][0]["geometry_aabb"]["max"]
13262            [0] = serde_json::json!(7.0);
13263        mutations.push(wrong_mesh_aabb);
13264
13265        let mut wrong_mesh_centroid = wire.clone();
13266        wrong_mesh_centroid["files"][0]["measurements"]["mesh_definitions"][0]["geometry_centroid"] =
13267            serde_json::json!([3.0, 7.0 / 3.0, 0.0]);
13268        mutations.push(wrong_mesh_centroid);
13269
13270        let mut wrong_sum = wire.clone();
13271        wrong_sum["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13272            serde_json::json!(3);
13273        mutations.push(wrong_sum);
13274
13275        let mut finite_sum_overflow = wire.clone();
13276        for primitive in
13277            finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13278                .as_array_mut()
13279                .unwrap()
13280        {
13281            primitive["vertex_count"] = serde_json::json!(u64::MAX);
13282            primitive["finite_vertex_count"] = serde_json::json!(u64::MAX);
13283        }
13284        finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13285            serde_json::json!(u64::MAX);
13286        mutations.push(finite_sum_overflow);
13287
13288        for mutation in mutations {
13289            assert!(matches!(
13290                reader_error(mutation),
13291                MeasurementReportError::File {
13292                    source: MeasurementFileError::InvalidMeasurements { .. },
13293                    ..
13294                }
13295            ));
13296        }
13297
13298        let mut unknown_root = wire.clone();
13299        unknown_root["files"][0]["measurements"]
13300            .as_object_mut()
13301            .unwrap()
13302            .insert("bogus".into(), serde_json::json!(true));
13303        let mut unknown_mesh = wire.clone();
13304        unknown_mesh["files"][0]["measurements"]["mesh_definitions"][0]
13305            .as_object_mut()
13306            .unwrap()
13307            .insert("bogus".into(), serde_json::json!(true));
13308        let mut unknown_primitive = wire.clone();
13309        unknown_primitive["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13310            .as_object_mut()
13311            .unwrap()
13312            .insert("bogus".into(), serde_json::json!(true));
13313        let mut unknown_aabb = wire;
13314        unknown_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13315            ["geometry_aabb"]
13316            .as_object_mut()
13317            .unwrap()
13318            .insert("bogus".into(), serde_json::json!(true));
13319        for mutation in [unknown_root, unknown_mesh, unknown_primitive, unknown_aabb] {
13320            assert!(matches!(
13321                reader_error(mutation),
13322                MeasurementReportError::File {
13323                    source: MeasurementFileError::InvalidMeasurementsShape { reason },
13324                    ..
13325                } if reason.contains("unknown field `bogus`")
13326            ));
13327        }
13328    }
13329
13330    #[test]
13331    fn current_v18_material_indices_follow_resource_coverage() {
13332        let unavailable = measure_wire(primitive_measurement_contract());
13333        serde_json::from_value::<MeasurementReportInput>(unavailable.clone())
13334            .unwrap()
13335            .into_files()
13336            .expect("unavailable inventory may retain a source material index");
13337
13338        let mut complete_out_of_range = unavailable.clone();
13339        complete_out_of_range["files"][0]["measurements"]["material_resource_coverage"] =
13340            serde_json::json!("complete");
13341        assert!(matches!(
13342            reader_error(complete_out_of_range),
13343            MeasurementReportError::File {
13344                source: MeasurementFileError::InvalidMeasurements { .. },
13345                ..
13346            }
13347        ));
13348
13349        let mut complete_in_range = unavailable;
13350        complete_in_range["files"][0]["measurements"]["material_resource_coverage"] =
13351            serde_json::json!("complete");
13352        complete_in_range["files"][0]["measurements"]["material_definitions"] = serde_json::json!([{
13353            "material_index": 0,
13354            "name": null,
13355            "texture_bindings": []
13356        }]);
13357        complete_in_range["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["material_index"] =
13358            serde_json::json!(0);
13359        serde_json::from_value::<MeasurementReportInput>(complete_in_range)
13360            .unwrap()
13361            .into_files()
13362            .expect("complete inventory accepts an in-range primitive material index");
13363    }
13364
13365    #[test]
13366    fn current_v18_leading_magic_is_bounded_reason_specific_hex() {
13367        let mut assets = AssetMeasurements {
13368            material_resource_coverage: MaterialResourceCoverage::Complete,
13369            ..AssetMeasurements::default()
13370        };
13371        assets.images.push(ImageMeasurements {
13372            image_index: 0,
13373            name: None,
13374            source_kind: ImageSourceKind::Embedded,
13375            declared_mime_type: None,
13376            detected_container: None,
13377            leading_magic_hex: Some("00ff10".into()),
13378            width: None,
13379            height: None,
13380            channel_count: None,
13381            decoded_color_type: None,
13382            unavailable_reason: Some(ImageUnavailableReason::UnsupportedContainer),
13383        });
13384        let wire = measure_wire(MeasurementContract::new(BTreeMap::new(), assets).unwrap());
13385
13386        let mut unknown_image = wire.clone();
13387        unknown_image["files"][0]["measurements"]["images"][0]
13388            .as_object_mut()
13389            .unwrap()
13390            .insert("bogus".into(), serde_json::json!(true));
13391        assert!(matches!(
13392            reader_error(unknown_image),
13393            MeasurementReportError::File {
13394                source: MeasurementFileError::InvalidMeasurementsShape { reason },
13395                ..
13396            } if reason.contains("unknown field `bogus`")
13397        ));
13398
13399        for magic in ["", "0", "0F", "00ff00112233445566778899aabbccdde"] {
13400            let mut mutation = wire.clone();
13401            mutation["files"][0]["measurements"]["images"][0]["leading_magic_hex"] =
13402                serde_json::json!(magic);
13403            assert!(matches!(
13404                reader_error(mutation),
13405                MeasurementReportError::File {
13406                    source: MeasurementFileError::InvalidMeasurements { .. },
13407                    ..
13408                }
13409            ));
13410        }
13411
13412        let mut wrong_reason = wire;
13413        wrong_reason["files"][0]["measurements"]["images"][0]["unavailable_reason"] =
13414            serde_json::json!("resource_limit");
13415        assert!(matches!(
13416            reader_error(wrong_reason),
13417            MeasurementReportError::File {
13418                source: MeasurementFileError::InvalidMeasurements { .. },
13419                ..
13420            }
13421        ));
13422    }
13423
13424    #[test]
13425    fn v12_v15_round_trips_without_inventing_primitive_rows() {
13426        let current_provenance = prediction_test_provenance_v2();
13427        let historical_provenance = current_provenance.clone().historical_v15_for_test();
13428        let basis =
13429            EnginePredictionBasisV1::new_v16(vec![PredictionBasisReferenceV1::measurement_v16(
13430                crate::MeasurementPointerV1::new("/measurements/schema_version").unwrap(),
13431                PredictionScalarV1::UnsignedInteger { value: 15 },
13432            )])
13433            .unwrap();
13434        let facet = EnginePredictionFacetV2::required_unavailable(
13435            EvaluationScope::new(EvaluationScopeCode::custom("test:v12")),
13436            basis,
13437            vec![PredictionUnavailableReasonV2::MeasurementUnavailable],
13438        )
13439        .unwrap();
13440        let current_check =
13441            unavailable_check_v2("test:v12", &current_provenance, vec![facet.clone()]);
13442        let prediction =
13443            EnginePredictionV2::new(current_provenance.identity().clone(), vec![facet])
13444                .unwrap()
13445                .historical_v15_for_test(historical_provenance.identity().clone());
13446        let mut check_wire = serde_json::to_value(current_check).unwrap();
13447        check_wire["prediction"] = serde_json::to_value(prediction).unwrap();
13448        let mut historical_assets = AssetMeasurements::default();
13449        historical_assets
13450            .mesh_definitions
13451            .push(MeshDefinitionMeasurements {
13452                mesh_index: 0,
13453                name: "legacy-mesh".into(),
13454                primitives: None,
13455                vertex_count: 0,
13456                geometry_aabb: None,
13457                geometry_centroid: None,
13458                max_joints_per_vertex: 0,
13459                weight_sum_min: None,
13460                weight_sum_max: None,
13461                additional_influence_sets: Vec::new(),
13462            });
13463        let historical_measurements =
13464            MeasurementContract::historical_v15(BTreeMap::new(), historical_assets).unwrap();
13465        let wire = serde_json::json!({
13466            "schema_version": OUTPUT_V12_SCHEMA_VERSION,
13467            "schema": OUTPUT_V12_SCHEMA_ID,
13468            "tool": {},
13469            "command": "lint",
13470            "summary": {"prediction_facets": {
13471                "available": 0,
13472                "required_prediction_unavailable": 1
13473            }},
13474            "files": [{
13475                "path": "historical-v12.glb",
13476                "input": {"sha256": "00".repeat(32), "bytes": 0},
13477                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13478                "measurements": serde_json::to_value(historical_measurements).unwrap(),
13479                "prediction_provenance": serde_json::to_value(historical_provenance).unwrap(),
13480                "checks": [check_wire]
13481            }]
13482        });
13483
13484        let mut historical_with_unknowns = wire.clone();
13485        historical_with_unknowns["files"][0]["measurements"]
13486            .as_object_mut()
13487            .unwrap()
13488            .insert("future_root_field".into(), serde_json::json!(true));
13489        historical_with_unknowns["files"][0]["measurements"]["mesh_definitions"][0]
13490            .as_object_mut()
13491            .unwrap()
13492            .insert("future_mesh_field".into(), serde_json::json!(true));
13493        serde_json::from_value::<MeasurementReportInput>(historical_with_unknowns)
13494            .unwrap()
13495            .into_files()
13496            .expect("historical measurements-v15 retains permissive unknown-field readback");
13497
13498        let mut smuggled_primitives = wire.clone();
13499        smuggled_primitives["files"][0]["measurements"]["mesh_definitions"][0]["primitives"] =
13500            serde_json::json!([]);
13501        assert!(matches!(
13502            serde_json::from_value::<MeasurementReportInput>(smuggled_primitives)
13503                .unwrap()
13504                .into_files(),
13505            Err(MeasurementReportError::File {
13506                source: MeasurementFileError::InvalidMeasurements { source },
13507                ..
13508            }) if source.to_string().contains("measurements-v15 cannot carry per-primitive evidence")
13509        ));
13510
13511        let mut smuggled_magic = wire.clone();
13512        smuggled_magic["files"][0]["measurements"]["material_resource_coverage"] =
13513            serde_json::json!("complete");
13514        smuggled_magic["files"][0]["measurements"]["images"] = serde_json::json!([{
13515            "image_index": 0,
13516            "name": null,
13517            "source_kind": "embedded",
13518            "declared_mime_type": null,
13519            "detected_container": null,
13520            "leading_magic_hex": "00",
13521            "width": null,
13522            "height": null,
13523            "channel_count": null,
13524            "decoded_color_type": null,
13525            "unavailable_reason": "unsupported_container"
13526        }]);
13527        assert!(matches!(
13528            serde_json::from_value::<MeasurementReportInput>(smuggled_magic)
13529                .unwrap()
13530                .into_files(),
13531            Err(MeasurementReportError::File {
13532                source: MeasurementFileError::InvalidMeasurements { source },
13533                ..
13534            }) if source.to_string().contains("measurements-v15 cannot carry leading-magic evidence")
13535        ));
13536
13537        let files = serde_json::from_value::<MeasurementReportInput>(wire.clone())
13538            .unwrap()
13539            .into_files()
13540            .unwrap();
13541        let readback = serde_json::to_value(files[0].measurements()).unwrap();
13542        assert_eq!(readback["schema_version"], serde_json::json!(15));
13543        assert_eq!(
13544            readback["schema"],
13545            serde_json::json!(MEASUREMENTS_V15_SCHEMA_ID)
13546        );
13547        assert_eq!(readback["mesh_definitions"].as_array().unwrap().len(), 1);
13548        assert!(readback["mesh_definitions"][0].get("primitives").is_none());
13549
13550        let mut historical_max = wire.clone();
13551        historical_max["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13552            serde_json::json!(u32::MAX);
13553        serde_json::from_value::<MeasurementReportInput>(historical_max)
13554            .unwrap()
13555            .into_files()
13556            .expect("measurements-v15 retains its historical inclusive u32 maximum");
13557
13558        let mut historical_overflow = wire.clone();
13559        historical_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13560            serde_json::json!(u64::from(u32::MAX) + 1);
13561        assert!(matches!(
13562            lint_read_error(historical_overflow),
13563            MeasurementReportError::File {
13564                source: MeasurementFileError::InvalidMeasurements { .. },
13565                ..
13566            }
13567        ));
13568
13569        let mut v16_basis = wire;
13570        v16_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["references"][0]["schema"] =
13571            serde_json::json!(MEASUREMENTS_SCHEMA_ID);
13572        assert!(matches!(
13573            lint_read_error(v16_basis),
13574            MeasurementReportError::File {
13575                source: MeasurementFileError::InvalidPrediction {
13576                    source: PredictionContractError::InvalidSchema {
13577                        field: "basis.measurement.schema",
13578                        expected: MEASUREMENTS_V15_SCHEMA_ID,
13579                        ..
13580                    },
13581                    ..
13582                },
13583                ..
13584            }
13585        ));
13586    }
13587
13588    #[test]
13589    fn adjacent_output_revisions_reject_each_others_nested_measurements() {
13590        let current = measure_wire(prediction_test_measurements());
13591        let mut v12_with_v18 = current.clone();
13592        v12_with_v18["schema_version"] = serde_json::json!(OUTPUT_V12_SCHEMA_VERSION);
13593        v12_with_v18["schema"] = serde_json::json!(OUTPUT_V12_SCHEMA_ID);
13594        assert!(matches!(
13595            reader_error(v12_with_v18),
13596            MeasurementReportError::File {
13597                source: MeasurementFileError::UnsupportedMeasurementVersion {
13598                    found: 18,
13599                    expected: MEASUREMENTS_V15_SCHEMA_VERSION,
13600                },
13601                ..
13602            }
13603        ));
13604
13605        let historical =
13606            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13607                .unwrap();
13608        let mut v13_with_v15 = current;
13609        v13_with_v15["schema_version"] = serde_json::json!(OUTPUT_V13_SCHEMA_VERSION);
13610        v13_with_v15["schema"] = serde_json::json!(OUTPUT_V13_SCHEMA_ID);
13611        v13_with_v15["files"][0]["measurements"] = serde_json::to_value(historical).unwrap();
13612        assert!(matches!(
13613            reader_error(v13_with_v15),
13614            MeasurementReportError::File {
13615                source: MeasurementFileError::UnsupportedMeasurementVersion {
13616                    found: 15,
13617                    expected: MEASUREMENTS_V16_SCHEMA_VERSION,
13618                },
13619                ..
13620            }
13621        ));
13622    }
13623
13624    #[test]
13625    fn historical_output_v17_writer_projects_current_measurements_to_v16() {
13626        let report = LintFileReportV17::new(
13627            "historical.glb",
13628            InputIdentity::from_bytes(&[]),
13629            prediction_test_rig(),
13630            None,
13631            Vec::new(),
13632            prediction_test_measurements(),
13633        )
13634        .unwrap();
13635        assert_eq!(
13636            report.measurements().schema_version,
13637            MEASUREMENTS_V16_SCHEMA_VERSION
13638        );
13639        assert_eq!(report.measurements().schema, MEASUREMENTS_V16_SCHEMA_ID);
13640    }
13641
13642    #[test]
13643    fn current_v19_writers_refuse_measurements_v16_at_file_and_envelope_boundaries() {
13644        let mismatch = OutputContractError::CurrentMeasurementContractMismatch {
13645            output_version: OUTPUT_SCHEMA_VERSION,
13646            found_version: MEASUREMENTS_V16_SCHEMA_VERSION,
13647            found_schema: MEASUREMENTS_V16_SCHEMA_ID.into(),
13648        };
13649        assert_eq!(
13650            MeasureFileReport::new(
13651                "historical.glb",
13652                InputIdentity::from_bytes(&[]),
13653                prediction_test_rig(),
13654                prediction_test_measurements_v16(),
13655            )
13656            .unwrap_err(),
13657            mismatch
13658        );
13659        assert_eq!(
13660            LintFileReportV19::new(
13661                "historical.glb",
13662                InputIdentity::from_bytes(&[]),
13663                prediction_test_rig(),
13664                None,
13665                Vec::new(),
13666                prediction_test_measurements_v16(),
13667            )
13668            .unwrap_err(),
13669            mismatch
13670        );
13671
13672        let malformed_measure = MeasureFileReport {
13673            evidence: FileEvidence::new(
13674                "historical.glb",
13675                InputIdentity::from_bytes(&[]),
13676                prediction_test_rig(),
13677                prediction_test_measurements_v16(),
13678            ),
13679        };
13680        assert_eq!(
13681            MeasureEnvelope::new(
13682                ToolInfo::animsmith(ToolSource::new(None, None)),
13683                vec![malformed_measure],
13684            )
13685            .unwrap_err(),
13686            mismatch
13687        );
13688        let malformed_lint = LintFileReportV19 {
13689            evidence: FileEvidence::new(
13690                "historical.glb",
13691                InputIdentity::from_bytes(&[]),
13692                prediction_test_rig(),
13693                prediction_test_measurements_v16(),
13694            ),
13695            prediction_provenance: None,
13696            checks: Vec::new(),
13697        };
13698        assert_eq!(
13699            LintEnvelopeV19::new(
13700                ToolInfo::animsmith(ToolSource::new(None, None)),
13701                vec![malformed_lint],
13702            )
13703            .unwrap_err(),
13704            mismatch
13705        );
13706    }
13707
13708    #[test]
13709    fn v17_to_v16_projection_is_total_and_used_by_prediction_and_historical_writers() {
13710        let fully_measured = loop_projection_measurements(&[
13711            MeasurementAvailability::Measured,
13712            MeasurementAvailability::Measured,
13713        ]);
13714        let projected = fully_measured.prediction_v16_projection().unwrap();
13715        assert_eq!(projected.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13716        let continuity = projected.clips()["loop"]
13717            .loop_continuity
13718            .as_ref()
13719            .expect("fully measured rows survive projection");
13720        assert_eq!(continuity.bones.len(), 2);
13721        let wire = serde_json::to_value(&projected).unwrap();
13722        assert!(
13723            wire["clips"]["loop"]["loop_continuity"]["bones"]
13724                .as_array()
13725                .unwrap()
13726                .iter()
13727                .all(|bone| bone.get("availability").is_none())
13728        );
13729
13730        // Historical prediction writers consume a V16 projection. Exercise
13731        // every new V18 rotation location with real skeleton and skin facts so
13732        // the projection cannot accidentally leak an exclusive V18 field.
13733        let rotation_facts = prediction_measurements_with_rotation_facts();
13734        let projected_rotation_facts = rotation_facts.prediction_v16_projection().unwrap();
13735        let projected_wire = serde_json::to_value(&projected_rotation_facts).unwrap();
13736        let projected_linear_paths = [
13737            &projected_wire["skeleton_nodes"][0]["rest_world_linear"],
13738            &projected_wire["skins"][0]["joints"][0]["joint_bind_to_mesh"]["linear"],
13739            &projected_wire["skins"][0]["joints"][0]["mesh_bind_world"]["linear"],
13740        ];
13741        assert!(
13742            projected_linear_paths
13743                .into_iter()
13744                .all(|linear| linear.get("rotation_xyzw").is_none()),
13745            "V16 projection must remove every V18-only rotation fact"
13746        );
13747        let historical_rotation_report = LintFileReport::new(
13748            "rotation-facts.glb",
13749            InputIdentity::from_bytes(&[]),
13750            prediction_test_rig(),
13751            None,
13752            Vec::new(),
13753            rotation_facts,
13754        )
13755        .expect("historical writer projects non-empty rotation facts");
13756        let historical_rotation_wire = serde_json::to_value(&historical_rotation_report).unwrap();
13757        assert_eq!(
13758            historical_rotation_report.measurements().schema_version,
13759            MEASUREMENTS_V16_SCHEMA_VERSION
13760        );
13761        let historical_linear_paths = [
13762            &historical_rotation_wire["measurements"]["skeleton_nodes"][0]["rest_world_linear"],
13763            &historical_rotation_wire["measurements"]["skins"][0]["joints"][0]["joint_bind_to_mesh"]
13764                ["linear"],
13765            &historical_rotation_wire["measurements"]["skins"][0]["joints"][0]["mesh_bind_world"]["linear"],
13766        ];
13767        assert!(
13768            historical_linear_paths
13769                .into_iter()
13770                .all(|linear| linear.get("rotation_xyzw").is_none()),
13771            "historical V15 writer must preserve the V16 measurement shape"
13772        );
13773        let historical_envelope = LintEnvelope::new(
13774            ToolInfo::animsmith(ToolSource::new(None, None)),
13775            vec![historical_rotation_report],
13776        )
13777        .expect("historical envelope accepts projected non-empty rotation facts");
13778        let historical_envelope_wire = serde_json::to_value(historical_envelope).unwrap();
13779        let historical_input: MeasurementReportInput =
13780            serde_json::from_value(historical_envelope_wire).unwrap();
13781        assert_eq!(historical_input.file_count(), Some(1));
13782        historical_input
13783            .into_files()
13784            .expect("historical reader accepts the projected V16 wire shape");
13785
13786        let partial = loop_projection_measurements(&[
13787            MeasurementAvailability::Measured,
13788            MeasurementAvailability::Unavailable,
13789        ]);
13790        let all_unavailable = loop_projection_measurements(&[
13791            MeasurementAvailability::Unavailable,
13792            MeasurementAvailability::Unavailable,
13793        ]);
13794        for measurements in [&partial, &all_unavailable] {
13795            let projected = measurements.prediction_v16_projection().unwrap();
13796            assert_eq!(
13797                projected.clips()["loop"].loop_continuity_availability,
13798                MeasurementAvailability::Unavailable
13799            );
13800            assert!(projected.clips()["loop"].loop_continuity.is_none());
13801        }
13802
13803        let current_v3 = LintFileReportV19::new(
13804            "v3.glb",
13805            InputIdentity::from_bytes(&[]),
13806            prediction_test_rig(),
13807            None,
13808            Vec::new(),
13809            fully_measured,
13810        )
13811        .unwrap();
13812        let current_v5 = LintFileReportV19::new_v5(
13813            "v5.glb",
13814            InputIdentity::from_bytes(&[]),
13815            prediction_test_rig(),
13816            None,
13817            Vec::new(),
13818            partial.clone(),
13819        )
13820        .unwrap();
13821        let current_v6 = LintFileReportV19::new_v6(
13822            "v6.glb",
13823            InputIdentity::from_bytes(&[]),
13824            prediction_test_rig(),
13825            None,
13826            Vec::new(),
13827            all_unavailable.clone(),
13828        )
13829        .unwrap();
13830        assert!(
13831            current_v3.measurements().clips()["loop"]
13832                .loop_continuity
13833                .is_some()
13834        );
13835        assert!(
13836            current_v5.measurements().clips()["loop"]
13837                .loop_continuity
13838                .is_some()
13839        );
13840        assert!(
13841            current_v6.measurements().clips()["loop"]
13842                .loop_continuity
13843                .is_some()
13844        );
13845
13846        let historical_v15 = LintFileReport::new(
13847            "v15.glb",
13848            InputIdentity::from_bytes(&[]),
13849            prediction_test_rig(),
13850            None,
13851            Vec::new(),
13852            partial.clone(),
13853        )
13854        .unwrap();
13855        let historical_v16 = LintFileReportV16::new(
13856            "v16.glb",
13857            InputIdentity::from_bytes(&[]),
13858            prediction_test_rig(),
13859            None,
13860            Vec::new(),
13861            partial.clone(),
13862        )
13863        .unwrap();
13864        let historical_v17 = LintFileReportV17::new(
13865            "v17.glb",
13866            InputIdentity::from_bytes(&[]),
13867            prediction_test_rig(),
13868            None,
13869            Vec::new(),
13870            partial,
13871        )
13872        .unwrap();
13873        for measurements in [
13874            historical_v15.measurements(),
13875            historical_v16.measurements(),
13876            historical_v17.measurements(),
13877        ] {
13878            assert_eq!(measurements.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13879            assert_eq!(
13880                measurements.clips()["loop"].loop_continuity_availability,
13881                MeasurementAvailability::Unavailable
13882            );
13883            assert!(measurements.clips()["loop"].loop_continuity.is_none());
13884        }
13885    }
13886
13887    #[test]
13888    fn legacy_output_v11_reader_dispatch_preserves_measurement_recovery() {
13889        let provenance = prediction_test_provenance_v2();
13890        let legacy = PredictionProvenanceV1::new(
13891            provenance.profile().clone(),
13892            provenance.source_format(),
13893            ResolvedEngineSettingsV1::new(provenance.profile(), Vec::new(), Vec::new()).unwrap(),
13894            provenance.raw_source().clone(),
13895            provenance.dependency_closure().clone(),
13896        )
13897        .unwrap()
13898        .historical_v15_for_test();
13899        let legacy_prediction = EnginePredictionV1::new(
13900            legacy.identity().clone(),
13901            vec![
13902                EnginePredictionFacetV1::required_unavailable(
13903                    EvaluationScope::new(EvaluationScopeCode::custom("test:legacy-v11")),
13904                    EnginePredictionBasisV1::new(Vec::new()).unwrap(),
13905                    vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
13906                )
13907                .unwrap(),
13908            ],
13909        )
13910        .unwrap()
13911        .historical_v15_for_test(legacy.identity().clone());
13912        let legacy_measurements =
13913            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13914                .unwrap();
13915        // This is an actual V11/V1 wire shape, deliberately constructed
13916        // without producing a V12 envelope and swapping only its header.
13917        let wire = serde_json::json!({
13918            "schema_version": OUTPUT_V11_SCHEMA_VERSION,
13919            "schema": OUTPUT_V11_SCHEMA_ID,
13920            "tool": {},
13921            "command": "lint",
13922            "summary": {
13923                "prediction_facets": {
13924                    "available": 0,
13925                    "required_prediction_unavailable": 1,
13926                },
13927            },
13928            "files": [{
13929                "path": "legacy-v11.glb",
13930                "input": { "sha256": "00".repeat(32), "bytes": 0 },
13931                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13932                "measurements": serde_json::to_value(legacy_measurements).unwrap(),
13933                "prediction_provenance": serde_json::to_value(legacy.clone()).unwrap(),
13934                "checks": [{
13935                    "check_id": "test:legacy-v11",
13936                    "selection": "selected",
13937                    "configuration": "enabled",
13938                    "applicability": "applicable",
13939                    "evaluation": "not_evaluated",
13940                    "findings": [],
13941                    "evaluated_scopes": [],
13942                    "gaps": [],
13943                    "prediction": legacy_prediction,
13944                }],
13945            }],
13946        });
13947        let report: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
13948        assert_eq!(report.file_count(), Some(1));
13949        assert_eq!(report.into_files().unwrap().len(), 1);
13950
13951        let mut bad_provenance = wire.clone();
13952        bad_provenance["files"][0]["prediction_provenance"]["schema"] =
13953            serde_json::json!("urn:forged");
13954        assert!(matches!(
13955            lint_read_error(bad_provenance),
13956            MeasurementReportError::File {
13957                source: MeasurementFileError::InvalidPredictionProvenance { .. },
13958                ..
13959            }
13960        ));
13961
13962        let mut bad_prediction = wire.clone();
13963        bad_prediction["files"][0]["checks"] = serde_json::json!([{
13964            "check_id": "test:legacy-v11",
13965            "selection": "selected",
13966            "configuration": "enabled",
13967            "applicability": "applicable",
13968            "evaluation": "not_evaluated",
13969            "findings": [],
13970            "evaluated_scopes": [],
13971            "gaps": [],
13972            "prediction": { "schema": "urn:forged" }
13973        }]);
13974        assert!(matches!(
13975            lint_read_error(bad_prediction),
13976            MeasurementReportError::File {
13977                source: MeasurementFileError::InvalidPredictionShape { .. },
13978                ..
13979            }
13980        ));
13981
13982        let mut missing_provenance_before_malformed_prediction = wire.clone();
13983        missing_provenance_before_malformed_prediction["files"][0]["prediction_provenance"] =
13984            serde_json::Value::Null;
13985        missing_provenance_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13986            serde_json::json!({ "schema": "urn:forged" });
13987        assert!(matches!(
13988            lint_read_error(missing_provenance_before_malformed_prediction),
13989            MeasurementReportError::File {
13990                source: MeasurementFileError::PredictionWithoutProvenance { check_index: 0 },
13991                ..
13992            }
13993        ));
13994
13995        let mut inactive_before_malformed_prediction = wire.clone();
13996        inactive_before_malformed_prediction["files"][0]["checks"][0]["selection"] =
13997            serde_json::json!("unselected");
13998        inactive_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
13999            serde_json::json!({ "schema": "urn:forged" });
14000        assert!(matches!(
14001            lint_read_error(inactive_before_malformed_prediction),
14002            MeasurementReportError::File {
14003                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
14004                ..
14005            }
14006        ));
14007
14008        // A V11 aggregate overflow is terminal at the check that creates it;
14009        // a malformed later check must never change that first error.
14010        let mut overbudget_before_later_malformed = wire.clone();
14011        let facet =
14012            overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"][0]
14013                .clone();
14014        overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"] =
14015            serde_json::Value::Array(
14016                std::iter::repeat_n(facet, PREDICTION_V1_MAX_FACETS_PER_FILE + 1).collect(),
14017            );
14018        overbudget_before_later_malformed["files"][0]["checks"]
14019            .as_array_mut()
14020            .unwrap()
14021            .push(serde_json::json!({ "check_id": 7 }));
14022        let precedence_error = lint_read_error(overbudget_before_later_malformed);
14023        assert!(
14024            matches!(
14025                precedence_error,
14026                MeasurementReportError::File {
14027                    source: MeasurementFileError::InvalidPrediction {
14028                        source: PredictionContractError::TooManyFacets { .. },
14029                        ..
14030                    },
14031                    ..
14032                },
14033            ),
14034            "{precedence_error:?}"
14035        );
14036    }
14037}
14038
14039#[derive(Debug, Clone, Serialize)]
14040struct FileEvidence {
14041    path: String,
14042    input: InputIdentity,
14043    rig: RigInfo,
14044    measurements: MeasurementContract,
14045}
14046
14047impl FileEvidence {
14048    fn new(
14049        path: impl Into<String>,
14050        input: InputIdentity,
14051        rig: RigInfo,
14052        measurements: MeasurementContract,
14053    ) -> Self {
14054        Self {
14055            path: path.into(),
14056            input,
14057            rig,
14058            measurements,
14059        }
14060    }
14061
14062    fn historical_v16(
14063        path: impl Into<String>,
14064        input: InputIdentity,
14065        rig: RigInfo,
14066        measurements: MeasurementContract,
14067    ) -> Result<Self, OutputContractError> {
14068        Ok(Self::new(
14069            path,
14070            input,
14071            rig,
14072            measurements.prediction_v16_projection()?,
14073        ))
14074    }
14075}
14076
14077/// One source file and its measurement-command evidence.
14078#[derive(Debug, Clone, Serialize)]
14079pub struct MeasureFileReport {
14080    #[serde(flatten)]
14081    evidence: FileEvidence,
14082}
14083
14084impl MeasureFileReport {
14085    /// Construct a measurement-command file report.
14086    ///
14087    /// # Errors
14088    ///
14089    /// Returns [`OutputContractError::CurrentMeasurementContractMismatch`] if
14090    /// the nested evidence is not the exact current measurements-v18 contract.
14091    pub fn new(
14092        path: impl Into<String>,
14093        input: InputIdentity,
14094        rig: RigInfo,
14095        measurements: MeasurementContract,
14096    ) -> Result<Self, OutputContractError> {
14097        require_measurements_v18(OUTPUT_SCHEMA_VERSION, &measurements)?;
14098        Ok(Self {
14099            evidence: FileEvidence::new(path, input, rig, measurements),
14100        })
14101    }
14102
14103    /// Display path supplied by the producer.
14104    pub fn path(&self) -> &str {
14105        &self.evidence.path
14106    }
14107
14108    /// Immutable identity of the source bytes used to produce this record.
14109    pub fn input(&self) -> &InputIdentity {
14110        &self.evidence.input
14111    }
14112
14113    /// Nested measurement evidence.
14114    pub fn measurements(&self) -> &MeasurementContract {
14115        &self.evidence.measurements
14116    }
14117}
14118
14119/// A producer attempted to construct output outside the immutable v11 contract.
14120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14121#[non_exhaustive]
14122pub enum OutputContractError {
14123    /// One envelope carried too many file records.
14124    #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
14125    TooManyFiles {
14126        /// Supplied file count.
14127        found: usize,
14128        /// Immutable v10 limit.
14129        limit: usize,
14130    },
14131    /// One lint file carried too many check records.
14132    #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
14133    TooManyChecks {
14134        /// Supplied check count.
14135        found: usize,
14136        /// Immutable v10 limit.
14137        limit: usize,
14138    },
14139    /// A check carried prediction evidence without its file-scoped authority.
14140    #[error("engine prediction requires non-null file prediction_provenance")]
14141    PredictionWithoutProvenance,
14142    /// A current output-v15 lint record attempted to attach historical prediction evidence.
14143    #[error("output-v15 lint cannot carry historical engine-prediction evidence")]
14144    HistoricalPredictionInV2Output,
14145    /// File provenance and check prediction used different contract revisions.
14146    #[error(
14147        "output-v15 prediction provenance and check attachments must use one correlated revision"
14148    )]
14149    PredictionRevisionMismatch,
14150    /// File and provenance primary-input identities differed.
14151    #[error("prediction provenance primary input does not match the lint file input")]
14152    PredictionPrimaryInputMismatch,
14153    /// Aggregate prediction facets exceeded the V1 file limit.
14154    #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
14155    TooManyPredictionFacets {
14156        /// Supplied facet count.
14157        found: usize,
14158        /// Immutable V1 limit.
14159        limit: usize,
14160    },
14161    /// A V2 rule emitted a budget summary although the file did not consume
14162    /// every one of its shared prediction-facet slots.
14163    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
14164    FacetBudgetSummaryWithoutExhaustedFileBudget {
14165        /// Aggregate facet count.
14166        found: usize,
14167        /// Immutable shared file limit.
14168        limit: usize,
14169    },
14170    /// Aggregate basis references exceeded the V1 file limit.
14171    #[error(
14172        "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
14173    )]
14174    TooManyPredictionBasisReferences {
14175        /// Supplied reference count.
14176        found: usize,
14177        /// Immutable V1 limit.
14178        limit: usize,
14179    },
14180    /// Aggregate retained provenance/prediction text exceeded the V1 limit.
14181    #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
14182    TooMuchPredictionText {
14183        /// Supplied UTF-8 byte count.
14184        found: usize,
14185        /// Immutable V1 limit.
14186        limit: usize,
14187    },
14188    /// Checked aggregate accounting overflowed.
14189    #[error("checked arithmetic overflow while validating output-v11 bounds")]
14190    ArithmeticOverflow,
14191    /// Nested prediction evidence violated its contract.
14192    #[error("invalid prediction evidence: {0}")]
14193    InvalidPrediction(#[from] PredictionContractError),
14194    /// Current measurements could not be projected into the immutable V16
14195    /// evidence view consumed by existing prediction contracts.
14196    #[error("invalid measurements-v16 prediction projection: {0}")]
14197    InvalidMeasurementProjection(#[from] MeasurementContractError),
14198    /// A historical output writer was paired with a different nested
14199    /// measurement contract.
14200    #[error(
14201        "output-v{output_version} requires measurements-v16, found version {found_version} with identity {found_schema}"
14202    )]
14203    HistoricalMeasurementContractMismatch {
14204        /// Historical outer output revision.
14205        output_version: u32,
14206        /// Supplied nested measurement revision.
14207        found_version: u32,
14208        /// Supplied nested measurement identity.
14209        found_schema: String,
14210    },
14211    /// A current output writer received a non-current nested measurement contract.
14212    #[error(
14213        "output-v{output_version} requires measurements-v18, found measurement schema_version {found_version} ({found_schema})"
14214    )]
14215    CurrentMeasurementContractMismatch {
14216        /// Current outer output revision.
14217        output_version: u32,
14218        /// Supplied nested measurement revision.
14219        found_version: u32,
14220        /// Supplied nested measurement identity.
14221        found_schema: String,
14222    },
14223}
14224
14225fn require_measurements_v18(
14226    output_version: u32,
14227    measurements: &MeasurementContract,
14228) -> Result<(), OutputContractError> {
14229    if measurements.schema_version == MEASUREMENTS_SCHEMA_VERSION
14230        && measurements.schema == MEASUREMENTS_SCHEMA_ID
14231    {
14232        Ok(())
14233    } else {
14234        Err(OutputContractError::CurrentMeasurementContractMismatch {
14235            output_version,
14236            found_version: measurements.schema_version,
14237            found_schema: measurements.schema.to_owned(),
14238        })
14239    }
14240}
14241
14242fn require_measurements_v16(
14243    output_version: u32,
14244    measurements: &MeasurementContract,
14245) -> Result<(), OutputContractError> {
14246    if measurements.schema_version == MEASUREMENTS_V16_SCHEMA_VERSION
14247        && measurements.schema == MEASUREMENTS_V16_SCHEMA_ID
14248    {
14249        Ok(())
14250    } else {
14251        Err(OutputContractError::HistoricalMeasurementContractMismatch {
14252            output_version,
14253            found_version: measurements.schema_version,
14254            found_schema: measurements.schema.to_owned(),
14255        })
14256    }
14257}
14258
14259#[derive(Debug, Clone, Serialize)]
14260struct EnvelopeHeader {
14261    schema_version: u32,
14262    schema: &'static str,
14263    tool: ToolInfo,
14264    command: &'static str,
14265}
14266
14267impl EnvelopeHeader {
14268    fn new(tool: ToolInfo, command: &'static str) -> Self {
14269        Self {
14270            schema_version: OUTPUT_SCHEMA_VERSION,
14271            schema: OUTPUT_SCHEMA_ID,
14272            tool,
14273            command,
14274        }
14275    }
14276}
14277
14278#[derive(Debug, Clone, Serialize)]
14279struct MeasureSummary {
14280    files: usize,
14281}
14282
14283#[derive(Debug, Clone, Default, Serialize)]
14284struct FindingSummary {
14285    error: usize,
14286    warning: usize,
14287    note: usize,
14288}
14289
14290impl FindingSummary {
14291    fn add(&mut self, severity: Severity) {
14292        match severity {
14293            Severity::Error => self.error += 1,
14294            Severity::Warning => self.warning += 1,
14295            Severity::Note => self.note += 1,
14296        }
14297    }
14298}
14299
14300#[derive(Debug, Clone, Default, Serialize)]
14301struct SelectionSummary {
14302    selected: usize,
14303    unselected: usize,
14304}
14305
14306#[derive(Debug, Clone, Default, Serialize)]
14307struct ConfigurationSummary {
14308    enabled: usize,
14309    disabled: usize,
14310}
14311
14312#[derive(Debug, Clone, Default, Serialize)]
14313struct ApplicabilitySummary {
14314    applicable: usize,
14315    not_applicable: usize,
14316}
14317
14318#[derive(Debug, Clone, Default, Serialize)]
14319struct EvaluationStateSummary {
14320    complete: usize,
14321    partial: usize,
14322    not_evaluated: usize,
14323}
14324
14325#[derive(Debug, Clone, Default, Serialize)]
14326struct CheckSummary {
14327    total: usize,
14328    selection: SelectionSummary,
14329    configuration: ConfigurationSummary,
14330    applicability: ApplicabilitySummary,
14331    evaluation: EvaluationStateSummary,
14332    gaps: usize,
14333}
14334
14335#[derive(Debug, Clone, Serialize)]
14336struct LintSummary {
14337    files: usize,
14338    findings: FindingSummary,
14339    checks: CheckSummary,
14340    prediction_facets: PredictionFacetSummary,
14341}
14342
14343#[derive(Debug, Clone, Default, Serialize)]
14344struct PredictionFacetSummary {
14345    available: usize,
14346    required_prediction_unavailable: usize,
14347}
14348
14349/// Current measure-command result envelope.
14350#[derive(Debug, Clone, Serialize)]
14351pub struct MeasureEnvelope {
14352    #[serde(flatten)]
14353    header: EnvelopeHeader,
14354    summary: MeasureSummary,
14355    files: Vec<MeasureFileReport>,
14356}
14357
14358impl MeasureEnvelope {
14359    /// Construct a schema-valid measurement envelope.
14360    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
14361        if files.len() > OUTPUT_V11_MAX_FILES {
14362            return Err(OutputContractError::TooManyFiles {
14363                found: files.len(),
14364                limit: OUTPUT_V11_MAX_FILES,
14365            });
14366        }
14367        for file in &files {
14368            require_measurements_v18(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
14369        }
14370        Ok(Self {
14371            header: EnvelopeHeader::new(tool, "measure"),
14372            summary: MeasureSummary { files: files.len() },
14373            files,
14374        })
14375    }
14376}
14377
14378#[derive(Debug, Clone, Serialize)]
14379#[serde(untagged)]
14380#[allow(
14381    clippy::large_enum_variant,
14382    reason = "the internal correlated revision enum preserves value ownership for both immutable wire types"
14383)]
14384enum CurrentPredictionProvenance {
14385    V3(PredictionProvenanceV3),
14386    V4(PredictionProvenanceV4),
14387}
14388
14389/// Current output-v15 lint file evidence with correlated V3 or V4 prediction provenance.
14390#[derive(Debug, Clone, Serialize)]
14391pub struct LintFileReport {
14392    #[serde(flatten)]
14393    evidence: FileEvidence,
14394    prediction_provenance: Option<CurrentPredictionProvenance>,
14395    checks: Vec<CheckEvaluation>,
14396}
14397
14398impl LintFileReport {
14399    /// Construct a legacy-V3 lint file report inside output-v15.
14400    pub fn new(
14401        path: impl Into<String>,
14402        input: InputIdentity,
14403        rig: RigInfo,
14404        prediction_provenance: Option<PredictionProvenanceV3>,
14405        checks: Vec<CheckEvaluation>,
14406        measurements: MeasurementContract,
14407    ) -> Result<Self, OutputContractError> {
14408        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14409            return Err(OutputContractError::TooManyChecks {
14410                found: checks.len(),
14411                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14412            });
14413        }
14414        let report = Self {
14415            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14416            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V3),
14417            checks,
14418        };
14419        report.validate()?;
14420        Ok(report)
14421    }
14422
14423    /// V3 prediction provenance, or `None` for engine-neutral lint.
14424    pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV3> {
14425        match self.prediction_provenance.as_ref() {
14426            Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14427            Some(CurrentPredictionProvenance::V4(_)) | None => None,
14428        }
14429    }
14430
14431    /// Construct a result-bearing V4 lint file report.
14432    pub fn new_v4(
14433        path: impl Into<String>,
14434        input: InputIdentity,
14435        rig: RigInfo,
14436        prediction_provenance: Option<PredictionProvenanceV4>,
14437        checks: Vec<CheckEvaluation>,
14438        measurements: MeasurementContract,
14439    ) -> Result<Self, OutputContractError> {
14440        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14441            return Err(OutputContractError::TooManyChecks {
14442                found: checks.len(),
14443                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14444            });
14445        }
14446        let report = Self {
14447            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14448            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V4),
14449            checks,
14450        };
14451        report.validate()?;
14452        Ok(report)
14453    }
14454
14455    /// V4 prediction provenance, when this file uses revision 4.
14456    pub const fn prediction_provenance_v4(&self) -> Option<&PredictionProvenanceV4> {
14457        match self.prediction_provenance.as_ref() {
14458            Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14459            Some(CurrentPredictionProvenance::V3(_)) | None => None,
14460        }
14461    }
14462
14463    /// Display path supplied by the producer.
14464    pub fn path(&self) -> &str {
14465        &self.evidence.path
14466    }
14467
14468    /// Immutable identity of the source bytes used to produce this record.
14469    pub fn input(&self) -> &InputIdentity {
14470        &self.evidence.input
14471    }
14472
14473    /// Nested measurement evidence.
14474    pub fn measurements(&self) -> &MeasurementContract {
14475        &self.evidence.measurements
14476    }
14477
14478    /// Catalog-ordered check records.
14479    pub fn checks(&self) -> &[CheckEvaluation] {
14480        &self.checks
14481    }
14482
14483    fn validate(&self) -> Result<(), OutputContractError> {
14484        require_measurements_v16(OUTPUT_V15_SCHEMA_VERSION, &self.evidence.measurements)?;
14485        if let Some(provenance) = &self.prediction_provenance {
14486            let primary_input = match provenance {
14487                CurrentPredictionProvenance::V3(provenance) => {
14488                    provenance.validate()?;
14489                    provenance.raw_source().primary_input()
14490                }
14491                CurrentPredictionProvenance::V4(provenance) => {
14492                    provenance.validate()?;
14493                    provenance.raw_source().primary_input()
14494                }
14495            };
14496            if primary_input != &self.evidence.input {
14497                return Err(OutputContractError::PredictionPrimaryInputMismatch);
14498            }
14499        }
14500        let mut facets = 0usize;
14501        let mut references = 0usize;
14502        let mut has_facet_budget_summary = false;
14503        let mut text = self
14504            .prediction_provenance
14505            .as_ref()
14506            .map(|provenance| match provenance {
14507                CurrentPredictionProvenance::V3(provenance) => provenance.retained_text_bytes(),
14508                CurrentPredictionProvenance::V4(provenance) => provenance.retained_text_bytes(),
14509            })
14510            .transpose()?
14511            .unwrap_or(0);
14512        for check in &self.checks {
14513            if check.engine_prediction().is_some() || check.engine_prediction_v2().is_some() {
14514                return Err(OutputContractError::HistoricalPredictionInV2Output);
14515            }
14516            let provenance_revision_matches = matches!(
14517                (
14518                    &self.prediction_provenance,
14519                    check.engine_prediction_v3(),
14520                    check.engine_prediction_v4()
14521                ),
14522                (Some(CurrentPredictionProvenance::V3(_)), Some(_), None)
14523                    | (Some(CurrentPredictionProvenance::V4(_)), None, Some(_))
14524                    | (_, None, None)
14525            );
14526            if !provenance_revision_matches {
14527                if self.prediction_provenance.is_none()
14528                    && (check.engine_prediction_v3().is_some()
14529                        || check.engine_prediction_v4().is_some())
14530                {
14531                    return Err(OutputContractError::PredictionWithoutProvenance);
14532                }
14533                return Err(OutputContractError::PredictionRevisionMismatch);
14534            }
14535            let legacy_v3_provenance = match self.prediction_provenance.as_ref() {
14536                Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14537                Some(CurrentPredictionProvenance::V4(_)) | None => None,
14538            };
14539            if !matches!(
14540                self.prediction_provenance.as_ref(),
14541                Some(CurrentPredictionProvenance::V4(_))
14542            ) {
14543                validate_current_engine_clip_boundary_applicability_v3(
14544                    check.check_id(),
14545                    check.applicability(),
14546                    legacy_v3_provenance,
14547                )?;
14548            }
14549            if check.check_id() == ENGINE_CLIP_BOUNDARY_CHECK_ID
14550                && check.selection() == SelectionState::Selected
14551                && check.configuration() == ConfigurationState::Enabled
14552                && check.applicability() == Applicability::Applicable
14553                && check.engine_prediction_v3().is_none()
14554            {
14555                return Err(OutputContractError::InvalidPrediction(
14556                    PredictionContractError::EngineClipBoundaryFacetMismatch,
14557                ));
14558            }
14559            let current_v4_provenance = match self.prediction_provenance.as_ref() {
14560                Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14561                Some(CurrentPredictionProvenance::V3(_)) | None => None,
14562            };
14563            validate_current_engine_unit_scale_prediction_v4(
14564                check.check_id(),
14565                check.selection(),
14566                check.configuration(),
14567                check.applicability(),
14568                check.engine_prediction_v4(),
14569                current_v4_provenance,
14570                &self.evidence.measurements,
14571            )?;
14572            if let Some(prediction) = check.engine_prediction_v3() {
14573                let Some(CurrentPredictionProvenance::V3(provenance)) =
14574                    self.prediction_provenance.as_ref()
14575                else {
14576                    return Err(OutputContractError::PredictionRevisionMismatch);
14577                };
14578                prediction.validate_against_provenance(provenance)?;
14579                prediction.validate_for_check(
14580                    check.check_id(),
14581                    check.evaluated_scopes(),
14582                    check.gaps(),
14583                    check.findings(),
14584                )?;
14585                validate_current_engine_addressability_prediction_v3(
14586                    check.check_id(),
14587                    prediction,
14588                    provenance,
14589                )?;
14590                let finding_scopes = check
14591                    .findings()
14592                    .iter()
14593                    .filter_map(|finding| finding.prediction_scope.as_ref())
14594                    .collect::<Vec<_>>();
14595                validate_current_engine_clip_boundary_prediction_v3(
14596                    check.check_id(),
14597                    prediction,
14598                    provenance,
14599                    check.evaluated_scopes(),
14600                    &finding_scopes,
14601                )?;
14602                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14603                facets = facets
14604                    .checked_add(prediction.facets().len())
14605                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14606                references = references
14607                    .checked_add(prediction.basis_reference_count())
14608                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14609                text = text
14610                    .checked_add(prediction.retained_text_bytes()?)
14611                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14612            }
14613            if let Some(prediction) = check.engine_prediction_v4() {
14614                let Some(CurrentPredictionProvenance::V4(provenance)) =
14615                    self.prediction_provenance.as_ref()
14616                else {
14617                    return Err(OutputContractError::PredictionRevisionMismatch);
14618                };
14619                prediction.validate_against_provenance(provenance)?;
14620                prediction.validate_for_check(
14621                    check.check_id(),
14622                    check.evaluated_scopes(),
14623                    check.gaps(),
14624                    check.findings(),
14625                )?;
14626                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14627                facets = facets
14628                    .checked_add(prediction.facets().len())
14629                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14630                references = references
14631                    .checked_add(prediction.basis_reference_count())
14632                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14633                text = text
14634                    .checked_add(prediction.retained_text_bytes()?)
14635                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14636            }
14637        }
14638        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14639            return Err(OutputContractError::TooManyPredictionFacets {
14640                found: facets,
14641                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14642            });
14643        }
14644        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14645            return Err(
14646                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14647                    found: facets,
14648                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14649                },
14650            );
14651        }
14652        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14653            return Err(OutputContractError::TooManyPredictionBasisReferences {
14654                found: references,
14655                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14656            });
14657        }
14658        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14659            return Err(OutputContractError::TooMuchPredictionText {
14660                found: text,
14661                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14662            });
14663        }
14664        validate_measurement_references_batch_v3(
14665            &self.evidence.measurements,
14666            self.checks
14667                .iter()
14668                .enumerate()
14669                .filter_map(|(check_index, check)| {
14670                    check
14671                        .engine_prediction_v3()
14672                        .map(|prediction| (check_index, prediction))
14673                }),
14674        )
14675        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14676        validate_measurement_references_batch_v4(
14677            &self.evidence.measurements,
14678            self.checks
14679                .iter()
14680                .enumerate()
14681                .filter_map(|(check_index, check)| {
14682                    check
14683                        .engine_prediction_v4()
14684                        .map(|prediction| (check_index, prediction))
14685                }),
14686        )
14687        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14688        Ok(())
14689    }
14690}
14691
14692#[derive(Debug, Clone, Serialize)]
14693struct EnvelopeHeaderV2 {
14694    schema_version: u32,
14695    schema: &'static str,
14696    tool: ToolInfo,
14697    command: &'static str,
14698}
14699
14700/// Immutable historical output-v15 lint envelope.
14701#[derive(Debug, Clone, Serialize)]
14702pub struct LintEnvelope {
14703    #[serde(flatten)]
14704    header: EnvelopeHeaderV2,
14705    summary: LintSummary,
14706    files: Vec<LintFileReport>,
14707}
14708
14709impl LintEnvelope {
14710    /// Construct a V2 lint envelope and derive its summaries.
14711    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
14712        if files.len() > OUTPUT_V11_MAX_FILES {
14713            return Err(OutputContractError::TooManyFiles {
14714                found: files.len(),
14715                limit: OUTPUT_V11_MAX_FILES,
14716            });
14717        }
14718        let mut findings = FindingSummary::default();
14719        let mut checks = CheckSummary::default();
14720        let mut prediction_facets = PredictionFacetSummary::default();
14721        for file in &files {
14722            file.validate()?;
14723            for check in file.checks() {
14724                checks.total += 1;
14725                for finding in check.findings() {
14726                    findings.add(finding.severity);
14727                }
14728                match check.selection() {
14729                    SelectionState::Selected => checks.selection.selected += 1,
14730                    SelectionState::Unselected => checks.selection.unselected += 1,
14731                }
14732                match check.configuration() {
14733                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14734                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14735                }
14736                match check.applicability() {
14737                    Applicability::Applicable => checks.applicability.applicable += 1,
14738                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14739                }
14740                match check.evaluation() {
14741                    EvaluationState::Complete => checks.evaluation.complete += 1,
14742                    EvaluationState::Partial => checks.evaluation.partial += 1,
14743                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14744                }
14745                checks.gaps += check.gaps().len();
14746                if let Some(prediction) = check.engine_prediction_v3() {
14747                    for facet in prediction.facets() {
14748                        match facet.state() {
14749                            EnginePredictionFacetStateV1::Available => {
14750                                prediction_facets.available += 1;
14751                            }
14752                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14753                                prediction_facets.required_prediction_unavailable += 1;
14754                            }
14755                        }
14756                    }
14757                }
14758                if let Some(prediction) = check.engine_prediction_v4() {
14759                    for facet in prediction.facets() {
14760                        match facet.state() {
14761                            EnginePredictionFacetStateV1::Available => {
14762                                prediction_facets.available += 1;
14763                            }
14764                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14765                                prediction_facets.required_prediction_unavailable += 1;
14766                            }
14767                        }
14768                    }
14769                }
14770            }
14771        }
14772        Ok(Self {
14773            header: EnvelopeHeaderV2 {
14774                schema_version: OUTPUT_V15_SCHEMA_VERSION,
14775                schema: OUTPUT_V15_SCHEMA_ID,
14776                tool,
14777                command: "lint",
14778            },
14779            summary: LintSummary {
14780                files: files.len(),
14781                findings,
14782                checks,
14783                prediction_facets,
14784            },
14785            files,
14786        })
14787    }
14788}
14789
14790#[derive(Debug, Clone, Serialize)]
14791#[serde(untagged)]
14792#[allow(clippy::large_enum_variant)]
14793enum CurrentPredictionProvenanceV16 {
14794    V3(PredictionProvenanceV3),
14795    V5(PredictionProvenanceV5),
14796}
14797
14798/// Current output-v16 lint file evidence with V3 or V5 prediction provenance.
14799#[derive(Debug, Clone, Serialize)]
14800pub struct LintFileReportV16 {
14801    #[serde(flatten)]
14802    evidence: FileEvidence,
14803    prediction_provenance: Option<CurrentPredictionProvenanceV16>,
14804    checks: Vec<CheckEvaluation>,
14805}
14806
14807impl LintFileReportV16 {
14808    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14809    pub fn new(
14810        path: impl Into<String>,
14811        input: InputIdentity,
14812        rig: RigInfo,
14813        prediction_provenance: Option<PredictionProvenanceV3>,
14814        checks: Vec<CheckEvaluation>,
14815        measurements: MeasurementContract,
14816    ) -> Result<Self, OutputContractError> {
14817        let report = Self {
14818            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14819            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V3),
14820            checks,
14821        };
14822        report.validate()?;
14823        Ok(report)
14824    }
14825
14826    /// Construct a revision-2-contract record using V5 provenance and checks.
14827    pub fn new_v5(
14828        path: impl Into<String>,
14829        input: InputIdentity,
14830        rig: RigInfo,
14831        prediction_provenance: Option<PredictionProvenanceV5>,
14832        checks: Vec<CheckEvaluation>,
14833        measurements: MeasurementContract,
14834    ) -> Result<Self, OutputContractError> {
14835        let report = Self {
14836            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14837            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V5),
14838            checks,
14839        };
14840        report.validate()?;
14841        Ok(report)
14842    }
14843
14844    /// Display path supplied by the producer.
14845    pub fn path(&self) -> &str {
14846        &self.evidence.path
14847    }
14848    /// Immutable source-byte identity.
14849    pub fn input(&self) -> &InputIdentity {
14850        &self.evidence.input
14851    }
14852    /// Nested measurement evidence.
14853    pub fn measurements(&self) -> &MeasurementContract {
14854        &self.evidence.measurements
14855    }
14856    /// Catalog-ordered check records.
14857    pub fn checks(&self) -> &[CheckEvaluation] {
14858        &self.checks
14859    }
14860    /// V3 provenance for a revision-1 profile.
14861    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14862        match self.prediction_provenance.as_ref() {
14863            Some(CurrentPredictionProvenanceV16::V3(provenance)) => Some(provenance),
14864            Some(CurrentPredictionProvenanceV16::V5(_)) | None => None,
14865        }
14866    }
14867    /// V5 provenance for a revision-2-contract profile.
14868    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14869        match self.prediction_provenance.as_ref() {
14870            Some(CurrentPredictionProvenanceV16::V5(provenance)) => Some(provenance),
14871            Some(CurrentPredictionProvenanceV16::V3(_)) | None => None,
14872        }
14873    }
14874
14875    fn validate(&self) -> Result<(), OutputContractError> {
14876        require_measurements_v16(OUTPUT_V16_SCHEMA_VERSION, &self.evidence.measurements)?;
14877        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14878            return Err(OutputContractError::TooManyChecks {
14879                found: self.checks.len(),
14880                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14881            });
14882        }
14883        if let Some(CurrentPredictionProvenanceV16::V3(provenance)) = &self.prediction_provenance {
14884            return LintFileReport {
14885                evidence: self.evidence.clone(),
14886                prediction_provenance: Some(CurrentPredictionProvenance::V3(provenance.clone())),
14887                checks: self.checks.clone(),
14888            }
14889            .validate();
14890        }
14891        let provenance = match &self.prediction_provenance {
14892            Some(CurrentPredictionProvenanceV16::V5(provenance)) => {
14893                provenance.validate()?;
14894                if provenance.base().raw_source().primary_input() != &self.evidence.input {
14895                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
14896                }
14897                Some(provenance)
14898            }
14899            Some(CurrentPredictionProvenanceV16::V3(_)) => unreachable!(),
14900            None => None,
14901        };
14902        let mut facets = 0usize;
14903        let mut references = 0usize;
14904        let mut has_facet_budget_summary = false;
14905        let mut text = provenance
14906            .map(PredictionProvenanceV5::retained_text_bytes)
14907            .transpose()?
14908            .unwrap_or(0);
14909        for check in &self.checks {
14910            if check.engine_prediction().is_some()
14911                || check.engine_prediction_v2().is_some()
14912                || check.engine_prediction_v3().is_some()
14913                || check.engine_prediction_v4().is_some()
14914            {
14915                return Err(OutputContractError::HistoricalPredictionInV2Output);
14916            }
14917            let prediction = check.engine_prediction_v5();
14918            if provenance.is_none() && prediction.is_some() {
14919                return Err(OutputContractError::PredictionWithoutProvenance);
14920            }
14921            if let Some(prediction) = prediction {
14922                let provenance =
14923                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
14924                prediction.validate_against_provenance(provenance)?;
14925                prediction.validate_for_check(
14926                    check.check_id(),
14927                    check.evaluated_scopes(),
14928                    check.gaps(),
14929                    check.findings(),
14930                )?;
14931                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
14932                facets = facets
14933                    .checked_add(prediction.facets().len())
14934                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14935                references = references
14936                    .checked_add(prediction.basis_reference_count())
14937                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14938                text = text
14939                    .checked_add(prediction.retained_text_bytes()?)
14940                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14941            }
14942            validate_current_engine_track_support_prediction_v5(
14943                check.check_id(),
14944                check.selection(),
14945                check.configuration(),
14946                check.applicability(),
14947                prediction,
14948                provenance,
14949                check.findings().is_empty(),
14950            )?;
14951            validate_current_engine_unit_scale_prediction_v5(
14952                check.check_id(),
14953                check.selection(),
14954                check.configuration(),
14955                check.applicability(),
14956                prediction,
14957                provenance,
14958                &self.evidence.measurements,
14959            )?;
14960        }
14961        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14962            return Err(OutputContractError::TooManyPredictionFacets {
14963                found: facets,
14964                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14965            });
14966        }
14967        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14968            return Err(
14969                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14970                    found: facets,
14971                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14972                },
14973            );
14974        }
14975        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14976            return Err(OutputContractError::TooManyPredictionBasisReferences {
14977                found: references,
14978                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14979            });
14980        }
14981        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14982            return Err(OutputContractError::TooMuchPredictionText {
14983                found: text,
14984                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14985            });
14986        }
14987        validate_measurement_references_batch_v4(
14988            &self.evidence.measurements,
14989            self.checks.iter().enumerate().filter_map(|(index, check)| {
14990                check
14991                    .engine_prediction_v5()
14992                    .map(|prediction| (index, prediction.base_prediction()))
14993            }),
14994        )
14995        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14996        Ok(())
14997    }
14998}
14999
15000/// Current output-v16 lint envelope.
15001#[derive(Debug, Clone, Serialize)]
15002pub struct LintEnvelopeV16 {
15003    #[serde(flatten)]
15004    header: EnvelopeHeaderV2,
15005    summary: LintSummary,
15006    files: Vec<LintFileReportV16>,
15007}
15008
15009impl LintEnvelopeV16 {
15010    /// Construct a schema-valid V16 lint envelope and derive summaries.
15011    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV16>) -> Result<Self, OutputContractError> {
15012        if files.len() > OUTPUT_V11_MAX_FILES {
15013            return Err(OutputContractError::TooManyFiles {
15014                found: files.len(),
15015                limit: OUTPUT_V11_MAX_FILES,
15016            });
15017        }
15018        let mut findings = FindingSummary::default();
15019        let mut checks = CheckSummary::default();
15020        let mut prediction_facets = PredictionFacetSummary::default();
15021        for file in &files {
15022            file.validate()?;
15023            for check in file.checks() {
15024                checks.total += 1;
15025                for finding in check.findings() {
15026                    findings.add(finding.severity);
15027                }
15028                match check.selection() {
15029                    SelectionState::Selected => checks.selection.selected += 1,
15030                    SelectionState::Unselected => checks.selection.unselected += 1,
15031                }
15032                match check.configuration() {
15033                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
15034                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
15035                }
15036                match check.applicability() {
15037                    Applicability::Applicable => checks.applicability.applicable += 1,
15038                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
15039                }
15040                match check.evaluation() {
15041                    EvaluationState::Complete => checks.evaluation.complete += 1,
15042                    EvaluationState::Partial => checks.evaluation.partial += 1,
15043                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
15044                }
15045                checks.gaps += check.gaps().len();
15046                for facet in check
15047                    .engine_prediction_v3()
15048                    .into_iter()
15049                    .flat_map(EnginePredictionV3::facets)
15050                {
15051                    match facet.state() {
15052                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15053                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15054                            prediction_facets.required_prediction_unavailable += 1
15055                        }
15056                    }
15057                }
15058                for facet in check
15059                    .engine_prediction_v5()
15060                    .into_iter()
15061                    .flat_map(EnginePredictionV5::facets)
15062                {
15063                    match facet.state() {
15064                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15065                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15066                            prediction_facets.required_prediction_unavailable += 1
15067                        }
15068                    }
15069                }
15070            }
15071        }
15072        Ok(Self {
15073            header: EnvelopeHeaderV2 {
15074                schema_version: OUTPUT_V16_SCHEMA_VERSION,
15075                schema: OUTPUT_V16_SCHEMA_ID,
15076                tool,
15077                command: "lint",
15078            },
15079            summary: LintSummary {
15080                files: files.len(),
15081                findings,
15082                checks,
15083                prediction_facets,
15084            },
15085            files,
15086        })
15087    }
15088}
15089
15090#[derive(Debug, Clone, Serialize)]
15091#[serde(untagged)]
15092#[allow(clippy::large_enum_variant)]
15093enum CurrentPredictionProvenanceV17 {
15094    V3(PredictionProvenanceV3),
15095    V5(PredictionProvenanceV5),
15096    V6(PredictionProvenanceV6),
15097}
15098
15099/// Historical output-v17 lint file evidence with immutable V3, V5, or V6 provenance.
15100#[derive(Debug, Clone, Serialize)]
15101pub struct LintFileReportV17 {
15102    #[serde(flatten)]
15103    evidence: FileEvidence,
15104    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15105    checks: Vec<CheckEvaluation>,
15106}
15107
15108impl LintFileReportV17 {
15109    /// Construct a revision-1-profile record whose V3 graph is unchanged.
15110    pub fn new(
15111        path: impl Into<String>,
15112        input: InputIdentity,
15113        rig: RigInfo,
15114        prediction_provenance: Option<PredictionProvenanceV3>,
15115        checks: Vec<CheckEvaluation>,
15116        measurements: MeasurementContract,
15117    ) -> Result<Self, OutputContractError> {
15118        let report = Self {
15119            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15120            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
15121            checks,
15122        };
15123        report.validate()?;
15124        Ok(report)
15125    }
15126
15127    /// Construct a V5 record whose output-v16 graph remains unchanged.
15128    pub fn new_v5(
15129        path: impl Into<String>,
15130        input: InputIdentity,
15131        rig: RigInfo,
15132        prediction_provenance: Option<PredictionProvenanceV5>,
15133        checks: Vec<CheckEvaluation>,
15134        measurements: MeasurementContract,
15135    ) -> Result<Self, OutputContractError> {
15136        let report = Self {
15137            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15138            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
15139            checks,
15140        };
15141        report.validate()?;
15142        Ok(report)
15143    }
15144
15145    /// Construct a transform-path-and-intent-bound V6 record.
15146    pub fn new_v6(
15147        path: impl Into<String>,
15148        input: InputIdentity,
15149        rig: RigInfo,
15150        prediction_provenance: Option<PredictionProvenanceV6>,
15151        checks: Vec<CheckEvaluation>,
15152        measurements: MeasurementContract,
15153    ) -> Result<Self, OutputContractError> {
15154        let report = Self {
15155            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15156            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
15157            checks,
15158        };
15159        report.validate()?;
15160        Ok(report)
15161    }
15162
15163    /// Display path supplied by the producer.
15164    pub fn path(&self) -> &str {
15165        &self.evidence.path
15166    }
15167    /// Immutable source-byte identity.
15168    pub fn input(&self) -> &InputIdentity {
15169        &self.evidence.input
15170    }
15171    /// Nested measurement evidence.
15172    pub fn measurements(&self) -> &MeasurementContract {
15173        &self.evidence.measurements
15174    }
15175    /// Catalog-ordered check records.
15176    pub fn checks(&self) -> &[CheckEvaluation] {
15177        &self.checks
15178    }
15179    /// V3 provenance for a revision-1 profile.
15180    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
15181        match self.prediction_provenance.as_ref() {
15182            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
15183            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
15184            | None => None,
15185        }
15186    }
15187    /// Immutable V5 provenance.
15188    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
15189        match self.prediction_provenance.as_ref() {
15190            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
15191            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
15192            | None => None,
15193        }
15194    }
15195    /// Current V6 provenance.
15196    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
15197        match self.prediction_provenance.as_ref() {
15198            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
15199            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
15200            | None => None,
15201        }
15202    }
15203
15204    fn validate(&self) -> Result<(), OutputContractError> {
15205        require_measurements_v16(OUTPUT_V17_SCHEMA_VERSION, &self.evidence.measurements)?;
15206        match &self.prediction_provenance {
15207            Some(CurrentPredictionProvenanceV17::V3(provenance)) => {
15208                return LintFileReportV16::new(
15209                    self.evidence.path.clone(),
15210                    self.evidence.input.clone(),
15211                    self.evidence.rig.clone(),
15212                    Some(provenance.clone()),
15213                    self.checks.clone(),
15214                    self.evidence.measurements.clone(),
15215                )
15216                .map(|_| ());
15217            }
15218            Some(CurrentPredictionProvenanceV17::V5(provenance)) => {
15219                return LintFileReportV16::new_v5(
15220                    self.evidence.path.clone(),
15221                    self.evidence.input.clone(),
15222                    self.evidence.rig.clone(),
15223                    Some(provenance.clone()),
15224                    self.checks.clone(),
15225                    self.evidence.measurements.clone(),
15226                )
15227                .map(|_| ());
15228            }
15229            Some(CurrentPredictionProvenanceV17::V6(_)) => {}
15230            None => {
15231                return LintFileReportV16::new(
15232                    self.evidence.path.clone(),
15233                    self.evidence.input.clone(),
15234                    self.evidence.rig.clone(),
15235                    None,
15236                    self.checks.clone(),
15237                    self.evidence.measurements.clone(),
15238                )
15239                .map(|_| ());
15240            }
15241        }
15242        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
15243            return Err(OutputContractError::TooManyChecks {
15244                found: self.checks.len(),
15245                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
15246            });
15247        }
15248        let provenance = match &self.prediction_provenance {
15249            Some(CurrentPredictionProvenanceV17::V6(provenance)) => {
15250                provenance.validate()?;
15251                if provenance.base().base().raw_source().primary_input() != &self.evidence.input {
15252                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
15253                }
15254                Some(provenance)
15255            }
15256            _ => unreachable!(),
15257        };
15258        let mut facets = 0usize;
15259        let mut references = 0usize;
15260        let mut has_facet_budget_summary = false;
15261        let mut text = provenance
15262            .map(PredictionProvenanceV6::retained_text_bytes)
15263            .transpose()?
15264            .unwrap_or(0);
15265        for check in &self.checks {
15266            if check.engine_prediction().is_some()
15267                || check.engine_prediction_v2().is_some()
15268                || check.engine_prediction_v3().is_some()
15269                || check.engine_prediction_v4().is_some()
15270                || check.engine_prediction_v5().is_some()
15271            {
15272                return Err(OutputContractError::HistoricalPredictionInV2Output);
15273            }
15274            let prediction = check.engine_prediction_v6();
15275            if let Some(prediction) = prediction {
15276                let provenance =
15277                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
15278                prediction.validate_against_provenance(provenance)?;
15279                prediction.validate_for_check(
15280                    check.check_id(),
15281                    check.evaluated_scopes(),
15282                    check.gaps(),
15283                    check.findings(),
15284                )?;
15285                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
15286                facets = facets
15287                    .checked_add(prediction.facets().len())
15288                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15289                references = references
15290                    .checked_add(prediction.basis_reference_count())
15291                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15292                text = text
15293                    .checked_add(prediction.retained_text_bytes()?)
15294                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15295            }
15296            validate_current_engine_root_motion_prediction_v6(
15297                check.check_id(),
15298                check.selection(),
15299                check.configuration(),
15300                check.applicability(),
15301                prediction,
15302                provenance,
15303                check.findings(),
15304                &self.evidence.rig,
15305                &self.evidence.measurements,
15306            )?;
15307        }
15308        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
15309            return Err(OutputContractError::TooManyPredictionFacets {
15310                found: facets,
15311                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15312            });
15313        }
15314        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
15315            return Err(
15316                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
15317                    found: facets,
15318                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15319                },
15320            );
15321        }
15322        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
15323            return Err(OutputContractError::TooManyPredictionBasisReferences {
15324                found: references,
15325                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
15326            });
15327        }
15328        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
15329            return Err(OutputContractError::TooMuchPredictionText {
15330                found: text,
15331                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
15332            });
15333        }
15334        validate_measurement_references_batch_v4(
15335            &self.evidence.measurements,
15336            self.checks.iter().enumerate().filter_map(|(index, check)| {
15337                check
15338                    .engine_prediction_v6()
15339                    .map(|prediction| (index, prediction.base_prediction()))
15340            }),
15341        )
15342        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
15343        Ok(())
15344    }
15345}
15346
15347/// Current output-v19 lint-file payload. Immutable prediction contracts are
15348/// checked against the deterministic measurements-v16 projection while the
15349/// report retains the complete measurements-v18 evidence.
15350#[derive(Debug, Clone, Serialize)]
15351pub struct LintFileReportV19 {
15352    #[serde(flatten)]
15353    evidence: FileEvidence,
15354    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15355    checks: Vec<CheckEvaluation>,
15356}
15357
15358impl LintFileReportV19 {
15359    /// Construct a revision-1-profile record whose V3 graph is unchanged.
15360    pub fn new(
15361        path: impl Into<String>,
15362        input: InputIdentity,
15363        rig: RigInfo,
15364        prediction_provenance: Option<PredictionProvenanceV3>,
15365        checks: Vec<CheckEvaluation>,
15366        measurements: MeasurementContract,
15367    ) -> Result<Self, OutputContractError> {
15368        Self::build(
15369            path,
15370            input,
15371            rig,
15372            prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
15373            checks,
15374            measurements,
15375        )
15376    }
15377
15378    /// Construct a V5 record whose immutable prediction graph consumes V16.
15379    pub fn new_v5(
15380        path: impl Into<String>,
15381        input: InputIdentity,
15382        rig: RigInfo,
15383        prediction_provenance: Option<PredictionProvenanceV5>,
15384        checks: Vec<CheckEvaluation>,
15385        measurements: MeasurementContract,
15386    ) -> Result<Self, OutputContractError> {
15387        Self::build(
15388            path,
15389            input,
15390            rig,
15391            prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
15392            checks,
15393            measurements,
15394        )
15395    }
15396
15397    /// Construct a V6 record whose immutable prediction graph consumes V16.
15398    pub fn new_v6(
15399        path: impl Into<String>,
15400        input: InputIdentity,
15401        rig: RigInfo,
15402        prediction_provenance: Option<PredictionProvenanceV6>,
15403        checks: Vec<CheckEvaluation>,
15404        measurements: MeasurementContract,
15405    ) -> Result<Self, OutputContractError> {
15406        Self::build(
15407            path,
15408            input,
15409            rig,
15410            prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
15411            checks,
15412            measurements,
15413        )
15414    }
15415
15416    fn build(
15417        path: impl Into<String>,
15418        input: InputIdentity,
15419        rig: RigInfo,
15420        prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15421        checks: Vec<CheckEvaluation>,
15422        measurements: MeasurementContract,
15423    ) -> Result<Self, OutputContractError> {
15424        require_measurements_v18(OUTPUT_SCHEMA_VERSION, &measurements)?;
15425        let report = Self {
15426            evidence: FileEvidence::new(path, input, rig, measurements),
15427            prediction_provenance,
15428            checks,
15429        };
15430        report.historical_prediction_view()?;
15431        Ok(report)
15432    }
15433
15434    /// Display path supplied by the producer.
15435    pub fn path(&self) -> &str {
15436        &self.evidence.path
15437    }
15438
15439    /// Immutable source-byte identity.
15440    pub fn input(&self) -> &InputIdentity {
15441        &self.evidence.input
15442    }
15443
15444    /// Nested current measurement evidence.
15445    pub fn measurements(&self) -> &MeasurementContract {
15446        &self.evidence.measurements
15447    }
15448
15449    /// Catalog-ordered check records.
15450    pub fn checks(&self) -> &[CheckEvaluation] {
15451        &self.checks
15452    }
15453
15454    /// V3 provenance for a revision-1 profile.
15455    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
15456        match self.prediction_provenance.as_ref() {
15457            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
15458            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
15459            | None => None,
15460        }
15461    }
15462
15463    /// Immutable V5 provenance.
15464    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
15465        match self.prediction_provenance.as_ref() {
15466            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
15467            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
15468            | None => None,
15469        }
15470    }
15471
15472    /// Immutable V6 provenance.
15473    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
15474        match self.prediction_provenance.as_ref() {
15475            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
15476            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
15477            | None => None,
15478        }
15479    }
15480
15481    fn historical_prediction_view(&self) -> Result<LintFileReportV17, OutputContractError> {
15482        let measurements = self.evidence.measurements.prediction_v16_projection()?;
15483        let report = LintFileReportV17 {
15484            evidence: FileEvidence::new(
15485                self.evidence.path.clone(),
15486                self.evidence.input.clone(),
15487                self.evidence.rig.clone(),
15488                measurements,
15489            ),
15490            prediction_provenance: self.prediction_provenance.clone(),
15491            checks: self.checks.clone(),
15492        };
15493        report.validate()?;
15494        Ok(report)
15495    }
15496}
15497
15498/// Historical output-v17 lint envelope retained for immutable writer tests and
15499/// the output-v19 prediction projection.
15500#[derive(Debug, Clone, Serialize)]
15501pub struct LintEnvelopeV17 {
15502    #[serde(flatten)]
15503    header: EnvelopeHeaderV2,
15504    summary: LintSummary,
15505    files: Vec<LintFileReportV17>,
15506}
15507
15508impl LintEnvelopeV17 {
15509    /// Construct a schema-valid V17 lint envelope and derive summaries.
15510    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV17>) -> Result<Self, OutputContractError> {
15511        if files.len() > OUTPUT_V11_MAX_FILES {
15512            return Err(OutputContractError::TooManyFiles {
15513                found: files.len(),
15514                limit: OUTPUT_V11_MAX_FILES,
15515            });
15516        }
15517        let mut findings = FindingSummary::default();
15518        let mut checks = CheckSummary::default();
15519        let mut prediction_facets = PredictionFacetSummary::default();
15520        for file in &files {
15521            file.validate()?;
15522            for check in file.checks() {
15523                checks.total += 1;
15524                for finding in check.findings() {
15525                    findings.add(finding.severity);
15526                }
15527                match check.selection() {
15528                    SelectionState::Selected => checks.selection.selected += 1,
15529                    SelectionState::Unselected => checks.selection.unselected += 1,
15530                }
15531                match check.configuration() {
15532                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
15533                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
15534                }
15535                match check.applicability() {
15536                    Applicability::Applicable => checks.applicability.applicable += 1,
15537                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
15538                }
15539                match check.evaluation() {
15540                    EvaluationState::Complete => checks.evaluation.complete += 1,
15541                    EvaluationState::Partial => checks.evaluation.partial += 1,
15542                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
15543                }
15544                checks.gaps += check.gaps().len();
15545                let mut add_facet = |state| match state {
15546                    EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15547                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15548                        prediction_facets.required_prediction_unavailable += 1
15549                    }
15550                };
15551                for facet in check
15552                    .engine_prediction_v3()
15553                    .into_iter()
15554                    .flat_map(EnginePredictionV3::facets)
15555                {
15556                    add_facet(facet.state());
15557                }
15558                for facet in check
15559                    .engine_prediction_v5()
15560                    .into_iter()
15561                    .flat_map(EnginePredictionV5::facets)
15562                {
15563                    add_facet(facet.state());
15564                }
15565                for facet in check
15566                    .engine_prediction_v6()
15567                    .into_iter()
15568                    .flat_map(EnginePredictionV6::facets)
15569                {
15570                    add_facet(facet.state());
15571                }
15572            }
15573        }
15574        Ok(Self {
15575            header: EnvelopeHeaderV2 {
15576                schema_version: OUTPUT_V17_SCHEMA_VERSION,
15577                schema: OUTPUT_V17_SCHEMA_ID,
15578                tool,
15579                command: "lint",
15580            },
15581            summary: LintSummary {
15582                files: files.len(),
15583                findings,
15584                checks,
15585                prediction_facets,
15586            },
15587            files,
15588        })
15589    }
15590}
15591
15592/// Current output-v19 lint envelope.
15593#[derive(Debug, Clone, Serialize)]
15594pub struct LintEnvelopeV19 {
15595    #[serde(flatten)]
15596    header: EnvelopeHeaderV2,
15597    summary: LintSummary,
15598    files: Vec<LintFileReportV19>,
15599}
15600
15601impl LintEnvelopeV19 {
15602    /// Construct a schema-valid V19 lint envelope and derive summaries.
15603    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV19>) -> Result<Self, OutputContractError> {
15604        if files.len() > OUTPUT_V11_MAX_FILES {
15605            return Err(OutputContractError::TooManyFiles {
15606                found: files.len(),
15607                limit: OUTPUT_V11_MAX_FILES,
15608            });
15609        }
15610        for file in &files {
15611            require_measurements_v18(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
15612        }
15613        let historical_files = files
15614            .iter()
15615            .map(LintFileReportV19::historical_prediction_view)
15616            .collect::<Result<Vec<_>, _>>()?;
15617        let historical = LintEnvelopeV17::new(tool.clone(), historical_files)?;
15618        Ok(Self {
15619            header: EnvelopeHeaderV2 {
15620                schema_version: OUTPUT_SCHEMA_VERSION,
15621                schema: OUTPUT_SCHEMA_ID,
15622                tool,
15623                command: "lint",
15624            },
15625            summary: historical.summary,
15626            files,
15627        })
15628    }
15629}
15630
15631#[derive(Debug, Clone, Serialize)]
15632struct DiffInputs {
15633    before: String,
15634    after: String,
15635}
15636
15637#[derive(Debug, Clone, Serialize)]
15638struct DiffSummary {
15639    deltas: usize,
15640}
15641
15642/// Current diff-command result envelope.
15643#[derive(Debug, Serialize)]
15644pub struct DiffEnvelope {
15645    #[serde(flatten)]
15646    header: EnvelopeHeader,
15647    inputs: DiffInputs,
15648    summary: DiffSummary,
15649    deltas: Vec<MetricDelta>,
15650}
15651
15652impl DiffEnvelope {
15653    /// Construct a schema-valid diff envelope.
15654    pub fn new(
15655        tool: ToolInfo,
15656        before: impl Into<String>,
15657        after: impl Into<String>,
15658        deltas: Vec<MetricDelta>,
15659    ) -> Self {
15660        Self {
15661            header: EnvelopeHeader::new(tool, "diff"),
15662            inputs: DiffInputs {
15663                before: before.into(),
15664                after: after.into(),
15665            },
15666            summary: DiffSummary {
15667                deltas: deltas.len(),
15668            },
15669            deltas,
15670        }
15671    }
15672}