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    /// Construct an identity from an already-formatted lowercase hexadecimal
228    /// SHA-256 digest and exact byte count.
229    ///
230    /// Returns `None` unless `sha256` is exactly 64 lowercase hexadecimal
231    /// digits. Uppercase is refused rather than normalized, so every stored
232    /// digest keeps the one lowercase authority [`sha256_hex`] establishes and
233    /// the wire deserializers already require.
234    pub fn from_sha256_hex(sha256: &str, bytes: u64) -> Option<Self> {
235        let digits = sha256.as_bytes();
236        if digits.len() != 64
237            || !digits
238                .iter()
239                .all(|digit| digit.is_ascii_digit() || (b'a'..=b'f').contains(digit))
240        {
241            return None;
242        }
243        Some(Self {
244            sha256: sha256.to_owned(),
245            bytes,
246        })
247    }
248
249    /// Lowercase hexadecimal SHA-256 digest of the source bytes.
250    pub fn sha256(&self) -> &str {
251        &self.sha256
252    }
253
254    /// Number of source bytes represented by this identity.
255    pub fn bytes(&self) -> u64 {
256        self.bytes
257    }
258}
259
260/// Rig profile and resolved semantic-role bindings for one input file.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct RigInfo {
264    profile: String,
265    resolution_outcome: String,
266    resolved_roles: BTreeMap<String, String>,
267    resolved_role_policies: BTreeMap<String, String>,
268}
269
270/// Resolved-role evidence did not belong to the supplied document.
271#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
272#[non_exhaustive]
273pub enum RigInfoError {
274    /// A resolved role referenced a bone outside the document's skeleton.
275    #[error(
276        "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
277    )]
278    InvalidBoneId {
279        /// Stable semantic role name.
280        role: &'static str,
281        /// Invalid bone index carried by the resolution.
282        bone: usize,
283        /// Number of bones available in the supplied document.
284        bone_count: usize,
285    },
286    /// A valid bone index now names a different bone than the resolution did.
287    #[error(
288        "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
289    )]
290    BoneNameMismatch {
291        /// Stable semantic role name.
292        role: &'static str,
293        /// Bone index carried by the resolution.
294        bone: usize,
295        /// Bone name captured when the role was resolved.
296        expected: String,
297        /// Bone name at that index in the supplied document.
298        found: String,
299    },
300}
301
302impl RigInfo {
303    /// Project resolved roles into their stable role names and source bone
304    /// names for the result contract.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`RigInfoError`] when `roles` references a bone outside the
309    /// supplied document, such as a resolution produced from another
310    /// skeleton.
311    pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
312        let resolved = roles
313            .iter_with_details()
314            .map(|(role, bone, expected_name, policy)| {
315                let name = doc
316                    .skeleton
317                    .bones
318                    .get(bone)
319                    .ok_or(RigInfoError::InvalidBoneId {
320                        role: role.as_str(),
321                        bone,
322                        bone_count: doc.skeleton.bones.len(),
323                    })?;
324                if name.name != expected_name {
325                    return Err(RigInfoError::BoneNameMismatch {
326                        role: role.as_str(),
327                        bone,
328                        expected: expected_name.to_owned(),
329                        found: name.name.clone(),
330                    });
331                }
332                Ok((role.as_str(), (name.name.clone(), policy.as_str())))
333            })
334            .collect::<Result<BTreeMap<_, _>, _>>()?;
335        Ok(Self {
336            profile: roles.profile.clone(),
337            resolution_outcome: roles.outcome().as_str().to_owned(),
338            resolved_roles: resolved
339                .iter()
340                .map(|(&role, (name, _))| (role.to_owned(), name.clone()))
341                .collect(),
342            resolved_role_policies: resolved
343                .into_iter()
344                .map(|(role, (_, policy))| (role.to_owned(), policy.to_owned()))
345                .collect(),
346        })
347    }
348}
349
350/// Independently versioned measurement payload nested in measure and lint
351/// file records.
352#[derive(Debug, Clone, Serialize)]
353pub struct MeasurementContract {
354    schema_version: u32,
355    schema: &'static str,
356    clips: BTreeMap<String, ClipMeasurements>,
357    #[serde(flatten)]
358    assets: AssetMeasurements,
359}
360
361/// Measurement evidence could not satisfy the current measurement contract.
362#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
363#[non_exhaustive]
364pub enum MeasurementContractError {
365    /// A required or present numeric value was non-finite.
366    #[error("measurement value {path} must be finite")]
367    NonFiniteValue {
368        /// Human-readable location within the measurement contract.
369        path: String,
370    },
371    /// Related measurement fields were structurally inconsistent.
372    #[error("measurement structure {path} is invalid: {reason}")]
373    InvalidStructure {
374        /// Human-readable location within the measurement contract.
375        path: String,
376        /// Stable explanation of the violated relationship.
377        reason: String,
378    },
379}
380
381impl MeasurementContract {
382    /// Construct the current measurement contract.
383    ///
384    /// # Errors
385    ///
386    /// Returns [`MeasurementContractError`] when required or present numeric
387    /// evidence is non-finite or structurally inconsistent.
388    pub fn new(
389        clips: BTreeMap<String, ClipMeasurements>,
390        assets: AssetMeasurements,
391    ) -> Result<Self, MeasurementContractError> {
392        validate_measurements(&clips, &assets, MeasurementRevision::V18)?;
393        Ok(Self {
394            schema_version: MEASUREMENTS_SCHEMA_VERSION,
395            schema: MEASUREMENTS_SCHEMA_ID,
396            clips,
397            assets,
398        })
399    }
400
401    pub(crate) fn historical_v15(
402        clips: BTreeMap<String, ClipMeasurements>,
403        assets: AssetMeasurements,
404    ) -> Result<Self, MeasurementContractError> {
405        validate_measurements(&clips, &assets, MeasurementRevision::V15)?;
406        Ok(Self {
407            schema_version: MEASUREMENTS_V15_SCHEMA_VERSION,
408            schema: MEASUREMENTS_V15_SCHEMA_ID,
409            clips,
410            assets,
411        })
412    }
413
414    pub(crate) fn historical_v16(
415        clips: BTreeMap<String, ClipMeasurements>,
416        assets: AssetMeasurements,
417    ) -> Result<Self, MeasurementContractError> {
418        validate_measurements(&clips, &assets, MeasurementRevision::V16)?;
419        Ok(Self {
420            schema_version: MEASUREMENTS_V16_SCHEMA_VERSION,
421            schema: MEASUREMENTS_V16_SCHEMA_ID,
422            clips,
423            assets,
424        })
425    }
426
427    pub(crate) fn historical_v17(
428        clips: BTreeMap<String, ClipMeasurements>,
429        assets: AssetMeasurements,
430    ) -> Result<Self, MeasurementContractError> {
431        validate_measurements(&clips, &assets, MeasurementRevision::V17)?;
432        Ok(Self {
433            schema_version: MEASUREMENTS_V17_SCHEMA_VERSION,
434            schema: MEASUREMENTS_V17_SCHEMA_ID,
435            clips,
436            assets,
437        })
438    }
439
440    /// Reconstruct the measurements-v18-to-v16 evidence view consumed by
441    /// immutable prediction contracts embedded in current output. V16
442    /// represented loop continuity as all-or-nothing, so any unavailable V17
443    /// bone projects to an unavailable outer fact; fully measured rows omit
444    /// the V17-only per-bone availability field, and every V18-only linear
445    /// rotation fact is removed.
446    fn prediction_v16_projection(&self) -> Result<Self, MeasurementContractError> {
447        let mut clips = self.clips.clone();
448        for clip in clips.values_mut() {
449            let has_unavailable_bone = clip.loop_continuity.as_ref().is_some_and(|continuity| {
450                continuity
451                    .bones
452                    .iter()
453                    .any(|bone| bone.availability != MeasurementAvailability::Measured)
454            });
455            if has_unavailable_bone {
456                clip.loop_continuity = None;
457                clip.loop_continuity_availability = MeasurementAvailability::Unavailable;
458            } else if let Some(continuity) = &mut clip.loop_continuity {
459                for bone in &mut continuity.bones {
460                    bone.availability_was_present = false;
461                }
462            }
463        }
464        let mut assets = self.assets.clone();
465        for node in &mut assets.skeleton_nodes {
466            node.rest_world_linear.rotation_xyzw = None;
467        }
468        for skin in &mut assets.skins {
469            for joint in &mut skin.joints {
470                if let Some(linear) = &mut joint.joint_bind_to_mesh.linear {
471                    linear.rotation_xyzw = None;
472                }
473                if let Some(linear) = &mut joint.mesh_bind_world.linear {
474                    linear.rotation_xyzw = None;
475                }
476            }
477        }
478        Self::historical_v16(clips, assets)
479    }
480
481    /// Per-clip measurements keyed by clip name.
482    pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
483        &self.clips
484    }
485
486    /// Static source-geometry, node-instance, and declared-scene evidence.
487    pub fn assets(&self) -> &AssetMeasurements {
488        &self.assets
489    }
490
491    /// Consume the contract and return its clip and static asset measurements.
492    pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
493        (self.clips, self.assets)
494    }
495}
496
497#[derive(Clone, Copy, PartialEq, Eq)]
498enum MeasurementRevision {
499    V15,
500    V16,
501    V17,
502    V18,
503}
504
505fn validate_measurements(
506    clips: &BTreeMap<String, ClipMeasurements>,
507    assets: &AssetMeasurements,
508    revision: MeasurementRevision,
509) -> Result<(), MeasurementContractError> {
510    let finite = |value: f64, path: String| {
511        value
512            .is_finite()
513            .then_some(())
514            .ok_or(MeasurementContractError::NonFiniteValue { path })
515    };
516    let permits_roundoff = |observed: f64, lower_bound: f64| {
517        let tolerance = 1.0e-9 * observed.abs().max(lower_bound.abs()).max(1.0);
518        observed + tolerance >= lower_bound
519    };
520    let check_availability = |value_present: bool,
521                              availability: MeasurementAvailability,
522                              path: String| {
523        match (value_present, availability) {
524            (true, MeasurementAvailability::Measured) => Ok(()),
525            (
526                false,
527                MeasurementAvailability::NotApplicable | MeasurementAvailability::Unavailable,
528            ) => Ok(()),
529            _ => Err(MeasurementContractError::InvalidStructure {
530                path,
531                reason: "value presence must match availability status".into(),
532            }),
533        }
534    };
535    for (clip_name, clip) in clips {
536        finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
537        let mut previous_bone_index = None;
538        let mut covered_bone_names = BTreeSet::new();
539        for (offset, bone) in clip.bone_channels.iter().enumerate() {
540            let path = format!("clips[{clip_name:?}].bone_channels[{offset}]");
541            if previous_bone_index.is_some_and(|previous| previous >= bone.bone_index) {
542                return Err(MeasurementContractError::InvalidStructure {
543                    path: format!("{path}.bone_index"),
544                    reason: "bone channel entries must use strictly increasing unique bone indices"
545                        .into(),
546                });
547            }
548            previous_bone_index = Some(bone.bone_index);
549            if bone.properties.is_empty() {
550                return Err(MeasurementContractError::InvalidStructure {
551                    path: format!("{path}.properties"),
552                    reason: "bone channel coverage must contain at least one property".into(),
553                });
554            }
555            if bone
556                .properties
557                .windows(2)
558                .any(|properties| properties[0] >= properties[1])
559            {
560                return Err(MeasurementContractError::InvalidStructure {
561                    path: format!("{path}.properties"),
562                    reason:
563                        "channel properties must be unique and ordered translation, rotation, scale"
564                            .into(),
565                });
566            }
567            covered_bone_names.insert(bone.bone_name.clone());
568        }
569        let expected_animated_bones: Vec<_> = covered_bone_names.into_iter().collect();
570        if clip.animated_bones != expected_animated_bones {
571            return Err(MeasurementContractError::InvalidStructure {
572                path: format!("clips[{clip_name:?}].animated_bones"),
573                reason: "animated_bones must equal the sorted unique bone names in bone_channels"
574                    .into(),
575            });
576        }
577        for (bone, value) in &clip.bone_rotation_range_deg {
578            if clip.animated_bones.binary_search(bone).is_err() {
579                return Err(MeasurementContractError::InvalidStructure {
580                    path: format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
581                    reason: "rotation-range bones must be present in animated_bones".into(),
582                });
583            }
584            finite(
585                *value,
586                format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
587            )?;
588        }
589        check_availability(
590            clip.loop_continuity.is_some(),
591            clip.loop_continuity_availability,
592            format!("clips[{clip_name:?}].loop_continuity"),
593        )?;
594        check_availability(
595            clip.loop_endpoint_mode.is_some(),
596            clip.loop_endpoint_mode_availability,
597            format!("clips[{clip_name:?}].loop_endpoint_mode"),
598        )?;
599        check_availability(
600            clip.frame_grid.is_some(),
601            clip.frame_grid_availability,
602            format!("clips[{clip_name:?}].frame_grid"),
603        )?;
604        check_availability(
605            clip.loop_seam_ratio.is_some(),
606            clip.loop_seam_ratio_availability,
607            format!("clips[{clip_name:?}].loop_seam_ratio"),
608        )?;
609        check_availability(
610            clip.gait.is_some(),
611            clip.gait_availability,
612            format!("clips[{clip_name:?}].gait"),
613        )?;
614        check_availability(
615            clip.root_trajectory.is_some(),
616            clip.root_trajectory_availability,
617            format!("clips[{clip_name:?}].root_trajectory"),
618        )?;
619        check_availability(
620            clip.speed_mps.is_some(),
621            clip.speed_mps_availability,
622            format!("clips[{clip_name:?}].speed_mps"),
623        )?;
624        if let Some(gait) = &clip.gait {
625            check_availability(
626                gait.phase.is_some(),
627                gait.phase_availability,
628                format!("clips[{clip_name:?}].gait.phase"),
629            )?;
630        }
631        if let Some(trajectory) = &clip.root_trajectory {
632            let path = format!("clips[{clip_name:?}].root_trajectory");
633            check_availability(
634                trajectory.translation.is_some(),
635                trajectory.translation_availability,
636                format!("{path}.translation"),
637            )?;
638            check_availability(
639                trajectory.yaw.is_some(),
640                trajectory.yaw_availability,
641                format!("{path}.yaw"),
642            )?;
643            if trajectory.translation_availability == MeasurementAvailability::NotApplicable {
644                return Err(MeasurementContractError::InvalidStructure {
645                    path: format!("{path}.translation_availability"),
646                    reason:
647                        "translation remains applicable when a root-trajectory bone is selected"
648                            .into(),
649                });
650            }
651            if trajectory.yaw_availability == MeasurementAvailability::NotApplicable {
652                return Err(MeasurementContractError::InvalidStructure {
653                    path: format!("{path}.yaw_availability"),
654                    reason: "yaw remains applicable when a root-trajectory bone is selected".into(),
655                });
656            }
657            if let Some(translation) = trajectory.translation {
658                for (field, value) in [
659                    (
660                        "horizontal_displacement_x_m",
661                        translation.horizontal_displacement_x_m,
662                    ),
663                    (
664                        "horizontal_displacement_z_m",
665                        translation.horizontal_displacement_z_m,
666                    ),
667                    ("horizontal_travel_m", translation.horizontal_travel_m),
668                    (
669                        "vertical_displacement_m",
670                        translation.vertical_displacement_m,
671                    ),
672                    (
673                        "vertical_min_displacement_m",
674                        translation.vertical_min_displacement_m,
675                    ),
676                    (
677                        "vertical_max_displacement_m",
678                        translation.vertical_max_displacement_m,
679                    ),
680                ] {
681                    finite(value, format!("{path}.translation.{field}"))?;
682                }
683                if translation.horizontal_travel_m < 0.0 {
684                    return Err(MeasurementContractError::InvalidStructure {
685                        path: format!("{path}.translation.horizontal_travel_m"),
686                        reason: "sampled horizontal travel must be non-negative".into(),
687                    });
688                }
689                let horizontal_displacement_m = translation
690                    .horizontal_displacement_x_m
691                    .hypot(translation.horizontal_displacement_z_m);
692                if !permits_roundoff(translation.horizontal_travel_m, horizontal_displacement_m) {
693                    return Err(MeasurementContractError::InvalidStructure {
694                        path: format!("{path}.translation.horizontal_travel_m"),
695                        reason: "sampled horizontal travel must contain endpoint displacement"
696                            .into(),
697                    });
698                }
699                if translation.vertical_min_displacement_m > 0.0
700                    || translation.vertical_max_displacement_m < 0.0
701                    || translation.vertical_displacement_m < translation.vertical_min_displacement_m
702                    || translation.vertical_displacement_m > translation.vertical_max_displacement_m
703                {
704                    return Err(MeasurementContractError::InvalidStructure {
705                        path: format!("{path}.translation"),
706                        reason: "vertical extrema must include zero and the endpoint displacement"
707                            .into(),
708                    });
709                }
710            }
711            if let Some(yaw) = trajectory.yaw {
712                finite(yaw.net_yaw_deg, format!("{path}.yaw.net_yaw_deg"))?;
713                finite(
714                    yaw.unwrapped_yaw_deg,
715                    format!("{path}.yaw.unwrapped_yaw_deg"),
716                )?;
717                finite(yaw.yaw_travel_deg, format!("{path}.yaw.yaw_travel_deg"))?;
718                if !(-180.0..=180.0).contains(&yaw.net_yaw_deg) {
719                    return Err(MeasurementContractError::InvalidStructure {
720                        path: format!("{path}.yaw.net_yaw_deg"),
721                        reason: "net yaw must be in the inclusive range [-180, 180]".into(),
722                    });
723                }
724                if yaw.net_yaw_deg != canonical_net_yaw_deg(yaw.unwrapped_yaw_deg) {
725                    return Err(MeasurementContractError::InvalidStructure {
726                        path: format!("{path}.yaw.net_yaw_deg"),
727                        reason: "net yaw must be the canonical endpoint-equivalent unwrapped yaw"
728                            .into(),
729                    });
730                }
731                if yaw.yaw_travel_deg < 0.0 {
732                    return Err(MeasurementContractError::InvalidStructure {
733                        path: format!("{path}.yaw.yaw_travel_deg"),
734                        reason: "sampled yaw travel must be non-negative".into(),
735                    });
736                }
737                if !permits_roundoff(yaw.yaw_travel_deg, yaw.unwrapped_yaw_deg.abs()) {
738                    return Err(MeasurementContractError::InvalidStructure {
739                        path: format!("{path}.yaw.yaw_travel_deg"),
740                        reason: "sampled yaw travel must contain signed unwrapped yaw".into(),
741                    });
742                }
743            }
744        }
745        if let Some(loop_continuity) = &clip.loop_continuity {
746            if loop_continuity.bones.is_empty() {
747                return Err(MeasurementContractError::InvalidStructure {
748                    path: format!("clips[{clip_name:?}].loop_continuity.bones"),
749                    reason: "present loop-continuity evidence must contain at least one bone"
750                        .into(),
751                });
752            }
753            for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
754                let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
755                if usize::try_from(bone.bone_index) != Ok(expected_index) {
756                    return Err(MeasurementContractError::InvalidStructure {
757                        path: format!("{path}.bone_index"),
758                        reason: format!(
759                            "expected skeleton-order index {expected_index}, found {}",
760                            bone.bone_index
761                        ),
762                    });
763                }
764                if matches!(
765                    revision,
766                    MeasurementRevision::V17 | MeasurementRevision::V18
767                ) && !bone.availability_was_present
768                {
769                    return Err(MeasurementContractError::InvalidStructure {
770                        path: format!("{path}.availability"),
771                        reason: "measurements-v17 requires explicit per-bone availability".into(),
772                    });
773                }
774                if !matches!(
775                    revision,
776                    MeasurementRevision::V17 | MeasurementRevision::V18
777                ) && bone.availability_was_present
778                {
779                    return Err(MeasurementContractError::InvalidStructure {
780                        path: format!("{path}.availability"),
781                        reason:
782                            "per-bone loop-continuity availability is exclusive to measurements-v17"
783                                .into(),
784                    });
785                }
786                if !matches!(
787                    revision,
788                    MeasurementRevision::V17 | MeasurementRevision::V18
789                ) && bone.availability != MeasurementAvailability::Measured
790                {
791                    return Err(MeasurementContractError::InvalidStructure {
792                        path: format!("{path}.availability"),
793                        reason: "historical measurement contracts require every present loop-continuity row to be measured"
794                            .into(),
795                    });
796                }
797                if bone.availability == MeasurementAvailability::NotApplicable {
798                    return Err(MeasurementContractError::InvalidStructure {
799                        path: format!("{path}.availability"),
800                        reason: "an existing bone's loop-continuity evidence remains applicable"
801                            .into(),
802                    });
803                }
804                let values = [
805                    ("position_delta_m", bone.position_delta_m),
806                    ("rotation_delta_deg", bone.rotation_delta_deg),
807                    ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
808                    (
809                        "seam_angular_velocity_delta_degps",
810                        bone.seam_angular_velocity_delta_degps,
811                    ),
812                ];
813                let values_present = values.iter().filter(|(_, value)| value.is_some()).count();
814                match (bone.availability, values_present) {
815                    (MeasurementAvailability::Measured, 4)
816                    | (MeasurementAvailability::Unavailable, 0) => {}
817                    _ => {
818                        return Err(MeasurementContractError::InvalidStructure {
819                            path: path.clone(),
820                            reason: "all loop-continuity values must be present exactly when the bone is measured"
821                                .into(),
822                        });
823                    }
824                }
825                for (field, value) in values {
826                    let Some(value) = value else { continue };
827                    finite(value, format!("{path}.{field}"))?;
828                    if value < 0.0 {
829                        return Err(MeasurementContractError::InvalidStructure {
830                            path: format!("{path}.{field}"),
831                            reason: "loop-continuity deltas must be non-negative".into(),
832                        });
833                    }
834                }
835            }
836        }
837        if let Some(frame_grid) = &clip.frame_grid {
838            let path = format!("clips[{clip_name:?}].frame_grid");
839            finite(frame_grid.fps, format!("{path}.fps"))?;
840            if frame_grid.fps <= 0.0 {
841                return Err(MeasurementContractError::InvalidStructure {
842                    path: format!("{path}.fps"),
843                    reason: "declared frame-grid FPS must be positive".into(),
844                });
845            }
846            if frame_grid.frame_intervals == 0 {
847                return Err(MeasurementContractError::InvalidStructure {
848                    path: format!("{path}.frame_intervals"),
849                    reason: "declared frame-grid evidence must contain at least one interval"
850                        .into(),
851                });
852            }
853        }
854        if let Some(value) = clip.loop_seam_ratio {
855            finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
856        }
857        if let Some(gait) = &clip.gait {
858            if let Some(value) = gait.phase {
859                finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
860            }
861            finite(
862                gait.lr_amplitude_m,
863                format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
864            )?;
865        }
866        if let Some(value) = clip.speed_mps {
867            finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
868        }
869    }
870    let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
871        path,
872        reason: reason.to_owned(),
873    };
874    let finite_aabb = |aabb: &Aabb, path: &str| {
875        for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
876            for (axis, value) in values.into_iter().enumerate() {
877                finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
878            }
879        }
880        for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
881            if min > max {
882                return Err(invalid(
883                    format!("{path}.min[{axis}]"),
884                    "AABB minimum cannot exceed maximum",
885                ));
886            }
887        }
888        Ok(())
889    };
890
891    let mut mesh_indices = BTreeSet::new();
892    for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
893        if !mesh_indices.insert(mesh.mesh_index) {
894            return Err(invalid(
895                format!("mesh_definitions[{index}].mesh_index"),
896                "mesh_index must be unique",
897            ));
898        }
899        if let Some(aabb) = &mesh.geometry_aabb {
900            finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
901        }
902        if let Some(centroid) = mesh.geometry_centroid {
903            for (axis, value) in centroid.into_iter().enumerate() {
904                finite(
905                    f64::from(value),
906                    format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
907                )?;
908            }
909        }
910        if let Some(value) = mesh.weight_sum_min {
911            finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
912        }
913        if let Some(value) = mesh.weight_sum_max {
914            finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
915        }
916        if revision == MeasurementRevision::V15 && mesh.vertex_count > u64::from(u32::MAX) {
917            return Err(invalid(
918                format!("mesh_definitions[{index}].vertex_count"),
919                "measurements-v15 vertex_count cannot exceed its historical u32 maximum",
920            ));
921        }
922        match (&mesh.primitives, revision) {
923            (
924                None,
925                MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
926            ) => {
927                return Err(invalid(
928                    format!("mesh_definitions[{index}].primitives"),
929                    "measurements-v16 requires per-primitive evidence",
930                ));
931            }
932            (Some(_), MeasurementRevision::V15) => {
933                return Err(invalid(
934                    format!("mesh_definitions[{index}].primitives"),
935                    "measurements-v15 cannot carry per-primitive evidence",
936                ));
937            }
938            (
939                Some(primitives),
940                MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
941            ) => {
942                let mut summed_vertex_count = 0u64;
943                let mut summed_finite_vertex_count = 0u64;
944                let mut aggregate_min = [f32::INFINITY; 3];
945                let mut aggregate_max = [f32::NEG_INFINITY; 3];
946                let mut weighted_centroid_sum = [0.0f64; 3];
947                let mut previous_primitive_index = None;
948                for (primitive_offset, primitive) in primitives.iter().enumerate() {
949                    let path = format!("mesh_definitions[{index}].primitives[{primitive_offset}]");
950                    if previous_primitive_index
951                        .is_some_and(|previous| previous >= primitive.primitive_index)
952                    {
953                        return Err(invalid(
954                            format!("{path}.primitive_index"),
955                            "primitive_index must be unique and strictly increasing in source order",
956                        ));
957                    }
958                    previous_primitive_index = Some(primitive.primitive_index);
959                    if primitive.finite_vertex_count > primitive.vertex_count {
960                        return Err(invalid(
961                            format!("{path}.finite_vertex_count"),
962                            "finite_vertex_count cannot exceed vertex_count",
963                        ));
964                    }
965                    match (
966                        primitive.finite_vertex_count,
967                        primitive.geometry_aabb.as_ref(),
968                        primitive.geometry_centroid,
969                    ) {
970                        (0, None, None) => {}
971                        (1.., Some(aabb), Some(centroid)) => {
972                            finite_aabb(aabb, &format!("{path}.geometry_aabb"))?;
973                            for (axis, value) in centroid.into_iter().enumerate() {
974                                finite(
975                                    f64::from(value),
976                                    format!("{path}.geometry_centroid[{axis}]"),
977                                )?;
978                                if value < aabb.min[axis] || value > aabb.max[axis] {
979                                    return Err(invalid(
980                                        format!("{path}.geometry_centroid[{axis}]"),
981                                        "primitive centroid must lie inside its geometry AABB",
982                                    ));
983                                }
984                                aggregate_min[axis] = aggregate_min[axis].min(aabb.min[axis]);
985                                aggregate_max[axis] = aggregate_max[axis].max(aabb.max[axis]);
986                                weighted_centroid_sum[axis] +=
987                                    f64::from(value) * primitive.finite_vertex_count as f64;
988                            }
989                        }
990                        (0, _, _) => {
991                            return Err(invalid(
992                                path,
993                                "a primitive with no finite vertices cannot carry geometry facts",
994                            ));
995                        }
996                        (1.., _, _) => {
997                            return Err(invalid(
998                                path,
999                                "a primitive with finite vertices requires both geometry facts",
1000                            ));
1001                        }
1002                    }
1003                    if assets.material_resource_coverage == MaterialResourceCoverage::Complete
1004                        && primitive.material_index.is_some_and(|material_index| {
1005                            material_index >= assets.material_definitions.len()
1006                        })
1007                    {
1008                        return Err(invalid(
1009                            format!("{path}.material_index"),
1010                            "material_index must reference a source material when material resource coverage is complete",
1011                        ));
1012                    }
1013                    summed_vertex_count = summed_vertex_count
1014                        .checked_add(primitive.vertex_count)
1015                        .ok_or_else(|| {
1016                        invalid(
1017                            format!("mesh_definitions[{index}].vertex_count"),
1018                            "primitive vertex-count sum overflows u64",
1019                        )
1020                    })?;
1021                    summed_finite_vertex_count = summed_finite_vertex_count
1022                        .checked_add(primitive.finite_vertex_count)
1023                        .ok_or_else(|| {
1024                            invalid(
1025                                format!("mesh_definitions[{index}].primitives"),
1026                                "primitive finite-vertex-count sum overflows u64",
1027                            )
1028                        })?;
1029                }
1030                if summed_vertex_count != mesh.vertex_count {
1031                    return Err(invalid(
1032                        format!("mesh_definitions[{index}].vertex_count"),
1033                        "vertex_count must equal the checked sum of primitive vertex counts",
1034                    ));
1035                }
1036                let expected_aabb = (summed_finite_vertex_count != 0).then_some(Aabb {
1037                    min: aggregate_min,
1038                    max: aggregate_max,
1039                });
1040                let expected_centroid = (summed_finite_vertex_count != 0).then(|| {
1041                    let count = summed_finite_vertex_count as f64;
1042                    weighted_centroid_sum.map(|sum| (sum / count) as f32)
1043                });
1044                match (
1045                    summed_finite_vertex_count,
1046                    mesh.geometry_aabb.as_ref(),
1047                    mesh.geometry_centroid,
1048                ) {
1049                    (0, None, None) | (1.., Some(_), Some(_)) => {}
1050                    (0, _, _) => {
1051                        return Err(invalid(
1052                            format!("mesh_definitions[{index}]"),
1053                            "a mesh with no finite primitive vertices cannot carry geometry facts",
1054                        ));
1055                    }
1056                    (1.., _, _) => {
1057                        return Err(invalid(
1058                            format!("mesh_definitions[{index}]"),
1059                            "a mesh with finite primitive vertices requires both geometry facts",
1060                        ));
1061                    }
1062                }
1063                if mesh.geometry_aabb != expected_aabb {
1064                    return Err(invalid(
1065                        format!("mesh_definitions[{index}].geometry_aabb"),
1066                        "mesh AABB must equal the exact union of primitive AABBs",
1067                    ));
1068                }
1069                if mesh.geometry_centroid != expected_centroid {
1070                    return Err(invalid(
1071                        format!("mesh_definitions[{index}].geometry_centroid"),
1072                        "mesh centroid must equal the finite-count-weighted primitive centroids",
1073                    ));
1074                }
1075            }
1076            (None, MeasurementRevision::V15) => {}
1077        }
1078        let mut previous_set_index = None;
1079        for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
1080            let path = format!(
1081                "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
1082            );
1083            if set.set_index == 0 {
1084                return Err(invalid(path, "set_index must be at least 1"));
1085            }
1086            if !set.joints_present && !set.weights_present {
1087                return Err(invalid(
1088                    format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
1089                    "an additional influence set must declare joints, weights, or both",
1090                ));
1091            }
1092            if set.joints_without_weights_present && !set.joints_present {
1093                return Err(invalid(
1094                    format!(
1095                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1096                    ),
1097                    "joints_without_weights_present requires joints_present",
1098                ));
1099            }
1100            if set.weights_without_joints_present && !set.weights_present {
1101                return Err(invalid(
1102                    format!(
1103                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1104                    ),
1105                    "weights_without_joints_present requires weights_present",
1106                ));
1107            }
1108            if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
1109                return Err(invalid(
1110                    format!(
1111                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
1112                    ),
1113                    "joints_without_weights_present is required when weights_present is false",
1114                ));
1115            }
1116            if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
1117                return Err(invalid(
1118                    format!(
1119                        "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
1120                    ),
1121                    "weights_without_joints_present is required when joints_present is false",
1122                ));
1123            }
1124            if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
1125                return Err(invalid(
1126                    path,
1127                    "set_index values must be strictly increasing and unique",
1128                ));
1129            }
1130            previous_set_index = Some(set.set_index);
1131        }
1132    }
1133
1134    let mut node_indices = BTreeSet::new();
1135    for (index, instance) in assets.node_instances.iter().enumerate() {
1136        if !node_indices.insert(instance.node_index) {
1137            return Err(invalid(
1138                format!("node_instances[{index}].node_index"),
1139                "node_index must be unique",
1140            ));
1141        }
1142        if !mesh_indices.contains(&instance.mesh_index) {
1143            return Err(invalid(
1144                format!("node_instances[{index}].mesh_index"),
1145                "mesh_index must reference a mesh definition",
1146            ));
1147        }
1148        match (
1149            instance.static_node_world_aabb.as_ref(),
1150            instance.static_node_world_aabb_unavailable_reason,
1151        ) {
1152            (Some(aabb), None) => finite_aabb(
1153                aabb,
1154                &format!("node_instances[{index}].static_node_world_aabb"),
1155            )?,
1156            (None, Some(_)) => {}
1157            (Some(_), Some(_)) => {
1158                return Err(invalid(
1159                    format!("node_instances[{index}]"),
1160                    "an available static node AABB cannot have an unavailable reason",
1161                ));
1162            }
1163            (None, None) => {
1164                return Err(invalid(
1165                    format!("node_instances[{index}]"),
1166                    "a missing static node AABB requires an unavailable reason",
1167                ));
1168            }
1169        }
1170    }
1171
1172    let mut scene_indices = BTreeSet::new();
1173    for (index, scene) in assets.scenes.iter().enumerate() {
1174        if !scene_indices.insert(scene.scene_index) {
1175            return Err(invalid(
1176                format!("scenes[{index}].scene_index"),
1177                "scene_index must be unique",
1178            ));
1179        }
1180        if scene.excluded_instance_count > scene.instance_count {
1181            return Err(invalid(
1182                format!("scenes[{index}].excluded_instance_count"),
1183                "excluded_instance_count cannot exceed instance_count",
1184            ));
1185        }
1186        let available = scene.instance_count - scene.excluded_instance_count;
1187        match (&scene.static_scene_world_aabb, available) {
1188            (Some(aabb), 1..) => {
1189                finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
1190            }
1191            (None, 0) => {}
1192            (Some(_), 0) => {
1193                return Err(invalid(
1194                    format!("scenes[{index}].static_scene_world_aabb"),
1195                    "a scene with no available instances cannot have an AABB",
1196                ));
1197            }
1198            (None, _) => {
1199                return Err(invalid(
1200                    format!("scenes[{index}].static_scene_world_aabb"),
1201                    "a scene with available instances requires an AABB",
1202                ));
1203            }
1204        }
1205    }
1206    if let Some(default_scene_index) = assets.default_scene_index
1207        && !scene_indices.contains(&default_scene_index)
1208    {
1209        return Err(invalid(
1210            "default_scene_index".into(),
1211            "default_scene_index must reference a declared scene",
1212        ));
1213    }
1214    validate_skeleton_measurements(assets, revision, &invalid)?;
1215    validate_material_resources(assets, revision, &invalid)?;
1216    Ok(())
1217}
1218
1219fn validate_linear_transform_fields(
1220    linear: &LinearTransformMeasurements,
1221    path: &str,
1222    revision: MeasurementRevision,
1223    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1224) -> Result<(), MeasurementContractError> {
1225    let numeric_fields_present = linear.axis_lengths.is_some()
1226        && linear.determinant.is_some()
1227        && linear.orientation.is_some();
1228    if linear.classification == LinearTransformClassification::NonFinite {
1229        if linear.axis_lengths.is_some()
1230            || linear.determinant.is_some()
1231            || linear.orientation.is_some()
1232            || linear.rotation_xyzw.is_some()
1233            || linear.uniform_scale.is_some()
1234        {
1235            return Err(invalid(
1236                path.into(),
1237                "a non_finite classification cannot carry numeric linear-transform facts",
1238            ));
1239        }
1240        return Ok(());
1241    }
1242    if !numeric_fields_present {
1243        return Err(invalid(
1244            path.into(),
1245            "a finite classification requires axis_lengths, determinant, and orientation",
1246        ));
1247    }
1248    let rotation_present = linear.rotation_xyzw.is_some();
1249    if revision == MeasurementRevision::V18 {
1250        let rotation_required =
1251            linear.classification == LinearTransformClassification::UnitOrthonormal;
1252        if rotation_present != rotation_required {
1253            return Err(invalid(
1254                path.into(),
1255                "rotation_xyzw must be present exactly for unit_orthonormal linear transforms",
1256            ));
1257        }
1258        if let Some(rotation) = linear.rotation_xyzw {
1259            for (component, value) in rotation.into_iter().enumerate() {
1260                if !value.is_finite() {
1261                    return Err(MeasurementContractError::NonFiniteValue {
1262                        path: format!("{path}.rotation_xyzw[{component}]"),
1263                    });
1264                }
1265            }
1266        }
1267    } else if rotation_present {
1268        return Err(invalid(
1269            path.into(),
1270            "rotation_xyzw is exclusive to measurements-v18",
1271        ));
1272    }
1273    for (axis, value) in linear
1274        .axis_lengths
1275        .expect("presence checked")
1276        .into_iter()
1277        .enumerate()
1278    {
1279        if !value.is_finite() {
1280            return Err(MeasurementContractError::NonFiniteValue {
1281                path: format!("{path}.axis_lengths[{axis}]"),
1282            });
1283        }
1284        if value < 0.0 {
1285            return Err(invalid(
1286                format!("{path}.axis_lengths[{axis}]"),
1287                "axis lengths must be non-negative",
1288            ));
1289        }
1290    }
1291    if !linear.determinant.expect("presence checked").is_finite() {
1292        return Err(MeasurementContractError::NonFiniteValue {
1293            path: format!("{path}.determinant"),
1294        });
1295    }
1296    if let Some(scale) = linear.uniform_scale {
1297        if !scale.is_finite() {
1298            return Err(MeasurementContractError::NonFiniteValue {
1299                path: format!("{path}.uniform_scale"),
1300            });
1301        }
1302        if scale < 0.0 {
1303            return Err(invalid(
1304                format!("{path}.uniform_scale"),
1305                "uniform scale must be non-negative",
1306            ));
1307        }
1308    }
1309    Ok(())
1310}
1311
1312fn validate_skeleton_measurements(
1313    assets: &AssetMeasurements,
1314    revision: MeasurementRevision,
1315    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1316) -> Result<(), MeasurementContractError> {
1317    if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
1318        if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
1319            return Err(invalid(
1320                "skeleton_source_coverage".into(),
1321                "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
1322            ));
1323        }
1324        return Ok(());
1325    }
1326
1327    let finite_matrix = |matrix: &[f32; 16], path: &str| {
1328        for (component, value) in matrix.iter().enumerate() {
1329            if !value.is_finite() {
1330                return Err(MeasurementContractError::NonFiniteValue {
1331                    path: format!("{path}[{component}]"),
1332                });
1333            }
1334        }
1335        Ok(())
1336    };
1337    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1338        if node.node_index != offset {
1339            return Err(invalid(
1340                format!("skeleton_nodes[{offset}].node_index"),
1341                "node_index must be contiguous and match source order",
1342            ));
1343        }
1344        match &node.local_rest {
1345            SkeletonNodeLocalRestMeasurements::Trs {
1346                translation_parent_space_m,
1347                rotation_xyzw,
1348                scale,
1349            } => {
1350                for (field, values) in [
1351                    (
1352                        "translation_parent_space_m",
1353                        translation_parent_space_m.as_slice(),
1354                    ),
1355                    ("rotation_xyzw", rotation_xyzw.as_slice()),
1356                    ("scale", scale.as_slice()),
1357                ] {
1358                    for (component, value) in values.iter().enumerate() {
1359                        if !value.is_finite() {
1360                            return Err(MeasurementContractError::NonFiniteValue {
1361                                path: format!(
1362                                    "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
1363                                ),
1364                            });
1365                        }
1366                    }
1367                }
1368            }
1369            SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
1370                matrix,
1371                &format!("skeleton_nodes[{offset}].local_rest.matrix"),
1372            )?,
1373            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
1374        }
1375        let node_path = format!("skeleton_nodes[{offset}]");
1376        validate_linear_transform_fields(
1377            &node.rest_world_linear,
1378            &format!("{node_path}.rest_world_linear"),
1379            revision,
1380            invalid,
1381        )?;
1382        match (
1383            node.rest_world_matrix.as_ref(),
1384            node.rest_world_translation_m.as_ref(),
1385            node.rest_world_matrix_unavailable_reason,
1386        ) {
1387            (Some(matrix), Some(translation), None) => {
1388                finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
1389                for (component, value) in translation.iter().enumerate() {
1390                    if !value.is_finite() {
1391                        return Err(MeasurementContractError::NonFiniteValue {
1392                            path: format!("{node_path}.rest_world_translation_m[{component}]"),
1393                        });
1394                    }
1395                }
1396                let expected_translation = [matrix[12], matrix[13], matrix[14]];
1397                if *translation != expected_translation {
1398                    return Err(invalid(
1399                        format!("{node_path}.rest_world_translation_m"),
1400                        "rest_world_translation_m must equal the rest-world matrix translation column",
1401                    ));
1402                }
1403                let mut expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
1404                if revision != MeasurementRevision::V18 {
1405                    expected_linear.rotation_xyzw = None;
1406                }
1407                if node.rest_world_linear != expected_linear {
1408                    return Err(invalid(
1409                        format!("{node_path}.rest_world_linear"),
1410                        "rest_world_linear must be derived from rest_world_matrix",
1411                    ));
1412                }
1413            }
1414            (None, None, Some(_)) => {
1415                if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
1416                {
1417                    return Err(invalid(
1418                        format!("{node_path}.rest_world_linear"),
1419                        "an unavailable rest-world matrix requires a non_finite linear classification",
1420                    ));
1421                }
1422            }
1423            (Some(_), Some(_), Some(_)) => {
1424                return Err(invalid(
1425                    node_path,
1426                    "an available rest_world_matrix cannot have an unavailable reason",
1427                ));
1428            }
1429            _ => {
1430                return Err(invalid(
1431                    node_path,
1432                    "rest-world matrix, translation, and unavailable reason fields are inconsistent",
1433                ));
1434            }
1435        }
1436    }
1437    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1438        if let Some(parent) = node.parent_node_index
1439            && parent >= assets.skeleton_nodes.len()
1440        {
1441            return Err(invalid(
1442                format!("skeleton_nodes[{offset}].parent_node_index"),
1443                "parent_node_index must reference a skeleton node",
1444            ));
1445        }
1446        let mut previous_scene = None;
1447        for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
1448            if !assets
1449                .scenes
1450                .iter()
1451                .any(|scene| scene.scene_index == *scene_index)
1452            {
1453                return Err(invalid(
1454                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1455                    "scene_root_indices values must reference declared scenes",
1456                ));
1457            }
1458            if previous_scene.is_some_and(|previous| previous >= *scene_index) {
1459                return Err(invalid(
1460                    format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
1461                    "scene_root_indices values must be strictly increasing and unique",
1462                ));
1463            }
1464            previous_scene = Some(*scene_index);
1465        }
1466    }
1467    let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
1468    for start in 0..assets.skeleton_nodes.len() {
1469        if visits.get(start) != Some(&ParentVisit::Unvisited) {
1470            continue;
1471        }
1472        let mut path = Vec::new();
1473        let mut current = start;
1474        loop {
1475            match visits.get(current).copied().ok_or_else(|| {
1476                invalid(
1477                    format!("skeleton_nodes[{current}].parent_node_index"),
1478                    "parent_node_index must reference a skeleton node",
1479                )
1480            })? {
1481                ParentVisit::Done => break,
1482                ParentVisit::Visiting => {
1483                    return Err(invalid(
1484                        format!("skeleton_nodes[{current}].parent_node_index"),
1485                        "source node parent graph must be acyclic",
1486                    ));
1487                }
1488                ParentVisit::Unvisited => {
1489                    *visits.get_mut(current).ok_or_else(|| {
1490                        invalid(
1491                            format!("skeleton_nodes[{current}].parent_node_index"),
1492                            "parent_node_index must reference a skeleton node",
1493                        )
1494                    })? = ParentVisit::Visiting;
1495                    path.push(current);
1496                    match assets
1497                        .skeleton_nodes
1498                        .get(current)
1499                        .ok_or_else(|| {
1500                            invalid(
1501                                format!("skeleton_nodes[{current}].parent_node_index"),
1502                                "parent_node_index must reference a skeleton node",
1503                            )
1504                        })?
1505                        .parent_node_index
1506                    {
1507                        Some(parent) => current = parent,
1508                        None => break,
1509                    }
1510                }
1511            }
1512        }
1513        for node_index in path {
1514            *visits.get_mut(node_index).ok_or_else(|| {
1515                invalid(
1516                    format!("skeleton_nodes[{node_index}].parent_node_index"),
1517                    "parent_node_index must reference a skeleton node",
1518                )
1519            })? = ParentVisit::Done;
1520        }
1521    }
1522
1523    for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
1524        let local_rest_available = !matches!(
1525            node.local_rest,
1526            SkeletonNodeLocalRestMeasurements::Unavailable { .. }
1527        );
1528        let path = format!("skeleton_nodes[{offset}]");
1529        if !local_rest_available {
1530            if node.rest_world_matrix.is_some()
1531                || node.rest_world_matrix_unavailable_reason
1532                    != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
1533            {
1534                return Err(invalid(
1535                    path,
1536                    "an unavailable local_rest requires a non_finite_local_rest rest-world result",
1537                ));
1538            }
1539            continue;
1540        }
1541
1542        let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
1543            let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
1544                invalid(
1545                    format!("skeleton_nodes[{offset}].parent_node_index"),
1546                    "parent_node_index must reference a skeleton node",
1547                )
1548            })?;
1549            if parent.rest_world_matrix.is_none() {
1550                Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
1551            } else {
1552                Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
1553            }
1554        } else {
1555            None
1556        };
1557        match (
1558            node.rest_world_matrix.is_some(),
1559            expected_unavailable_reason,
1560        ) {
1561            (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
1562            }
1563            (false, Some(expected))
1564                if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
1565            _ => {
1566                return Err(invalid(
1567                    path,
1568                    "rest-world availability must agree with local rest and parent rest-world evidence",
1569                ));
1570            }
1571        }
1572    }
1573
1574    for (offset, skin) in assets.skins.iter().enumerate() {
1575        if skin.skin_index != offset {
1576            return Err(invalid(
1577                format!("skins[{offset}].skin_index"),
1578                "skin_index must be contiguous and match source order",
1579            ));
1580        }
1581        if let Some(root) = skin.skeleton_root_node_index
1582            && root >= assets.skeleton_nodes.len()
1583        {
1584            return Err(invalid(
1585                format!("skins[{offset}].skeleton_root_node_index"),
1586                "skeleton_root_node_index must reference a skeleton node",
1587            ));
1588        }
1589        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1590            if joint.joint_index != joint_offset {
1591                return Err(invalid(
1592                    format!("skins[{offset}].joints[{joint_offset}].joint_index"),
1593                    "joint_index must be contiguous and match declared skin order",
1594                ));
1595            }
1596            if joint.node_index >= assets.skeleton_nodes.len() {
1597                return Err(invalid(
1598                    format!("skins[{offset}].joints[{joint_offset}].node_index"),
1599                    "joint node_index must reference a skeleton node",
1600                ));
1601            }
1602        }
1603        match skin.inverse_bind_accessor.status {
1604            SourceInverseBindAccessorStatus::Absent => {
1605                if skin.inverse_bind_accessor.declared_count.is_some()
1606                    || !skin.inverse_bind_accessor.matrices.is_empty()
1607                {
1608                    return Err(invalid(
1609                        format!("skins[{offset}].inverse_bind_accessor"),
1610                        "an absent inverse-bind declaration has no declared count or matrices",
1611                    ));
1612                }
1613            }
1614            SourceInverseBindAccessorStatus::EmptyAccessor => {
1615                if skin.inverse_bind_accessor.declared_count != Some(0)
1616                    || !skin.inverse_bind_accessor.matrices.is_empty()
1617                {
1618                    return Err(invalid(
1619                        format!("skins[{offset}].inverse_bind_accessor"),
1620                        "an empty inverse-bind declaration has declared_count 0 and no matrices",
1621                    ));
1622                }
1623            }
1624            SourceInverseBindAccessorStatus::Available => {
1625                if skin.inverse_bind_accessor.declared_count
1626                    != Some(skin.inverse_bind_accessor.matrices.len())
1627                    || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
1628                {
1629                    return Err(invalid(
1630                        format!("skins[{offset}].inverse_bind_accessor"),
1631                        "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
1632                    ));
1633                }
1634            }
1635            SourceInverseBindAccessorStatus::CountMismatch => {
1636                if skin.inverse_bind_accessor.declared_count
1637                    != Some(skin.inverse_bind_accessor.matrices.len())
1638                    || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
1639                {
1640                    return Err(invalid(
1641                        format!("skins[{offset}].inverse_bind_accessor"),
1642                        "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
1643                    ));
1644                }
1645            }
1646            SourceInverseBindAccessorStatus::Unreadable => {
1647                if skin.inverse_bind_accessor.declared_count.is_none()
1648                    || !skin.inverse_bind_accessor.matrices.is_empty()
1649                {
1650                    return Err(invalid(
1651                        format!("skins[{offset}].inverse_bind_accessor"),
1652                        "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
1653                    ));
1654                }
1655            }
1656        }
1657        for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
1658            finite_matrix(
1659                matrix,
1660                &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
1661            )?;
1662        }
1663        for (joint_offset, joint) in skin.joints.iter().enumerate() {
1664            let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
1665            let joint_bind_path =
1666                format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
1667            validate_derived_matrix(
1668                &joint.joint_bind_to_mesh,
1669                &joint_bind_path,
1670                revision,
1671                &finite_matrix,
1672                invalid,
1673            )?;
1674            validate_derived_reason_compatibility(
1675                &joint.joint_bind_to_mesh,
1676                skin.inverse_bind_accessor.status,
1677                skin.inverse_bind_accessor.matrices.len(),
1678                joint_offset,
1679                &joint_bind_path,
1680                DerivedMatrixDomain::JointBindToMesh,
1681                invalid,
1682            )?;
1683            validate_derived_source(
1684                &joint.joint_bind_to_mesh,
1685                expected_source,
1686                None,
1687                &joint_bind_path,
1688                DerivedMatrixDomain::JointBindToMesh,
1689                invalid,
1690            )?;
1691
1692            let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
1693            validate_derived_matrix(
1694                &joint.mesh_bind_world,
1695                &mesh_bind_path,
1696                revision,
1697                &finite_matrix,
1698                invalid,
1699            )?;
1700            validate_derived_reason_compatibility(
1701                &joint.mesh_bind_world,
1702                skin.inverse_bind_accessor.status,
1703                skin.inverse_bind_accessor.matrices.len(),
1704                joint_offset,
1705                &mesh_bind_path,
1706                DerivedMatrixDomain::MeshBindWorld,
1707                invalid,
1708            )?;
1709            let joint_rest_world_available = assets
1710                .skeleton_nodes
1711                .get(joint.node_index)
1712                .ok_or_else(|| {
1713                    invalid(
1714                        format!("skins[{offset}].joints[{joint_offset}].node_index"),
1715                        "joint node_index must reference a skeleton node",
1716                    )
1717                })?
1718                .rest_world_matrix
1719                .is_some();
1720            let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1721                .rest_world_matrix
1722                .as_ref();
1723            validate_mesh_bind_world_reason_compatibility(
1724                &joint.mesh_bind_world,
1725                joint_rest_world_available,
1726                &mesh_bind_path,
1727                invalid,
1728            )?;
1729            validate_derived_source(
1730                &joint.mesh_bind_world,
1731                expected_source,
1732                joint_rest_world,
1733                &mesh_bind_path,
1734                DerivedMatrixDomain::MeshBindWorld,
1735                invalid,
1736            )?;
1737        }
1738        if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1739            && !scale.is_finite()
1740        {
1741            return Err(MeasurementContractError::NonFiniteValue {
1742                path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1743            });
1744        }
1745        let expected_summary = summarize_skin_bind_linear(&skin.joints);
1746        if skin.joint_bind_linear_summary != expected_summary {
1747            return Err(invalid(
1748                format!("skins[{offset}].joint_bind_linear_summary"),
1749                "joint-bind linear summary must match the skin joint observations",
1750            ));
1751        }
1752        let mut previous_attachment_node = None;
1753        for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1754            if attachment.node_index >= assets.skeleton_nodes.len() {
1755                return Err(invalid(
1756                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1757                    "attachment node_index must reference a skeleton node",
1758                ));
1759            }
1760            if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1761                return Err(invalid(
1762                    format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1763                    "attachment node_index values must be strictly increasing and unique",
1764                ));
1765            }
1766            previous_attachment_node = Some(attachment.node_index);
1767        }
1768    }
1769    Ok(())
1770}
1771
1772fn validate_derived_reason_compatibility(
1773    matrix: &SkinDerivedMatrixMeasurements,
1774    status: SourceInverseBindAccessorStatus,
1775    readable_matrix_count: usize,
1776    joint_index: usize,
1777    path: &str,
1778    domain: DerivedMatrixDomain,
1779    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1780) -> Result<(), MeasurementContractError> {
1781    let requires_accessor_reason = match status {
1782        SourceInverseBindAccessorStatus::Absent => {
1783            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1784        }
1785        SourceInverseBindAccessorStatus::EmptyAccessor => {
1786            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1787        }
1788        SourceInverseBindAccessorStatus::Unreadable => {
1789            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1790        }
1791        SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1792            Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1793        }
1794        SourceInverseBindAccessorStatus::Available
1795        | SourceInverseBindAccessorStatus::CountMismatch => None,
1796    };
1797    if let Some(expected) = requires_accessor_reason {
1798        if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1799            return Err(invalid(
1800                path.into(),
1801                "derived matrices without a usable inverse bind must carry the matching accessor reason",
1802            ));
1803        }
1804    } else {
1805        match (domain, matrix.unavailable_reason) {
1806            (
1807                _,
1808                Some(
1809                    SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1810                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1811                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1812                    | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1813                ),
1814            ) => {
1815                return Err(invalid(
1816                    format!("{path}.unavailable_reason"),
1817                    "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1818                ));
1819            }
1820            (
1821                DerivedMatrixDomain::JointBindToMesh,
1822                Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1823            ) => {
1824                return Err(invalid(
1825                    format!("{path}.unavailable_reason"),
1826                    "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1827                ));
1828            }
1829            (
1830                DerivedMatrixDomain::MeshBindWorld,
1831                Some(
1832                    SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1833                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1834                    | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1835                ),
1836            ) => {
1837                return Err(invalid(
1838                    format!("{path}.unavailable_reason"),
1839                    "mesh_bind_world does not require an invertible inverse-bind matrix",
1840                ));
1841            }
1842            _ => {}
1843        }
1844    }
1845    Ok(())
1846}
1847
1848fn validate_mesh_bind_world_reason_compatibility(
1849    matrix: &SkinDerivedMatrixMeasurements,
1850    joint_rest_world_available: bool,
1851    path: &str,
1852    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1853) -> Result<(), MeasurementContractError> {
1854    match matrix.unavailable_reason {
1855        Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1856            if joint_rest_world_available =>
1857        {
1858            Err(invalid(
1859                format!("{path}.unavailable_reason"),
1860                "an available joint rest-world matrix cannot be reported as unavailable",
1861            ))
1862        }
1863        Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1864            if !joint_rest_world_available =>
1865        {
1866            Err(invalid(
1867                format!("{path}.unavailable_reason"),
1868                "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1869            ))
1870        }
1871        _ => Ok(()),
1872    }
1873}
1874
1875#[derive(Clone, Copy, PartialEq, Eq)]
1876enum ParentVisit {
1877    Unvisited,
1878    Visiting,
1879    Done,
1880}
1881
1882#[derive(Clone, Copy)]
1883enum DerivedMatrixDomain {
1884    JointBindToMesh,
1885    MeshBindWorld,
1886}
1887
1888fn validate_derived_source(
1889    measurements: &SkinDerivedMatrixMeasurements,
1890    expected_source: Option<&[f32; 16]>,
1891    joint_rest_world: Option<&[f32; 16]>,
1892    path: &str,
1893    domain: DerivedMatrixDomain,
1894    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1895) -> Result<(), MeasurementContractError> {
1896    if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1897        return Err(invalid(
1898            format!("{path}.source_inverse_bind_matrix"),
1899            "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1900        ));
1901    }
1902    let Some(source) = expected_source else {
1903        if measurements.inversion_quality.is_some() {
1904            return Err(invalid(
1905                format!("{path}.inversion_quality"),
1906                "inversion quality requires a readable source inverse-bind matrix",
1907            ));
1908        }
1909        return Ok(());
1910    };
1911    let raw = Mat4::from_cols_array(source);
1912    match domain {
1913        DerivedMatrixDomain::JointBindToMesh => {
1914            let assessment = assess_inverse_bind(raw);
1915            if measurements.inversion_quality != assessment.quality {
1916                return Err(invalid(
1917                    format!("{path}.inversion_quality"),
1918                    "inversion quality must be derived from the source linear 3x3",
1919                ));
1920            }
1921            match assessment.inverse {
1922                Ok(inverse) => {
1923                    if measurements.matrix != Some(inverse.to_cols_array())
1924                        || measurements.unavailable_reason.is_some()
1925                    {
1926                        return Err(invalid(
1927                            path.into(),
1928                            "a trustworthy source inverse-bind matrix requires its exact inverse",
1929                        ));
1930                    }
1931                }
1932                Err(reason) => {
1933                    if measurements.matrix.is_some()
1934                        || measurements.unavailable_reason != Some(reason)
1935                    {
1936                        return Err(invalid(
1937                            path.into(),
1938                            "an untrustworthy source inverse-bind matrix requires its derived reason",
1939                        ));
1940                    }
1941                }
1942            }
1943        }
1944        DerivedMatrixDomain::MeshBindWorld => {
1945            if measurements.inversion_quality.is_some() {
1946                return Err(invalid(
1947                    format!("{path}.inversion_quality"),
1948                    "mesh_bind_world does not invert its source matrix",
1949                ));
1950            }
1951            if let Some(world) = joint_rest_world {
1952                let expected = Mat4::from_cols_array(world) * raw;
1953                if expected.to_cols_array().into_iter().all(f32::is_finite) {
1954                    if measurements.matrix != Some(expected.to_cols_array())
1955                        || measurements.unavailable_reason.is_some()
1956                    {
1957                        return Err(invalid(
1958                            path.into(),
1959                            "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1960                        ));
1961                    }
1962                } else if measurements.unavailable_reason
1963                    != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1964                {
1965                    return Err(invalid(
1966                        format!("{path}.unavailable_reason"),
1967                        "a non-finite mesh-bind product requires its typed unavailable reason",
1968                    ));
1969                }
1970            }
1971        }
1972    }
1973    Ok(())
1974}
1975
1976fn validate_derived_matrix(
1977    matrix: &SkinDerivedMatrixMeasurements,
1978    path: &str,
1979    revision: MeasurementRevision,
1980    finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1981    invalid: &impl Fn(String, &str) -> MeasurementContractError,
1982) -> Result<(), MeasurementContractError> {
1983    if let Some(source) = &matrix.source_inverse_bind_matrix {
1984        finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1985    }
1986    if let Some(quality) = matrix.inversion_quality {
1987        let value = quality.reciprocal_condition_number_inf;
1988        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1989            return Err(invalid(
1990                format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1991                "reciprocal condition number must be finite and between zero and one",
1992            ));
1993        }
1994    }
1995    match (
1996        &matrix.matrix,
1997        matrix.linear.as_ref(),
1998        matrix.unavailable_reason,
1999    ) {
2000        (Some(matrix), Some(linear), None) => {
2001            finite_matrix(matrix, &format!("{path}.matrix"))?;
2002            validate_linear_transform_fields(linear, &format!("{path}.linear"), revision, invalid)?;
2003            let mut expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
2004            if revision != MeasurementRevision::V18 {
2005                expected_linear.rotation_xyzw = None;
2006            }
2007            if *linear != expected_linear {
2008                return Err(invalid(
2009                    format!("{path}.linear"),
2010                    "linear facts must be derived from the available matrix",
2011                ));
2012            }
2013        }
2014        (None, None, Some(_)) => {}
2015        (Some(_), Some(_), Some(_)) => {
2016            return Err(invalid(
2017                path.into(),
2018                "an available derived matrix cannot have an unavailable reason",
2019            ));
2020        }
2021        _ => {
2022            return Err(invalid(
2023                path.into(),
2024                "derived matrix, linear facts, and unavailable reason fields are inconsistent",
2025            ));
2026        }
2027    }
2028    Ok(())
2029}
2030
2031fn validate_material_resources(
2032    assets: &AssetMeasurements,
2033    revision: MeasurementRevision,
2034    invalid: &impl Fn(String, &str) -> MeasurementContractError,
2035) -> Result<(), MeasurementContractError> {
2036    let absent = assets.material_definitions.is_empty()
2037        && assets.textures.is_empty()
2038        && assets.images.is_empty();
2039    if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
2040        return Err(invalid(
2041            "material_resource_coverage".into(),
2042            "unavailable resource coverage requires empty material, texture, and image arrays",
2043        ));
2044    }
2045
2046    for (offset, material) in assets.material_definitions.iter().enumerate() {
2047        if material.material_index != offset {
2048            return Err(invalid(
2049                format!("material_definitions[{offset}].material_index"),
2050                "material_index must be contiguous and match source order",
2051            ));
2052        }
2053        let mut previous_slot = None;
2054        for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
2055            if binding.texture_index >= assets.textures.len() {
2056                return Err(invalid(
2057                    format!(
2058                        "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
2059                    ),
2060                    "texture_index must reference a source texture",
2061                ));
2062            }
2063            if previous_slot.is_some_and(|previous| previous >= binding.slot) {
2064                return Err(invalid(
2065                    format!(
2066                        "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
2067                    ),
2068                    "texture bindings must be strictly ordered by slot and unique",
2069                ));
2070            }
2071            previous_slot = Some(binding.slot);
2072        }
2073    }
2074    for (offset, texture) in assets.textures.iter().enumerate() {
2075        if texture.texture_index != offset {
2076            return Err(invalid(
2077                format!("textures[{offset}].texture_index"),
2078                "texture_index must be contiguous and match source order",
2079            ));
2080        }
2081        if texture.image_index >= assets.images.len() {
2082            return Err(invalid(
2083                format!("textures[{offset}].image_index"),
2084                "image_index must reference a source image",
2085            ));
2086        }
2087    }
2088    for (offset, image) in assets.images.iter().enumerate() {
2089        validate_image_measurement(image, offset, revision, invalid)?;
2090    }
2091    Ok(())
2092}
2093
2094fn validate_image_measurement(
2095    image: &ImageMeasurements,
2096    offset: usize,
2097    revision: MeasurementRevision,
2098    invalid: &impl Fn(String, &str) -> MeasurementContractError,
2099) -> Result<(), MeasurementContractError> {
2100    if image.image_index != offset {
2101        return Err(invalid(
2102            format!("images[{offset}].image_index"),
2103            "image_index must be contiguous and match source order",
2104        ));
2105    }
2106    let available = [
2107        image.width.is_some(),
2108        image.height.is_some(),
2109        image.channel_count.is_some(),
2110        image.decoded_color_type.is_some(),
2111    ];
2112    match (
2113        available.into_iter().all(|value| value),
2114        image.unavailable_reason,
2115    ) {
2116        (true, None) => {
2117            let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
2118                image.width,
2119                image.height,
2120                image.channel_count,
2121                image.decoded_color_type,
2122            ) else {
2123                return Err(invalid(
2124                    format!("images[{offset}]"),
2125                    "available image metadata must include width, height, channel_count, and decoded_color_type",
2126                ));
2127            };
2128            if width == 0 || height == 0 {
2129                return Err(invalid(
2130                    format!("images[{offset}]"),
2131                    "available image dimensions must be greater than zero",
2132                ));
2133            }
2134            if channel_count != color_type_channel_count(decoded_color_type) {
2135                return Err(invalid(
2136                    format!("images[{offset}].channel_count"),
2137                    "channel_count must match decoded_color_type",
2138                ));
2139            }
2140            if image.detected_container.is_none() {
2141                return Err(invalid(
2142                    format!("images[{offset}].detected_container"),
2143                    "available image metadata requires a detected_container",
2144                ));
2145            }
2146        }
2147        (false, Some(_)) if available.into_iter().all(|value| !value) => {}
2148        (true, Some(_)) => {
2149            return Err(invalid(
2150                format!("images[{offset}]"),
2151                "available image metadata cannot have an unavailable_reason",
2152            ));
2153        }
2154        (false, None) if available.into_iter().all(|value| !value) => {
2155            return Err(invalid(
2156                format!("images[{offset}]"),
2157                "missing image metadata requires an unavailable_reason",
2158            ));
2159        }
2160        (false, _) => {
2161            return Err(invalid(
2162                format!("images[{offset}]"),
2163                "available image metadata must include width, height, channel_count, and decoded_color_type",
2164            ));
2165        }
2166    }
2167    match image.unavailable_reason {
2168        Some(crate::model::ImageUnavailableReason::DecodeFailed)
2169            if image.detected_container.is_none() =>
2170        {
2171            return Err(invalid(
2172                format!("images[{offset}].detected_container"),
2173                "decode_failed requires a detected_container",
2174            ));
2175        }
2176        Some(
2177            crate::model::ImageUnavailableReason::SourceUnavailable
2178            | crate::model::ImageUnavailableReason::InvalidDataUri
2179            | crate::model::ImageUnavailableReason::UnsupportedContainer,
2180        ) if image.detected_container.is_some() => {
2181            return Err(invalid(
2182                format!("images[{offset}].detected_container"),
2183                "this unavailable_reason cannot have a detected_container",
2184            ));
2185        }
2186        _ => {}
2187    }
2188    match (revision, image.unavailable_reason, &image.leading_magic_hex) {
2189        (MeasurementRevision::V15, _, Some(_)) => {
2190            return Err(invalid(
2191                format!("images[{offset}].leading_magic_hex"),
2192                "measurements-v15 cannot carry leading-magic evidence",
2193            ));
2194        }
2195        (
2196            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2197            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2198            Some(magic),
2199        ) => {
2200            if magic.is_empty()
2201                || magic.len() > 32
2202                || !magic.len().is_multiple_of(2)
2203                || !magic
2204                    .bytes()
2205                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2206            {
2207                return Err(invalid(
2208                    format!("images[{offset}].leading_magic_hex"),
2209                    "leading_magic_hex must be nonempty lowercase even-length hex for at most 16 bytes",
2210                ));
2211            }
2212        }
2213        (
2214            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2215            Some(crate::model::ImageUnavailableReason::UnsupportedContainer),
2216            None,
2217        )
2218        | (MeasurementRevision::V15, _, None) => {}
2219        (
2220            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2221            _,
2222            Some(_),
2223        ) => {
2224            return Err(invalid(
2225                format!("images[{offset}].leading_magic_hex"),
2226                "leading_magic_hex is permitted only for unsupported_container",
2227            ));
2228        }
2229        (
2230            MeasurementRevision::V16 | MeasurementRevision::V17 | MeasurementRevision::V18,
2231            _,
2232            None,
2233        ) => {}
2234    }
2235    Ok(())
2236}
2237
2238fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
2239    match color_type {
2240        DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
2241        DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
2242        DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
2243        DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
2244    }
2245}
2246
2247/// Typed read-side subset accepted when a consumer needs measurements from a
2248/// current `measure` or `lint` report.
2249///
2250/// This intentionally models only the fields needed to recover the nested
2251/// measurement contract while retaining every legitimate output-v11 root
2252/// field. The frozen schema is closed, while all protocol identities and
2253/// command constraints are validated by [`MeasurementReportInput::into_files`].
2254#[derive(Debug)]
2255pub struct MeasurementReportInput {
2256    schema_version: Option<u32>,
2257    schema: Option<String>,
2258    _tool: Option<Box<RawValue>>,
2259    command: Option<String>,
2260    summary: Option<MeasurementReportSummaryInput>,
2261    files: Option<Vec<Box<RawValue>>>,
2262    _inputs: Option<Box<RawValue>>,
2263    _deltas: Option<Box<RawValue>>,
2264    extra: BTreeMap<String, Box<RawValue>>,
2265}
2266
2267impl<'de> Deserialize<'de> for MeasurementReportInput {
2268    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2269    where
2270        D: Deserializer<'de>,
2271    {
2272        struct MeasurementReportInputVisitor;
2273
2274        impl<'de> Visitor<'de> for MeasurementReportInputVisitor {
2275            type Value = MeasurementReportInput;
2276
2277            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2278                formatter.write_str("an output report object")
2279            }
2280
2281            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
2282            where
2283                A: MapAccess<'de>,
2284            {
2285                let mut schema_version = None;
2286                let mut schema = None;
2287                let mut tool = None;
2288                let mut command = None;
2289                let mut summary = None;
2290                let mut files = None;
2291                let mut inputs = None;
2292                let mut deltas = None;
2293                let mut extra = BTreeMap::new();
2294                while let Some(field) = map.next_key::<String>()? {
2295                    match field.as_str() {
2296                        "schema_version" => {
2297                            if schema_version.is_some() {
2298                                return Err(serde::de::Error::duplicate_field("schema_version"));
2299                            }
2300                            schema_version = Some(map.next_value()?);
2301                        }
2302                        "schema" => {
2303                            if schema.is_some() {
2304                                return Err(serde::de::Error::duplicate_field("schema"));
2305                            }
2306                            schema = Some(map.next_value()?);
2307                        }
2308                        "tool" => {
2309                            if tool.is_some() {
2310                                return Err(serde::de::Error::duplicate_field("tool"));
2311                            }
2312                            tool = Some(map.next_value()?);
2313                        }
2314                        "command" => {
2315                            if command.is_some() {
2316                                return Err(serde::de::Error::duplicate_field("command"));
2317                            }
2318                            command = Some(map.next_value()?);
2319                        }
2320                        "summary" => {
2321                            if summary.is_some() {
2322                                return Err(serde::de::Error::duplicate_field("summary"));
2323                            }
2324                            summary = Some(map.next_value()?);
2325                        }
2326                        "files" => {
2327                            if files.is_some() {
2328                                return Err(serde::de::Error::duplicate_field("files"));
2329                            }
2330                            files = Some(map.next_value()?);
2331                        }
2332                        "inputs" => {
2333                            if inputs.is_some() {
2334                                return Err(serde::de::Error::duplicate_field("inputs"));
2335                            }
2336                            inputs = Some(map.next_value()?);
2337                        }
2338                        "deltas" => {
2339                            if deltas.is_some() {
2340                                return Err(serde::de::Error::duplicate_field("deltas"));
2341                            }
2342                            deltas = Some(map.next_value()?);
2343                        }
2344                        _ => {
2345                            extra.insert(field, map.next_value()?);
2346                        }
2347                    }
2348                }
2349                Ok(MeasurementReportInput {
2350                    schema_version: schema_version.unwrap_or_default(),
2351                    schema: schema.unwrap_or_default(),
2352                    _tool: tool,
2353                    command: command.unwrap_or_default(),
2354                    summary: summary.unwrap_or_default(),
2355                    files: files.unwrap_or_default(),
2356                    _inputs: inputs.unwrap_or_default(),
2357                    _deltas: deltas.unwrap_or_default(),
2358                    extra,
2359                })
2360            }
2361        }
2362
2363        deserializer.deserialize_map(MeasurementReportInputVisitor)
2364    }
2365}
2366
2367#[derive(Debug, Deserialize)]
2368#[serde(deny_unknown_fields)]
2369struct MeasurementFileWireInput {
2370    path: Option<String>,
2371    input: Option<InputIdentityInput>,
2372    rig: Box<RawValue>,
2373    measurements: Option<Box<RawValue>>,
2374    #[serde(default, deserialize_with = "deserialize_required_nullable")]
2375    prediction_provenance: RequiredNullable<Box<RawValue>>,
2376    checks: Option<Vec<Box<RawValue>>>,
2377}
2378
2379#[derive(Debug)]
2380struct MeasurementFileInput {
2381    path: Option<String>,
2382    input: Option<InputIdentityInput>,
2383    /// V17 alone retains the strictly decoded rig needed by the shared
2384    /// root-motion reconstruction hook. Historical readers keep treating rig
2385    /// as opaque evidence, preserving their released acceptance behavior.
2386    rig_v17: Option<RigInfo>,
2387    measurements: Option<Box<RawValue>>,
2388    prediction_provenance: RequiredNullable<PredictionProvenanceV2>,
2389    checks: Option<Vec<PredictionCheckInput>>,
2390    legacy_prediction_provenance: RequiredNullable<PredictionProvenanceV1>,
2391    legacy_checks: Option<Vec<LegacyPredictionCheckInput>>,
2392    prediction_provenance_v3: RequiredNullable<PredictionProvenanceV3>,
2393    checks_v3: Option<Vec<PredictionCheckInputV3>>,
2394    prediction_provenance_v4: RequiredNullable<PredictionProvenanceV4>,
2395    checks_v4: Option<Vec<PredictionCheckInputV4>>,
2396    prediction_provenance_v5: RequiredNullable<PredictionProvenanceV5>,
2397    checks_v5: Option<Vec<PredictionCheckInputV5>>,
2398    prediction_provenance_v6: RequiredNullable<PredictionProvenanceV6>,
2399    checks_v6: Option<Vec<PredictionCheckInputV6>>,
2400}
2401
2402#[derive(Debug, Deserialize)]
2403#[serde(deny_unknown_fields)]
2404struct LegacyPredictionCheckWireV11 {
2405    check_id: String,
2406    selection: SelectionState,
2407    configuration: ConfigurationState,
2408    applicability: Applicability,
2409    evaluation: EvaluationState,
2410    findings: Vec<PredictionFindingInput>,
2411    #[serde(default)]
2412    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2413    #[serde(default)]
2414    gaps: Vec<PredictionGapInput>,
2415    prediction: Option<Box<RawValue>>,
2416}
2417
2418/// The immutable V11 check attachment.  This deliberately remains a separate
2419/// internal shape: V11 evidence is validated with its V1 identity and staged
2420/// decoding rules, never converted into a V2 attachment.
2421#[derive(Debug)]
2422struct LegacyPredictionCheckInput {
2423    check_id: String,
2424    selection: SelectionState,
2425    configuration: ConfigurationState,
2426    applicability: Applicability,
2427    evaluation: EvaluationState,
2428    findings: Vec<PredictionFindingInput>,
2429    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2430    gaps: Vec<PredictionGapInput>,
2431    prediction: Option<EnginePredictionV1>,
2432}
2433
2434#[derive(Debug, Default)]
2435enum RequiredNullable<T> {
2436    #[default]
2437    Missing,
2438    Present(Option<T>),
2439}
2440
2441impl<T> RequiredNullable<T> {
2442    fn as_present(&self) -> Option<&T> {
2443        match self {
2444            Self::Missing | Self::Present(None) => None,
2445            Self::Present(Some(value)) => Some(value),
2446        }
2447    }
2448}
2449
2450fn deserialize_required_nullable<'de, D, T>(
2451    deserializer: D,
2452) -> Result<RequiredNullable<T>, D::Error>
2453where
2454    D: Deserializer<'de>,
2455    T: Deserialize<'de>,
2456{
2457    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
2458}
2459
2460#[derive(Debug, Deserialize)]
2461#[serde(deny_unknown_fields)]
2462struct MeasurementReportSummaryInput {
2463    #[serde(rename = "files")]
2464    _files: Option<Box<RawValue>>,
2465    #[serde(rename = "findings")]
2466    _findings: Option<Box<RawValue>>,
2467    #[serde(rename = "checks")]
2468    _checks: Option<Box<RawValue>>,
2469    #[serde(rename = "deltas")]
2470    _deltas: Option<Box<RawValue>>,
2471    prediction_facets: Option<PredictionFacetSummaryInput>,
2472}
2473
2474#[derive(Debug, Deserialize)]
2475#[serde(deny_unknown_fields)]
2476struct PredictionFacetSummaryInput {
2477    available: usize,
2478    required_prediction_unavailable: usize,
2479}
2480
2481#[derive(Debug, Deserialize)]
2482#[serde(deny_unknown_fields)]
2483struct PredictionCheckWireInput {
2484    check_id: String,
2485    selection: SelectionState,
2486    configuration: ConfigurationState,
2487    applicability: Applicability,
2488    evaluation: EvaluationState,
2489    findings: Vec<PredictionFindingInput>,
2490    #[serde(default)]
2491    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2492    #[serde(default)]
2493    gaps: Vec<PredictionGapInput>,
2494    prediction: Option<Box<RawValue>>,
2495}
2496
2497#[derive(Debug)]
2498struct PredictionCheckInput {
2499    check_id: String,
2500    selection: SelectionState,
2501    configuration: ConfigurationState,
2502    applicability: Applicability,
2503    evaluation: EvaluationState,
2504    findings: Vec<PredictionFindingInput>,
2505    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2506    gaps: Vec<PredictionGapInput>,
2507    prediction: Option<EnginePredictionV2>,
2508}
2509
2510#[derive(Debug)]
2511struct PredictionCheckInputV3 {
2512    check_id: String,
2513    selection: SelectionState,
2514    configuration: ConfigurationState,
2515    applicability: Applicability,
2516    evaluation: EvaluationState,
2517    findings: Vec<PredictionFindingInput>,
2518    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2519    gaps: Vec<PredictionGapInput>,
2520    prediction: Option<EnginePredictionV3>,
2521}
2522
2523#[derive(Debug)]
2524struct PredictionCheckInputV4 {
2525    check_id: String,
2526    selection: SelectionState,
2527    configuration: ConfigurationState,
2528    applicability: Applicability,
2529    evaluation: EvaluationState,
2530    findings: Vec<PredictionFindingInput>,
2531    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2532    gaps: Vec<PredictionGapInput>,
2533    prediction: Option<EnginePredictionV4>,
2534}
2535
2536#[derive(Debug)]
2537struct PredictionCheckInputV5 {
2538    check_id: String,
2539    selection: SelectionState,
2540    configuration: ConfigurationState,
2541    applicability: Applicability,
2542    evaluation: EvaluationState,
2543    findings: Vec<PredictionFindingInput>,
2544    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2545    gaps: Vec<PredictionGapInput>,
2546    prediction: Option<EnginePredictionV5>,
2547}
2548
2549#[derive(Debug)]
2550struct PredictionCheckInputV6 {
2551    check_id: String,
2552    selection: SelectionState,
2553    configuration: ConfigurationState,
2554    applicability: Applicability,
2555    evaluation: EvaluationState,
2556    findings: Vec<PredictionFindingInput>,
2557    evaluated_scopes: Vec<crate::evaluation::EvaluationScope>,
2558    gaps: Vec<PredictionGapInput>,
2559    prediction: Option<EnginePredictionV6>,
2560}
2561
2562#[derive(Debug, Serialize, Deserialize)]
2563#[serde(deny_unknown_fields)]
2564struct PredictionFindingInput {
2565    check_id: String,
2566    #[serde(rename = "severity")]
2567    _severity: PredictionSeverityInput,
2568    #[serde(rename = "clip", skip_serializing_if = "Option::is_none")]
2569    _clip: Option<String>,
2570    #[serde(rename = "bone", skip_serializing_if = "Option::is_none")]
2571    _bone: Option<String>,
2572    #[serde(rename = "node", skip_serializing_if = "Option::is_none")]
2573    _node: Option<String>,
2574    #[serde(skip_serializing_if = "Option::is_none")]
2575    prediction_scope: Option<crate::evaluation::EvaluationScope>,
2576    #[serde(rename = "time_s", skip_serializing_if = "Option::is_none")]
2577    _time_s: Option<f32>,
2578    #[serde(rename = "measured", skip_serializing_if = "Option::is_none")]
2579    _measured: Option<Box<RawValue>>,
2580    #[serde(rename = "expected", skip_serializing_if = "Option::is_none")]
2581    _expected: Option<Box<RawValue>>,
2582    #[serde(rename = "members", skip_serializing_if = "Option::is_none")]
2583    _members: Option<Box<RawValue>>,
2584    #[serde(rename = "message")]
2585    _message: String,
2586}
2587
2588#[derive(Debug, Deserialize)]
2589#[serde(deny_unknown_fields)]
2590struct PredictionGapInput {
2591    code: String,
2592    #[serde(rename = "message")]
2593    _message: String,
2594    scope: Option<crate::evaluation::EvaluationScope>,
2595}
2596
2597#[derive(Debug, Serialize, Deserialize)]
2598#[serde(rename_all = "snake_case")]
2599enum PredictionSeverityInput {
2600    Error,
2601    Warning,
2602    Note,
2603}
2604
2605#[derive(Debug, Deserialize)]
2606#[serde(deny_unknown_fields)]
2607struct InputIdentityInput {
2608    sha256: Option<String>,
2609    bytes: Option<u64>,
2610}
2611
2612/// Recursively preserves the historical JSON-f64-to-Rust-f32 narrowing path
2613/// without materializing an unbounded generic JSON value.
2614///
2615/// `serde_json` rejects a finite JSON number that exceeds `f32::MAX` when it
2616/// directly services `deserialize_f32`. Output-v9 readback first retained the
2617/// number as `f64`, then narrowed it to `f32`; semantic measurement validation
2618/// consequently reported the resulting infinity as a typed non-finite value.
2619/// This adapter retains that contract while streaming directly into the bounded
2620/// typed measurement DTO.
2621struct MeasurementF32NarrowingDeserializer<D>(D);
2622
2623macro_rules! delegate_measurement_deserializer {
2624    ($method:ident $(, $argument:ident: $argument_type:ty)*) => {
2625        fn $method<V>(
2626            self,
2627            $($argument: $argument_type,)*
2628            visitor: V,
2629        ) -> Result<V::Value, Self::Error>
2630        where
2631            V: Visitor<'de>,
2632        {
2633            self.0.$method(
2634                $($argument,)*
2635                MeasurementF32NarrowingVisitor(visitor),
2636            )
2637        }
2638    };
2639}
2640
2641impl<'de, D> Deserializer<'de> for MeasurementF32NarrowingDeserializer<D>
2642where
2643    D: Deserializer<'de>,
2644{
2645    type Error = D::Error;
2646
2647    delegate_measurement_deserializer!(deserialize_any);
2648    delegate_measurement_deserializer!(deserialize_bool);
2649    delegate_measurement_deserializer!(deserialize_i8);
2650    delegate_measurement_deserializer!(deserialize_i16);
2651    delegate_measurement_deserializer!(deserialize_i32);
2652    delegate_measurement_deserializer!(deserialize_i64);
2653    delegate_measurement_deserializer!(deserialize_i128);
2654    delegate_measurement_deserializer!(deserialize_u8);
2655    delegate_measurement_deserializer!(deserialize_u16);
2656    delegate_measurement_deserializer!(deserialize_u32);
2657    delegate_measurement_deserializer!(deserialize_u64);
2658    delegate_measurement_deserializer!(deserialize_u128);
2659
2660    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
2661    where
2662        V: Visitor<'de>,
2663    {
2664        self.0
2665            .deserialize_f64(MeasurementF32NarrowingNumberVisitor(visitor))
2666    }
2667
2668    delegate_measurement_deserializer!(deserialize_f64);
2669    delegate_measurement_deserializer!(deserialize_char);
2670    delegate_measurement_deserializer!(deserialize_str);
2671    delegate_measurement_deserializer!(deserialize_string);
2672    delegate_measurement_deserializer!(deserialize_bytes);
2673    delegate_measurement_deserializer!(deserialize_byte_buf);
2674    delegate_measurement_deserializer!(deserialize_option);
2675    delegate_measurement_deserializer!(deserialize_unit);
2676    delegate_measurement_deserializer!(deserialize_unit_struct, name: &'static str);
2677    delegate_measurement_deserializer!(deserialize_newtype_struct, name: &'static str);
2678    delegate_measurement_deserializer!(deserialize_seq);
2679    delegate_measurement_deserializer!(deserialize_tuple, len: usize);
2680    delegate_measurement_deserializer!(
2681        deserialize_tuple_struct,
2682        name: &'static str,
2683        len: usize
2684    );
2685    delegate_measurement_deserializer!(deserialize_map);
2686    delegate_measurement_deserializer!(
2687        deserialize_struct,
2688        name: &'static str,
2689        fields: &'static [&'static str]
2690    );
2691    delegate_measurement_deserializer!(
2692        deserialize_enum,
2693        name: &'static str,
2694        variants: &'static [&'static str]
2695    );
2696    delegate_measurement_deserializer!(deserialize_identifier);
2697    delegate_measurement_deserializer!(deserialize_ignored_any);
2698
2699    fn is_human_readable(&self) -> bool {
2700        self.0.is_human_readable()
2701    }
2702}
2703
2704struct MeasurementF32NarrowingNumberVisitor<V>(V);
2705
2706impl<'de, V> Visitor<'de> for MeasurementF32NarrowingNumberVisitor<V>
2707where
2708    V: Visitor<'de>,
2709{
2710    type Value = V::Value;
2711
2712    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2713        self.0.expecting(formatter)
2714    }
2715
2716    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
2717    where
2718        E: serde::de::Error,
2719    {
2720        self.0.visit_f32(value)
2721    }
2722
2723    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
2724    where
2725        E: serde::de::Error,
2726    {
2727        self.0.visit_f32(value as f32)
2728    }
2729
2730    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
2731    where
2732        E: serde::de::Error,
2733    {
2734        self.0.visit_f32(value as f32)
2735    }
2736
2737    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
2738    where
2739        E: serde::de::Error,
2740    {
2741        self.0.visit_f32(value as f32)
2742    }
2743
2744    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
2745    where
2746        E: serde::de::Error,
2747    {
2748        self.0.visit_f32(value as f32)
2749    }
2750
2751    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
2752    where
2753        E: serde::de::Error,
2754    {
2755        self.0.visit_f32(value as f32)
2756    }
2757}
2758
2759struct MeasurementF32NarrowingVisitor<V>(V);
2760
2761macro_rules! delegate_measurement_visitor {
2762    ($method:ident, $value_type:ty) => {
2763        fn $method<E>(self, value: $value_type) -> Result<Self::Value, E>
2764        where
2765            E: serde::de::Error,
2766        {
2767            self.0.$method(value)
2768        }
2769    };
2770}
2771
2772impl<'de, V> Visitor<'de> for MeasurementF32NarrowingVisitor<V>
2773where
2774    V: Visitor<'de>,
2775{
2776    type Value = V::Value;
2777
2778    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2779        self.0.expecting(formatter)
2780    }
2781
2782    delegate_measurement_visitor!(visit_bool, bool);
2783    delegate_measurement_visitor!(visit_i8, i8);
2784    delegate_measurement_visitor!(visit_i16, i16);
2785    delegate_measurement_visitor!(visit_i32, i32);
2786    delegate_measurement_visitor!(visit_i64, i64);
2787    delegate_measurement_visitor!(visit_i128, i128);
2788    delegate_measurement_visitor!(visit_u8, u8);
2789    delegate_measurement_visitor!(visit_u16, u16);
2790    delegate_measurement_visitor!(visit_u32, u32);
2791    delegate_measurement_visitor!(visit_u64, u64);
2792    delegate_measurement_visitor!(visit_u128, u128);
2793    delegate_measurement_visitor!(visit_f32, f32);
2794    delegate_measurement_visitor!(visit_f64, f64);
2795    delegate_measurement_visitor!(visit_char, char);
2796
2797    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
2798    where
2799        E: serde::de::Error,
2800    {
2801        self.0.visit_str(value)
2802    }
2803
2804    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
2805    where
2806        E: serde::de::Error,
2807    {
2808        self.0.visit_borrowed_str(value)
2809    }
2810
2811    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
2812    where
2813        E: serde::de::Error,
2814    {
2815        self.0.visit_string(value)
2816    }
2817
2818    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
2819    where
2820        E: serde::de::Error,
2821    {
2822        self.0.visit_bytes(value)
2823    }
2824
2825    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
2826    where
2827        E: serde::de::Error,
2828    {
2829        self.0.visit_borrowed_bytes(value)
2830    }
2831
2832    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
2833    where
2834        E: serde::de::Error,
2835    {
2836        self.0.visit_byte_buf(value)
2837    }
2838
2839    fn visit_none<E>(self) -> Result<Self::Value, E>
2840    where
2841        E: serde::de::Error,
2842    {
2843        self.0.visit_none()
2844    }
2845
2846    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2847    where
2848        D: Deserializer<'de>,
2849    {
2850        self.0
2851            .visit_some(MeasurementF32NarrowingDeserializer(deserializer))
2852    }
2853
2854    fn visit_unit<E>(self) -> Result<Self::Value, E>
2855    where
2856        E: serde::de::Error,
2857    {
2858        self.0.visit_unit()
2859    }
2860
2861    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2862    where
2863        D: Deserializer<'de>,
2864    {
2865        self.0
2866            .visit_newtype_struct(MeasurementF32NarrowingDeserializer(deserializer))
2867    }
2868
2869    fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error>
2870    where
2871        A: SeqAccess<'de>,
2872    {
2873        self.0.visit_seq(MeasurementF32NarrowingSeqAccess(sequence))
2874    }
2875
2876    fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
2877    where
2878        A: MapAccess<'de>,
2879    {
2880        self.0.visit_map(MeasurementF32NarrowingMapAccess(map))
2881    }
2882
2883    fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
2884    where
2885        A: EnumAccess<'de>,
2886    {
2887        self.0.visit_enum(MeasurementF32NarrowingEnumAccess(data))
2888    }
2889}
2890
2891struct MeasurementF32NarrowingSeed<S>(S);
2892
2893impl<'de, S> DeserializeSeed<'de> for MeasurementF32NarrowingSeed<S>
2894where
2895    S: DeserializeSeed<'de>,
2896{
2897    type Value = S::Value;
2898
2899    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
2900    where
2901        D: Deserializer<'de>,
2902    {
2903        self.0
2904            .deserialize(MeasurementF32NarrowingDeserializer(deserializer))
2905    }
2906}
2907
2908struct MeasurementF32NarrowingSeqAccess<A>(A);
2909
2910impl<'de, A> SeqAccess<'de> for MeasurementF32NarrowingSeqAccess<A>
2911where
2912    A: SeqAccess<'de>,
2913{
2914    type Error = A::Error;
2915
2916    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
2917    where
2918        T: DeserializeSeed<'de>,
2919    {
2920        self.0.next_element_seed(MeasurementF32NarrowingSeed(seed))
2921    }
2922
2923    fn size_hint(&self) -> Option<usize> {
2924        self.0.size_hint()
2925    }
2926}
2927
2928struct MeasurementF32NarrowingMapAccess<A>(A);
2929
2930impl<'de, A> MapAccess<'de> for MeasurementF32NarrowingMapAccess<A>
2931where
2932    A: MapAccess<'de>,
2933{
2934    type Error = A::Error;
2935
2936    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
2937    where
2938        K: DeserializeSeed<'de>,
2939    {
2940        self.0.next_key_seed(MeasurementF32NarrowingSeed(seed))
2941    }
2942
2943    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
2944    where
2945        V: DeserializeSeed<'de>,
2946    {
2947        self.0.next_value_seed(MeasurementF32NarrowingSeed(seed))
2948    }
2949
2950    fn size_hint(&self) -> Option<usize> {
2951        self.0.size_hint()
2952    }
2953}
2954
2955struct MeasurementF32NarrowingEnumAccess<A>(A);
2956
2957impl<'de, A> EnumAccess<'de> for MeasurementF32NarrowingEnumAccess<A>
2958where
2959    A: EnumAccess<'de>,
2960{
2961    type Error = A::Error;
2962    type Variant = MeasurementF32NarrowingVariantAccess<A::Variant>;
2963
2964    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
2965    where
2966        V: DeserializeSeed<'de>,
2967    {
2968        let (value, variant) = self.0.variant_seed(MeasurementF32NarrowingSeed(seed))?;
2969        Ok((value, MeasurementF32NarrowingVariantAccess(variant)))
2970    }
2971}
2972
2973struct MeasurementF32NarrowingVariantAccess<A>(A);
2974
2975impl<'de, A> VariantAccess<'de> for MeasurementF32NarrowingVariantAccess<A>
2976where
2977    A: VariantAccess<'de>,
2978{
2979    type Error = A::Error;
2980
2981    fn unit_variant(self) -> Result<(), Self::Error> {
2982        self.0.unit_variant()
2983    }
2984
2985    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
2986    where
2987        T: DeserializeSeed<'de>,
2988    {
2989        self.0
2990            .newtype_variant_seed(MeasurementF32NarrowingSeed(seed))
2991    }
2992
2993    fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
2994    where
2995        V: Visitor<'de>,
2996    {
2997        self.0
2998            .tuple_variant(len, MeasurementF32NarrowingVisitor(visitor))
2999    }
3000
3001    fn struct_variant<V>(
3002        self,
3003        fields: &'static [&'static str],
3004        visitor: V,
3005    ) -> Result<V::Value, Self::Error>
3006    where
3007        V: Visitor<'de>,
3008    {
3009        self.0
3010            .struct_variant(fields, MeasurementF32NarrowingVisitor(visitor))
3011    }
3012}
3013
3014#[derive(Debug, Deserialize)]
3015#[serde(untagged)]
3016enum SkeletonNodeMeasurementInput {
3017    Current(Box<crate::measure::SkeletonNodeMeasurements>),
3018    Earlier {
3019        #[serde(rename = "node_index")]
3020        _node_index: usize,
3021    },
3022}
3023
3024#[derive(Debug, Deserialize)]
3025#[serde(untagged)]
3026enum SkinMeasurementInput {
3027    Current(Box<crate::measure::SkinMeasurements>),
3028    Earlier {
3029        #[serde(rename = "skin_index")]
3030        _skin_index: usize,
3031    },
3032}
3033
3034#[derive(Debug, Deserialize)]
3035struct MeasurementPayloadInput {
3036    schema_version: Option<u32>,
3037    schema: Option<String>,
3038    clips: Option<BTreeMap<String, ClipMeasurements>>,
3039    material_resource_coverage: Option<MaterialResourceCoverage>,
3040    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
3041    textures: Option<Vec<TextureMeasurements>>,
3042    images: Option<Vec<ImageMeasurements>>,
3043    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
3044    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
3045    skins: Option<Vec<SkinMeasurementInput>>,
3046    mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
3047    node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
3048    scenes: Option<Vec<crate::measure::SceneMeasurements>>,
3049    default_scene_index: Option<usize>,
3050}
3051
3052/// Current measurement payload readback is closed at the root and at every
3053/// domain introduced or extended by measurements-v16. Historical readers use
3054/// [`MeasurementPayloadInput`] directly so their accepted JSON shape does not
3055/// change retroactively.
3056#[derive(Debug, Deserialize)]
3057#[serde(deny_unknown_fields)]
3058struct MeasurementPayloadV16Input {
3059    schema_version: Option<u32>,
3060    schema: Option<String>,
3061    clips: Option<BTreeMap<String, ClipMeasurements>>,
3062    material_resource_coverage: Option<MaterialResourceCoverage>,
3063    material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
3064    textures: Option<Vec<TextureMeasurements>>,
3065    images: Option<Vec<ImageMeasurementsV16Input>>,
3066    skeleton_source_coverage: Option<SourceSkeletonCoverage>,
3067    skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
3068    skins: Option<Vec<SkinMeasurementInput>>,
3069    mesh_definitions: Option<Vec<MeshDefinitionMeasurementsV16Input>>,
3070    node_instances: Option<Vec<NodeInstanceMeasurementsV16Input>>,
3071    scenes: Option<Vec<SceneMeasurementsV16Input>>,
3072    default_scene_index: Option<usize>,
3073}
3074
3075#[derive(Debug, Deserialize)]
3076#[serde(deny_unknown_fields)]
3077struct AabbV16Input {
3078    min: [f32; 3],
3079    max: [f32; 3],
3080}
3081
3082impl From<AabbV16Input> for Aabb {
3083    fn from(value: AabbV16Input) -> Self {
3084        Self {
3085            min: value.min,
3086            max: value.max,
3087        }
3088    }
3089}
3090
3091#[derive(Debug, Deserialize)]
3092#[serde(deny_unknown_fields)]
3093struct PrimitiveMeasurementsV16Input {
3094    primitive_index: usize,
3095    #[serde(deserialize_with = "deserialize_required_optional_usize")]
3096    material_index: Option<usize>,
3097    vertex_count: u64,
3098    finite_vertex_count: u64,
3099    geometry_aabb: Option<AabbV16Input>,
3100    geometry_centroid: Option<[f32; 3]>,
3101}
3102
3103fn deserialize_required_optional_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
3104where
3105    D: Deserializer<'de>,
3106{
3107    Option::<usize>::deserialize(deserializer)
3108}
3109
3110impl From<PrimitiveMeasurementsV16Input> for PrimitiveMeasurements {
3111    fn from(value: PrimitiveMeasurementsV16Input) -> Self {
3112        Self {
3113            primitive_index: value.primitive_index,
3114            material_index: value.material_index,
3115            vertex_count: value.vertex_count,
3116            finite_vertex_count: value.finite_vertex_count,
3117            geometry_aabb: value.geometry_aabb.map(Into::into),
3118            geometry_centroid: value.geometry_centroid,
3119        }
3120    }
3121}
3122
3123#[derive(Debug, Deserialize)]
3124#[serde(deny_unknown_fields)]
3125struct MeshDefinitionMeasurementsV16Input {
3126    mesh_index: usize,
3127    name: String,
3128    primitives: Option<Vec<PrimitiveMeasurementsV16Input>>,
3129    vertex_count: u64,
3130    geometry_aabb: Option<AabbV16Input>,
3131    geometry_centroid: Option<[f32; 3]>,
3132    max_joints_per_vertex: u32,
3133    weight_sum_min: Option<f64>,
3134    weight_sum_max: Option<f64>,
3135    additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
3136}
3137
3138impl From<MeshDefinitionMeasurementsV16Input> for MeshDefinitionMeasurements {
3139    fn from(value: MeshDefinitionMeasurementsV16Input) -> Self {
3140        Self {
3141            mesh_index: value.mesh_index,
3142            name: value.name,
3143            primitives: value
3144                .primitives
3145                .map(|primitives| primitives.into_iter().map(Into::into).collect()),
3146            vertex_count: value.vertex_count,
3147            geometry_aabb: value.geometry_aabb.map(Into::into),
3148            geometry_centroid: value.geometry_centroid,
3149            max_joints_per_vertex: value.max_joints_per_vertex,
3150            weight_sum_min: value.weight_sum_min,
3151            weight_sum_max: value.weight_sum_max,
3152            additional_influence_sets: value.additional_influence_sets,
3153        }
3154    }
3155}
3156
3157#[derive(Debug, Deserialize)]
3158#[serde(deny_unknown_fields)]
3159struct ImageMeasurementsV16Input {
3160    image_index: usize,
3161    name: Option<String>,
3162    source_kind: crate::model::ImageSourceKind,
3163    declared_mime_type: Option<String>,
3164    detected_container: Option<crate::model::ImageContainerFormat>,
3165    leading_magic_hex: Option<String>,
3166    width: Option<u32>,
3167    height: Option<u32>,
3168    channel_count: Option<u8>,
3169    decoded_color_type: Option<DecodedImageColorType>,
3170    unavailable_reason: Option<crate::model::ImageUnavailableReason>,
3171}
3172
3173impl From<ImageMeasurementsV16Input> for ImageMeasurements {
3174    fn from(value: ImageMeasurementsV16Input) -> Self {
3175        Self {
3176            image_index: value.image_index,
3177            name: value.name,
3178            source_kind: value.source_kind,
3179            declared_mime_type: value.declared_mime_type,
3180            detected_container: value.detected_container,
3181            leading_magic_hex: value.leading_magic_hex,
3182            width: value.width,
3183            height: value.height,
3184            channel_count: value.channel_count,
3185            decoded_color_type: value.decoded_color_type,
3186            unavailable_reason: value.unavailable_reason,
3187        }
3188    }
3189}
3190
3191#[derive(Debug, Deserialize)]
3192#[serde(deny_unknown_fields)]
3193struct NodeInstanceMeasurementsV16Input {
3194    node_index: usize,
3195    node_name: String,
3196    mesh_index: usize,
3197    static_node_world_aabb: Option<AabbV16Input>,
3198    static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
3199}
3200
3201impl From<NodeInstanceMeasurementsV16Input> for NodeInstanceMeasurements {
3202    fn from(value: NodeInstanceMeasurementsV16Input) -> Self {
3203        Self {
3204            node_index: value.node_index,
3205            node_name: value.node_name,
3206            mesh_index: value.mesh_index,
3207            static_node_world_aabb: value.static_node_world_aabb.map(Into::into),
3208            static_node_world_aabb_unavailable_reason: value
3209                .static_node_world_aabb_unavailable_reason,
3210        }
3211    }
3212}
3213
3214#[derive(Debug, Deserialize)]
3215#[serde(deny_unknown_fields)]
3216struct SceneMeasurementsV16Input {
3217    scene_index: usize,
3218    name: Option<String>,
3219    instance_count: usize,
3220    static_scene_world_aabb: Option<AabbV16Input>,
3221    excluded_instance_count: usize,
3222}
3223
3224impl From<SceneMeasurementsV16Input> for SceneMeasurements {
3225    fn from(value: SceneMeasurementsV16Input) -> Self {
3226        Self {
3227            scene_index: value.scene_index,
3228            name: value.name,
3229            instance_count: value.instance_count,
3230            static_scene_world_aabb: value.static_scene_world_aabb.map(Into::into),
3231            excluded_instance_count: value.excluded_instance_count,
3232        }
3233    }
3234}
3235
3236impl From<MeasurementPayloadV16Input> for MeasurementPayloadInput {
3237    fn from(value: MeasurementPayloadV16Input) -> Self {
3238        Self {
3239            schema_version: value.schema_version,
3240            schema: value.schema,
3241            clips: value.clips,
3242            material_resource_coverage: value.material_resource_coverage,
3243            material_definitions: value.material_definitions,
3244            textures: value.textures,
3245            images: value
3246                .images
3247                .map(|images| images.into_iter().map(Into::into).collect()),
3248            skeleton_source_coverage: value.skeleton_source_coverage,
3249            skeleton_nodes: value.skeleton_nodes,
3250            skins: value.skins,
3251            mesh_definitions: value
3252                .mesh_definitions
3253                .map(|meshes| meshes.into_iter().map(Into::into).collect()),
3254            node_instances: value
3255                .node_instances
3256                .map(|instances| instances.into_iter().map(Into::into).collect()),
3257            scenes: value
3258                .scenes
3259                .map(|scenes| scenes.into_iter().map(Into::into).collect()),
3260            default_scene_index: value.default_scene_index,
3261        }
3262    }
3263}
3264
3265fn decode_measurement_payload(
3266    raw: &RawValue,
3267    strict_v16: bool,
3268) -> Result<MeasurementPayloadInput, serde_json::Error> {
3269    let mut deserializer = serde_json::Deserializer::from_str(raw.get());
3270    let payload = if strict_v16 {
3271        MeasurementPayloadV16Input::deserialize(MeasurementF32NarrowingDeserializer(
3272            &mut deserializer,
3273        ))?
3274        .into()
3275    } else {
3276        MeasurementPayloadInput::deserialize(MeasurementF32NarrowingDeserializer(
3277            &mut deserializer,
3278        ))?
3279    };
3280    deserializer.end()?;
3281    Ok(payload)
3282}
3283
3284/// One validated file record recovered from a measurement report.
3285///
3286/// The record retains its source path and full nested measurement contract so
3287/// consumers can choose the clip, mesh, and cardinality policies appropriate
3288/// to their workflow.
3289#[derive(Debug, Clone)]
3290pub struct MeasurementReportFile {
3291    path: String,
3292    input: InputIdentity,
3293    measurements: MeasurementContract,
3294}
3295
3296impl MeasurementReportFile {
3297    /// Source path recorded by the producing report.
3298    pub fn path(&self) -> &str {
3299        &self.path
3300    }
3301
3302    /// Immutable identity of the source bytes used to produce this record.
3303    pub fn input(&self) -> &InputIdentity {
3304        &self.input
3305    }
3306
3307    /// Validated nested measurement contract.
3308    pub fn measurements(&self) -> &MeasurementContract {
3309        &self.measurements
3310    }
3311
3312    /// Consume this record and return its validated measurement contract.
3313    pub fn into_measurements(self) -> MeasurementContract {
3314        self.measurements
3315    }
3316}
3317
3318/// A typed measurement-report subset failed current-contract validation.
3319#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3320#[non_exhaustive]
3321pub enum MeasurementReportError {
3322    /// The outer envelope omitted its version.
3323    #[error("report envelope has no `schema_version`")]
3324    MissingOutputVersion,
3325    /// The outer envelope uses an unsupported version.
3326    #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
3327    UnsupportedOutputVersion {
3328        /// Version found in the input.
3329        found: u32,
3330    },
3331    /// The outer envelope does not carry the immutable current identity.
3332    #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
3333    WrongOutputIdentity,
3334    /// The outer envelope omitted its command.
3335    #[error("report envelope has no `command`")]
3336    MissingCommand,
3337    /// The outer envelope belongs to a command without file measurements.
3338    #[error("report command {command:?} does not carry measurement file records")]
3339    UnsupportedCommand {
3340        /// Command found in the input.
3341        command: String,
3342    },
3343    /// A current output-v11 envelope carried a field outside its closed schema.
3344    #[error("report envelope has unknown field `{field}`")]
3345    UnknownOutputField {
3346        /// Lexically first unknown root field.
3347        field: String,
3348    },
3349    /// A current output-v11 envelope omitted its producer metadata.
3350    #[error("report envelope has no `tool` object")]
3351    MissingTool,
3352    /// The outer envelope omitted its file array.
3353    #[error("report envelope has no `files` array")]
3354    MissingFiles,
3355    /// The outer envelope exceeds the immutable file-record bound.
3356    #[error("report contains {found} files, exceeding the output-v11 limit of {limit}")]
3357    TooManyFiles {
3358        /// Supplied file count.
3359        found: usize,
3360        /// Immutable output-v11 limit.
3361        limit: usize,
3362    },
3363    /// A lint report omitted the derived prediction-facet summary.
3364    #[error("lint report summary has no `prediction_facets` object")]
3365    MissingPredictionFacetSummary,
3366    /// A measure report carried a lint-only prediction-facet summary.
3367    #[error("measure report summary must not carry `prediction_facets`")]
3368    UnexpectedPredictionFacetSummary,
3369    /// Derived prediction-facet totals did not match the lint summary.
3370    #[error("lint report prediction-facet summary does not match its check records")]
3371    PredictionFacetSummaryMismatch,
3372    /// One file record failed validation.
3373    #[error("files[{file_index}] {source}")]
3374    File {
3375        /// Zero-based index of the invalid file record.
3376        file_index: usize,
3377        /// Typed record-validation failure.
3378        #[source]
3379        source: MeasurementFileError,
3380    },
3381}
3382
3383/// A serialized output-v11 report could not be read within the public bound.
3384#[derive(Debug, thiserror::Error)]
3385#[non_exhaustive]
3386pub enum MeasurementReportReadError {
3387    /// Reading the bounded input failed.
3388    #[error("cannot read report: {source}")]
3389    Io {
3390        /// Underlying bounded-reader failure.
3391        #[source]
3392        source: std::io::Error,
3393    },
3394    /// The serialized report exceeded the immutable output-v11 byte limit.
3395    #[error("report exceeds the output-v11 limit of {limit} bytes")]
3396    ReportTooLarge {
3397        /// Immutable maximum accepted byte count.
3398        limit: u64,
3399    },
3400    /// The bounded bytes were not valid JSON for the output-v11 read shape.
3401    #[error("invalid report JSON: {source}")]
3402    InvalidJson {
3403        /// JSON syntax or typed-shape failure.
3404        #[source]
3405        source: serde_json::Error,
3406    },
3407}
3408
3409/// One measurement-report file record failed validation.
3410#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3411#[non_exhaustive]
3412pub enum MeasurementFileError {
3413    /// The bounded file record could not be decoded after the outer v11
3414    /// identity was accepted.
3415    #[error("has invalid output-v11 file shape: {reason}")]
3416    InvalidFileShape {
3417        /// Stable serde diagnostic for the malformed nested record.
3418        reason: String,
3419    },
3420    /// The file record omitted its source path.
3421    #[error("has no `path`")]
3422    MissingPath,
3423    /// The file record omitted its source-byte identity.
3424    #[error("has no `input`")]
3425    MissingInput,
3426    /// The source-byte identity omitted its SHA-256 digest.
3427    #[error("input has no `sha256`")]
3428    MissingSha256,
3429    /// The source-byte identity uses a malformed SHA-256 digest.
3430    #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
3431    InvalidSha256,
3432    /// The source-byte identity omitted its byte count.
3433    #[error("input has no `bytes`")]
3434    MissingBytes,
3435    /// The file record omitted its nested measurement contract.
3436    #[error("has no measurements")]
3437    MissingMeasurements,
3438    /// A lint file omitted its required nullable provenance field.
3439    #[error("has no required `prediction_provenance` field")]
3440    MissingPredictionProvenance,
3441    /// A measure file carried lint-only prediction provenance.
3442    #[error("measure file must not carry `prediction_provenance`")]
3443    UnexpectedPredictionProvenance,
3444    /// A lint file omitted its check array.
3445    #[error("lint file has no `checks` array")]
3446    MissingChecks,
3447    /// A measure file carried lint-only check records.
3448    #[error("measure file must not carry `checks`")]
3449    UnexpectedChecks,
3450    /// A lint file exceeded the immutable per-file check bound.
3451    #[error("contains {found} checks, exceeding the output-v11 limit of {limit}")]
3452    TooManyChecks {
3453        /// Supplied check count.
3454        found: usize,
3455        /// Immutable output-v11 limit.
3456        limit: usize,
3457    },
3458    /// File and prediction-provenance primary identities differ.
3459    #[error("prediction provenance primary input does not match file input")]
3460    PredictionPrimaryInputMismatch,
3461    /// File-scoped prediction provenance violated its immutable contract.
3462    #[error("has invalid prediction provenance: {source}")]
3463    InvalidPredictionProvenance {
3464        /// Typed nested provenance failure.
3465        #[source]
3466        source: PredictionContractError,
3467    },
3468    /// The serialized provenance object could not be decoded as the strict V1 wire.
3469    #[error("has invalid prediction provenance shape: {reason}")]
3470    InvalidPredictionProvenanceShape {
3471        /// Stable serde diagnostic for the malformed nested object.
3472        reason: String,
3473    },
3474    /// One check carried a prediction without file provenance.
3475    #[error("checks[{check_index}] has prediction without non-null file provenance")]
3476    PredictionWithoutProvenance {
3477        /// Zero-based check index.
3478        check_index: usize,
3479    },
3480    /// One check's prediction evidence violated its immutable contract.
3481    #[error("checks[{check_index}] has invalid prediction evidence: {source}")]
3482    InvalidPrediction {
3483        /// Zero-based check index.
3484        check_index: usize,
3485        /// Typed nested prediction failure.
3486        #[source]
3487        source: PredictionContractError,
3488    },
3489    /// One serialized check or prediction object could not be decoded as the strict V1 wire.
3490    #[error("checks[{check_index}] has invalid prediction shape: {reason}")]
3491    InvalidPredictionShape {
3492        /// Zero-based check index.
3493        check_index: usize,
3494        /// Stable serde diagnostic for the malformed nested object.
3495        reason: String,
3496    },
3497    /// One check's prediction attachment contradicts the sole check lifecycle.
3498    #[error("checks[{check_index}] has invalid prediction lifecycle: {reason}")]
3499    InvalidPredictionLifecycle {
3500        /// Zero-based check index.
3501        check_index: usize,
3502        /// Stable relationship failure.
3503        reason: &'static str,
3504    },
3505    /// Aggregate prediction facets exceeded the per-file V1 bound.
3506    #[error("contains {found} prediction facets, exceeding the V1 limit of {limit}")]
3507    TooManyPredictionFacets {
3508        /// Supplied facet count.
3509        found: usize,
3510        /// Immutable V1 limit.
3511        limit: usize,
3512    },
3513    /// A decoded V2 budget summary did not coincide with an exhausted shared
3514    /// file facet budget.
3515    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
3516    FacetBudgetSummaryWithoutExhaustedFileBudget {
3517        /// Aggregate facet count.
3518        found: usize,
3519        /// Immutable shared file limit.
3520        limit: usize,
3521    },
3522    /// Aggregate prediction basis rows exceeded the per-file V1 bound.
3523    #[error("contains {found} prediction basis rows, exceeding the V1 limit of {limit}")]
3524    TooManyPredictionBasisReferences {
3525        /// Supplied basis-row count.
3526        found: usize,
3527        /// Immutable V1 limit.
3528        limit: usize,
3529    },
3530    /// Aggregate prediction/provenance retained text exceeded the per-file bound.
3531    #[error("retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
3532    TooMuchPredictionText {
3533        /// Supplied UTF-8 byte count.
3534        found: usize,
3535        /// Immutable V1 limit.
3536        limit: usize,
3537    },
3538    /// Checked prediction accounting overflowed.
3539    #[error("prediction bound accounting overflowed")]
3540    PredictionAccountingOverflow,
3541    /// The nested measurement contract omitted its version.
3542    #[error("has no versioned measurement contract")]
3543    MissingMeasurementVersion,
3544    /// The nested measurement contract uses an unsupported version.
3545    #[error(
3546        "has measurement schema_version {found}; this reader expects measurement schema_version {expected}"
3547    )]
3548    UnsupportedMeasurementVersion {
3549        /// Version found in the nested contract.
3550        found: u32,
3551        /// Version required by the output reader revision.
3552        expected: u32,
3553    },
3554    /// The nested contract does not carry the immutable measurement identity.
3555    #[error("does not identify measurement contract {expected}")]
3556    WrongMeasurementIdentity {
3557        /// Identity required by the output reader revision.
3558        expected: &'static str,
3559    },
3560    /// The nested contract omitted its clip-measurement map.
3561    #[error("measurement contract has no `clips` map")]
3562    MissingClips,
3563    /// The nested contract omitted material resource coverage.
3564    #[error("measurement contract has no `material_resource_coverage`")]
3565    MissingMaterialResourceCoverage,
3566    /// The nested contract omitted its material definition array.
3567    #[error("measurement contract has no `material_definitions` array")]
3568    MissingMaterialDefinitions,
3569    /// The nested contract omitted its texture array.
3570    #[error("measurement contract has no `textures` array")]
3571    MissingTextures,
3572    /// The nested contract omitted its image array.
3573    #[error("measurement contract has no `images` array")]
3574    MissingImages,
3575    /// The nested contract omitted skeleton source coverage.
3576    #[error("measurement contract has no `skeleton_source_coverage`")]
3577    MissingSkeletonSourceCoverage,
3578    /// The nested contract omitted its source skeleton-node array.
3579    #[error("measurement contract has no `skeleton_nodes` array")]
3580    MissingSkeletonNodes,
3581    /// The nested contract omitted its source skin array.
3582    #[error("measurement contract has no `skins` array")]
3583    MissingSkins,
3584    /// The nested contract omitted its mesh-definition array.
3585    #[error("measurement contract has no `mesh_definitions` array")]
3586    MissingMeshDefinitions,
3587    /// The nested contract omitted its node-instance array.
3588    #[error("measurement contract has no `node_instances` array")]
3589    MissingNodeInstances,
3590    /// The nested contract omitted its scene array.
3591    #[error("measurement contract has no `scenes` array")]
3592    MissingScenes,
3593    /// The nested measurements object could not be decoded after prediction-independent
3594    /// validation completed.
3595    #[error("has invalid measurements shape: {reason}")]
3596    InvalidMeasurementsShape {
3597        /// Stable serde diagnostic for the malformed nested measurements.
3598        reason: String,
3599    },
3600    /// The nested measurement values do not satisfy the current contract.
3601    #[error("has invalid measurements: {source}")]
3602    InvalidMeasurements {
3603        /// Measurement validation failure.
3604        #[source]
3605        source: MeasurementContractError,
3606    },
3607}
3608
3609impl MeasurementReportError {
3610    /// Zero-based file index for an error in one report record.
3611    ///
3612    /// Envelope-level errors return `None`.
3613    pub fn file_index(&self) -> Option<usize> {
3614        match self {
3615            Self::File { file_index, .. } => Some(*file_index),
3616            _ => None,
3617        }
3618    }
3619
3620    fn file(file_index: usize, source: MeasurementFileError) -> Self {
3621        Self::File { file_index, source }
3622    }
3623}
3624
3625fn prediction_file_error(
3626    file_index: usize,
3627    source: MeasurementFileError,
3628) -> MeasurementReportError {
3629    MeasurementReportError::file(file_index, source)
3630}
3631
3632fn decode_prediction_phase_file(
3633    command: &str,
3634    file_index: usize,
3635    raw: &RawValue,
3636    expected_measurement_schema: &'static str,
3637) -> Result<MeasurementFileInput, MeasurementReportError> {
3638    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3639        prediction_file_error(
3640            file_index,
3641            MeasurementFileError::InvalidFileShape {
3642                reason: source.to_string(),
3643            },
3644        )
3645    })?;
3646
3647    if command == "measure" {
3648        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3649            return Err(prediction_file_error(
3650                file_index,
3651                MeasurementFileError::UnexpectedPredictionProvenance,
3652            ));
3653        }
3654        if wire.checks.is_some() {
3655            return Err(prediction_file_error(
3656                file_index,
3657                MeasurementFileError::UnexpectedChecks,
3658            ));
3659        }
3660        return Ok(MeasurementFileInput {
3661            path: wire.path,
3662            input: wire.input,
3663            rig_v17: None,
3664            measurements: wire.measurements,
3665            prediction_provenance: RequiredNullable::Missing,
3666            checks: None,
3667            legacy_prediction_provenance: RequiredNullable::Missing,
3668            legacy_checks: None,
3669            prediction_provenance_v3: RequiredNullable::Missing,
3670            checks_v3: None,
3671            prediction_provenance_v4: RequiredNullable::Missing,
3672            checks_v4: None,
3673            prediction_provenance_v5: RequiredNullable::Missing,
3674            checks_v5: None,
3675            prediction_provenance_v6: RequiredNullable::Missing,
3676            checks_v6: None,
3677        });
3678    }
3679
3680    if wire
3681        .checks
3682        .as_ref()
3683        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3684    {
3685        return Err(prediction_file_error(
3686            file_index,
3687            MeasurementFileError::TooManyChecks {
3688                found: wire.checks.as_ref().map_or(0, Vec::len),
3689                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3690            },
3691        ));
3692    }
3693
3694    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3695        return Err(prediction_file_error(
3696            file_index,
3697            MeasurementFileError::MissingPredictionProvenance,
3698        ));
3699    }
3700
3701    let prediction_provenance = match wire.prediction_provenance {
3702        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3703        RequiredNullable::Present(None) => RequiredNullable::Present(None),
3704        RequiredNullable::Present(Some(raw)) => {
3705            let provenance = decode_prediction_provenance_v2_with_measurement_schema(
3706                raw.get(),
3707                expected_measurement_schema,
3708            )
3709            .map_err(|error| {
3710                let source = match error {
3711                    PredictionDecodeError::Shape(source) => {
3712                        MeasurementFileError::InvalidPredictionProvenanceShape {
3713                            reason: source.to_string(),
3714                        }
3715                    }
3716                    PredictionDecodeError::Semantic(source) => {
3717                        MeasurementFileError::InvalidPredictionProvenance { source }
3718                    }
3719                    PredictionDecodeError::TooManyFileFacets
3720                    | PredictionDecodeError::TooManyFileBasisReferences => {
3721                        unreachable!("provenance never consumes prediction budgets")
3722                    }
3723                };
3724                prediction_file_error(file_index, source)
3725            })?;
3726            RequiredNullable::Present(Some(provenance))
3727        }
3728    };
3729    let mut decoded_facets = 0usize;
3730    let mut decoded_references = 0usize;
3731    let mut has_facet_budget_summary = false;
3732    let mut decoded_text = match &prediction_provenance {
3733        RequiredNullable::Present(Some(provenance)) => {
3734            provenance.retained_text_bytes().map_err(|source| {
3735                prediction_file_error(
3736                    file_index,
3737                    MeasurementFileError::InvalidPredictionProvenance { source },
3738                )
3739            })?
3740        }
3741        RequiredNullable::Missing | RequiredNullable::Present(None) => 0,
3742    };
3743    let provenance_for_checks = match &prediction_provenance {
3744        RequiredNullable::Present(provenance) => provenance.as_ref(),
3745        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
3746    };
3747    let checks = wire
3748        .checks
3749        .map(|raw_checks| {
3750            let mut checks = Vec::with_capacity(raw_checks.len());
3751            for (check_index, raw) in raw_checks.into_iter().enumerate() {
3752                let wire: PredictionCheckWireInput =
3753                    serde_json::from_str(raw.get()).map_err(|source| {
3754                        prediction_file_error(
3755                            file_index,
3756                            MeasurementFileError::InvalidPredictionShape {
3757                                check_index,
3758                                reason: source.to_string(),
3759                            },
3760                        )
3761                    })?;
3762                if provenance_for_checks.is_none() && wire.prediction.is_some() {
3763                    return Err(prediction_file_error(
3764                        file_index,
3765                        MeasurementFileError::PredictionWithoutProvenance { check_index },
3766                    ));
3767                }
3768                if (wire.selection == SelectionState::Unselected
3769                    || wire.configuration == ConfigurationState::Disabled
3770                    || wire.applicability == Applicability::NotApplicable)
3771                    && wire.prediction.is_some()
3772                {
3773                    return Err(prediction_file_error(
3774                        file_index,
3775                        MeasurementFileError::InvalidPredictionLifecycle {
3776                            check_index,
3777                            reason: "inactive check must have empty output",
3778                        },
3779                    ));
3780                }
3781                // Allocate the remaining aggregate facet/reference budgets
3782                // before parsing this attachment.  This prevents an over-limit
3783                // later prediction from retaining a prefix whose findings can
3784                // no longer be represented by the file contract.
3785                let prediction = wire
3786                    .prediction
3787                    .map(|raw| {
3788                        let facet_limit =
3789                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets);
3790                        let reference_limit = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3791                            .saturating_sub(decoded_references);
3792                        decode_engine_prediction_v2_with_measurement_schema(
3793                            raw.get(),
3794                            facet_limit,
3795                            reference_limit,
3796                            expected_measurement_schema,
3797                        )
3798                        .map_err(|error| {
3799                            let source = match error {
3800                                PredictionDecodeError::Shape(source) => {
3801                                    MeasurementFileError::InvalidPredictionShape {
3802                                        check_index,
3803                                        reason: source.to_string(),
3804                                    }
3805                                }
3806                                PredictionDecodeError::Semantic(source) => {
3807                                    MeasurementFileError::InvalidPrediction {
3808                                        check_index,
3809                                        source,
3810                                    }
3811                                }
3812                                PredictionDecodeError::TooManyFileFacets => {
3813                                    MeasurementFileError::TooManyPredictionFacets {
3814                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
3815                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3816                                    }
3817                                }
3818                                PredictionDecodeError::TooManyFileBasisReferences => {
3819                                    MeasurementFileError::TooManyPredictionBasisReferences {
3820                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
3821                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3822                                    }
3823                                }
3824                            };
3825                            prediction_file_error(file_index, source)
3826                        })
3827                    })
3828                    .transpose()?;
3829                let check = PredictionCheckInput {
3830                    check_id: wire.check_id,
3831                    selection: wire.selection,
3832                    configuration: wire.configuration,
3833                    applicability: wire.applicability,
3834                    evaluation: wire.evaluation,
3835                    findings: wire.findings,
3836                    evaluated_scopes: wire.evaluated_scopes,
3837                    gaps: wire.gaps,
3838                    prediction,
3839                };
3840                check
3841                    .validate(
3842                        check_index,
3843                        provenance_for_checks,
3844                        expected_measurement_schema,
3845                    )
3846                    .map_err(|source| prediction_file_error(file_index, source))?;
3847                if let Some(prediction) = &check.prediction {
3848                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
3849                    decoded_facets = decoded_facets
3850                        .checked_add(prediction.facets().len())
3851                        .ok_or_else(|| {
3852                            prediction_file_error(
3853                                file_index,
3854                                MeasurementFileError::PredictionAccountingOverflow,
3855                            )
3856                        })?;
3857                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
3858                        return Err(prediction_file_error(
3859                            file_index,
3860                            MeasurementFileError::TooManyPredictionFacets {
3861                                found: decoded_facets,
3862                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3863                            },
3864                        ));
3865                    }
3866                    decoded_references = decoded_references
3867                        .checked_add(prediction.basis_reference_count())
3868                        .ok_or_else(|| {
3869                            prediction_file_error(
3870                                file_index,
3871                                MeasurementFileError::PredictionAccountingOverflow,
3872                            )
3873                        })?;
3874                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
3875                        return Err(prediction_file_error(
3876                            file_index,
3877                            MeasurementFileError::TooManyPredictionBasisReferences {
3878                                found: decoded_references,
3879                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
3880                            },
3881                        ));
3882                    }
3883                    decoded_text = decoded_text
3884                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
3885                            prediction_file_error(
3886                                file_index,
3887                                MeasurementFileError::InvalidPrediction {
3888                                    check_index,
3889                                    source,
3890                                },
3891                            )
3892                        })?)
3893                        .ok_or_else(|| {
3894                            prediction_file_error(
3895                                file_index,
3896                                MeasurementFileError::PredictionAccountingOverflow,
3897                            )
3898                        })?;
3899                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
3900                        return Err(prediction_file_error(
3901                            file_index,
3902                            MeasurementFileError::TooMuchPredictionText {
3903                                found: decoded_text,
3904                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
3905                            },
3906                        ));
3907                    }
3908                }
3909                checks.push(check);
3910            }
3911            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
3912                return Err(prediction_file_error(
3913                    file_index,
3914                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
3915                        found: decoded_facets,
3916                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
3917                    },
3918                ));
3919            }
3920            Ok(checks)
3921        })
3922        .transpose()?;
3923    Ok(MeasurementFileInput {
3924        path: wire.path,
3925        input: wire.input,
3926        rig_v17: None,
3927        measurements: wire.measurements,
3928        prediction_provenance,
3929        checks,
3930        legacy_prediction_provenance: RequiredNullable::Missing,
3931        legacy_checks: None,
3932        prediction_provenance_v3: RequiredNullable::Missing,
3933        checks_v3: None,
3934        prediction_provenance_v4: RequiredNullable::Missing,
3935        checks_v4: None,
3936        prediction_provenance_v5: RequiredNullable::Missing,
3937        checks_v5: None,
3938        prediction_provenance_v6: RequiredNullable::Missing,
3939        checks_v6: None,
3940    })
3941}
3942
3943fn decode_prediction_phase_file_v14(
3944    command: &str,
3945    file_index: usize,
3946    raw: &RawValue,
3947) -> Result<MeasurementFileInput, MeasurementReportError> {
3948    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
3949        prediction_file_error(
3950            file_index,
3951            MeasurementFileError::InvalidFileShape {
3952                reason: source.to_string(),
3953            },
3954        )
3955    })?;
3956    if command == "measure" {
3957        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
3958            return Err(prediction_file_error(
3959                file_index,
3960                MeasurementFileError::UnexpectedPredictionProvenance,
3961            ));
3962        }
3963        if wire.checks.is_some() {
3964            return Err(prediction_file_error(
3965                file_index,
3966                MeasurementFileError::UnexpectedChecks,
3967            ));
3968        }
3969        return Ok(MeasurementFileInput {
3970            path: wire.path,
3971            input: wire.input,
3972            rig_v17: None,
3973            measurements: wire.measurements,
3974            prediction_provenance: RequiredNullable::Missing,
3975            checks: None,
3976            legacy_prediction_provenance: RequiredNullable::Missing,
3977            legacy_checks: None,
3978            prediction_provenance_v3: RequiredNullable::Missing,
3979            checks_v3: None,
3980            prediction_provenance_v4: RequiredNullable::Missing,
3981            checks_v4: None,
3982            prediction_provenance_v5: RequiredNullable::Missing,
3983            checks_v5: None,
3984            prediction_provenance_v6: RequiredNullable::Missing,
3985            checks_v6: None,
3986        });
3987    }
3988    if wire
3989        .checks
3990        .as_ref()
3991        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
3992    {
3993        return Err(prediction_file_error(
3994            file_index,
3995            MeasurementFileError::TooManyChecks {
3996                found: wire.checks.as_ref().map_or(0, Vec::len),
3997                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
3998            },
3999        ));
4000    }
4001    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4002        return Err(prediction_file_error(
4003            file_index,
4004            MeasurementFileError::MissingPredictionProvenance,
4005        ));
4006    }
4007    let prediction_provenance_v3 = match wire.prediction_provenance {
4008        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4009        RequiredNullable::Present(None) => RequiredNullable::Present(None),
4010        RequiredNullable::Present(Some(raw)) => {
4011            let provenance = decode_prediction_provenance_v3(raw.get()).map_err(|error| {
4012                let source = match error {
4013                    PredictionDecodeError::Shape(source) => {
4014                        MeasurementFileError::InvalidPredictionProvenanceShape {
4015                            reason: source.to_string(),
4016                        }
4017                    }
4018                    PredictionDecodeError::Semantic(source) => {
4019                        MeasurementFileError::InvalidPredictionProvenance { source }
4020                    }
4021                    PredictionDecodeError::TooManyFileFacets
4022                    | PredictionDecodeError::TooManyFileBasisReferences => {
4023                        unreachable!("provenance never consumes prediction budgets")
4024                    }
4025                };
4026                prediction_file_error(file_index, source)
4027            })?;
4028            RequiredNullable::Present(Some(provenance))
4029        }
4030    };
4031    let provenance_for_checks = match &prediction_provenance_v3 {
4032        RequiredNullable::Present(provenance) => provenance.as_ref(),
4033        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
4034    };
4035    let mut decoded_facets = 0usize;
4036    let mut decoded_references = 0usize;
4037    let mut has_facet_budget_summary = false;
4038    let mut decoded_text = provenance_for_checks
4039        .map(PredictionProvenanceV3::retained_text_bytes)
4040        .transpose()
4041        .map_err(|source| {
4042            prediction_file_error(
4043                file_index,
4044                MeasurementFileError::InvalidPredictionProvenance { source },
4045            )
4046        })?
4047        .unwrap_or(0);
4048    let checks_v3 = wire
4049        .checks
4050        .map(|raw_checks| {
4051            let mut checks = Vec::with_capacity(raw_checks.len());
4052            for (check_index, raw) in raw_checks.into_iter().enumerate() {
4053                let wire: PredictionCheckWireInput =
4054                    serde_json::from_str(raw.get()).map_err(|source| {
4055                        prediction_file_error(
4056                            file_index,
4057                            MeasurementFileError::InvalidPredictionShape {
4058                                check_index,
4059                                reason: source.to_string(),
4060                            },
4061                        )
4062                    })?;
4063                if provenance_for_checks.is_none() && wire.prediction.is_some() {
4064                    return Err(prediction_file_error(
4065                        file_index,
4066                        MeasurementFileError::PredictionWithoutProvenance { check_index },
4067                    ));
4068                }
4069                if (wire.selection == SelectionState::Unselected
4070                    || wire.configuration == ConfigurationState::Disabled
4071                    || wire.applicability == Applicability::NotApplicable)
4072                    && wire.prediction.is_some()
4073                {
4074                    return Err(prediction_file_error(
4075                        file_index,
4076                        MeasurementFileError::InvalidPredictionLifecycle {
4077                            check_index,
4078                            reason: "inactive check must have empty output",
4079                        },
4080                    ));
4081                }
4082                let prediction = wire
4083                    .prediction
4084                    .map(|raw| {
4085                        decode_engine_prediction_v3(
4086                            raw.get(),
4087                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4088                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4089                                .saturating_sub(decoded_references),
4090                        )
4091                        .map_err(|error| {
4092                            let source = match error {
4093                                PredictionDecodeError::Shape(source) => {
4094                                    MeasurementFileError::InvalidPredictionShape {
4095                                        check_index,
4096                                        reason: source.to_string(),
4097                                    }
4098                                }
4099                                PredictionDecodeError::Semantic(source) => {
4100                                    MeasurementFileError::InvalidPrediction {
4101                                        check_index,
4102                                        source,
4103                                    }
4104                                }
4105                                PredictionDecodeError::TooManyFileFacets => {
4106                                    MeasurementFileError::TooManyPredictionFacets {
4107                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4108                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4109                                    }
4110                                }
4111                                PredictionDecodeError::TooManyFileBasisReferences => {
4112                                    MeasurementFileError::TooManyPredictionBasisReferences {
4113                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4114                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4115                                    }
4116                                }
4117                            };
4118                            prediction_file_error(file_index, source)
4119                        })
4120                    })
4121                    .transpose()?;
4122                let check = PredictionCheckInputV3 {
4123                    check_id: wire.check_id,
4124                    selection: wire.selection,
4125                    configuration: wire.configuration,
4126                    applicability: wire.applicability,
4127                    evaluation: wire.evaluation,
4128                    findings: wire.findings,
4129                    evaluated_scopes: wire.evaluated_scopes,
4130                    gaps: wire.gaps,
4131                    prediction,
4132                };
4133                check
4134                    .validate(check_index, provenance_for_checks)
4135                    .map_err(|source| prediction_file_error(file_index, source))?;
4136                if let Some(prediction) = &check.prediction {
4137                    has_facet_budget_summary |= prediction.has_facet_budget_summary();
4138                    decoded_facets = decoded_facets
4139                        .checked_add(prediction.facets().len())
4140                        .ok_or_else(|| {
4141                            prediction_file_error(
4142                                file_index,
4143                                MeasurementFileError::PredictionAccountingOverflow,
4144                            )
4145                        })?;
4146                    decoded_references = decoded_references
4147                        .checked_add(prediction.basis_reference_count())
4148                        .ok_or_else(|| {
4149                            prediction_file_error(
4150                                file_index,
4151                                MeasurementFileError::PredictionAccountingOverflow,
4152                            )
4153                        })?;
4154                    decoded_text = decoded_text
4155                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4156                            prediction_file_error(
4157                                file_index,
4158                                MeasurementFileError::InvalidPrediction {
4159                                    check_index,
4160                                    source,
4161                                },
4162                            )
4163                        })?)
4164                        .ok_or_else(|| {
4165                            prediction_file_error(
4166                                file_index,
4167                                MeasurementFileError::PredictionAccountingOverflow,
4168                            )
4169                        })?;
4170                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4171                        return Err(prediction_file_error(
4172                            file_index,
4173                            MeasurementFileError::TooMuchPredictionText {
4174                                found: decoded_text,
4175                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4176                            },
4177                        ));
4178                    }
4179                }
4180                checks.push(check);
4181            }
4182            if has_facet_budget_summary && decoded_facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
4183                return Err(prediction_file_error(
4184                    file_index,
4185                    MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
4186                        found: decoded_facets,
4187                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4188                    },
4189                ));
4190            }
4191            Ok(checks)
4192        })
4193        .transpose()?;
4194    Ok(MeasurementFileInput {
4195        path: wire.path,
4196        input: wire.input,
4197        rig_v17: None,
4198        measurements: wire.measurements,
4199        prediction_provenance: RequiredNullable::Missing,
4200        checks: None,
4201        legacy_prediction_provenance: RequiredNullable::Missing,
4202        legacy_checks: None,
4203        prediction_provenance_v3,
4204        checks_v3,
4205        prediction_provenance_v4: RequiredNullable::Missing,
4206        checks_v4: None,
4207        prediction_provenance_v5: RequiredNullable::Missing,
4208        checks_v5: None,
4209        prediction_provenance_v6: RequiredNullable::Missing,
4210        checks_v6: None,
4211    })
4212}
4213
4214fn decode_prediction_phase_file_v15(
4215    command: &str,
4216    file_index: usize,
4217    raw: &RawValue,
4218) -> Result<MeasurementFileInput, MeasurementReportError> {
4219    #[derive(Deserialize)]
4220    struct SchemaProbe {
4221        schema: String,
4222    }
4223
4224    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4225        prediction_file_error(
4226            file_index,
4227            MeasurementFileError::InvalidFileShape {
4228                reason: source.to_string(),
4229            },
4230        )
4231    })?;
4232    if command == "measure"
4233        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4234    {
4235        return decode_prediction_phase_file_v14(command, file_index, raw);
4236    }
4237    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4238        return decode_prediction_phase_file_v14(command, file_index, raw);
4239    };
4240    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4241        prediction_file_error(
4242            file_index,
4243            MeasurementFileError::InvalidPredictionProvenanceShape {
4244                reason: source.to_string(),
4245            },
4246        )
4247    })?;
4248    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4249        return decode_prediction_phase_file_v14(command, file_index, raw);
4250    }
4251    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V4_ID {
4252        return Err(prediction_file_error(
4253            file_index,
4254            MeasurementFileError::InvalidPredictionProvenance {
4255                source: PredictionContractError::InvalidSchema {
4256                    field: "prediction provenance.schema",
4257                    expected: crate::prediction::PREDICTION_PROVENANCE_V4_ID,
4258                    found: schema.schema,
4259                },
4260            },
4261        ));
4262    }
4263
4264    let MeasurementFileWireInput {
4265        path,
4266        input,
4267        measurements,
4268        prediction_provenance,
4269        checks,
4270        ..
4271    } = probe;
4272    if checks
4273        .as_ref()
4274        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4275    {
4276        return Err(prediction_file_error(
4277            file_index,
4278            MeasurementFileError::TooManyChecks {
4279                found: checks.as_ref().map_or(0, Vec::len),
4280                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4281            },
4282        ));
4283    }
4284    let RequiredNullable::Present(Some(provenance_raw)) = prediction_provenance else {
4285        unreachable!("V4 provenance was probed above")
4286    };
4287    let provenance = decode_prediction_provenance_v4(provenance_raw.get()).map_err(|error| {
4288        let source = match error {
4289            PredictionDecodeError::Shape(source) => {
4290                MeasurementFileError::InvalidPredictionProvenanceShape {
4291                    reason: source.to_string(),
4292                }
4293            }
4294            PredictionDecodeError::Semantic(source) => {
4295                MeasurementFileError::InvalidPredictionProvenance { source }
4296            }
4297            PredictionDecodeError::TooManyFileFacets
4298            | PredictionDecodeError::TooManyFileBasisReferences => unreachable!(),
4299        };
4300        prediction_file_error(file_index, source)
4301    })?;
4302    let mut decoded_facets = 0usize;
4303    let mut decoded_references = 0usize;
4304    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4305        prediction_file_error(
4306            file_index,
4307            MeasurementFileError::InvalidPredictionProvenance { source },
4308        )
4309    })?;
4310    let checks_v4 = checks
4311        .map(|raw_checks| {
4312            let mut decoded = Vec::with_capacity(raw_checks.len());
4313            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4314                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4315                    .map_err(|source| {
4316                        prediction_file_error(
4317                            file_index,
4318                            MeasurementFileError::InvalidPredictionShape {
4319                                check_index,
4320                                reason: source.to_string(),
4321                            },
4322                        )
4323                    })?;
4324                if (wire.selection == SelectionState::Unselected
4325                    || wire.configuration == ConfigurationState::Disabled
4326                    || wire.applicability == Applicability::NotApplicable)
4327                    && wire.prediction.is_some()
4328                {
4329                    return Err(prediction_file_error(
4330                        file_index,
4331                        MeasurementFileError::InvalidPredictionLifecycle {
4332                            check_index,
4333                            reason: "inactive check must have empty output",
4334                        },
4335                    ));
4336                }
4337                let prediction = wire
4338                    .prediction
4339                    .map(|prediction_raw| {
4340                        decode_engine_prediction_v4(
4341                            prediction_raw.get(),
4342                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
4343                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
4344                                .saturating_sub(decoded_references),
4345                        )
4346                        .map_err(|error| {
4347                            let source = match error {
4348                                PredictionDecodeError::Shape(source) => {
4349                                    MeasurementFileError::InvalidPredictionShape {
4350                                        check_index,
4351                                        reason: source.to_string(),
4352                                    }
4353                                }
4354                                PredictionDecodeError::Semantic(source) => {
4355                                    MeasurementFileError::InvalidPrediction {
4356                                        check_index,
4357                                        source,
4358                                    }
4359                                }
4360                                PredictionDecodeError::TooManyFileFacets => {
4361                                    MeasurementFileError::TooManyPredictionFacets {
4362                                        found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
4363                                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4364                                    }
4365                                }
4366                                PredictionDecodeError::TooManyFileBasisReferences => {
4367                                    MeasurementFileError::TooManyPredictionBasisReferences {
4368                                        found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
4369                                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4370                                    }
4371                                }
4372                            };
4373                            prediction_file_error(file_index, source)
4374                        })
4375                    })
4376                    .transpose()?;
4377                let check = PredictionCheckInputV4 {
4378                    check_id: wire.check_id,
4379                    selection: wire.selection,
4380                    configuration: wire.configuration,
4381                    applicability: wire.applicability,
4382                    evaluation: wire.evaluation,
4383                    findings: wire.findings,
4384                    evaluated_scopes: wire.evaluated_scopes,
4385                    gaps: wire.gaps,
4386                    prediction,
4387                };
4388                check
4389                    .validate(check_index, Some(&provenance))
4390                    .map_err(|source| prediction_file_error(file_index, source))?;
4391                if let Some(prediction) = &check.prediction {
4392                    decoded_facets = decoded_facets
4393                        .checked_add(prediction.facets().len())
4394                        .ok_or_else(|| {
4395                            prediction_file_error(
4396                                file_index,
4397                                MeasurementFileError::PredictionAccountingOverflow,
4398                            )
4399                        })?;
4400                    decoded_references = decoded_references
4401                        .checked_add(prediction.basis_reference_count())
4402                        .ok_or_else(|| {
4403                            prediction_file_error(
4404                                file_index,
4405                                MeasurementFileError::PredictionAccountingOverflow,
4406                            )
4407                        })?;
4408                    decoded_text = decoded_text
4409                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4410                            prediction_file_error(
4411                                file_index,
4412                                MeasurementFileError::InvalidPrediction {
4413                                    check_index,
4414                                    source,
4415                                },
4416                            )
4417                        })?)
4418                        .ok_or_else(|| {
4419                            prediction_file_error(
4420                                file_index,
4421                                MeasurementFileError::PredictionAccountingOverflow,
4422                            )
4423                        })?;
4424                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4425                        return Err(prediction_file_error(
4426                            file_index,
4427                            MeasurementFileError::TooMuchPredictionText {
4428                                found: decoded_text,
4429                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4430                            },
4431                        ));
4432                    }
4433                }
4434                decoded.push(check);
4435            }
4436            Ok(decoded)
4437        })
4438        .transpose()?;
4439    Ok(MeasurementFileInput {
4440        path,
4441        input,
4442        rig_v17: None,
4443        measurements,
4444        prediction_provenance: RequiredNullable::Missing,
4445        checks: None,
4446        legacy_prediction_provenance: RequiredNullable::Missing,
4447        legacy_checks: None,
4448        prediction_provenance_v3: RequiredNullable::Missing,
4449        checks_v3: None,
4450        prediction_provenance_v4: RequiredNullable::Present(Some(provenance)),
4451        checks_v4,
4452        prediction_provenance_v5: RequiredNullable::Missing,
4453        checks_v5: None,
4454        prediction_provenance_v6: RequiredNullable::Missing,
4455        checks_v6: None,
4456    })
4457}
4458
4459fn decode_prediction_phase_file_v16(
4460    command: &str,
4461    file_index: usize,
4462    raw: &RawValue,
4463) -> Result<MeasurementFileInput, MeasurementReportError> {
4464    #[derive(Deserialize)]
4465    struct SchemaProbe {
4466        schema: String,
4467    }
4468
4469    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4470        prediction_file_error(
4471            file_index,
4472            MeasurementFileError::InvalidFileShape {
4473                reason: source.to_string(),
4474            },
4475        )
4476    })?;
4477    if command == "measure"
4478        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4479    {
4480        return decode_prediction_phase_file_v15(command, file_index, raw);
4481    }
4482    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4483        return decode_prediction_phase_file_v15(command, file_index, raw);
4484    };
4485    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4486        prediction_file_error(
4487            file_index,
4488            MeasurementFileError::InvalidPredictionProvenanceShape {
4489                reason: source.to_string(),
4490            },
4491        )
4492    })?;
4493    if schema.schema == crate::prediction::PREDICTION_PROVENANCE_V3_ID {
4494        return decode_prediction_phase_file_v15(command, file_index, raw);
4495    }
4496    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V5_ID {
4497        return Err(prediction_file_error(
4498            file_index,
4499            MeasurementFileError::InvalidPredictionProvenance {
4500                source: PredictionContractError::InvalidSchema {
4501                    field: "prediction provenance.schema",
4502                    expected: crate::prediction::PREDICTION_PROVENANCE_V5_ID,
4503                    found: schema.schema,
4504                },
4505            },
4506        ));
4507    }
4508    let MeasurementFileWireInput {
4509        path,
4510        input,
4511        measurements,
4512        prediction_provenance,
4513        checks,
4514        ..
4515    } = probe;
4516    if checks
4517        .as_ref()
4518        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4519    {
4520        return Err(prediction_file_error(
4521            file_index,
4522            MeasurementFileError::TooManyChecks {
4523                found: checks.as_ref().map_or(0, Vec::len),
4524                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4525            },
4526        ));
4527    }
4528    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4529        unreachable!()
4530    };
4531    let provenance: PredictionProvenanceV5 =
4532        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4533            prediction_file_error(
4534                file_index,
4535                MeasurementFileError::InvalidPredictionProvenanceShape {
4536                    reason: source.to_string(),
4537                },
4538            )
4539        })?;
4540    provenance.validate().map_err(|source| {
4541        prediction_file_error(
4542            file_index,
4543            MeasurementFileError::InvalidPredictionProvenance { source },
4544        )
4545    })?;
4546    let mut decoded_facets = 0usize;
4547    let mut decoded_references = 0usize;
4548    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4549        prediction_file_error(
4550            file_index,
4551            MeasurementFileError::InvalidPredictionProvenance { source },
4552        )
4553    })?;
4554    let checks_v5 = checks
4555        .map(|raw_checks| {
4556            let mut decoded = Vec::with_capacity(raw_checks.len());
4557            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4558                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4559                    .map_err(|source| {
4560                        prediction_file_error(
4561                            file_index,
4562                            MeasurementFileError::InvalidPredictionShape {
4563                                check_index,
4564                                reason: source.to_string(),
4565                            },
4566                        )
4567                    })?;
4568                if (wire.selection == SelectionState::Unselected
4569                    || wire.configuration == ConfigurationState::Disabled
4570                    || wire.applicability == Applicability::NotApplicable)
4571                    && wire.prediction.is_some()
4572                {
4573                    return Err(prediction_file_error(
4574                        file_index,
4575                        MeasurementFileError::InvalidPredictionLifecycle {
4576                            check_index,
4577                            reason: "inactive check must have empty output",
4578                        },
4579                    ));
4580                }
4581                let prediction =
4582                    wire.prediction
4583                        .map(|raw_prediction| {
4584                            serde_json::from_str::<EnginePredictionV5>(raw_prediction.get())
4585                                .map_err(|source| {
4586                                    prediction_file_error(
4587                                        file_index,
4588                                        MeasurementFileError::InvalidPredictionShape {
4589                                            check_index,
4590                                            reason: source.to_string(),
4591                                        },
4592                                    )
4593                                })
4594                        })
4595                        .transpose()?;
4596                let check = PredictionCheckInputV5 {
4597                    check_id: wire.check_id,
4598                    selection: wire.selection,
4599                    configuration: wire.configuration,
4600                    applicability: wire.applicability,
4601                    evaluation: wire.evaluation,
4602                    findings: wire.findings,
4603                    evaluated_scopes: wire.evaluated_scopes,
4604                    gaps: wire.gaps,
4605                    prediction,
4606                };
4607                check
4608                    .validate(check_index, Some(&provenance))
4609                    .map_err(|source| prediction_file_error(file_index, source))?;
4610                if let Some(prediction) = &check.prediction {
4611                    decoded_facets = decoded_facets
4612                        .checked_add(prediction.facets().len())
4613                        .ok_or_else(|| {
4614                            prediction_file_error(
4615                                file_index,
4616                                MeasurementFileError::PredictionAccountingOverflow,
4617                            )
4618                        })?;
4619                    decoded_references = decoded_references
4620                        .checked_add(prediction.basis_reference_count())
4621                        .ok_or_else(|| {
4622                            prediction_file_error(
4623                                file_index,
4624                                MeasurementFileError::PredictionAccountingOverflow,
4625                            )
4626                        })?;
4627                    decoded_text = decoded_text
4628                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4629                            prediction_file_error(
4630                                file_index,
4631                                MeasurementFileError::InvalidPrediction {
4632                                    check_index,
4633                                    source,
4634                                },
4635                            )
4636                        })?)
4637                        .ok_or_else(|| {
4638                            prediction_file_error(
4639                                file_index,
4640                                MeasurementFileError::PredictionAccountingOverflow,
4641                            )
4642                        })?;
4643                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4644                        return Err(prediction_file_error(
4645                            file_index,
4646                            MeasurementFileError::TooManyPredictionFacets {
4647                                found: decoded_facets,
4648                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4649                            },
4650                        ));
4651                    }
4652                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4653                        return Err(prediction_file_error(
4654                            file_index,
4655                            MeasurementFileError::TooManyPredictionBasisReferences {
4656                                found: decoded_references,
4657                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4658                            },
4659                        ));
4660                    }
4661                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4662                        return Err(prediction_file_error(
4663                            file_index,
4664                            MeasurementFileError::TooMuchPredictionText {
4665                                found: decoded_text,
4666                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4667                            },
4668                        ));
4669                    }
4670                }
4671                decoded.push(check);
4672            }
4673            Ok(decoded)
4674        })
4675        .transpose()?;
4676    Ok(MeasurementFileInput {
4677        path,
4678        input,
4679        rig_v17: None,
4680        measurements,
4681        prediction_provenance: RequiredNullable::Missing,
4682        checks: None,
4683        legacy_prediction_provenance: RequiredNullable::Missing,
4684        legacy_checks: None,
4685        prediction_provenance_v3: RequiredNullable::Missing,
4686        checks_v3: None,
4687        prediction_provenance_v4: RequiredNullable::Missing,
4688        checks_v4: None,
4689        prediction_provenance_v5: RequiredNullable::Present(Some(provenance)),
4690        checks_v5,
4691        prediction_provenance_v6: RequiredNullable::Missing,
4692        checks_v6: None,
4693    })
4694}
4695
4696fn decode_prediction_phase_file_v17(
4697    command: &str,
4698    file_index: usize,
4699    raw: &RawValue,
4700) -> Result<MeasurementFileInput, MeasurementReportError> {
4701    #[derive(Deserialize)]
4702    struct SchemaProbe {
4703        schema: String,
4704    }
4705    let probe: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4706        prediction_file_error(
4707            file_index,
4708            MeasurementFileError::InvalidFileShape {
4709                reason: source.to_string(),
4710            },
4711        )
4712    })?;
4713    if command == "measure"
4714        || matches!(probe.prediction_provenance, RequiredNullable::Present(None))
4715    {
4716        return decode_prediction_phase_file_v16(command, file_index, raw);
4717    }
4718    let RequiredNullable::Present(Some(provenance_raw)) = &probe.prediction_provenance else {
4719        return decode_prediction_phase_file_v16(command, file_index, raw);
4720    };
4721    let schema = serde_json::from_str::<SchemaProbe>(provenance_raw.get()).map_err(|source| {
4722        prediction_file_error(
4723            file_index,
4724            MeasurementFileError::InvalidPredictionProvenanceShape {
4725                reason: source.to_string(),
4726            },
4727        )
4728    })?;
4729    if matches!(
4730        schema.schema.as_str(),
4731        crate::prediction::PREDICTION_PROVENANCE_V3_ID
4732            | crate::prediction::PREDICTION_PROVENANCE_V5_ID
4733    ) {
4734        return decode_prediction_phase_file_v16(command, file_index, raw);
4735    }
4736    if schema.schema != crate::prediction::PREDICTION_PROVENANCE_V6_ID {
4737        return Err(prediction_file_error(
4738            file_index,
4739            MeasurementFileError::InvalidPredictionProvenance {
4740                source: PredictionContractError::InvalidSchema {
4741                    field: "prediction provenance.schema",
4742                    expected: crate::prediction::PREDICTION_PROVENANCE_V6_ID,
4743                    found: schema.schema,
4744                },
4745            },
4746        ));
4747    }
4748    let MeasurementFileWireInput {
4749        path,
4750        input,
4751        rig,
4752        measurements,
4753        prediction_provenance,
4754        checks,
4755    } = probe;
4756    let rig_v17 = serde_json::from_str::<RigInfo>(rig.get()).map_err(|source| {
4757        prediction_file_error(
4758            file_index,
4759            MeasurementFileError::InvalidFileShape {
4760                reason: format!("invalid output-v17 rig evidence: {source}"),
4761            },
4762        )
4763    })?;
4764    if checks
4765        .as_ref()
4766        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4767    {
4768        return Err(prediction_file_error(
4769            file_index,
4770            MeasurementFileError::TooManyChecks {
4771                found: checks.as_ref().map_or(0, Vec::len),
4772                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
4773            },
4774        ));
4775    }
4776    let RequiredNullable::Present(Some(raw_provenance)) = prediction_provenance else {
4777        unreachable!()
4778    };
4779    let provenance: PredictionProvenanceV6 =
4780        serde_json::from_str(raw_provenance.get()).map_err(|source| {
4781            prediction_file_error(
4782                file_index,
4783                MeasurementFileError::InvalidPredictionProvenanceShape {
4784                    reason: source.to_string(),
4785                },
4786            )
4787        })?;
4788    provenance.validate().map_err(|source| {
4789        prediction_file_error(
4790            file_index,
4791            MeasurementFileError::InvalidPredictionProvenance { source },
4792        )
4793    })?;
4794    let mut decoded_facets = 0usize;
4795    let mut decoded_references = 0usize;
4796    let mut decoded_text = provenance.retained_text_bytes().map_err(|source| {
4797        prediction_file_error(
4798            file_index,
4799            MeasurementFileError::InvalidPredictionProvenance { source },
4800        )
4801    })?;
4802    let checks_v6 = checks
4803        .map(|raw_checks| {
4804            let mut decoded = Vec::with_capacity(raw_checks.len());
4805            for (check_index, raw_check) in raw_checks.into_iter().enumerate() {
4806                let wire: PredictionCheckWireInput = serde_json::from_str(raw_check.get())
4807                    .map_err(|source| {
4808                        prediction_file_error(
4809                            file_index,
4810                            MeasurementFileError::InvalidPredictionShape {
4811                                check_index,
4812                                reason: source.to_string(),
4813                            },
4814                        )
4815                    })?;
4816                if (wire.selection == SelectionState::Unselected
4817                    || wire.configuration == ConfigurationState::Disabled
4818                    || wire.applicability == Applicability::NotApplicable)
4819                    && wire.prediction.is_some()
4820                {
4821                    return Err(prediction_file_error(
4822                        file_index,
4823                        MeasurementFileError::InvalidPredictionLifecycle {
4824                            check_index,
4825                            reason: "inactive check must have empty output",
4826                        },
4827                    ));
4828                }
4829                let prediction =
4830                    wire.prediction
4831                        .map(|raw_prediction| {
4832                            serde_json::from_str::<EnginePredictionV6>(raw_prediction.get())
4833                                .map_err(|source| {
4834                                    prediction_file_error(
4835                                        file_index,
4836                                        MeasurementFileError::InvalidPredictionShape {
4837                                            check_index,
4838                                            reason: source.to_string(),
4839                                        },
4840                                    )
4841                                })
4842                        })
4843                        .transpose()?;
4844                let check = PredictionCheckInputV6 {
4845                    check_id: wire.check_id,
4846                    selection: wire.selection,
4847                    configuration: wire.configuration,
4848                    applicability: wire.applicability,
4849                    evaluation: wire.evaluation,
4850                    findings: wire.findings,
4851                    evaluated_scopes: wire.evaluated_scopes,
4852                    gaps: wire.gaps,
4853                    prediction,
4854                };
4855                check
4856                    .validate(check_index, Some(&provenance))
4857                    .map_err(|source| prediction_file_error(file_index, source))?;
4858                if let Some(prediction) = &check.prediction {
4859                    decoded_facets = decoded_facets
4860                        .checked_add(prediction.facets().len())
4861                        .ok_or_else(|| {
4862                            prediction_file_error(
4863                                file_index,
4864                                MeasurementFileError::PredictionAccountingOverflow,
4865                            )
4866                        })?;
4867                    decoded_references = decoded_references
4868                        .checked_add(prediction.basis_reference_count())
4869                        .ok_or_else(|| {
4870                            prediction_file_error(
4871                                file_index,
4872                                MeasurementFileError::PredictionAccountingOverflow,
4873                            )
4874                        })?;
4875                    decoded_text = decoded_text
4876                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
4877                            prediction_file_error(
4878                                file_index,
4879                                MeasurementFileError::InvalidPrediction {
4880                                    check_index,
4881                                    source,
4882                                },
4883                            )
4884                        })?)
4885                        .ok_or_else(|| {
4886                            prediction_file_error(
4887                                file_index,
4888                                MeasurementFileError::PredictionAccountingOverflow,
4889                            )
4890                        })?;
4891                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
4892                        return Err(prediction_file_error(
4893                            file_index,
4894                            MeasurementFileError::TooManyPredictionFacets {
4895                                found: decoded_facets,
4896                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
4897                            },
4898                        ));
4899                    }
4900                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
4901                        return Err(prediction_file_error(
4902                            file_index,
4903                            MeasurementFileError::TooManyPredictionBasisReferences {
4904                                found: decoded_references,
4905                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
4906                            },
4907                        ));
4908                    }
4909                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
4910                        return Err(prediction_file_error(
4911                            file_index,
4912                            MeasurementFileError::TooMuchPredictionText {
4913                                found: decoded_text,
4914                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
4915                            },
4916                        ));
4917                    }
4918                }
4919                decoded.push(check);
4920            }
4921            Ok(decoded)
4922        })
4923        .transpose()?;
4924    Ok(MeasurementFileInput {
4925        path,
4926        input,
4927        rig_v17: Some(rig_v17),
4928        measurements,
4929        prediction_provenance: RequiredNullable::Missing,
4930        checks: None,
4931        legacy_prediction_provenance: RequiredNullable::Missing,
4932        legacy_checks: None,
4933        prediction_provenance_v3: RequiredNullable::Missing,
4934        checks_v3: None,
4935        prediction_provenance_v4: RequiredNullable::Missing,
4936        checks_v4: None,
4937        prediction_provenance_v5: RequiredNullable::Missing,
4938        checks_v5: None,
4939        prediction_provenance_v6: RequiredNullable::Present(Some(provenance)),
4940        checks_v6,
4941    })
4942}
4943
4944/// Decode the immutable output-v11 file envelope with its original V1 staged
4945/// reader.  Historical evidence remains V1 all the way through validation;
4946/// accepting it must be neither weaker nor a reinterpretation as V2.
4947fn decode_legacy_v11_file(
4948    command: &str,
4949    file_index: usize,
4950    raw: &RawValue,
4951) -> Result<MeasurementFileInput, MeasurementReportError> {
4952    let wire: MeasurementFileWireInput = serde_json::from_str(raw.get()).map_err(|source| {
4953        prediction_file_error(
4954            file_index,
4955            MeasurementFileError::InvalidFileShape {
4956                reason: source.to_string(),
4957            },
4958        )
4959    })?;
4960    if command == "measure" {
4961        if !matches!(wire.prediction_provenance, RequiredNullable::Missing) {
4962            return Err(prediction_file_error(
4963                file_index,
4964                MeasurementFileError::UnexpectedPredictionProvenance,
4965            ));
4966        }
4967        if wire.checks.is_some() {
4968            return Err(prediction_file_error(
4969                file_index,
4970                MeasurementFileError::UnexpectedChecks,
4971            ));
4972        }
4973        return Ok(MeasurementFileInput {
4974            path: wire.path,
4975            input: wire.input,
4976            rig_v17: None,
4977            measurements: wire.measurements,
4978            prediction_provenance: RequiredNullable::Missing,
4979            checks: None,
4980            legacy_prediction_provenance: RequiredNullable::Missing,
4981            legacy_checks: None,
4982            prediction_provenance_v3: RequiredNullable::Missing,
4983            checks_v3: None,
4984            prediction_provenance_v4: RequiredNullable::Missing,
4985            checks_v4: None,
4986            prediction_provenance_v5: RequiredNullable::Missing,
4987            checks_v5: None,
4988            prediction_provenance_v6: RequiredNullable::Missing,
4989            checks_v6: None,
4990        });
4991    }
4992
4993    if wire
4994        .checks
4995        .as_ref()
4996        .is_some_and(|checks| checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE)
4997    {
4998        return Err(prediction_file_error(
4999            file_index,
5000            MeasurementFileError::TooManyChecks {
5001                found: wire.checks.as_ref().map_or(0, Vec::len),
5002                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5003            },
5004        ));
5005    }
5006    if matches!(wire.prediction_provenance, RequiredNullable::Missing) {
5007        return Err(prediction_file_error(
5008            file_index,
5009            MeasurementFileError::MissingPredictionProvenance,
5010        ));
5011    }
5012    let legacy_prediction_provenance = match wire.prediction_provenance {
5013        RequiredNullable::Missing => unreachable!("missing provenance was rejected above"),
5014        RequiredNullable::Present(None) => RequiredNullable::Present(None),
5015        RequiredNullable::Present(Some(raw)) => RequiredNullable::Present(Some(
5016            decode_prediction_provenance_v1_with_measurement_schema(
5017                raw.get(),
5018                MEASUREMENTS_V15_SCHEMA_ID,
5019            )
5020            .map_err(|error| {
5021                prediction_file_error(
5022                    file_index,
5023                    match error {
5024                        PredictionDecodeError::Shape(source) => {
5025                            MeasurementFileError::InvalidPredictionProvenanceShape {
5026                                reason: source.to_string(),
5027                            }
5028                        }
5029                        PredictionDecodeError::Semantic(source) => {
5030                            MeasurementFileError::InvalidPredictionProvenance { source }
5031                        }
5032                        PredictionDecodeError::TooManyFileFacets
5033                        | PredictionDecodeError::TooManyFileBasisReferences => {
5034                            unreachable!("provenance decoding cannot consume prediction budgets")
5035                        }
5036                    },
5037                )
5038            })?,
5039        )),
5040    };
5041    let mut decoded_facets = 0usize;
5042    let mut decoded_references = 0usize;
5043    let mut decoded_text = legacy_prediction_provenance
5044        .as_present()
5045        .map(PredictionProvenanceV1::retained_text_bytes)
5046        .transpose()
5047        .map_err(|source| {
5048            prediction_file_error(
5049                file_index,
5050                MeasurementFileError::InvalidPredictionProvenance { source },
5051            )
5052        })?
5053        .unwrap_or(0);
5054    let provenance_for_checks = legacy_prediction_provenance.as_present();
5055    let legacy_checks = wire
5056        .checks
5057        .map(|raw_checks| {
5058            let mut checks = Vec::with_capacity(raw_checks.len());
5059            for (check_index, raw) in raw_checks.into_iter().enumerate() {
5060                let wire: LegacyPredictionCheckWireV11 =
5061                    serde_json::from_str(raw.get()).map_err(|source| {
5062                        prediction_file_error(
5063                            file_index,
5064                            MeasurementFileError::InvalidPredictionShape {
5065                                check_index,
5066                                reason: source.to_string(),
5067                            },
5068                        )
5069                    })?;
5070                // Preserve the released V11 reader's precedence: these
5071                // lifecycle violations are decided from the raw attachment
5072                // presence before a malformed prediction can be decoded.
5073                if provenance_for_checks.is_none() && wire.prediction.is_some() {
5074                    return Err(prediction_file_error(
5075                        file_index,
5076                        MeasurementFileError::PredictionWithoutProvenance { check_index },
5077                    ));
5078                }
5079                if (wire.selection == SelectionState::Unselected
5080                    || wire.configuration == ConfigurationState::Disabled
5081                    || wire.applicability == Applicability::NotApplicable)
5082                    && wire.prediction.is_some()
5083                {
5084                    return Err(prediction_file_error(
5085                        file_index,
5086                        MeasurementFileError::InvalidPredictionLifecycle {
5087                            check_index,
5088                            reason: "inactive check must have empty output",
5089                        },
5090                    ));
5091                }
5092                let prediction = wire
5093                    .prediction
5094                    .map(|raw| {
5095                        decode_engine_prediction_v1_with_measurement_schema(
5096                            raw.get(),
5097                            PREDICTION_V1_MAX_FACETS_PER_FILE.saturating_sub(decoded_facets),
5098                            PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
5099                                .saturating_sub(decoded_references),
5100                            MEASUREMENTS_V15_SCHEMA_ID,
5101                        )
5102                        .map_err(|error| {
5103                            prediction_file_error(
5104                                file_index,
5105                                match error {
5106                                    PredictionDecodeError::Shape(source) => {
5107                                        MeasurementFileError::InvalidPredictionShape {
5108                                            check_index,
5109                                            reason: source.to_string(),
5110                                        }
5111                                    }
5112                                    PredictionDecodeError::Semantic(source) => {
5113                                        MeasurementFileError::InvalidPrediction {
5114                                            check_index,
5115                                            source,
5116                                        }
5117                                    }
5118                                    PredictionDecodeError::TooManyFileFacets => {
5119                                        MeasurementFileError::TooManyPredictionFacets {
5120                                            found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
5121                                            limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5122                                        }
5123                                    }
5124                                    PredictionDecodeError::TooManyFileBasisReferences => {
5125                                        MeasurementFileError::TooManyPredictionBasisReferences {
5126                                            found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
5127                                            limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5128                                        }
5129                                    }
5130                                },
5131                            )
5132                        })
5133                    })
5134                    .transpose()?;
5135                let check = LegacyPredictionCheckInput {
5136                    check_id: wire.check_id,
5137                    selection: wire.selection,
5138                    configuration: wire.configuration,
5139                    applicability: wire.applicability,
5140                    evaluation: wire.evaluation,
5141                    findings: wire.findings,
5142                    evaluated_scopes: wire.evaluated_scopes,
5143                    gaps: wire.gaps,
5144                    prediction,
5145                };
5146                check
5147                    .validate(
5148                        check_index,
5149                        provenance_for_checks,
5150                        MEASUREMENTS_V15_SCHEMA_ID,
5151                    )
5152                    .map_err(|source| prediction_file_error(file_index, source))?;
5153                if let Some(prediction) = &check.prediction {
5154                    decoded_facets = decoded_facets
5155                        .checked_add(prediction.facets().len())
5156                        .ok_or_else(|| {
5157                            prediction_file_error(
5158                                file_index,
5159                                MeasurementFileError::PredictionAccountingOverflow,
5160                            )
5161                        })?;
5162                    if decoded_facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5163                        return Err(prediction_file_error(
5164                            file_index,
5165                            MeasurementFileError::TooManyPredictionFacets {
5166                                found: decoded_facets,
5167                                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5168                            },
5169                        ));
5170                    }
5171                    decoded_references = decoded_references
5172                        .checked_add(prediction.basis_reference_count())
5173                        .ok_or_else(|| {
5174                            prediction_file_error(
5175                                file_index,
5176                                MeasurementFileError::PredictionAccountingOverflow,
5177                            )
5178                        })?;
5179                    if decoded_references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5180                        return Err(prediction_file_error(
5181                            file_index,
5182                            MeasurementFileError::TooManyPredictionBasisReferences {
5183                                found: decoded_references,
5184                                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5185                            },
5186                        ));
5187                    }
5188                    decoded_text = decoded_text
5189                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5190                            prediction_file_error(
5191                                file_index,
5192                                MeasurementFileError::InvalidPrediction {
5193                                    check_index,
5194                                    source,
5195                                },
5196                            )
5197                        })?)
5198                        .ok_or_else(|| {
5199                            prediction_file_error(
5200                                file_index,
5201                                MeasurementFileError::PredictionAccountingOverflow,
5202                            )
5203                        })?;
5204                    if decoded_text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5205                        return Err(prediction_file_error(
5206                            file_index,
5207                            MeasurementFileError::TooMuchPredictionText {
5208                                found: decoded_text,
5209                                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5210                            },
5211                        ));
5212                    }
5213                }
5214                checks.push(check);
5215            }
5216            Ok(checks)
5217        })
5218        .transpose()?;
5219    Ok(MeasurementFileInput {
5220        path: wire.path,
5221        input: wire.input,
5222        rig_v17: None,
5223        measurements: wire.measurements,
5224        prediction_provenance: RequiredNullable::Missing,
5225        checks: None,
5226        legacy_prediction_provenance,
5227        legacy_checks,
5228        prediction_provenance_v3: RequiredNullable::Missing,
5229        checks_v3: None,
5230        prediction_provenance_v4: RequiredNullable::Missing,
5231        checks_v4: None,
5232        prediction_provenance_v5: RequiredNullable::Missing,
5233        checks_v5: None,
5234        prediction_provenance_v6: RequiredNullable::Missing,
5235        checks_v6: None,
5236    })
5237}
5238
5239fn validate_legacy_v11_prediction_phase_file(
5240    command: &str,
5241    file_index: usize,
5242    file: &MeasurementFileInput,
5243) -> Result<(usize, usize), MeasurementReportError> {
5244    match command {
5245        "measure" => return Ok((0, 0)),
5246        "lint" => {}
5247        _ => unreachable!("command was validated before prediction phase"),
5248    }
5249    let provenance = match &file.legacy_prediction_provenance {
5250        RequiredNullable::Missing => {
5251            return Err(prediction_file_error(
5252                file_index,
5253                MeasurementFileError::MissingPredictionProvenance,
5254            ));
5255        }
5256        RequiredNullable::Present(provenance) => provenance.as_ref(),
5257    };
5258    let checks = file
5259        .legacy_checks
5260        .as_ref()
5261        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5262    if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5263        return Err(prediction_file_error(
5264            file_index,
5265            MeasurementFileError::TooManyChecks {
5266                found: checks.len(),
5267                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5268            },
5269        ));
5270    }
5271    if let Some(provenance) = provenance {
5272        provenance
5273            .validate_with_measurement_schema(MEASUREMENTS_V15_SCHEMA_ID)
5274            .map_err(|source| {
5275                prediction_file_error(
5276                    file_index,
5277                    MeasurementFileError::InvalidPredictionProvenance { source },
5278                )
5279            })?;
5280        let input = file
5281            .input
5282            .as_ref()
5283            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5284        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5285            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5286        {
5287            return Err(prediction_file_error(
5288                file_index,
5289                MeasurementFileError::PredictionPrimaryInputMismatch,
5290            ));
5291        }
5292    }
5293    let mut facets = 0usize;
5294    let mut references = 0usize;
5295    let mut text = provenance
5296        .map(PredictionProvenanceV1::retained_text_bytes)
5297        .transpose()
5298        .map_err(|source| {
5299            prediction_file_error(
5300                file_index,
5301                MeasurementFileError::InvalidPredictionProvenance { source },
5302            )
5303        })?
5304        .unwrap_or(0);
5305    let mut available = 0usize;
5306    let mut unavailable = 0usize;
5307    for (check_index, check) in checks.iter().enumerate() {
5308        if let Some(prediction) = &check.prediction {
5309            facets = facets
5310                .checked_add(prediction.facets().len())
5311                .ok_or_else(|| {
5312                    prediction_file_error(
5313                        file_index,
5314                        MeasurementFileError::PredictionAccountingOverflow,
5315                    )
5316                })?;
5317            references = references
5318                .checked_add(prediction.basis_reference_count())
5319                .ok_or_else(|| {
5320                    prediction_file_error(
5321                        file_index,
5322                        MeasurementFileError::PredictionAccountingOverflow,
5323                    )
5324                })?;
5325            text = text
5326                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5327                    prediction_file_error(
5328                        file_index,
5329                        MeasurementFileError::InvalidPrediction {
5330                            check_index,
5331                            source,
5332                        },
5333                    )
5334                })?)
5335                .ok_or_else(|| {
5336                    prediction_file_error(
5337                        file_index,
5338                        MeasurementFileError::PredictionAccountingOverflow,
5339                    )
5340                })?;
5341            for facet in prediction.facets() {
5342                match facet.state() {
5343                    EnginePredictionFacetStateV1::Available => {
5344                        available = available
5345                            .checked_add(1)
5346                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5347                    }
5348                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5349                        unavailable = unavailable
5350                            .checked_add(1)
5351                            .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
5352                    }
5353                }
5354            }
5355        }
5356    }
5357    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5358        return Err(prediction_file_error(
5359            file_index,
5360            MeasurementFileError::TooManyPredictionFacets {
5361                found: facets,
5362                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5363            },
5364        ));
5365    }
5366    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5367        return Err(prediction_file_error(
5368            file_index,
5369            MeasurementFileError::TooManyPredictionBasisReferences {
5370                found: references,
5371                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5372            },
5373        ));
5374    }
5375    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5376        return Err(prediction_file_error(
5377            file_index,
5378            MeasurementFileError::TooMuchPredictionText {
5379                found: text,
5380                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5381            },
5382        ));
5383    }
5384    Ok((available, unavailable))
5385}
5386
5387impl LegacyPredictionCheckInput {
5388    fn validate(
5389        &self,
5390        check_index: usize,
5391        provenance: Option<&PredictionProvenanceV1>,
5392        expected_measurement_schema: &'static str,
5393    ) -> Result<(), MeasurementFileError> {
5394        let gap_refs = self
5395            .gaps
5396            .iter()
5397            .map(|gap| CheckEvaluationGapRef {
5398                code: &gap.code,
5399                scope: gap.scope.as_ref(),
5400            })
5401            .collect::<Vec<_>>();
5402        let finding_check_ids = self
5403            .findings
5404            .iter()
5405            .map(|finding| finding.check_id.as_str())
5406            .collect::<Vec<_>>();
5407        let prediction_scopes = self
5408            .prediction
5409            .as_ref()
5410            .into_iter()
5411            .flat_map(EnginePredictionV1::facets)
5412            .map(|facet| facet.scope())
5413            .collect::<Vec<_>>();
5414        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
5415            check_id: &self.check_id,
5416            selection: self.selection,
5417            configuration: self.configuration,
5418            applicability: self.applicability,
5419            finding_check_ids: &finding_check_ids,
5420            evaluated_scopes: &self.evaluated_scopes,
5421            gaps: &gap_refs,
5422            prediction_scopes: &prediction_scopes,
5423            has_prediction: self.prediction.is_some(),
5424            prediction_has_required_unavailable: self
5425                .prediction
5426                .as_ref()
5427                .is_some_and(EnginePredictionV1::has_required_unavailable),
5428        })
5429        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
5430            check_index,
5431            reason: error.reason(),
5432        })?;
5433        if self.evaluation != derived {
5434            return Err(MeasurementFileError::InvalidPredictionLifecycle {
5435                check_index,
5436                reason: "evaluation does not match completed and missing prediction work",
5437            });
5438        }
5439        let Some(prediction) = &self.prediction else {
5440            if self
5441                .findings
5442                .iter()
5443                .any(|finding| finding.prediction_scope.is_some())
5444            {
5445                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5446                    check_index,
5447                    reason: "finding has prediction_scope without prediction",
5448                });
5449            }
5450            return Ok(());
5451        };
5452        let provenance =
5453            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
5454        prediction
5455            .validate_against_provenance_with_measurement_schema(
5456                provenance,
5457                expected_measurement_schema,
5458            )
5459            .map_err(|source| MeasurementFileError::InvalidPrediction {
5460                check_index,
5461                source,
5462            })?;
5463        for facet in prediction.facets() {
5464            let evaluated = self
5465                .evaluated_scopes
5466                .iter()
5467                .filter(|scope| *scope == facet.scope())
5468                .count();
5469            let duplicated_gap = self
5470                .gaps
5471                .iter()
5472                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
5473            match facet.state() {
5474                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
5475                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5476                        check_index,
5477                        reason: "available facet scope must occur exactly once in evaluated_scopes",
5478                    });
5479                }
5480                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
5481                    if evaluated != 0 || duplicated_gap =>
5482                {
5483                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
5484                        check_index,
5485                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
5486                    });
5487                }
5488                _ => {}
5489            }
5490        }
5491        for finding in &self.findings {
5492            let Some(scope) = &finding.prediction_scope else {
5493                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5494                    check_index,
5495                    reason: "prediction-backed finding must carry prediction_scope",
5496                });
5497            };
5498            if prediction
5499                .facets()
5500                .iter()
5501                .filter(|facet| {
5502                    facet.scope() == scope
5503                        && facet.state() == EnginePredictionFacetStateV1::Available
5504                })
5505                .count()
5506                != 1
5507            {
5508                return Err(MeasurementFileError::InvalidPredictionLifecycle {
5509                    check_index,
5510                    reason: "finding prediction_scope must name one available facet",
5511                });
5512            }
5513        }
5514        Ok(())
5515    }
5516}
5517
5518fn validate_prediction_phase_file(
5519    command: &str,
5520    file_index: usize,
5521    file: &MeasurementFileInput,
5522    expected_measurement_schema: &'static str,
5523) -> Result<(usize, usize), MeasurementReportError> {
5524    let mut available = 0usize;
5525    let mut unavailable = 0usize;
5526    match command {
5527        "measure" => {
5528            if !matches!(file.prediction_provenance, RequiredNullable::Missing) {
5529                return Err(prediction_file_error(
5530                    file_index,
5531                    MeasurementFileError::UnexpectedPredictionProvenance,
5532                ));
5533            }
5534            if file.checks.is_some() {
5535                return Err(prediction_file_error(
5536                    file_index,
5537                    MeasurementFileError::UnexpectedChecks,
5538                ));
5539            }
5540        }
5541        "lint" => {
5542            let provenance = match &file.prediction_provenance {
5543                RequiredNullable::Missing => {
5544                    return Err(prediction_file_error(
5545                        file_index,
5546                        MeasurementFileError::MissingPredictionProvenance,
5547                    ));
5548                }
5549                RequiredNullable::Present(provenance) => provenance.as_ref(),
5550            };
5551            let checks = file.checks.as_ref().ok_or_else(|| {
5552                prediction_file_error(file_index, MeasurementFileError::MissingChecks)
5553            })?;
5554            if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
5555                return Err(prediction_file_error(
5556                    file_index,
5557                    MeasurementFileError::TooManyChecks {
5558                        found: checks.len(),
5559                        limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
5560                    },
5561                ));
5562            }
5563            if let Some(provenance) = provenance {
5564                provenance
5565                    .validate_with_measurement_schema(expected_measurement_schema)
5566                    .map_err(|source| {
5567                        prediction_file_error(
5568                            file_index,
5569                            MeasurementFileError::InvalidPredictionProvenance { source },
5570                        )
5571                    })?;
5572                let input = file.input.as_ref().ok_or_else(|| {
5573                    prediction_file_error(file_index, MeasurementFileError::MissingInput)
5574                })?;
5575                if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5576                    || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5577                {
5578                    return Err(prediction_file_error(
5579                        file_index,
5580                        MeasurementFileError::PredictionPrimaryInputMismatch,
5581                    ));
5582                }
5583            }
5584
5585            let mut facets = 0usize;
5586            let mut references = 0usize;
5587            let mut text = provenance
5588                .map(PredictionProvenanceV2::retained_text_bytes)
5589                .transpose()
5590                .map_err(|source| {
5591                    prediction_file_error(
5592                        file_index,
5593                        MeasurementFileError::InvalidPredictionProvenance { source },
5594                    )
5595                })?
5596                .unwrap_or(0);
5597            for (check_index, check) in checks.iter().enumerate() {
5598                if let Some(prediction) = &check.prediction {
5599                    facets = facets
5600                        .checked_add(prediction.facets().len())
5601                        .ok_or_else(|| {
5602                            prediction_file_error(
5603                                file_index,
5604                                MeasurementFileError::PredictionAccountingOverflow,
5605                            )
5606                        })?;
5607                    references = references
5608                        .checked_add(prediction.basis_reference_count())
5609                        .ok_or_else(|| {
5610                            prediction_file_error(
5611                                file_index,
5612                                MeasurementFileError::PredictionAccountingOverflow,
5613                            )
5614                        })?;
5615                    text = text
5616                        .checked_add(prediction.retained_text_bytes().map_err(|source| {
5617                            prediction_file_error(
5618                                file_index,
5619                                MeasurementFileError::InvalidPrediction {
5620                                    check_index,
5621                                    source,
5622                                },
5623                            )
5624                        })?)
5625                        .ok_or_else(|| {
5626                            prediction_file_error(
5627                                file_index,
5628                                MeasurementFileError::PredictionAccountingOverflow,
5629                            )
5630                        })?;
5631                    for facet in prediction.facets() {
5632                        match facet.state() {
5633                            EnginePredictionFacetStateV1::Available => {
5634                                available = available.checked_add(1).ok_or(
5635                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5636                                )?;
5637                            }
5638                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
5639                                unavailable = unavailable.checked_add(1).ok_or(
5640                                    MeasurementReportError::PredictionFacetSummaryMismatch,
5641                                )?;
5642                            }
5643                        }
5644                    }
5645                }
5646            }
5647            if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5648                return Err(prediction_file_error(
5649                    file_index,
5650                    MeasurementFileError::TooManyPredictionFacets {
5651                        found: facets,
5652                        limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5653                    },
5654                ));
5655            }
5656            if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5657                return Err(prediction_file_error(
5658                    file_index,
5659                    MeasurementFileError::TooManyPredictionBasisReferences {
5660                        found: references,
5661                        limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5662                    },
5663                ));
5664            }
5665            if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5666                return Err(prediction_file_error(
5667                    file_index,
5668                    MeasurementFileError::TooMuchPredictionText {
5669                        found: text,
5670                        limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5671                    },
5672                ));
5673            }
5674        }
5675        _ => unreachable!("command was validated before prediction phase"),
5676    }
5677    Ok((available, unavailable))
5678}
5679
5680fn validate_prediction_phase_file_v14(
5681    command: &str,
5682    file_index: usize,
5683    file: &MeasurementFileInput,
5684) -> Result<(usize, usize), MeasurementReportError> {
5685    if command == "measure" {
5686        if !matches!(file.prediction_provenance_v3, RequiredNullable::Missing) {
5687            return Err(prediction_file_error(
5688                file_index,
5689                MeasurementFileError::UnexpectedPredictionProvenance,
5690            ));
5691        }
5692        if file.checks_v3.is_some() {
5693            return Err(prediction_file_error(
5694                file_index,
5695                MeasurementFileError::UnexpectedChecks,
5696            ));
5697        }
5698        return Ok((0, 0));
5699    }
5700    let provenance = match &file.prediction_provenance_v3 {
5701        RequiredNullable::Missing => {
5702            return Err(prediction_file_error(
5703                file_index,
5704                MeasurementFileError::MissingPredictionProvenance,
5705            ));
5706        }
5707        RequiredNullable::Present(provenance) => provenance.as_ref(),
5708    };
5709    let checks = file
5710        .checks_v3
5711        .as_ref()
5712        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5713    if let Some(provenance) = provenance {
5714        provenance.validate().map_err(|source| {
5715            prediction_file_error(
5716                file_index,
5717                MeasurementFileError::InvalidPredictionProvenance { source },
5718            )
5719        })?;
5720        let input = file
5721            .input
5722            .as_ref()
5723            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5724        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5725            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5726        {
5727            return Err(prediction_file_error(
5728                file_index,
5729                MeasurementFileError::PredictionPrimaryInputMismatch,
5730            ));
5731        }
5732    }
5733    let mut available = 0usize;
5734    let mut unavailable = 0usize;
5735    let mut facets = 0usize;
5736    let mut references = 0usize;
5737    let mut text = provenance
5738        .map(PredictionProvenanceV3::retained_text_bytes)
5739        .transpose()
5740        .map_err(|source| {
5741            prediction_file_error(
5742                file_index,
5743                MeasurementFileError::InvalidPredictionProvenance { source },
5744            )
5745        })?
5746        .unwrap_or(0);
5747    for (check_index, check) in checks.iter().enumerate() {
5748        check
5749            .validate(check_index, provenance)
5750            .map_err(|source| prediction_file_error(file_index, source))?;
5751        if let Some(prediction) = &check.prediction {
5752            facets = facets
5753                .checked_add(prediction.facets().len())
5754                .ok_or_else(|| {
5755                    prediction_file_error(
5756                        file_index,
5757                        MeasurementFileError::PredictionAccountingOverflow,
5758                    )
5759                })?;
5760            references = references
5761                .checked_add(prediction.basis_reference_count())
5762                .ok_or_else(|| {
5763                    prediction_file_error(
5764                        file_index,
5765                        MeasurementFileError::PredictionAccountingOverflow,
5766                    )
5767                })?;
5768            text = text
5769                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5770                    prediction_file_error(
5771                        file_index,
5772                        MeasurementFileError::InvalidPrediction {
5773                            check_index,
5774                            source,
5775                        },
5776                    )
5777                })?)
5778                .ok_or_else(|| {
5779                    prediction_file_error(
5780                        file_index,
5781                        MeasurementFileError::PredictionAccountingOverflow,
5782                    )
5783                })?;
5784            for facet in prediction.facets() {
5785                match facet.state() {
5786                    EnginePredictionFacetStateV1::Available => available += 1,
5787                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5788                }
5789            }
5790        }
5791    }
5792    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5793        return Err(prediction_file_error(
5794            file_index,
5795            MeasurementFileError::TooManyPredictionFacets {
5796                found: facets,
5797                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5798            },
5799        ));
5800    }
5801    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5802        return Err(prediction_file_error(
5803            file_index,
5804            MeasurementFileError::TooManyPredictionBasisReferences {
5805                found: references,
5806                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5807            },
5808        ));
5809    }
5810    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5811        return Err(prediction_file_error(
5812            file_index,
5813            MeasurementFileError::TooMuchPredictionText {
5814                found: text,
5815                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5816            },
5817        ));
5818    }
5819    Ok((available, unavailable))
5820}
5821
5822fn validate_prediction_phase_file_v15(
5823    command: &str,
5824    file_index: usize,
5825    file: &MeasurementFileInput,
5826) -> Result<(usize, usize), MeasurementReportError> {
5827    if matches!(file.prediction_provenance_v4, RequiredNullable::Missing) {
5828        return validate_prediction_phase_file_v14(command, file_index, file);
5829    }
5830    if command == "measure" {
5831        return Err(prediction_file_error(
5832            file_index,
5833            MeasurementFileError::UnexpectedPredictionProvenance,
5834        ));
5835    }
5836    let provenance = match &file.prediction_provenance_v4 {
5837        RequiredNullable::Present(provenance) => provenance.as_ref(),
5838        RequiredNullable::Missing => unreachable!(),
5839    };
5840    let checks = file
5841        .checks_v4
5842        .as_ref()
5843        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5844    if let Some(provenance) = provenance {
5845        provenance.validate().map_err(|source| {
5846            prediction_file_error(
5847                file_index,
5848                MeasurementFileError::InvalidPredictionProvenance { source },
5849            )
5850        })?;
5851        let input = file
5852            .input
5853            .as_ref()
5854            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5855        if input.sha256.as_deref() != Some(provenance.raw_source().primary_input().sha256())
5856            || input.bytes != Some(provenance.raw_source().primary_input().bytes())
5857        {
5858            return Err(prediction_file_error(
5859                file_index,
5860                MeasurementFileError::PredictionPrimaryInputMismatch,
5861            ));
5862        }
5863    }
5864    let mut available = 0usize;
5865    let mut unavailable = 0usize;
5866    let mut has_facet_budget_summary = false;
5867    let mut facets = 0usize;
5868    let mut references = 0usize;
5869    let mut text = provenance
5870        .map(PredictionProvenanceV4::retained_text_bytes)
5871        .transpose()
5872        .map_err(|source| {
5873            prediction_file_error(
5874                file_index,
5875                MeasurementFileError::InvalidPredictionProvenance { source },
5876            )
5877        })?
5878        .unwrap_or(0);
5879    for (check_index, check) in checks.iter().enumerate() {
5880        check
5881            .validate(check_index, provenance)
5882            .map_err(|source| prediction_file_error(file_index, source))?;
5883        if let Some(prediction) = &check.prediction {
5884            has_facet_budget_summary |= prediction.has_facet_budget_summary();
5885            facets = facets
5886                .checked_add(prediction.facets().len())
5887                .ok_or_else(|| {
5888                    prediction_file_error(
5889                        file_index,
5890                        MeasurementFileError::PredictionAccountingOverflow,
5891                    )
5892                })?;
5893            references = references
5894                .checked_add(prediction.basis_reference_count())
5895                .ok_or_else(|| {
5896                    prediction_file_error(
5897                        file_index,
5898                        MeasurementFileError::PredictionAccountingOverflow,
5899                    )
5900                })?;
5901            text = text
5902                .checked_add(prediction.retained_text_bytes().map_err(|source| {
5903                    prediction_file_error(
5904                        file_index,
5905                        MeasurementFileError::InvalidPrediction {
5906                            check_index,
5907                            source,
5908                        },
5909                    )
5910                })?)
5911                .ok_or_else(|| {
5912                    prediction_file_error(
5913                        file_index,
5914                        MeasurementFileError::PredictionAccountingOverflow,
5915                    )
5916                })?;
5917            for facet in prediction.facets() {
5918                match facet.state() {
5919                    EnginePredictionFacetStateV1::Available => available += 1,
5920                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
5921                }
5922            }
5923        }
5924    }
5925    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
5926        return Err(prediction_file_error(
5927            file_index,
5928            MeasurementFileError::TooManyPredictionFacets {
5929                found: facets,
5930                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5931            },
5932        ));
5933    }
5934    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
5935        return Err(prediction_file_error(
5936            file_index,
5937            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
5938                found: facets,
5939                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
5940            },
5941        ));
5942    }
5943    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
5944        return Err(prediction_file_error(
5945            file_index,
5946            MeasurementFileError::TooManyPredictionBasisReferences {
5947                found: references,
5948                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
5949            },
5950        ));
5951    }
5952    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
5953        return Err(prediction_file_error(
5954            file_index,
5955            MeasurementFileError::TooMuchPredictionText {
5956                found: text,
5957                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
5958            },
5959        ));
5960    }
5961    Ok((available, unavailable))
5962}
5963
5964fn validate_prediction_phase_file_v16(
5965    command: &str,
5966    file_index: usize,
5967    file: &MeasurementFileInput,
5968) -> Result<(usize, usize), MeasurementReportError> {
5969    if matches!(file.prediction_provenance_v5, RequiredNullable::Missing) {
5970        return validate_prediction_phase_file_v15(command, file_index, file);
5971    }
5972    if command == "measure" {
5973        return Err(prediction_file_error(
5974            file_index,
5975            MeasurementFileError::UnexpectedPredictionProvenance,
5976        ));
5977    }
5978    let provenance = match &file.prediction_provenance_v5 {
5979        RequiredNullable::Present(provenance) => provenance.as_ref(),
5980        RequiredNullable::Missing => unreachable!(),
5981    };
5982    let checks = file
5983        .checks_v5
5984        .as_ref()
5985        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
5986    if let Some(provenance) = provenance {
5987        provenance.validate().map_err(|source| {
5988            prediction_file_error(
5989                file_index,
5990                MeasurementFileError::InvalidPredictionProvenance { source },
5991            )
5992        })?;
5993        let input = file
5994            .input
5995            .as_ref()
5996            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
5997        if input.sha256.as_deref()
5998            != Some(provenance.raw_animation_channels().primary_input().sha256())
5999            || input.bytes != Some(provenance.raw_animation_channels().primary_input().bytes())
6000        {
6001            return Err(prediction_file_error(
6002                file_index,
6003                MeasurementFileError::PredictionPrimaryInputMismatch,
6004            ));
6005        }
6006    }
6007    let mut available = 0usize;
6008    let mut unavailable = 0usize;
6009    let mut facets = 0usize;
6010    let mut references = 0usize;
6011    let mut has_facet_budget_summary = false;
6012    let mut text = provenance
6013        .map(PredictionProvenanceV5::retained_text_bytes)
6014        .transpose()
6015        .map_err(|source| {
6016            prediction_file_error(
6017                file_index,
6018                MeasurementFileError::InvalidPredictionProvenance { source },
6019            )
6020        })?
6021        .unwrap_or(0);
6022    for (check_index, check) in checks.iter().enumerate() {
6023        check
6024            .validate(check_index, provenance)
6025            .map_err(|source| prediction_file_error(file_index, source))?;
6026        validate_current_engine_track_support_prediction_v5(
6027            &check.check_id,
6028            check.selection,
6029            check.configuration,
6030            check.applicability,
6031            check.prediction.as_ref(),
6032            provenance,
6033            check.findings.is_empty(),
6034        )
6035        .map_err(|source| {
6036            prediction_file_error(
6037                file_index,
6038                MeasurementFileError::InvalidPrediction {
6039                    check_index,
6040                    source,
6041                },
6042            )
6043        })?;
6044        if let Some(prediction) = &check.prediction {
6045            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
6046            facets = facets
6047                .checked_add(prediction.facets().len())
6048                .ok_or_else(|| {
6049                    prediction_file_error(
6050                        file_index,
6051                        MeasurementFileError::PredictionAccountingOverflow,
6052                    )
6053                })?;
6054            references = references
6055                .checked_add(prediction.basis_reference_count())
6056                .ok_or_else(|| {
6057                    prediction_file_error(
6058                        file_index,
6059                        MeasurementFileError::PredictionAccountingOverflow,
6060                    )
6061                })?;
6062            text = text
6063                .checked_add(prediction.retained_text_bytes().map_err(|source| {
6064                    prediction_file_error(
6065                        file_index,
6066                        MeasurementFileError::InvalidPrediction {
6067                            check_index,
6068                            source,
6069                        },
6070                    )
6071                })?)
6072                .ok_or_else(|| {
6073                    prediction_file_error(
6074                        file_index,
6075                        MeasurementFileError::PredictionAccountingOverflow,
6076                    )
6077                })?;
6078            for facet in prediction.facets() {
6079                match facet.state() {
6080                    EnginePredictionFacetStateV1::Available => available += 1,
6081                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6082                }
6083            }
6084        }
6085    }
6086    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6087        return Err(prediction_file_error(
6088            file_index,
6089            MeasurementFileError::TooManyPredictionFacets {
6090                found: facets,
6091                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6092            },
6093        ));
6094    }
6095    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6096        return Err(prediction_file_error(
6097            file_index,
6098            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6099                found: facets,
6100                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6101            },
6102        ));
6103    }
6104    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6105        return Err(prediction_file_error(
6106            file_index,
6107            MeasurementFileError::TooManyPredictionBasisReferences {
6108                found: references,
6109                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6110            },
6111        ));
6112    }
6113    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6114        return Err(prediction_file_error(
6115            file_index,
6116            MeasurementFileError::TooMuchPredictionText {
6117                found: text,
6118                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6119            },
6120        ));
6121    }
6122    Ok((available, unavailable))
6123}
6124
6125fn validate_prediction_phase_file_v17(
6126    command: &str,
6127    file_index: usize,
6128    file: &MeasurementFileInput,
6129) -> Result<(usize, usize), MeasurementReportError> {
6130    if matches!(file.prediction_provenance_v6, RequiredNullable::Missing) {
6131        return validate_prediction_phase_file_v16(command, file_index, file);
6132    }
6133    if command == "measure" {
6134        return Err(prediction_file_error(
6135            file_index,
6136            MeasurementFileError::UnexpectedPredictionProvenance,
6137        ));
6138    }
6139    let provenance = match &file.prediction_provenance_v6 {
6140        RequiredNullable::Present(provenance) => provenance.as_ref(),
6141        RequiredNullable::Missing => unreachable!(),
6142    };
6143    let checks = file
6144        .checks_v6
6145        .as_ref()
6146        .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingChecks))?;
6147    if let Some(provenance) = provenance {
6148        provenance.validate().map_err(|source| {
6149            prediction_file_error(
6150                file_index,
6151                MeasurementFileError::InvalidPredictionProvenance { source },
6152            )
6153        })?;
6154        let input = file
6155            .input
6156            .as_ref()
6157            .ok_or_else(|| prediction_file_error(file_index, MeasurementFileError::MissingInput))?;
6158        let primary = provenance.raw_transform_paths().primary_input();
6159        if input.sha256.as_deref() != Some(primary.sha256()) || input.bytes != Some(primary.bytes())
6160        {
6161            return Err(prediction_file_error(
6162                file_index,
6163                MeasurementFileError::PredictionPrimaryInputMismatch,
6164            ));
6165        }
6166    }
6167    let mut available = 0usize;
6168    let mut unavailable = 0usize;
6169    let mut facets = 0usize;
6170    let mut references = 0usize;
6171    let mut has_facet_budget_summary = false;
6172    let mut text = provenance
6173        .map(PredictionProvenanceV6::retained_text_bytes)
6174        .transpose()
6175        .map_err(|source| {
6176            prediction_file_error(
6177                file_index,
6178                MeasurementFileError::InvalidPredictionProvenance { source },
6179            )
6180        })?
6181        .unwrap_or(0);
6182    for (check_index, check) in checks.iter().enumerate() {
6183        check
6184            .validate(check_index, provenance)
6185            .map_err(|source| prediction_file_error(file_index, source))?;
6186        if let Some(prediction) = &check.prediction {
6187            has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
6188            facets = facets
6189                .checked_add(prediction.facets().len())
6190                .ok_or_else(|| {
6191                    prediction_file_error(
6192                        file_index,
6193                        MeasurementFileError::PredictionAccountingOverflow,
6194                    )
6195                })?;
6196            references = references
6197                .checked_add(prediction.basis_reference_count())
6198                .ok_or_else(|| {
6199                    prediction_file_error(
6200                        file_index,
6201                        MeasurementFileError::PredictionAccountingOverflow,
6202                    )
6203                })?;
6204            text = text
6205                .checked_add(prediction.retained_text_bytes().map_err(|source| {
6206                    prediction_file_error(
6207                        file_index,
6208                        MeasurementFileError::InvalidPrediction {
6209                            check_index,
6210                            source,
6211                        },
6212                    )
6213                })?)
6214                .ok_or_else(|| {
6215                    prediction_file_error(
6216                        file_index,
6217                        MeasurementFileError::PredictionAccountingOverflow,
6218                    )
6219                })?;
6220            for facet in prediction.facets() {
6221                match facet.state() {
6222                    EnginePredictionFacetStateV1::Available => available += 1,
6223                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => unavailable += 1,
6224                }
6225            }
6226        }
6227    }
6228    if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
6229        return Err(prediction_file_error(
6230            file_index,
6231            MeasurementFileError::TooManyPredictionFacets {
6232                found: facets,
6233                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6234            },
6235        ));
6236    }
6237    if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
6238        return Err(prediction_file_error(
6239            file_index,
6240            MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
6241                found: facets,
6242                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
6243            },
6244        ));
6245    }
6246    if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
6247        return Err(prediction_file_error(
6248            file_index,
6249            MeasurementFileError::TooManyPredictionBasisReferences {
6250                found: references,
6251                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
6252            },
6253        ));
6254    }
6255    if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
6256        return Err(prediction_file_error(
6257            file_index,
6258            MeasurementFileError::TooMuchPredictionText {
6259                found: text,
6260                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
6261            },
6262        ));
6263    }
6264    Ok((available, unavailable))
6265}
6266
6267fn validate_prediction_summary(
6268    command: &str,
6269    summary: Option<&MeasurementReportSummaryInput>,
6270    available: usize,
6271    unavailable: usize,
6272) -> Result<(), MeasurementReportError> {
6273    let summary = summary.and_then(|summary| summary.prediction_facets.as_ref());
6274    match (command, summary) {
6275        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6276        ("measure", None) => Ok(()),
6277        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6278        ("lint", Some(summary))
6279            if summary.available != available
6280                || summary.required_prediction_unavailable != unavailable =>
6281        {
6282            Err(MeasurementReportError::PredictionFacetSummaryMismatch)
6283        }
6284        ("lint", Some(_)) => Ok(()),
6285        _ => unreachable!("command was validated before prediction summary"),
6286    }
6287}
6288
6289fn validate_prediction_summary_presence(
6290    command: &str,
6291    summary: Option<&MeasurementReportSummaryInput>,
6292) -> Result<(), MeasurementReportError> {
6293    match (
6294        command,
6295        summary.and_then(|summary| summary.prediction_facets.as_ref()),
6296    ) {
6297        ("measure", Some(_)) => Err(MeasurementReportError::UnexpectedPredictionFacetSummary),
6298        ("lint", None) => Err(MeasurementReportError::MissingPredictionFacetSummary),
6299        ("measure", None) | ("lint", Some(_)) => Ok(()),
6300        _ => unreachable!("command was validated before prediction summary"),
6301    }
6302}
6303
6304impl PredictionCheckInput {
6305    fn validate(
6306        &self,
6307        check_index: usize,
6308        provenance: Option<&PredictionProvenanceV2>,
6309        expected_measurement_schema: &'static str,
6310    ) -> Result<(), MeasurementFileError> {
6311        let gap_refs = self
6312            .gaps
6313            .iter()
6314            .map(|gap| CheckEvaluationGapRef {
6315                code: &gap.code,
6316                scope: gap.scope.as_ref(),
6317            })
6318            .collect::<Vec<_>>();
6319        let finding_check_ids = self
6320            .findings
6321            .iter()
6322            .map(|finding| finding.check_id.as_str())
6323            .collect::<Vec<_>>();
6324        let prediction_scopes = self
6325            .prediction
6326            .as_ref()
6327            .into_iter()
6328            .flat_map(EnginePredictionV2::facets)
6329            .map(|facet| facet.scope())
6330            .collect::<Vec<_>>();
6331        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6332            check_id: &self.check_id,
6333            selection: self.selection,
6334            configuration: self.configuration,
6335            applicability: self.applicability,
6336            finding_check_ids: &finding_check_ids,
6337            evaluated_scopes: &self.evaluated_scopes,
6338            gaps: &gap_refs,
6339            prediction_scopes: &prediction_scopes,
6340            has_prediction: self.prediction.is_some(),
6341            prediction_has_required_unavailable: self
6342                .prediction
6343                .as_ref()
6344                .is_some_and(EnginePredictionV2::has_required_unavailable),
6345        })
6346        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6347            check_index,
6348            reason: error.reason(),
6349        })?;
6350        if self.evaluation != derived {
6351            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6352                check_index,
6353                reason: "evaluation does not match completed and missing prediction work",
6354            });
6355        }
6356
6357        let Some(prediction) = &self.prediction else {
6358            if self
6359                .findings
6360                .iter()
6361                .any(|finding| finding.prediction_scope.is_some())
6362            {
6363                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6364                    check_index,
6365                    reason: "finding has prediction_scope without prediction",
6366                });
6367            }
6368            return Ok(());
6369        };
6370        let provenance =
6371            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6372        prediction
6373            .validate_against_provenance_with_measurement_schema(
6374                provenance,
6375                expected_measurement_schema,
6376            )
6377            .map_err(|source| MeasurementFileError::InvalidPrediction {
6378                check_index,
6379                source,
6380            })?;
6381        prediction
6382            .validate_facet_budget_summary_for_check(&self.check_id)
6383            .map_err(|source| MeasurementFileError::InvalidPrediction {
6384                check_index,
6385                source,
6386            })?;
6387        validate_current_engine_addressability_prediction_v2(
6388            &self.check_id,
6389            prediction,
6390            provenance,
6391        )
6392        .map_err(|source| MeasurementFileError::InvalidPrediction {
6393            check_index,
6394            source,
6395        })?;
6396        for facet in prediction.facets() {
6397            let evaluated = self
6398                .evaluated_scopes
6399                .iter()
6400                .filter(|scope| *scope == facet.scope())
6401                .count();
6402            let duplicated_gap = self
6403                .gaps
6404                .iter()
6405                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6406            match facet.state() {
6407                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6408                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6409                        check_index,
6410                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6411                    });
6412                }
6413                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6414                    if evaluated != 0 || duplicated_gap =>
6415                {
6416                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6417                        check_index,
6418                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6419                    });
6420                }
6421                _ => {}
6422            }
6423        }
6424        for finding in &self.findings {
6425            let Some(scope) = &finding.prediction_scope else {
6426                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6427                    check_index,
6428                    reason: "prediction-backed finding must carry prediction_scope",
6429                });
6430            };
6431            if prediction
6432                .facets()
6433                .iter()
6434                .filter(|facet| {
6435                    facet.scope() == scope
6436                        && facet.state() == EnginePredictionFacetStateV1::Available
6437                })
6438                .count()
6439                != 1
6440            {
6441                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6442                    check_index,
6443                    reason: "finding prediction_scope must name one available facet",
6444                });
6445            }
6446        }
6447        Ok(())
6448    }
6449}
6450
6451impl PredictionCheckInputV3 {
6452    fn validate(
6453        &self,
6454        check_index: usize,
6455        provenance: Option<&PredictionProvenanceV3>,
6456    ) -> Result<(), MeasurementFileError> {
6457        let gap_refs = self
6458            .gaps
6459            .iter()
6460            .map(|gap| CheckEvaluationGapRef {
6461                code: &gap.code,
6462                scope: gap.scope.as_ref(),
6463            })
6464            .collect::<Vec<_>>();
6465        let finding_check_ids = self
6466            .findings
6467            .iter()
6468            .map(|finding| finding.check_id.as_str())
6469            .collect::<Vec<_>>();
6470        let prediction_scopes = self
6471            .prediction
6472            .as_ref()
6473            .into_iter()
6474            .flat_map(EnginePredictionV3::facets)
6475            .map(|facet| facet.scope())
6476            .collect::<Vec<_>>();
6477        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6478            check_id: &self.check_id,
6479            selection: self.selection,
6480            configuration: self.configuration,
6481            applicability: self.applicability,
6482            finding_check_ids: &finding_check_ids,
6483            evaluated_scopes: &self.evaluated_scopes,
6484            gaps: &gap_refs,
6485            prediction_scopes: &prediction_scopes,
6486            has_prediction: self.prediction.is_some(),
6487            prediction_has_required_unavailable: self
6488                .prediction
6489                .as_ref()
6490                .is_some_and(EnginePredictionV3::has_required_unavailable),
6491        })
6492        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6493            check_index,
6494            reason: error.reason(),
6495        })?;
6496        if self.evaluation != derived {
6497            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6498                check_index,
6499                reason: "evaluation does not match completed and missing prediction work",
6500            });
6501        }
6502        validate_current_engine_clip_boundary_applicability_v3(
6503            &self.check_id,
6504            self.applicability,
6505            provenance,
6506        )
6507        .map_err(|source| MeasurementFileError::InvalidPrediction {
6508            check_index,
6509            source,
6510        })?;
6511        let Some(prediction) = &self.prediction else {
6512            if self
6513                .findings
6514                .iter()
6515                .any(|finding| finding.prediction_scope.is_some())
6516            {
6517                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6518                    check_index,
6519                    reason: "finding has prediction_scope without prediction",
6520                });
6521            }
6522            if self.check_id == "engine-clip-boundary"
6523                && self.selection == SelectionState::Selected
6524                && self.configuration == ConfigurationState::Enabled
6525                && self.applicability == Applicability::Applicable
6526            {
6527                return Err(MeasurementFileError::InvalidPrediction {
6528                    check_index,
6529                    source: PredictionContractError::EngineClipBoundaryFacetMismatch,
6530                });
6531            }
6532            return Ok(());
6533        };
6534        let provenance =
6535            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6536        prediction
6537            .validate_against_provenance(provenance)
6538            .map_err(|source| MeasurementFileError::InvalidPrediction {
6539                check_index,
6540                source,
6541            })?;
6542        prediction
6543            .validate_facet_budget_summary_for_check(&self.check_id)
6544            .map_err(|source| MeasurementFileError::InvalidPrediction {
6545                check_index,
6546                source,
6547            })?;
6548        validate_current_engine_addressability_prediction_v3(
6549            &self.check_id,
6550            prediction,
6551            provenance,
6552        )
6553        .map_err(|source| MeasurementFileError::InvalidPrediction {
6554            check_index,
6555            source,
6556        })?;
6557        for facet in prediction.facets() {
6558            let evaluated = self
6559                .evaluated_scopes
6560                .iter()
6561                .filter(|scope| *scope == facet.scope())
6562                .count();
6563            let duplicated_gap = self
6564                .gaps
6565                .iter()
6566                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6567            match facet.state() {
6568                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6569                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6570                        check_index,
6571                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6572                    });
6573                }
6574                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6575                    if evaluated != 0 || duplicated_gap =>
6576                {
6577                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6578                        check_index,
6579                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6580                    });
6581                }
6582                _ => {}
6583            }
6584        }
6585        for finding in &self.findings {
6586            let Some(scope) = &finding.prediction_scope else {
6587                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6588                    check_index,
6589                    reason: "prediction-backed finding must carry prediction_scope",
6590                });
6591            };
6592            if prediction
6593                .facets()
6594                .iter()
6595                .filter(|facet| {
6596                    facet.scope() == scope
6597                        && facet.state() == EnginePredictionFacetStateV1::Available
6598                })
6599                .count()
6600                != 1
6601            {
6602                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6603                    check_index,
6604                    reason: "finding prediction_scope must name one available facet",
6605                });
6606            }
6607        }
6608        let finding_scopes = self
6609            .findings
6610            .iter()
6611            .filter_map(|finding| finding.prediction_scope.as_ref())
6612            .collect::<Vec<_>>();
6613        validate_current_engine_clip_boundary_prediction_v3(
6614            &self.check_id,
6615            prediction,
6616            provenance,
6617            &self.evaluated_scopes,
6618            &finding_scopes,
6619        )
6620        .map_err(|source| MeasurementFileError::InvalidPrediction {
6621            check_index,
6622            source,
6623        })?;
6624        Ok(())
6625    }
6626}
6627
6628impl PredictionCheckInputV4 {
6629    fn validate(
6630        &self,
6631        check_index: usize,
6632        provenance: Option<&PredictionProvenanceV4>,
6633    ) -> Result<(), MeasurementFileError> {
6634        let gap_refs = self
6635            .gaps
6636            .iter()
6637            .map(|gap| CheckEvaluationGapRef {
6638                code: &gap.code,
6639                scope: gap.scope.as_ref(),
6640            })
6641            .collect::<Vec<_>>();
6642        let finding_check_ids = self
6643            .findings
6644            .iter()
6645            .map(|finding| finding.check_id.as_str())
6646            .collect::<Vec<_>>();
6647        let prediction_scopes = self
6648            .prediction
6649            .as_ref()
6650            .into_iter()
6651            .flat_map(EnginePredictionV4::facets)
6652            .map(|facet| facet.scope())
6653            .collect::<Vec<_>>();
6654        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6655            check_id: &self.check_id,
6656            selection: self.selection,
6657            configuration: self.configuration,
6658            applicability: self.applicability,
6659            finding_check_ids: &finding_check_ids,
6660            evaluated_scopes: &self.evaluated_scopes,
6661            gaps: &gap_refs,
6662            prediction_scopes: &prediction_scopes,
6663            has_prediction: self.prediction.is_some(),
6664            prediction_has_required_unavailable: self
6665                .prediction
6666                .as_ref()
6667                .is_some_and(EnginePredictionV4::has_required_unavailable),
6668        })
6669        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6670            check_index,
6671            reason: error.reason(),
6672        })?;
6673        if self.evaluation != derived {
6674            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6675                check_index,
6676                reason: "evaluation does not match completed and missing prediction work",
6677            });
6678        }
6679        let Some(prediction) = &self.prediction else {
6680            if self
6681                .findings
6682                .iter()
6683                .any(|finding| finding.prediction_scope.is_some())
6684            {
6685                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6686                    check_index,
6687                    reason: "finding has prediction_scope without prediction",
6688                });
6689            }
6690            return Ok(());
6691        };
6692        let provenance =
6693            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6694        prediction
6695            .validate_against_provenance(provenance)
6696            .map_err(|source| MeasurementFileError::InvalidPrediction {
6697                check_index,
6698                source,
6699            })?;
6700        prediction
6701            .validate_facet_budget_summary_for_check(&self.check_id)
6702            .map_err(|source| MeasurementFileError::InvalidPrediction {
6703                check_index,
6704                source,
6705            })?;
6706        for facet in prediction.facets() {
6707            let evaluated = self
6708                .evaluated_scopes
6709                .iter()
6710                .filter(|scope| *scope == facet.scope())
6711                .count();
6712            let duplicated_gap = self
6713                .gaps
6714                .iter()
6715                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6716            match facet.state() {
6717                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6718                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6719                        check_index,
6720                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6721                    });
6722                }
6723                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6724                    if evaluated != 0 || duplicated_gap =>
6725                {
6726                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6727                        check_index,
6728                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6729                    });
6730                }
6731                _ => {}
6732            }
6733        }
6734        for finding in &self.findings {
6735            let Some(scope) = &finding.prediction_scope else {
6736                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6737                    check_index,
6738                    reason: "prediction-backed finding must carry prediction_scope",
6739                });
6740            };
6741            if prediction
6742                .facets()
6743                .iter()
6744                .filter(|facet| {
6745                    facet.scope() == scope
6746                        && facet.state() == EnginePredictionFacetStateV1::Available
6747                })
6748                .count()
6749                != 1
6750            {
6751                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6752                    check_index,
6753                    reason: "finding prediction_scope must name one available facet",
6754                });
6755            }
6756        }
6757        Ok(())
6758    }
6759}
6760
6761impl PredictionCheckInputV5 {
6762    fn validate(
6763        &self,
6764        check_index: usize,
6765        provenance: Option<&PredictionProvenanceV5>,
6766    ) -> Result<(), MeasurementFileError> {
6767        let gap_refs = self
6768            .gaps
6769            .iter()
6770            .map(|gap| CheckEvaluationGapRef {
6771                code: &gap.code,
6772                scope: gap.scope.as_ref(),
6773            })
6774            .collect::<Vec<_>>();
6775        let finding_check_ids = self
6776            .findings
6777            .iter()
6778            .map(|finding| finding.check_id.as_str())
6779            .collect::<Vec<_>>();
6780        let prediction_scopes = self
6781            .prediction
6782            .as_ref()
6783            .into_iter()
6784            .flat_map(EnginePredictionV5::facets)
6785            .map(|facet| facet.scope())
6786            .collect::<Vec<_>>();
6787        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6788            check_id: &self.check_id,
6789            selection: self.selection,
6790            configuration: self.configuration,
6791            applicability: self.applicability,
6792            finding_check_ids: &finding_check_ids,
6793            evaluated_scopes: &self.evaluated_scopes,
6794            gaps: &gap_refs,
6795            prediction_scopes: &prediction_scopes,
6796            has_prediction: self.prediction.is_some(),
6797            prediction_has_required_unavailable: self
6798                .prediction
6799                .as_ref()
6800                .is_some_and(EnginePredictionV5::has_required_unavailable),
6801        })
6802        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6803            check_index,
6804            reason: error.reason(),
6805        })?;
6806        if self.evaluation != derived {
6807            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6808                check_index,
6809                reason: "evaluation does not match completed and missing prediction work",
6810            });
6811        }
6812        let Some(prediction) = &self.prediction else {
6813            if self
6814                .findings
6815                .iter()
6816                .any(|finding| finding.prediction_scope.is_some())
6817            {
6818                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6819                    check_index,
6820                    reason: "finding has prediction_scope without prediction",
6821                });
6822            }
6823            return Ok(());
6824        };
6825        let provenance =
6826            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6827        prediction
6828            .validate_against_provenance(provenance)
6829            .map_err(|source| MeasurementFileError::InvalidPrediction {
6830                check_index,
6831                source,
6832            })?;
6833        prediction
6834            .base_prediction()
6835            .validate_facet_budget_summary_for_check(&self.check_id)
6836            .map_err(|source| MeasurementFileError::InvalidPrediction {
6837                check_index,
6838                source,
6839            })?;
6840        for facet in prediction.facets() {
6841            let evaluated = self
6842                .evaluated_scopes
6843                .iter()
6844                .filter(|scope| *scope == facet.scope())
6845                .count();
6846            let duplicated_gap = self
6847                .gaps
6848                .iter()
6849                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6850            match facet.state() {
6851                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6852                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6853                        check_index,
6854                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6855                    });
6856                }
6857                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6858                    if evaluated != 0 || duplicated_gap =>
6859                {
6860                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6861                        check_index,
6862                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6863                    });
6864                }
6865                _ => {}
6866            }
6867        }
6868        for finding in &self.findings {
6869            let Some(scope) = &finding.prediction_scope else {
6870                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6871                    check_index,
6872                    reason: "prediction-backed finding must carry prediction_scope",
6873                });
6874            };
6875            if prediction
6876                .facets()
6877                .iter()
6878                .filter(|facet| {
6879                    facet.scope() == scope
6880                        && facet.state() == EnginePredictionFacetStateV1::Available
6881                })
6882                .count()
6883                != 1
6884            {
6885                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6886                    check_index,
6887                    reason: "finding prediction_scope must name one available facet",
6888                });
6889            }
6890        }
6891        Ok(())
6892    }
6893}
6894
6895impl PredictionCheckInputV6 {
6896    fn validate(
6897        &self,
6898        check_index: usize,
6899        provenance: Option<&PredictionProvenanceV6>,
6900    ) -> Result<(), MeasurementFileError> {
6901        let gap_refs = self
6902            .gaps
6903            .iter()
6904            .map(|gap| CheckEvaluationGapRef {
6905                code: &gap.code,
6906                scope: gap.scope.as_ref(),
6907            })
6908            .collect::<Vec<_>>();
6909        let finding_check_ids = self
6910            .findings
6911            .iter()
6912            .map(|finding| finding.check_id.as_str())
6913            .collect::<Vec<_>>();
6914        let prediction_scopes = self
6915            .prediction
6916            .as_ref()
6917            .into_iter()
6918            .flat_map(EnginePredictionV6::facets)
6919            .map(|facet| facet.scope())
6920            .collect::<Vec<_>>();
6921        let derived = validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
6922            check_id: &self.check_id,
6923            selection: self.selection,
6924            configuration: self.configuration,
6925            applicability: self.applicability,
6926            finding_check_ids: &finding_check_ids,
6927            evaluated_scopes: &self.evaluated_scopes,
6928            gaps: &gap_refs,
6929            prediction_scopes: &prediction_scopes,
6930            has_prediction: self.prediction.is_some(),
6931            prediction_has_required_unavailable: self
6932                .prediction
6933                .as_ref()
6934                .is_some_and(EnginePredictionV6::has_required_unavailable),
6935        })
6936        .map_err(|error| MeasurementFileError::InvalidPredictionLifecycle {
6937            check_index,
6938            reason: error.reason(),
6939        })?;
6940        if self.evaluation != derived {
6941            return Err(MeasurementFileError::InvalidPredictionLifecycle {
6942                check_index,
6943                reason: "evaluation does not match completed and missing prediction work",
6944            });
6945        }
6946        let Some(prediction) = &self.prediction else {
6947            if self
6948                .findings
6949                .iter()
6950                .any(|finding| finding.prediction_scope.is_some())
6951            {
6952                return Err(MeasurementFileError::InvalidPredictionLifecycle {
6953                    check_index,
6954                    reason: "finding has prediction_scope without prediction",
6955                });
6956            }
6957            return Ok(());
6958        };
6959        let provenance =
6960            provenance.ok_or(MeasurementFileError::PredictionWithoutProvenance { check_index })?;
6961        prediction
6962            .validate_against_provenance(provenance)
6963            .map_err(|source| MeasurementFileError::InvalidPrediction {
6964                check_index,
6965                source,
6966            })?;
6967        prediction
6968            .base_prediction()
6969            .validate_facet_budget_summary_for_check(&self.check_id)
6970            .map_err(|source| MeasurementFileError::InvalidPrediction {
6971                check_index,
6972                source,
6973            })?;
6974        for facet in prediction.facets() {
6975            let evaluated = self
6976                .evaluated_scopes
6977                .iter()
6978                .filter(|scope| *scope == facet.scope())
6979                .count();
6980            let duplicated_gap = self
6981                .gaps
6982                .iter()
6983                .any(|gap| gap.scope.as_ref() == Some(facet.scope()));
6984            match facet.state() {
6985                EnginePredictionFacetStateV1::Available if evaluated != 1 => {
6986                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6987                        check_index,
6988                        reason: "available facet scope must occur exactly once in evaluated_scopes",
6989                    });
6990                }
6991                EnginePredictionFacetStateV1::RequiredPredictionUnavailable
6992                    if evaluated != 0 || duplicated_gap =>
6993                {
6994                    return Err(MeasurementFileError::InvalidPredictionLifecycle {
6995                        check_index,
6996                        reason: "required-unavailable facet scope must be absent from evaluated_scopes and gaps",
6997                    });
6998                }
6999                _ => {}
7000            }
7001        }
7002        for finding in &self.findings {
7003            let Some(scope) = &finding.prediction_scope else {
7004                return Err(MeasurementFileError::InvalidPredictionLifecycle {
7005                    check_index,
7006                    reason: "prediction-backed finding must carry prediction_scope",
7007                });
7008            };
7009            if prediction
7010                .facets()
7011                .iter()
7012                .filter(|facet| {
7013                    facet.scope() == scope
7014                        && facet.state() == EnginePredictionFacetStateV1::Available
7015                })
7016                .count()
7017                != 1
7018            {
7019                return Err(MeasurementFileError::InvalidPredictionLifecycle {
7020                    check_index,
7021                    reason: "finding prediction_scope must name one available facet",
7022                });
7023            }
7024        }
7025        Ok(())
7026    }
7027}
7028
7029/// Validate the frozen current-lint addressability inventory contract.  This
7030/// is deliberately output-facing: standalone V1 engine artifacts retain their
7031/// historic provenance and reason vocabulary.
7032fn validate_current_engine_addressability_prediction_v2(
7033    check_id: &str,
7034    prediction: &EnginePredictionV2,
7035    provenance: &PredictionProvenanceV2,
7036) -> Result<(), PredictionContractError> {
7037    if check_id != "engine-addressability" {
7038        return Ok(());
7039    }
7040    let raw_partial =
7041        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
7042    let settings_partial = matches!(
7043        provenance.settings().clip_coverage().state(),
7044        ResolvedEngineSettingsCoverageStateV2::Partial
7045    );
7046    let inventories = prediction
7047        .facets()
7048        .iter()
7049        .filter(|facet| {
7050            facet.scope().code.as_str() == "animation_asset_label_inventory"
7051                && facet.scope().subject.is_none()
7052        })
7053        .collect::<Vec<_>>();
7054    if !raw_partial && !settings_partial {
7055        if prediction.facets().iter().any(|facet| {
7056            facet
7057                .reasons()
7058                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
7059        }) {
7060            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7061        }
7062        let available = prediction
7063            .facets()
7064            .iter()
7065            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
7066            .collect::<Vec<_>>();
7067        let expected_rows = provenance.settings().clips().len();
7068        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
7069            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
7070        {
7071            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7072        }
7073        let mut seen = vec![false; available.len()];
7074        for facet in available {
7075            let Some(ordinal) = facet
7076                .scope()
7077                .subject
7078                .as_deref()
7079                .and_then(|subject| subject.strip_prefix("Animation"))
7080                .and_then(|ordinal| ordinal.parse::<usize>().ok())
7081            else {
7082                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7083            };
7084            if facet.scope().code.as_str() != "animation_asset_label"
7085                || ordinal >= seen.len()
7086                || std::mem::replace(&mut seen[ordinal], true)
7087                || !facet.basis().references().iter().any(|reference| {
7088                    matches!(
7089                        reference,
7090                        PredictionBasisReferenceV1::RawSource { reference }
7091                            if reference.domain() == RawSourceDomainV1::Clip
7092                                && matches!(
7093                                    reference.key(),
7094                                    RawSourceKeyV1::Clip { source_clip_index }
7095                                        if *source_clip_index == ordinal as u64
7096                                )
7097                                && reference.field().as_str() == "source_name.state"
7098                    )
7099                })
7100            {
7101                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7102            }
7103        }
7104        if seen.iter().any(|seen| !seen) {
7105            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7106        }
7107        return Ok(());
7108    }
7109
7110    let mut expected = Vec::new();
7111    if raw_partial {
7112        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7113    }
7114    if settings_partial {
7115        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7116    }
7117    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7118    {
7119        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7120    }
7121    if let Some(inventory) = inventories.first()
7122        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7123            || inventory.reasons() != expected)
7124    {
7125        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7126    }
7127    if inventories.is_empty() {
7128        // The allocator may replace this sole incomplete-inventory candidate
7129        // with its canonical budget summary.  The enclosing file validator
7130        // separately proves that the shared 4,096-slot budget was exhausted.
7131        return Ok(());
7132    }
7133    Ok(())
7134}
7135
7136/// Validate the frozen V14/V3 addressability inventory without retargeting
7137/// the immutable V13/V2 contract.
7138fn validate_current_engine_addressability_prediction_v3(
7139    check_id: &str,
7140    prediction: &EnginePredictionV3,
7141    provenance: &PredictionProvenanceV3,
7142) -> Result<(), PredictionContractError> {
7143    if check_id != "engine-addressability" {
7144        return Ok(());
7145    }
7146    let raw_partial =
7147        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
7148    let settings_partial = matches!(
7149        provenance.settings().clip_coverage().state(),
7150        ResolvedEngineSettingsCoverageStateV2::Partial
7151    );
7152    let inventories = prediction
7153        .facets()
7154        .iter()
7155        .filter(|facet| {
7156            facet.scope().code.as_str() == "animation_asset_label_inventory"
7157                && facet.scope().subject.is_none()
7158        })
7159        .collect::<Vec<_>>();
7160    if !raw_partial && !settings_partial {
7161        if prediction.facets().iter().any(|facet| {
7162            facet
7163                .reasons()
7164                .contains(&PredictionUnavailableReasonV2::ResolvedSettingsOverflow)
7165        }) {
7166            return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7167        }
7168        let available = prediction
7169            .facets()
7170            .iter()
7171            .filter(|facet| facet.state() == EnginePredictionFacetStateV1::Available)
7172            .collect::<Vec<_>>();
7173        let expected_rows = provenance.settings().clips().len();
7174        if (!prediction.has_facet_budget_summary() && available.len() != expected_rows)
7175            || (prediction.has_facet_budget_summary() && available.len() >= expected_rows)
7176        {
7177            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7178        }
7179        let mut seen = vec![false; available.len()];
7180        for facet in available {
7181            let Some(ordinal) = facet
7182                .scope()
7183                .subject
7184                .as_deref()
7185                .and_then(|subject| subject.strip_prefix("Animation"))
7186                .and_then(|ordinal| ordinal.parse::<usize>().ok())
7187            else {
7188                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7189            };
7190            if facet.scope().code.as_str() != "animation_asset_label"
7191                || ordinal >= seen.len()
7192                || std::mem::replace(&mut seen[ordinal], true)
7193                || !facet.basis().references().iter().any(|reference| {
7194                    matches!(
7195                        reference,
7196                        PredictionBasisReferenceV2::V1(PredictionBasisReferenceV1::RawSource { reference })
7197                            if reference.domain() == RawSourceDomainV1::Clip
7198                                && matches!(
7199                                    reference.key(),
7200                                    RawSourceKeyV1::Clip { source_clip_index }
7201                                        if *source_clip_index == ordinal as u64
7202                                )
7203                                && reference.field().as_str() == "source_name.state"
7204                    )
7205                })
7206            {
7207                return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7208            }
7209        }
7210        if seen.iter().any(|seen| !seen) {
7211            return Err(PredictionContractError::EngineAddressabilityFacetPrefixMismatch);
7212        }
7213        return Ok(());
7214    }
7215
7216    let mut expected = Vec::new();
7217    if raw_partial {
7218        expected.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7219    }
7220    if settings_partial {
7221        expected.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7222    }
7223    if inventories.len() != 1 && !(inventories.is_empty() && prediction.has_facet_budget_summary())
7224    {
7225        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7226    }
7227    if let Some(inventory) = inventories.first()
7228        && (inventory.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7229            || inventory.reasons() != expected)
7230    {
7231        return Err(PredictionContractError::EngineAddressabilityInventoryReasonsMismatch);
7232    }
7233    Ok(())
7234}
7235
7236const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
7237const ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE: &str = "engine-track-support:animation";
7238const ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
7239const ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE: &str = "engine-track-support:inventory";
7240const ENGINE_TRACK_SUPPORT_BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
7241
7242fn validate_current_engine_track_support_prediction_v5(
7243    check_id: &str,
7244    selection_state: SelectionState,
7245    configuration: ConfigurationState,
7246    applicability: Applicability,
7247    prediction: Option<&EnginePredictionV5>,
7248    provenance: Option<&PredictionProvenanceV5>,
7249    findings_empty: bool,
7250) -> Result<(), PredictionContractError> {
7251    if check_id != ENGINE_TRACK_SUPPORT_CHECK_ID {
7252        if prediction.is_some_and(|prediction| {
7253            prediction.facets().iter().any(|facet| {
7254                matches!(
7255                    facet.result(),
7256                    Some(EngineMachineResultV1::SourceImportDisposition(_))
7257                )
7258            })
7259        }) {
7260            return Err(PredictionContractError::InvalidMachineResult(
7261                "source-import disposition is confined to engine-track-support",
7262            ));
7263        }
7264        return Ok(());
7265    }
7266    let exact_profile = provenance.is_some_and(|provenance| {
7267        let base = provenance.base();
7268        let selected = base.profile().selection();
7269        selected.family() == "bevy"
7270            && selected.profile_revision() == 3
7271            && selected.engine_version() == "0.19.0"
7272            && selected.importer() == "gltf-asset-loader"
7273            && base.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
7274            && base.profile().facts_identity().sha256()
7275                == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
7276            && base.profile().facts_identity().bytes() == 4_849
7277            && matches!(
7278                base.source_format(),
7279                SourceFormatV1::GltfJson | SourceFormatV1::Glb
7280            )
7281            && !provenance.raw_animation_channels().is_complete_empty()
7282    });
7283    let expected_applicability = if exact_profile {
7284        Applicability::Applicable
7285    } else {
7286        Applicability::NotApplicable
7287    };
7288    if applicability != expected_applicability {
7289        return Err(PredictionContractError::InvalidMachineResult(
7290            "engine-track-support applicability mismatch",
7291        ));
7292    }
7293    if !exact_profile
7294        || selection_state != SelectionState::Selected
7295        || configuration != ConfigurationState::Enabled
7296    {
7297        return if prediction.is_none() {
7298            Ok(())
7299        } else {
7300            Err(PredictionContractError::InvalidMachineResult(
7301                "inactive engine-track-support carried prediction",
7302            ))
7303        };
7304    }
7305    if !findings_empty {
7306        return Err(PredictionContractError::InvalidMachineResult(
7307            "engine-track-support must not emit findings",
7308        ));
7309    }
7310    let provenance = provenance.ok_or(PredictionContractError::ProvenanceIdentityMismatch)?;
7311    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7312        "applicable engine-track-support check requires prediction",
7313    ))?;
7314    let inventory = provenance.raw_animation_channels();
7315    let mut expected = Vec::new();
7316    let has_summary = prediction.base_prediction().has_facet_budget_summary();
7317    let expected_demand = if !inventory.source_coverage_complete() {
7318        1
7319    } else {
7320        inventory.rows().len()
7321    };
7322    if inventory.source_coverage_complete() && inventory.candidate_overflow() && !has_summary {
7323        return Err(PredictionContractError::InvalidMachineResult(
7324            "saturated engine-track-support demand requires facet-budget summary",
7325        ));
7326    }
7327    let candidate_capacity = if has_summary {
7328        prediction.facets().len().saturating_sub(1)
7329    } else {
7330        expected_demand
7331    };
7332    if !inventory.is_complete_empty() && candidate_capacity != 0 {
7333        if !inventory.source_coverage_complete() {
7334            expected.push(track_unavailable(
7335                EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7336                    ENGINE_TRACK_SUPPORT_INVENTORY_SCOPE,
7337                )),
7338                track_inventory_basis(inventory),
7339                PredictionUnavailableReasonV2::RawSourceIncomplete,
7340            ));
7341        } else {
7342            for row in inventory.rows().iter().take(candidate_capacity) {
7343                let animation = row.source_animation_index();
7344                if let Some(channel) = row.source_channel_index() {
7345                    expected.push(track_subject_facet(
7346                        track_scope(
7347                            ENGINE_TRACK_SUPPORT_CHANNEL_SCOPE,
7348                            format!("source_animation:{animation}:source_channel:{channel}"),
7349                        ),
7350                        track_row_basis(inventory, animation, Some(channel)),
7351                        SourceImportSubjectKindV1::AnimationChannel,
7352                        track_gate(provenance),
7353                    ));
7354                } else {
7355                    expected.push(track_subject_facet(
7356                        track_scope(
7357                            ENGINE_TRACK_SUPPORT_ANIMATION_SCOPE,
7358                            format!("source_animation:{animation}"),
7359                        ),
7360                        track_row_basis(inventory, animation, None),
7361                        SourceImportSubjectKindV1::Animation,
7362                        track_gate(provenance),
7363                    ));
7364                }
7365            }
7366        }
7367    }
7368    if has_summary {
7369        expected.push(track_unavailable(
7370            EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(
7371                ENGINE_TRACK_SUPPORT_BUDGET_SCOPE,
7372            )),
7373            track_static_basis(),
7374            PredictionUnavailableReasonV2::FacetBudgetExceeded,
7375        ));
7376    }
7377    let expected = EnginePredictionV4::new(provenance.base().identity().clone(), expected)?
7378        .facets()
7379        .to_vec();
7380    if prediction.facets() != expected {
7381        return Err(PredictionContractError::InvalidMachineResult(
7382            "engine-track-support facets do not reconstruct from V5 provenance",
7383        ));
7384    }
7385    Ok(())
7386}
7387
7388/// Shared producer/readback hook for the immutable engine-root-motion rule.
7389///
7390/// The exact Unity revision-2 reconstruction is filled beside the production
7391/// rule once its final profile identity and facet grammar are frozen. Keeping
7392/// this hook at the common boundary prevents the writer and strict reader from
7393/// acquiring separate acceptance paths in the meantime.
7394#[allow(clippy::too_many_arguments)]
7395fn validate_current_engine_root_motion_prediction_v6<F: RootMotionFindingEvidence>(
7396    check_id: &str,
7397    selection: SelectionState,
7398    configuration: ConfigurationState,
7399    applicability: Applicability,
7400    prediction: Option<&EnginePredictionV6>,
7401    provenance: Option<&PredictionProvenanceV6>,
7402    findings: &[F],
7403    rig: &RigInfo,
7404    measurements: &MeasurementContract,
7405) -> Result<(), PredictionContractError> {
7406    const CHECK_ID: &str = "engine-root-motion";
7407    if check_id != CHECK_ID
7408        && prediction.is_some_and(|prediction| {
7409            prediction.facets().iter().any(|facet| {
7410                matches!(
7411                    facet.result(),
7412                    Some(EngineMachineResultV1::RootMotionRouting(_))
7413                )
7414            })
7415        })
7416    {
7417        return Err(PredictionContractError::InvalidMachineResult(
7418            "root-motion routing is confined to engine-root-motion",
7419        ));
7420    }
7421    if check_id != CHECK_ID {
7422        return Ok(());
7423    }
7424    let active =
7425        selection == SelectionState::Selected && configuration == ConfigurationState::Enabled;
7426    let exact = provenance.is_some_and(root_motion_is_exact_unity_v2);
7427    let has_work = provenance.is_some_and(root_motion_has_work);
7428    let expected_applicability = if exact && has_work {
7429        Applicability::Applicable
7430    } else {
7431        Applicability::NotApplicable
7432    };
7433    if applicability != expected_applicability {
7434        return Err(PredictionContractError::InvalidMachineResult(
7435            "engine-root-motion applicability does not reconstruct from V6 provenance",
7436        ));
7437    }
7438    if !active || expected_applicability == Applicability::NotApplicable {
7439        if prediction.is_some() || !findings.is_empty() {
7440            return Err(PredictionContractError::InvalidMachineResult(
7441                "inactive or inapplicable engine-root-motion must carry no prediction or findings",
7442            ));
7443        }
7444        return Ok(());
7445    }
7446    let provenance = provenance.ok_or(PredictionContractError::InvalidMachineResult(
7447        "applicable engine-root-motion has no V6 provenance",
7448    ))?;
7449    let prediction = prediction.ok_or(PredictionContractError::InvalidMachineResult(
7450        "applicable engine-root-motion has no V6 prediction",
7451    ))?;
7452    validate_root_motion_facets_v6(prediction, provenance, findings, rig, measurements)
7453}
7454
7455/// An exact profile remains applicable while declaration evidence needed to
7456/// prove complete-empty intent is incomplete. This keeps the strict reader
7457/// aligned with the producer: an unvisited ownerless tail is unavailable work,
7458/// not N/A.
7459fn root_motion_has_work(provenance: &PredictionProvenanceV6) -> bool {
7460    let intent = provenance.root_motion_project_intent();
7461    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete
7462        || provenance.base().base().settings().clip_coverage().state()
7463            != ResolvedEngineSettingsCoverageStateV2::Complete
7464    {
7465        return true;
7466    }
7467    match intent.declared_axis_candidates() {
7468        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => count != 0,
7469        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => true,
7470    }
7471}
7472
7473fn root_motion_is_exact_unity_v2(provenance: &PredictionProvenanceV6) -> bool {
7474    let profile = provenance.base().base().profile();
7475    let selection = profile.selection();
7476    selection.family() == "unity-generic"
7477        && selection.profile_revision() == 2
7478        && selection.engine_version() == "6000.3"
7479        && selection.importer() == "fbx-model-importer"
7480        && profile.fact_bundle_urn() == "urn:animsmith:engine-profile:unity-generic:2"
7481        && profile.facts_identity().sha256()
7482            == "740e1c324a7a5b13efa2d9980fe255a6245d858adec55fb3387614a3ff45274c"
7483        && profile.facts_identity().bytes() == 2_776
7484        && provenance.base().base().source_format() == SourceFormatV1::Fbx
7485        && profile.setting_descriptors().len() == 7
7486        && profile.primary_sources().len() == 3
7487        && [
7488            "unity-fbx-animation-clip-6000.3",
7489            "unity-fbx-model-importer-6000.3",
7490            "unity-fbx-motion-node-6000.3",
7491        ]
7492        .into_iter()
7493        .all(|id| profile.source(id).is_some())
7494}
7495
7496fn validate_root_motion_facets_v6<F: RootMotionFindingEvidence>(
7497    prediction: &EnginePredictionV6,
7498    provenance: &PredictionProvenanceV6,
7499    findings: &[F],
7500    rig: &RigInfo,
7501    measurements: &MeasurementContract,
7502) -> Result<(), PredictionContractError> {
7503    const INVENTORY_SCOPE: &str = "engine-root-motion:inventory";
7504    const AXIS_SCOPE: &str = "engine-root-motion:clip-axis";
7505    const BUDGET_SCOPE: &str = "engine-root-motion:facet-budget";
7506    let intent = provenance.root_motion_project_intent();
7507    let mut atomic = Vec::new();
7508    if provenance
7509        .base()
7510        .base()
7511        .raw_source()
7512        .clips_coverage()
7513        .state()
7514        != RawSourceSetCoverageStateV1::Complete
7515        || !matches!(
7516            provenance.raw_transform_paths().coverage(),
7517            crate::RawTransformPathCoverageV1::Complete
7518        )
7519    {
7520        atomic.push(PredictionUnavailableReasonV2::RawSourceIncomplete);
7521    }
7522    if intent.clip_coverage() != crate::EngineRootMotionProjectIntentCoverageV1::Complete {
7523        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7524    }
7525    if intent.declared_axis_candidates().overflowed()
7526        || intent.unmapped_declared_axis_candidates().overflowed()
7527    {
7528        atomic.push(PredictionUnavailableReasonV2::custom(
7529            "animsmith:root_motion_intent_work_budget_exceeded",
7530        )?);
7531    }
7532    if !matches!(
7533        intent.unmapped_declared_axis_candidates(),
7534        crate::EngineRootMotionProjectIntentCountV1::Exact { count: 0 }
7535    ) {
7536        atomic.push(PredictionUnavailableReasonV2::ProjectIntentUnavailable);
7537    }
7538    if provenance.base().base().settings().clip_coverage().state()
7539        != ResolvedEngineSettingsCoverageStateV2::Complete
7540    {
7541        atomic.push(PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
7542    }
7543    atomic.sort_by(|left, right| left.as_str().cmp(right.as_str()));
7544    atomic.dedup();
7545    if !atomic.is_empty() {
7546        let facets = prediction.facets();
7547        let valid_inventory = facets.len() == 1
7548            && facets[0].scope().code.as_str() == INVENTORY_SCOPE
7549            && facets[0].scope().subject.is_none()
7550            && facets[0].state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7551            && facets[0].reasons() == atomic
7552            && facets[0].basis() == &root_motion_inventory_basis(provenance)?;
7553        if !valid_inventory || !findings.is_empty() {
7554            return Err(PredictionContractError::InvalidMachineResult(
7555                "engine-root-motion atomic unavailable summary is not canonical",
7556            ));
7557        }
7558        return Ok(());
7559    }
7560
7561    let configured_path = provenance
7562        .base()
7563        .base()
7564        .settings()
7565        .document_setting(EngineSettingIdV2::RootMotionSource)
7566        .and_then(|row| match row.value() {
7567            EngineSettingValueV2::SourceTransformPath(path) => {
7568                crate::RawTransformPathV1::parse(path).ok()
7569            }
7570            _ => None,
7571        });
7572    let path_resolution = configured_path
7573        .as_ref()
7574        .map(|path| provenance.raw_transform_paths().resolve(path));
7575    let root_name = rig.resolved_roles.get("root").map(String::as_str);
7576    let mut name_counts = BTreeMap::new();
7577    for clip in intent.clips() {
7578        if let Some(name) = clip.normalized_clip_name() {
7579            *name_counts.entry(name).or_insert(0usize) += 1;
7580        }
7581    }
7582    let mut expected = Vec::new();
7583    for clip in intent.clips() {
7584        let Some(name) = clip.normalized_clip_name() else {
7585            continue;
7586        };
7587        for (axis, owner) in [
7588            (
7589                crate::RootMotionAxisV1::HorizontalXz,
7590                clip.movement_owner_xz(),
7591            ),
7592            (crate::RootMotionAxisV1::VerticalY, clip.movement_owner_y()),
7593            (crate::RootMotionAxisV1::Yaw, clip.movement_owner_yaw()),
7594        ]
7595        .into_iter()
7596        .filter_map(|(axis, owner)| owner.map(|owner| (axis, owner)))
7597        {
7598            let axis_name = match axis {
7599                crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7600                crate::RootMotionAxisV1::VerticalY => "vertical_y",
7601                crate::RootMotionAxisV1::Yaw => "yaw",
7602            };
7603            let scope = EvaluationScope::new(crate::EvaluationScopeCode::custom(AXIS_SCOPE))
7604                .subject(format!(
7605                    "source_clip:{:020}:axis:{axis_name}",
7606                    clip.source_clip_index()
7607                ));
7608            let setting_id = match axis {
7609                crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7610                crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7611                crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7612            };
7613            let setting = provenance
7614                .base()
7615                .base()
7616                .settings()
7617                .clip_row(
7618                    clip.normalized_clip_index().ok_or(
7619                        PredictionContractError::InvalidMachineResult(
7620                            "mapped root-motion intent is missing normalized clip index",
7621                        ),
7622                    )?,
7623                    name,
7624                )
7625                .and_then(|row| row.setting(setting_id));
7626            let measurement = measurements.clips().get(name);
7627            let reason = if name_counts.get(name).copied().unwrap_or(0) > 1 {
7628                Some(PredictionUnavailableReasonV2::MeasurementUnavailable)
7629            } else {
7630                root_motion_unavailable_reason(
7631                    path_resolution.as_ref(),
7632                    intent.resolved_root_bone_index(),
7633                    root_name,
7634                    measurement,
7635                    setting.map(|row| row.value()),
7636                    axis,
7637                )?
7638            };
7639            let basis = root_motion_candidate_basis(
7640                provenance,
7641                clip,
7642                name,
7643                name_counts.get(name).copied().unwrap_or(0),
7644                axis,
7645                owner,
7646                configured_path.as_ref(),
7647                path_resolution.as_ref(),
7648                setting.map(|row| row.value_origin()),
7649                measurement,
7650            )?;
7651            expected.push((
7652                scope.clone(),
7653                name.to_owned(),
7654                axis,
7655                owner,
7656                setting.map(|row| row.value()).cloned(),
7657                reason,
7658                basis,
7659            ));
7660        }
7661    }
7662    let facets = prediction.facets();
7663    let has_budget = facets
7664        .last()
7665        .is_some_and(|facet| facet.scope().code.as_str() == BUDGET_SCOPE);
7666    let candidates = if has_budget {
7667        &facets[..facets.len() - 1]
7668    } else {
7669        facets
7670    };
7671    if candidates.len() > expected.len() || (candidates.len() < expected.len() && !has_budget) {
7672        return Err(PredictionContractError::InvalidMachineResult(
7673            "engine-root-motion facet allocation is not a canonical prefix",
7674        ));
7675    }
7676    if has_budget {
7677        let facet = facets.last().unwrap();
7678        if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7679            || facet.scope().subject.is_some()
7680            || facet.reasons() != [PredictionUnavailableReasonV2::FacetBudgetExceeded]
7681            || facet.basis() != &root_motion_static_basis()?
7682        {
7683            return Err(PredictionContractError::InvalidMachineResult(
7684                "engine-root-motion facet-budget summary is invalid",
7685            ));
7686        }
7687    }
7688    let mut retained_conflicts = Vec::new();
7689    for (facet, (scope, clip_name, axis, owner, setting, reason, basis)) in
7690        candidates.iter().zip(expected.iter())
7691    {
7692        if facet.scope() != scope || facet.basis() != basis {
7693            return Err(PredictionContractError::InvalidMachineResult(
7694                "engine-root-motion scope or basis is not canonical",
7695            ));
7696        }
7697        match reason {
7698            Some(reason)
7699                if facet.state() == EnginePredictionFacetStateV1::RequiredPredictionUnavailable
7700                    && facet.reasons() == std::slice::from_ref(reason) => {}
7701            None => {
7702                let disposition = root_motion_disposition(setting.as_ref().unwrap()).unwrap();
7703                let compatible = matches!(
7704                    (owner, disposition),
7705                    (
7706                        crate::RootMotionProjectOwnerV1::Gameplay,
7707                        crate::RootMotionImporterDispositionV1::BakedIntoPose
7708                    ) | (
7709                        crate::RootMotionProjectOwnerV1::Animation,
7710                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion
7711                    )
7712                );
7713                let expected_result = crate::RootMotionRoutingResultV1 {
7714                    axis: *axis,
7715                    project_owner: *owner,
7716                    importer_disposition: disposition,
7717                    compatibility: if compatible {
7718                        crate::RootMotionCompatibilityV1::Compatible
7719                    } else {
7720                        crate::RootMotionCompatibilityV1::Conflict
7721                    },
7722                };
7723                if !matches!(facet.result(), Some(EngineMachineResultV1::RootMotionRouting(result)) if result == &expected_result)
7724                {
7725                    return Err(PredictionContractError::InvalidMachineResult(
7726                        "engine-root-motion result is not canonical",
7727                    ));
7728                }
7729                if !compatible {
7730                    let axis_label = match axis {
7731                        crate::RootMotionAxisV1::HorizontalXz => "horizontal XZ",
7732                        crate::RootMotionAxisV1::VerticalY => "vertical Y",
7733                        crate::RootMotionAxisV1::Yaw => "yaw",
7734                    };
7735                    let owner_label = match owner {
7736                        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7737                        crate::RootMotionProjectOwnerV1::Animation => "animation",
7738                    };
7739                    let disposition_label = match disposition {
7740                        crate::RootMotionImporterDispositionV1::BakedIntoPose => {
7741                            "baked into the pose"
7742                        }
7743                        crate::RootMotionImporterDispositionV1::StoredAsRootMotion => {
7744                            "stored as root motion"
7745                        }
7746                    };
7747                    retained_conflicts.push(serde_json::json!({
7748                        "check_id": "engine-root-motion",
7749                        "severity": "error",
7750                        "clip": clip_name,
7751                        "prediction_scope": scope,
7752                        "message": format!("clip {:?} assigns {} movement to {}, but Unity imports that axis as {}", clip_name, axis_label, owner_label, disposition_label),
7753                    }));
7754                }
7755            }
7756            _ => {
7757                return Err(PredictionContractError::InvalidMachineResult(
7758                    "engine-root-motion unavailable facet is not canonical",
7759                ));
7760            }
7761        }
7762    }
7763    let actual_findings = findings
7764        .iter()
7765        .map(RootMotionFindingEvidence::root_motion_wire_value)
7766        .collect::<Vec<_>>();
7767    if actual_findings != retained_conflicts {
7768        return Err(PredictionContractError::InvalidMachineResult(
7769            "engine-root-motion conflict findings are not canonical",
7770        ));
7771    }
7772    Ok(())
7773}
7774
7775fn root_motion_lift(reference: PredictionBasisReferenceV1) -> PredictionBasisReferenceV4 {
7776    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference))
7777}
7778
7779fn root_motion_static_basis() -> Result<EnginePredictionBasisV4, PredictionContractError> {
7780    let mut references = vec![root_motion_lift(PredictionBasisReferenceV1::profile_fact(
7781        "root_motion_addressability",
7782    )?)];
7783    for source in [
7784        "unity-fbx-model-importer-6000.3",
7785        "unity-fbx-animation-clip-6000.3",
7786        "unity-fbx-motion-node-6000.3",
7787    ] {
7788        references.push(root_motion_lift(
7789            PredictionBasisReferenceV1::primary_source(source)?,
7790        ));
7791    }
7792    for setting in [
7793        EngineSettingIdV2::AnimationType,
7794        EngineSettingIdV2::AvatarSetup,
7795        EngineSettingIdV2::ImportAnimation,
7796        EngineSettingIdV2::RootMotionSource,
7797    ] {
7798        references.push(root_motion_lift(
7799            PredictionBasisReferenceV1::resolved_setting(
7800                ResolvedSettingLocationV1::Document,
7801                setting.as_str(),
7802            )?,
7803        ));
7804    }
7805    EnginePredictionBasisV4::new(references)
7806}
7807
7808fn root_motion_inventory_basis(
7809    provenance: &PredictionProvenanceV6,
7810) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7811    let mut references = root_motion_static_basis()?.references().to_vec();
7812    let path_coverage = match provenance.raw_transform_paths().coverage() {
7813        crate::RawTransformPathCoverageV1::Complete => "complete",
7814        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
7815        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
7816    };
7817    let clip_coverage = match provenance.root_motion_project_intent().clip_coverage() {
7818        crate::EngineRootMotionProjectIntentCoverageV1::Complete => "complete",
7819        crate::EngineRootMotionProjectIntentCoverageV1::PartialProjectionBudgetExceeded => {
7820            "partial_projection_budget_exceeded"
7821        }
7822    };
7823    let count = match provenance
7824        .root_motion_project_intent()
7825        .declared_axis_candidates()
7826    {
7827        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7828            PredictionScalarV1::UnsignedInteger { value: count }
7829        }
7830        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7831            PredictionScalarV1::token("n_plus_one")?
7832        }
7833    };
7834    let unmapped_count = match provenance
7835        .root_motion_project_intent()
7836        .unmapped_declared_axis_candidates()
7837    {
7838        crate::EngineRootMotionProjectIntentCountV1::Exact { count } => {
7839            PredictionScalarV1::UnsignedInteger { value: count }
7840        }
7841        crate::EngineRootMotionProjectIntentCountV1::NPlusOne => {
7842            PredictionScalarV1::token("n_plus_one")?
7843        }
7844    };
7845    let raw_clip_coverage = match provenance
7846        .base()
7847        .base()
7848        .raw_source()
7849        .clips_coverage()
7850        .state()
7851    {
7852        RawSourceSetCoverageStateV1::Complete => "complete",
7853        RawSourceSetCoverageStateV1::Partial => "partial",
7854        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
7855    };
7856    let settings_coverage = match provenance.base().base().settings().clip_coverage().state() {
7857        ResolvedEngineSettingsCoverageStateV2::Complete => "complete",
7858        ResolvedEngineSettingsCoverageStateV2::Partial => "partial",
7859    };
7860    for (field, value) in [
7861        (
7862            "raw_source.clips.coverage",
7863            PredictionScalarV1::token(raw_clip_coverage)?,
7864        ),
7865        (
7866            "raw_transform_path_inventory.coverage",
7867            PredictionScalarV1::token(path_coverage)?,
7868        ),
7869        (
7870            "root_motion_project_intent.clip_coverage",
7871            PredictionScalarV1::token(clip_coverage)?,
7872        ),
7873        ("root_motion_project_intent.declared_axis_candidates", count),
7874        (
7875            "root_motion_project_intent.unmapped_declared_axis_candidates",
7876            unmapped_count,
7877        ),
7878        (
7879            "resolved_settings.clips.coverage",
7880            PredictionScalarV1::token(settings_coverage)?,
7881        ),
7882    ] {
7883        references.push(root_motion_lift(PredictionBasisReferenceV1::project_field(
7884            field, value,
7885        )?));
7886    }
7887    EnginePredictionBasisV4::new(references)
7888}
7889
7890fn root_motion_project_reference(
7891    field: &'static str,
7892    value: PredictionScalarV1,
7893) -> Result<PredictionBasisReferenceV4, PredictionContractError> {
7894    Ok(root_motion_lift(PredictionBasisReferenceV1::project_field(
7895        field, value,
7896    )?))
7897}
7898
7899fn root_motion_token(value: &'static str) -> Result<PredictionScalarV1, PredictionContractError> {
7900    PredictionScalarV1::token(value)
7901}
7902
7903#[allow(clippy::too_many_arguments)]
7904fn root_motion_candidate_basis(
7905    provenance: &PredictionProvenanceV6,
7906    clip: &crate::EngineRootMotionClipIntentV1,
7907    name: &str,
7908    duplicate_count: usize,
7909    axis: crate::RootMotionAxisV1,
7910    owner: crate::RootMotionProjectOwnerV1,
7911    configured_path: Option<&crate::RawTransformPathV1>,
7912    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
7913    setting_origin: Option<crate::EngineSettingValueOriginV3>,
7914    measurement: Option<&ClipMeasurements>,
7915) -> Result<EnginePredictionBasisV4, PredictionContractError> {
7916    let mut refs = root_motion_static_basis()?.references().to_vec();
7917    let settings = provenance.base().base().settings();
7918    for (id, field) in [
7919        (
7920            EngineSettingIdV2::AnimationType,
7921            "resolved_setting.document.animation_type.value_origin",
7922        ),
7923        (
7924            EngineSettingIdV2::AvatarSetup,
7925            "resolved_setting.document.avatar_setup.value_origin",
7926        ),
7927        (
7928            EngineSettingIdV2::ImportAnimation,
7929            "resolved_setting.document.import_animation.value_origin",
7930        ),
7931        (
7932            EngineSettingIdV2::RootMotionSource,
7933            "resolved_setting.document.root_motion_source.value_origin",
7934        ),
7935    ] {
7936        let value = settings
7937            .document_setting(id)
7938            .map_or(PredictionScalarV1::Null, |row| {
7939                root_motion_token(match row.value_origin() {
7940                    crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
7941                    crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
7942                })
7943                .expect("static origin token")
7944            });
7945        refs.push(root_motion_project_reference(field, value)?);
7946    }
7947    let setting_id = match axis {
7948        crate::RootMotionAxisV1::HorizontalXz => EngineSettingIdV2::RootPositionXz,
7949        crate::RootMotionAxisV1::VerticalY => EngineSettingIdV2::RootPositionY,
7950        crate::RootMotionAxisV1::Yaw => EngineSettingIdV2::RootRotation,
7951    };
7952    if let Some(index) = clip.normalized_clip_index() {
7953        refs.push(root_motion_lift(
7954            PredictionBasisReferenceV1::resolved_setting(
7955                ResolvedSettingLocationV1::Clip {
7956                    clip_ordinal: index,
7957                    clip_name: name.to_owned(),
7958                },
7959                setting_id.as_str(),
7960            )?,
7961        ));
7962    }
7963    refs.push(root_motion_project_reference(
7964        "root_motion_project_intent.source_clip_index",
7965        PredictionScalarV1::UnsignedInteger {
7966            value: clip.source_clip_index(),
7967        },
7968    )?);
7969    let mapping_state = match clip.normalized_clip_mapping_state() {
7970        crate::EngineRootMotionClipMappingStateV1::Observed => "observed",
7971        crate::EngineRootMotionClipMappingStateV1::ProvenAbsent => "proven_absent",
7972        crate::EngineRootMotionClipMappingStateV1::Unavailable => "unavailable",
7973    };
7974    for field in ["normalized_clip_index.state", "normalized_clip_index.value"] {
7975        if field.ends_with(".value") && clip.normalized_clip_index().is_none() {
7976            continue;
7977        }
7978        let value = if field.ends_with(".state") {
7979            root_motion_token(mapping_state)?
7980        } else {
7981            PredictionScalarV1::UnsignedInteger {
7982                value: clip.normalized_clip_index().unwrap(),
7983            }
7984        };
7985        let raw: RawSourceBasisReferenceV1 = serde_json::from_value(serde_json::json!({
7986            "domain": "clip", "key": {"kind": "clip", "source_clip_index": clip.source_clip_index()}, "field": field, "value": value
7987        })).map_err(|_| PredictionContractError::InvalidMachineResult("failed to reconstruct root-motion raw clip reference"))?;
7988        refs.push(root_motion_lift(PredictionBasisReferenceV1::raw_source(
7989            raw,
7990        )));
7991    }
7992    let axis_name = match axis {
7993        crate::RootMotionAxisV1::HorizontalXz => "horizontal_xz",
7994        crate::RootMotionAxisV1::VerticalY => "vertical_y",
7995        crate::RootMotionAxisV1::Yaw => "yaw",
7996    };
7997    let owner_name = match owner {
7998        crate::RootMotionProjectOwnerV1::Gameplay => "gameplay",
7999        crate::RootMotionProjectOwnerV1::Animation => "animation",
8000    };
8001    let origin = setting_origin.map_or(PredictionScalarV1::Null, |origin| {
8002        root_motion_token(match origin {
8003            crate::EngineSettingValueOriginV3::ExplicitConfig => "explicit_config",
8004            crate::EngineSettingValueOriginV3::ProfileDefault => "profile_default",
8005        })
8006        .expect("origin token")
8007    });
8008    let path_coverage = match provenance.raw_transform_paths().coverage() {
8009        crate::RawTransformPathCoverageV1::Complete => "complete",
8010        crate::RawTransformPathCoverageV1::Partial(_) => "partial",
8011        crate::RawTransformPathCoverageV1::Unavailable(_) => "unavailable",
8012    };
8013    let raw_coverage = match provenance
8014        .base()
8015        .base()
8016        .raw_source()
8017        .clips_coverage()
8018        .state()
8019    {
8020        RawSourceSetCoverageStateV1::Complete => "complete",
8021        RawSourceSetCoverageStateV1::Partial => "partial",
8022        RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8023    };
8024    let resolution = match path_resolution {
8025        Some(crate::RawTransformPathResolutionV1::Exact(_)) => "exact",
8026        Some(crate::RawTransformPathResolutionV1::NoMatch) => "no_match",
8027        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => "ambiguous",
8028        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8029            "coverage_incomplete"
8030        }
8031        None => "invalid_configured_path",
8032    };
8033    for (field, value) in [
8034        (
8035            "root_motion_project_intent.clip_mapping_state",
8036            root_motion_token(mapping_state)?,
8037        ),
8038        (
8039            "root_motion_project_intent.normalized_clip_index",
8040            clip.normalized_clip_index()
8041                .map_or(PredictionScalarV1::Null, |value| {
8042                    PredictionScalarV1::UnsignedInteger { value }
8043                }),
8044        ),
8045        (
8046            "root_motion_project_intent.normalized_clip_name",
8047            PredictionScalarV1::text(name)?,
8048        ),
8049        (
8050            "measurement.clip_name_match_count",
8051            PredictionScalarV1::UnsignedInteger {
8052                value: duplicate_count as u64,
8053            },
8054        ),
8055        (
8056            "measurement.clip_identity_state",
8057            root_motion_token(if duplicate_count > 1 {
8058                "duplicate"
8059            } else {
8060                "unique"
8061            })?,
8062        ),
8063        (
8064            "root_motion_project_intent.axis",
8065            root_motion_token(axis_name)?,
8066        ),
8067        (
8068            "root_motion_project_intent.owner",
8069            root_motion_token(owner_name)?,
8070        ),
8071        ("resolved_setting.clip.value_origin", origin),
8072        (
8073            "raw_source.clips.coverage",
8074            root_motion_token(raw_coverage)?,
8075        ),
8076        (
8077            "raw_transform_path_inventory.coverage",
8078            root_motion_token(path_coverage)?,
8079        ),
8080        (
8081            "resolved_role.root.bone_index",
8082            provenance
8083                .root_motion_project_intent()
8084                .resolved_root_bone_index()
8085                .map_or(PredictionScalarV1::Null, |value| {
8086                    PredictionScalarV1::UnsignedInteger { value }
8087                }),
8088        ),
8089        (
8090            "root_motion_source.configured_path",
8091            configured_path.map_or(PredictionScalarV1::Null, |path| {
8092                PredictionScalarV1::text(path.as_str()).expect("path text")
8093            }),
8094        ),
8095        (
8096            "root_motion_source.resolution_state",
8097            root_motion_token(resolution)?,
8098        ),
8099    ] {
8100        refs.push(root_motion_project_reference(field, value)?);
8101    }
8102    if let Some(crate::RawTransformPathResolutionV1::Ambiguous { matches }) = path_resolution {
8103        refs.push(root_motion_project_reference(
8104            "root_motion_source.match_count",
8105            PredictionScalarV1::UnsignedInteger {
8106                value: matches.len() as u64,
8107            },
8108        )?);
8109    }
8110    if let Some(crate::RawTransformPathResolutionV1::Exact(path_match)) = path_resolution {
8111        let digest = crate::sha256_hex(
8112            &serde_json::to_vec(path_match.parent_chain()).expect("parent chain serializes"),
8113        );
8114        for (field, value) in [
8115            (
8116                "root_motion_source.source_node_index",
8117                PredictionScalarV1::UnsignedInteger {
8118                    value: path_match.source_node_index(),
8119                },
8120            ),
8121            (
8122                "root_motion_source.projected_bone_index",
8123                path_match
8124                    .projected_bone_index()
8125                    .map_or(PredictionScalarV1::Null, |value| {
8126                        PredictionScalarV1::UnsignedInteger { value }
8127                    }),
8128            ),
8129            (
8130                "root_motion_source.path",
8131                PredictionScalarV1::text(path_match.path().as_str())?,
8132            ),
8133            ("root_motion_source.node_kind", root_motion_token("source")?),
8134            (
8135                "root_motion_source.parent_chain_count",
8136                PredictionScalarV1::UnsignedInteger {
8137                    value: path_match.parent_chain().len() as u64,
8138                },
8139            ),
8140            (
8141                "root_motion_source.parent_chain_sha256",
8142                PredictionScalarV1::text(digest)?,
8143            ),
8144        ] {
8145            refs.push(root_motion_project_reference(field, value)?);
8146        }
8147    }
8148    if duplicate_count == 1
8149        && let Some(measurement) = measurement
8150    {
8151        let escaped = name.replace('~', "~0").replace('/', "~1");
8152        let prefix = format!("/measurements/clips/{escaped}/root_trajectory");
8153        let availability = |value| {
8154            root_motion_token(match value {
8155                MeasurementAvailability::Measured => "measured",
8156                MeasurementAvailability::NotApplicable => "not_applicable",
8157                MeasurementAvailability::Unavailable => "unavailable",
8158            })
8159        };
8160        let mut add_measurement = |pointer: String, value| -> Result<(), PredictionContractError> {
8161            refs.push(root_motion_lift(
8162                PredictionBasisReferenceV1::measurement_v16(
8163                    crate::MeasurementPointerV1::new(pointer)?,
8164                    value,
8165                ),
8166            ));
8167            Ok(())
8168        };
8169        add_measurement(
8170            format!("{prefix}_availability"),
8171            availability(measurement.root_trajectory_availability)?,
8172        )?;
8173        if let Some(trajectory) = measurement.root_trajectory.as_ref() {
8174            add_measurement(
8175                format!("{prefix}/bone_index"),
8176                PredictionScalarV1::UnsignedInteger {
8177                    value: u64::from(trajectory.bone_index),
8178                },
8179            )?;
8180            add_measurement(
8181                format!("{prefix}/source_role"),
8182                root_motion_token(trajectory.source_role.as_str())?,
8183            )?;
8184            let (suffix, present_field, availability_value, present) = match axis {
8185                crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => (
8186                    "translation_availability",
8187                    "measurement.root_translation_present",
8188                    trajectory.translation_availability,
8189                    trajectory.translation.is_some(),
8190                ),
8191                crate::RootMotionAxisV1::Yaw => (
8192                    "yaw_availability",
8193                    "measurement.root_yaw_present",
8194                    trajectory.yaw_availability,
8195                    trajectory.yaw.is_some(),
8196                ),
8197            };
8198            add_measurement(
8199                format!("{prefix}/{suffix}"),
8200                availability(availability_value)?,
8201            )?;
8202            refs.push(root_motion_project_reference(
8203                present_field,
8204                PredictionScalarV1::Boolean { value: present },
8205            )?);
8206        }
8207    }
8208    EnginePredictionBasisV4::new(refs)
8209}
8210
8211trait RootMotionFindingEvidence {
8212    fn root_motion_wire_value(&self) -> serde_json::Value;
8213}
8214
8215impl RootMotionFindingEvidence for crate::Finding {
8216    fn root_motion_wire_value(&self) -> serde_json::Value {
8217        serde_json::to_value(self).expect("Finding serializes")
8218    }
8219}
8220
8221impl RootMotionFindingEvidence for PredictionFindingInput {
8222    fn root_motion_wire_value(&self) -> serde_json::Value {
8223        serde_json::to_value(self).expect("finding input serializes")
8224    }
8225}
8226
8227fn root_motion_disposition(
8228    setting: &EngineSettingValueV2,
8229) -> Option<crate::RootMotionImporterDispositionV1> {
8230    match setting {
8231        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Bake) => {
8232            Some(crate::RootMotionImporterDispositionV1::BakedIntoPose)
8233        }
8234        EngineSettingValueV2::BakeOrExtract(crate::EngineBakeOrExtractV1::Extract) => {
8235            Some(crate::RootMotionImporterDispositionV1::StoredAsRootMotion)
8236        }
8237        _ => None,
8238    }
8239}
8240
8241fn root_motion_unavailable_reason(
8242    path_resolution: Option<&crate::RawTransformPathResolutionV1>,
8243    resolved_root_bone_index: Option<u64>,
8244    root_name: Option<&str>,
8245    measurement: Option<&ClipMeasurements>,
8246    setting: Option<&EngineSettingValueV2>,
8247    axis: crate::RootMotionAxisV1,
8248) -> Result<Option<PredictionUnavailableReasonV2>, PredictionContractError> {
8249    let path_match = match path_resolution {
8250        Some(crate::RawTransformPathResolutionV1::Exact(path_match)) => path_match,
8251        Some(crate::RawTransformPathResolutionV1::NoMatch) | None => {
8252            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch));
8253        }
8254        Some(crate::RawTransformPathResolutionV1::Ambiguous { .. }) => {
8255            return Ok(Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous));
8256        }
8257        Some(crate::RawTransformPathResolutionV1::CoverageIncomplete { .. }) => {
8258            return Ok(Some(PredictionUnavailableReasonV2::RawSourceIncomplete));
8259        }
8260    };
8261    if resolved_root_bone_index.is_none()
8262        || path_match.projected_bone_index() != resolved_root_bone_index
8263    {
8264        return Ok(Some(PredictionUnavailableReasonV2::custom(
8265            "animsmith:root_motion_source_not_explicit_root",
8266        )?));
8267    }
8268    let Some(trajectory) = measurement
8269        .filter(|measurement| {
8270            measurement.root_trajectory_availability == MeasurementAvailability::Measured
8271        })
8272        .and_then(|measurement| measurement.root_trajectory.as_ref())
8273    else {
8274        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8275    };
8276    if trajectory.source_role != crate::measure::RootTrajectorySourceRole::Root
8277        || root_name != Some(trajectory.bone_name.as_str())
8278        || resolved_root_bone_index != Some(u64::from(trajectory.bone_index))
8279    {
8280        return Ok(Some(PredictionUnavailableReasonV2::custom(
8281            "animsmith:root_motion_source_not_explicit_root",
8282        )?));
8283    }
8284    let measured = match axis {
8285        crate::RootMotionAxisV1::HorizontalXz | crate::RootMotionAxisV1::VerticalY => {
8286            trajectory.translation_availability == MeasurementAvailability::Measured
8287                && trajectory.translation.is_some()
8288        }
8289        crate::RootMotionAxisV1::Yaw => {
8290            trajectory.yaw_availability == MeasurementAvailability::Measured
8291                && trajectory.yaw.is_some()
8292        }
8293    };
8294    if !measured {
8295        return Ok(Some(PredictionUnavailableReasonV2::MeasurementUnavailable));
8296    }
8297    if setting.and_then(root_motion_disposition).is_none() {
8298        return Ok(Some(
8299            PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
8300        ));
8301    }
8302    Ok(None)
8303}
8304
8305fn track_scope(code: &'static str, subject: String) -> EvaluationScope {
8306    EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code)).subject(subject)
8307}
8308
8309fn track_subject_facet(
8310    scope: EvaluationScope,
8311    basis: EnginePredictionBasisV4,
8312    subject_kind: SourceImportSubjectKindV1,
8313    gate: Option<EngineSettingIdV2>,
8314) -> crate::EnginePredictionFacetV4 {
8315    match gate {
8316        None => track_unavailable(
8317            scope,
8318            basis,
8319            PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
8320        ),
8321        Some(controlling_gate) => crate::EnginePredictionFacetV4::available(
8322            scope,
8323            basis,
8324            EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
8325                subject_kind,
8326                disposition: SourceImportDispositionV1::Dropped,
8327                controlling_gate: Some(controlling_gate),
8328            }),
8329        )
8330        .expect("reconstructed track result is valid"),
8331    }
8332}
8333
8334fn track_unavailable(
8335    scope: EvaluationScope,
8336    basis: EnginePredictionBasisV4,
8337    reason: PredictionUnavailableReasonV2,
8338) -> crate::EnginePredictionFacetV4 {
8339    crate::EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
8340        .expect("reconstructed track unavailable facet is valid")
8341}
8342
8343fn track_gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
8344    let settings = provenance.base().settings();
8345    if matches!(
8346        settings
8347            .document_setting(EngineSettingIdV2::BevyAnimationFeature)
8348            .map(|row| row.value()),
8349        Some(EngineSettingValueV2::Boolean(false))
8350    ) {
8351        Some(EngineSettingIdV2::BevyAnimationFeature)
8352    } else if matches!(
8353        settings
8354            .document_setting(EngineSettingIdV2::LoadAnimations)
8355            .map(|row| row.value()),
8356        Some(EngineSettingValueV2::Boolean(false))
8357    ) {
8358        Some(EngineSettingIdV2::LoadAnimations)
8359    } else {
8360        None
8361    }
8362}
8363
8364fn track_static_basis() -> EnginePredictionBasisV4 {
8365    let v1 = |reference| PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(reference));
8366    EnginePredictionBasisV4::new(vec![
8367        v1(PredictionBasisReferenceV1::profile_fact("source_import_disposition").unwrap()),
8368        v1(PredictionBasisReferenceV1::primary_source("bevy-gltf-loader-0.19.0-c6f634ca").unwrap()),
8369        v1(
8370            PredictionBasisReferenceV1::primary_source("bevy-feature-manifest-0.19.0-c6f634ca")
8371                .unwrap(),
8372        ),
8373        v1(PredictionBasisReferenceV1::resolved_setting(
8374            ResolvedSettingLocationV1::Document,
8375            EngineSettingIdV2::BevyAnimationFeature.as_str(),
8376        )
8377        .unwrap()),
8378        v1(PredictionBasisReferenceV1::resolved_setting(
8379            ResolvedSettingLocationV1::Document,
8380            EngineSettingIdV2::LoadAnimations.as_str(),
8381        )
8382        .unwrap()),
8383    ])
8384    .unwrap()
8385}
8386
8387fn track_inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
8388    let mut references = track_static_basis().references().to_vec();
8389    references.push(PredictionBasisReferenceV4::v2(
8390        PredictionBasisReferenceV2::v1(
8391            PredictionBasisReferenceV1::project_field(
8392                "raw_animation_channel_inventory.animation_coverage",
8393                PredictionScalarV1::text(match inventory.animation_coverage().state() {
8394                    RawSourceSetCoverageStateV1::Complete => "complete",
8395                    RawSourceSetCoverageStateV1::Partial => "partial",
8396                    RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8397                })
8398                .unwrap(),
8399            )
8400            .unwrap(),
8401        ),
8402    ));
8403    references.push(PredictionBasisReferenceV4::v2(
8404        PredictionBasisReferenceV2::v1(
8405            PredictionBasisReferenceV1::project_field(
8406                "raw_animation_channel_inventory.source_coverage_complete",
8407                PredictionScalarV1::Boolean {
8408                    value: inventory.source_coverage_complete(),
8409                },
8410            )
8411            .unwrap(),
8412        ),
8413    ));
8414    if let Some(row) = inventory.rows().iter().find(|row| {
8415        row.channel_coverage()
8416            .is_some_and(|coverage| coverage.state() != RawSourceSetCoverageStateV1::Complete)
8417    }) {
8418        let coverage = row.channel_coverage().unwrap();
8419        let state = match coverage.state() {
8420            RawSourceSetCoverageStateV1::Complete => "complete",
8421            RawSourceSetCoverageStateV1::Partial => "partial",
8422            RawSourceSetCoverageStateV1::Unavailable => "unavailable",
8423        };
8424        references.push(PredictionBasisReferenceV4::v2(
8425            PredictionBasisReferenceV2::v1(
8426                PredictionBasisReferenceV1::project_field(
8427                    "raw_animation_channel_inventory.incomplete_channel_animation_row",
8428                    PredictionScalarV1::UnsignedInteger {
8429                        value: row.source_animation_index(),
8430                    },
8431                )
8432                .unwrap(),
8433            ),
8434        ));
8435        references.push(PredictionBasisReferenceV4::v2(
8436            PredictionBasisReferenceV2::v1(
8437                PredictionBasisReferenceV1::project_field(
8438                    "raw_animation_channel_inventory.incomplete_channel_coverage",
8439                    PredictionScalarV1::text(state).unwrap(),
8440                )
8441                .unwrap(),
8442            ),
8443        ));
8444        if let Some(reason) = coverage.reason() {
8445            let reason = serde_json::to_value(reason).unwrap();
8446            references.push(PredictionBasisReferenceV4::v2(
8447                PredictionBasisReferenceV2::v1(
8448                    PredictionBasisReferenceV1::project_field(
8449                        "raw_animation_channel_inventory.incomplete_channel_reason",
8450                        PredictionScalarV1::text(reason.as_str().unwrap()).unwrap(),
8451                    )
8452                    .unwrap(),
8453                ),
8454            ));
8455        }
8456    }
8457    EnginePredictionBasisV4::new(references).unwrap()
8458}
8459
8460fn track_row_basis(
8461    inventory: &RawAnimationChannelInventoryV1,
8462    animation: u64,
8463    channel: Option<u64>,
8464) -> EnginePredictionBasisV4 {
8465    let mut references = track_inventory_basis(inventory).references().to_vec();
8466    references.push(PredictionBasisReferenceV4::v2(
8467        PredictionBasisReferenceV2::v1(
8468            PredictionBasisReferenceV1::project_field(
8469                "raw_animation_channel_inventory.animation_row",
8470                PredictionScalarV1::UnsignedInteger { value: animation },
8471            )
8472            .unwrap(),
8473        ),
8474    ));
8475    if let Some(channel) = channel {
8476        references.push(PredictionBasisReferenceV4::v2(
8477            PredictionBasisReferenceV2::v1(
8478                PredictionBasisReferenceV1::project_field(
8479                    "raw_animation_channel_inventory.channel_row",
8480                    PredictionScalarV1::UnsignedInteger { value: channel },
8481                )
8482                .unwrap(),
8483            ),
8484        ));
8485    }
8486    EnginePredictionBasisV4::new(references).unwrap()
8487}
8488
8489const ENGINE_UNIT_SCALE_CHECK_ID: &str = "engine-unit-scale";
8490const ENGINE_UNIT_SCALE_FILE_SCOPE: &str = "engine-unit-scale:file-unit";
8491const ENGINE_UNIT_SCALE_SCENE_SCOPE: &str = "engine-unit-scale:loader-scene-root";
8492const ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE: &str = "engine-unit-scale:scene-inventory";
8493const ENGINE_UNIT_SCALE_MESH_SCOPE: &str = "engine-unit-scale:loader-mesh-primitive";
8494const ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE: &str = "engine-unit-scale:mesh-inventory";
8495const ENGINE_UNIT_SCALE_SELECTED_SCOPE: &str = "engine-unit-scale:selected-source-node";
8496const ENGINE_UNIT_SCALE_BUDGET_SCOPE: &str = "engine-unit-scale:facet-budget";
8497const ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON: &str =
8498    "animsmith:selected_node_scene_reachability_unavailable";
8499const ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES: usize = 128;
8500
8501#[derive(Clone, PartialEq, Eq)]
8502struct ExpectedUnitScaleFacet {
8503    scope: EvaluationScope,
8504    result: Option<EngineMachineResultV1>,
8505    reasons: Vec<PredictionUnavailableReasonV2>,
8506}
8507
8508fn unit_scale_scope(code: &'static str, subject: Option<String>) -> EvaluationScope {
8509    let scope = EvaluationScope::new(crate::evaluation::EvaluationScopeCode::custom(code));
8510    subject.map_or(scope.clone(), |subject| scope.subject(subject))
8511}
8512
8513fn unit_scale_unavailable_reasons(
8514    reason: PredictionUnavailableReasonV2,
8515    dependency_complete: bool,
8516) -> Vec<PredictionUnavailableReasonV2> {
8517    let mut reasons = vec![reason];
8518    if !dependency_complete {
8519        reasons.push(PredictionUnavailableReasonV2::DependencyClosureIncomplete);
8520    }
8521    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
8522    reasons
8523}
8524
8525#[derive(Clone, Copy, PartialEq, Eq)]
8526enum UnitScaleSelectedReachability {
8527    Reachable(u64, u64),
8528    Unreachable,
8529    Unavailable,
8530    WorkBudgetExceeded,
8531}
8532
8533enum UnitScaleSelectedReachabilityPlan {
8534    Complete {
8535        node_index: u64,
8536        scene_witnesses: BTreeMap<u64, (u64, u64)>,
8537    },
8538    Refused {
8539        node_index: u64,
8540    },
8541}
8542
8543enum UnitScaleSelectedReachabilityPlans {
8544    Complete {
8545        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8546        selected_facet_count: usize,
8547    },
8548    Overflow {
8549        plans: BTreeMap<String, UnitScaleSelectedReachabilityPlan>,
8550    },
8551}
8552
8553impl UnitScaleSelectedReachabilityPlans {
8554    fn get(&self, selector: &str) -> Option<&UnitScaleSelectedReachabilityPlan> {
8555        match self {
8556            Self::Complete { plans, .. } | Self::Overflow { plans } => plans.get(selector),
8557        }
8558    }
8559
8560    const fn selected_facet_count(&self) -> usize {
8561        match self {
8562            Self::Complete {
8563                selected_facet_count,
8564                ..
8565            } => *selected_facet_count,
8566            Self::Overflow { .. } => PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE + 1,
8567        }
8568    }
8569}
8570
8571impl UnitScaleSelectedReachabilityPlan {
8572    const fn node_index(&self) -> u64 {
8573        match self {
8574            Self::Complete { node_index, .. } | Self::Refused { node_index } => *node_index,
8575        }
8576    }
8577}
8578
8579fn unit_scale_selected_reachability_budget_exceeded(work: &mut usize) -> bool {
8580    *work = work.saturating_add(1);
8581    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8582}
8583
8584fn unit_scale_selected_reachability(
8585    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8586    start: u64,
8587    roots: &[u64],
8588    work: &mut usize,
8589) -> UnitScaleSelectedReachability {
8590    let Ok(start) = usize::try_from(start) else {
8591        return UnitScaleSelectedReachability::Unavailable;
8592    };
8593    if *work >= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8594        return UnitScaleSelectedReachability::WorkBudgetExceeded;
8595    }
8596    let roots = roots
8597        .iter()
8598        .copied()
8599        .enumerate()
8600        .map(|(ordinal, node)| (node, ordinal as u64))
8601        .collect::<BTreeMap<_, _>>();
8602    let mut seen = BTreeSet::new();
8603    let mut current = Some(start);
8604    for _ in 0..ENGINE_UNIT_SCALE_SELECTED_MAX_REACHABILITY_NODES {
8605        let Some(index) = current else {
8606            return UnitScaleSelectedReachability::Unreachable;
8607        };
8608        if unit_scale_selected_reachability_budget_exceeded(work) {
8609            return UnitScaleSelectedReachability::WorkBudgetExceeded;
8610        }
8611        if let Some(ordinal) = roots.get(&(index as u64)) {
8612            return UnitScaleSelectedReachability::Reachable(*ordinal, index as u64);
8613        }
8614        if !seen.insert(index) {
8615            return UnitScaleSelectedReachability::Unavailable;
8616        }
8617        current = match nodes.get(&index) {
8618            Some(node) => node.parent_node_index,
8619            None => return UnitScaleSelectedReachability::Unavailable,
8620        };
8621    }
8622    match current {
8623        Some(_) => UnitScaleSelectedReachability::Unavailable,
8624        None => UnitScaleSelectedReachability::Unreachable,
8625    }
8626}
8627
8628fn unit_scale_selected_reachability_plans(
8629    provenance: &PredictionProvenanceV4,
8630    measurements: &MeasurementContract,
8631) -> UnitScaleSelectedReachabilityPlans {
8632    let assets = measurements.assets();
8633    let inventory = provenance
8634        .raw_scene_attachment()
8635        .inventory()
8636        .filter(|inventory| {
8637            assets.skeleton_source_coverage == SourceSkeletonCoverage::Complete
8638                && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
8639                && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
8640        });
8641    let nodes = assets
8642        .skeleton_nodes
8643        .iter()
8644        .map(|node| (node.node_index, node))
8645        .collect::<BTreeMap<_, _>>();
8646    let mut plans = BTreeMap::new();
8647    let mut selected_facet_count = 0usize;
8648    for selector in provenance.rule_inputs().runtime_node_selectors() {
8649        let Some(inventory) = inventory else {
8650            selected_facet_count = selected_facet_count.saturating_add(1);
8651            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8652                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8653            }
8654            continue;
8655        };
8656        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
8657            node.name
8658                .as_deref()
8659                .is_some_and(|name| crate::config::glob_match(selector, name))
8660        });
8661        let Some(node) = matches.next() else {
8662            selected_facet_count = selected_facet_count.saturating_add(1);
8663            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8664                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8665            }
8666            continue;
8667        };
8668        if matches.next().is_some() {
8669            selected_facet_count = selected_facet_count.saturating_add(1);
8670            if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8671                return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8672            }
8673            continue;
8674        }
8675        let node_index = node.node_index as u64;
8676        let mut work = 0usize;
8677        let remaining_capacity =
8678            PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(selected_facet_count);
8679        let mut would_overflow = false;
8680        let mut plan = UnitScaleSelectedReachabilityPlan::Complete {
8681            node_index,
8682            scene_witnesses: BTreeMap::new(),
8683        };
8684        for scene in inventory.scenes().rows() {
8685            match unit_scale_selected_reachability(
8686                &nodes,
8687                node_index,
8688                scene.root_node_indices(),
8689                &mut work,
8690            ) {
8691                UnitScaleSelectedReachability::Reachable(root_ordinal, root_node_index) => {
8692                    let UnitScaleSelectedReachabilityPlan::Complete {
8693                        scene_witnesses, ..
8694                    } = &mut plan
8695                    else {
8696                        unreachable!("a reachable scene cannot follow a refusal");
8697                    };
8698                    if scene_witnesses.len() < remaining_capacity {
8699                        scene_witnesses
8700                            .insert(scene.source_scene_index(), (root_ordinal, root_node_index));
8701                    } else {
8702                        would_overflow = true;
8703                    }
8704                }
8705                UnitScaleSelectedReachability::Unreachable => {}
8706                UnitScaleSelectedReachability::Unavailable
8707                | UnitScaleSelectedReachability::WorkBudgetExceeded => {
8708                    plan = UnitScaleSelectedReachabilityPlan::Refused { node_index };
8709                    break;
8710                }
8711            }
8712        }
8713        if matches!(&plan, UnitScaleSelectedReachabilityPlan::Complete { .. }) && would_overflow {
8714            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8715        }
8716        let facets = match &plan {
8717            UnitScaleSelectedReachabilityPlan::Complete {
8718                scene_witnesses, ..
8719            } => scene_witnesses.len().max(1),
8720            UnitScaleSelectedReachabilityPlan::Refused { .. } => 1,
8721        };
8722        selected_facet_count = selected_facet_count.saturating_add(facets);
8723        if selected_facet_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE {
8724            return UnitScaleSelectedReachabilityPlans::Overflow { plans };
8725        }
8726        plans.insert(selector.clone(), plan);
8727    }
8728    UnitScaleSelectedReachabilityPlans::Complete {
8729        plans,
8730        selected_facet_count,
8731    }
8732}
8733
8734fn unit_scale_selected_ancestry_reason(
8735    nodes: &BTreeMap<usize, &crate::measure::SkeletonNodeMeasurements>,
8736    start: usize,
8737) -> Option<PredictionUnavailableReasonV2> {
8738    let mut current = start;
8739    let mut seen = BTreeSet::new();
8740    for _ in 0..128 {
8741        if !seen.insert(current) {
8742            return Some(
8743                PredictionUnavailableReasonV2::custom(
8744                    "animsmith:selected_node_ancestry_unavailable",
8745                )
8746                .expect("static reason is valid"),
8747            );
8748        }
8749        let Some(node) = nodes.get(&current) else {
8750            return Some(
8751                PredictionUnavailableReasonV2::custom(
8752                    "animsmith:selected_node_ancestry_unavailable",
8753                )
8754                .expect("static reason is valid"),
8755            );
8756        };
8757        match node.local_rest {
8758            SkeletonNodeLocalRestMeasurements::Matrix { .. } => {
8759                return Some(
8760                    PredictionUnavailableReasonV2::custom(
8761                        "animsmith:matrix_authored_selected_node_or_ancestry",
8762                    )
8763                    .expect("static reason is valid"),
8764                );
8765            }
8766            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {
8767                return Some(
8768                    PredictionUnavailableReasonV2::custom(
8769                        "animsmith:selected_node_ancestry_unavailable",
8770                    )
8771                    .expect("static reason is valid"),
8772                );
8773            }
8774            SkeletonNodeLocalRestMeasurements::Trs { .. } => {}
8775        }
8776        let parent = node.parent_node_index?;
8777        current = parent;
8778    }
8779    Some(
8780        PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
8781            .expect("static reason is valid"),
8782    )
8783}
8784
8785#[derive(Clone, Copy)]
8786struct CurrentUnitScaleMeshRow {
8787    source_scene_index: u64,
8788    source_root_ordinal: u64,
8789    root_node_index: u64,
8790    source_node_index: u64,
8791    source_mesh_index: u64,
8792    source_primitive_index: u64,
8793}
8794
8795enum CurrentUnitScaleMeshPlan {
8796    Detailed(Vec<CurrentUnitScaleMeshRow>),
8797    CompleteEmpty,
8798    Incomplete,
8799    JoinOverflow,
8800}
8801
8802fn unit_scale_join_budget_exceeded(work: &mut usize) -> bool {
8803    *work = work.saturating_add(1);
8804    *work > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE
8805}
8806
8807fn unit_scale_reachable_root_indexed(
8808    start: u64,
8809    roots: &BTreeMap<u64, u64>,
8810    parents: &BTreeMap<u64, Option<u64>>,
8811    work: &mut usize,
8812) -> Result<Option<(u64, u64)>, ()> {
8813    let mut seen = BTreeSet::new();
8814    let mut current = Some(start);
8815    while let Some(index) = current {
8816        if unit_scale_join_budget_exceeded(work) {
8817            return Err(());
8818        }
8819        if let Some(ordinal) = roots.get(&index) {
8820            return Ok(Some((*ordinal, index)));
8821        }
8822        if !seen.insert(index) {
8823            return Ok(None);
8824        }
8825        current = match parents.get(&index) {
8826            Some(parent) => *parent,
8827            None => return Ok(None),
8828        };
8829    }
8830    Ok(None)
8831}
8832
8833fn current_unit_scale_mesh_plan(
8834    provenance: &PredictionProvenanceV4,
8835    measurements: &MeasurementContract,
8836) -> CurrentUnitScaleMeshPlan {
8837    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
8838        return CurrentUnitScaleMeshPlan::Incomplete;
8839    };
8840    if inventory.scenes().coverage() != RawSceneAttachmentCoverageV1::Complete
8841        || inventory.node_mesh_attachments().coverage() != RawSceneAttachmentCoverageV1::Complete
8842        || inventory.mesh_primitives().coverage() != RawSceneAttachmentCoverageV1::Complete
8843        || inventory.source_skeleton().coverage() != RawSceneAttachmentCoverageV1::Complete
8844        || measurements.assets().skeleton_source_coverage != SourceSkeletonCoverage::Complete
8845    {
8846        return CurrentUnitScaleMeshPlan::Incomplete;
8847    }
8848    let parents = measurements
8849        .assets()
8850        .skeleton_nodes
8851        .iter()
8852        .map(|node| {
8853            (
8854                node.node_index as u64,
8855                node.parent_node_index.map(|parent| parent as u64),
8856            )
8857        })
8858        .collect::<BTreeMap<_, _>>();
8859    let scene_roots = inventory
8860        .scenes()
8861        .rows()
8862        .iter()
8863        .map(|scene| {
8864            (
8865                scene.source_scene_index(),
8866                scene
8867                    .root_node_indices()
8868                    .iter()
8869                    .copied()
8870                    .enumerate()
8871                    .map(|(ordinal, node)| (node, ordinal as u64))
8872                    .collect::<BTreeMap<_, _>>(),
8873            )
8874        })
8875        .collect::<Vec<_>>();
8876    let primitives_by_mesh = inventory.mesh_primitives().rows().iter().fold(
8877        BTreeMap::<u64, Vec<u64>>::new(),
8878        |mut grouped, primitive| {
8879            grouped
8880                .entry(primitive.source_mesh_index())
8881                .or_default()
8882                .push(primitive.source_primitive_index());
8883            grouped
8884        },
8885    );
8886    let mut work = 0usize;
8887    let mut rows = Vec::new();
8888    for (source_scene_index, roots) in scene_roots {
8889        for attachment in inventory.node_mesh_attachments().rows() {
8890            if unit_scale_join_budget_exceeded(&mut work) {
8891                return CurrentUnitScaleMeshPlan::JoinOverflow;
8892            }
8893            let Ok(reachable) = unit_scale_reachable_root_indexed(
8894                attachment.source_node_index(),
8895                &roots,
8896                &parents,
8897                &mut work,
8898            ) else {
8899                return CurrentUnitScaleMeshPlan::JoinOverflow;
8900            };
8901            let Some((source_root_ordinal, root_node_index)) = reachable else {
8902                continue;
8903            };
8904            for &source_primitive_index in primitives_by_mesh
8905                .get(&attachment.source_mesh_index())
8906                .into_iter()
8907                .flatten()
8908            {
8909                if unit_scale_join_budget_exceeded(&mut work) {
8910                    return CurrentUnitScaleMeshPlan::JoinOverflow;
8911                }
8912                rows.push(CurrentUnitScaleMeshRow {
8913                    source_scene_index,
8914                    source_root_ordinal,
8915                    root_node_index,
8916                    source_node_index: attachment.source_node_index(),
8917                    source_mesh_index: attachment.source_mesh_index(),
8918                    source_primitive_index,
8919                });
8920            }
8921        }
8922    }
8923    if rows.is_empty() {
8924        CurrentUnitScaleMeshPlan::CompleteEmpty
8925    } else {
8926        CurrentUnitScaleMeshPlan::Detailed(rows)
8927    }
8928}
8929
8930fn current_unit_scale_selected_facet_count(
8931    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8932) -> usize {
8933    reachability_plans.selected_facet_count()
8934}
8935
8936fn expected_current_engine_unit_scale_facets(
8937    provenance: &PredictionProvenanceV4,
8938    measurements: &MeasurementContract,
8939    mesh_plan: &CurrentUnitScaleMeshPlan,
8940    candidate_capacity: usize,
8941    reachability_plans: &UnitScaleSelectedReachabilityPlans,
8942) -> Option<Vec<ExpectedUnitScaleFacet>> {
8943    let dependency_complete = matches!(
8944        provenance.dependency_closure().coverage(),
8945        DependencyClosureCoverageV1::Complete
8946    );
8947    let available = |scope, result| ExpectedUnitScaleFacet {
8948        scope,
8949        result: Some(result),
8950        reasons: vec![],
8951    };
8952    let unavailable = |scope, reasons| ExpectedUnitScaleFacet {
8953        scope,
8954        result: None,
8955        reasons,
8956    };
8957    let mut expected = Vec::with_capacity(candidate_capacity);
8958    if expected.len() < candidate_capacity {
8959        expected.push(if dependency_complete {
8960            available(
8961                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8962                EngineMachineResultV1::UnitMapping(
8963                    UnitMappingResultV1::gltf_to_engine_world_length_unit(),
8964                ),
8965            )
8966        } else {
8967            unavailable(
8968                unit_scale_scope(ENGINE_UNIT_SCALE_FILE_SCOPE, None),
8969                vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
8970            )
8971        });
8972    }
8973
8974    let inventory = provenance.raw_scene_attachment().inventory();
8975    match inventory
8976        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
8977    {
8978        Some(inventory) => {
8979            for scene in inventory.scenes().rows() {
8980                if expected.len() >= candidate_capacity {
8981                    break;
8982                }
8983                let scope = unit_scale_scope(
8984                    ENGINE_UNIT_SCALE_SCENE_SCOPE,
8985                    Some(format!("source_scene:{}", scene.source_scene_index())),
8986                );
8987                expected.push(if dependency_complete {
8988                    available(
8989                        scope,
8990                        EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
8991                            subject_kind: TransformScaleSubjectKindV1::LoaderSceneEntity,
8992                            creation: ImporterSubjectCreationV1::Created,
8993                            domain: TransformScaleDomainV1::Local,
8994                            classification: Some(LinearTransformClassification::UnitOrthonormal),
8995                        }),
8996                    )
8997                } else {
8998                    unavailable(
8999                        scope,
9000                        vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9001                    )
9002                });
9003            }
9004        }
9005        None if expected.len() < candidate_capacity => expected.push(unavailable(
9006            unit_scale_scope(ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE, None),
9007            unit_scale_unavailable_reasons(
9008                PredictionUnavailableReasonV2::RawSourceIncomplete,
9009                dependency_complete,
9010            ),
9011        )),
9012        None => {}
9013    }
9014
9015    let assets = measurements.assets();
9016    let nodes = assets
9017        .skeleton_nodes
9018        .iter()
9019        .map(|node| (node.node_index, node))
9020        .collect::<BTreeMap<_, _>>();
9021    if expected.len() < candidate_capacity {
9022        match mesh_plan {
9023            CurrentUnitScaleMeshPlan::Incomplete => expected.push(unavailable(
9024                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9025                unit_scale_unavailable_reasons(
9026                    PredictionUnavailableReasonV2::RawSourceIncomplete,
9027                    dependency_complete,
9028                ),
9029            )),
9030            CurrentUnitScaleMeshPlan::JoinOverflow => expected.push(unavailable(
9031                unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9032                vec![
9033                    PredictionUnavailableReasonV2::custom(
9034                        "animsmith:mesh_join_work_budget_exceeded",
9035                    )
9036                    .expect("static reason is valid"),
9037                ],
9038            )),
9039            CurrentUnitScaleMeshPlan::CompleteEmpty => expected.push(if dependency_complete {
9040                available(
9041                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9042                    EngineMachineResultV1::InventoryCoverage(InventoryCoverageResultV1 {
9043                        domain: PredictionInventoryDomainV1::LoaderMeshPrimitiveSubjects,
9044                        coverage: PredictionInventoryCoverageStateV1::Complete,
9045                        retained_rows: 0,
9046                    }),
9047                )
9048            } else {
9049                unavailable(
9050                    unit_scale_scope(ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE, None),
9051                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9052                )
9053            }),
9054            CurrentUnitScaleMeshPlan::Detailed(rows) => {
9055                let load_meshes = matches!(
9056                    provenance
9057                        .settings()
9058                        .document_setting(EngineSettingIdV2::LoadMeshes)
9059                        .map(|setting| setting.value()),
9060                    Some(EngineSettingValueV2::Token(value)) if value == "nonempty"
9061                );
9062                for row in rows {
9063                    if expected.len() >= candidate_capacity {
9064                        break;
9065                    }
9066                    let scope = unit_scale_scope(
9067                        ENGINE_UNIT_SCALE_MESH_SCOPE,
9068                        Some(format!(
9069                            "source_scene:{}:source_node:{}:source_mesh:{}:source_primitive:{}",
9070                            row.source_scene_index,
9071                            row.source_node_index,
9072                            row.source_mesh_index,
9073                            row.source_primitive_index,
9074                        )),
9075                    );
9076                    expected.push(if dependency_complete {
9077                        available(
9078                            scope,
9079                            EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9080                                subject_kind:
9081                                    TransformScaleSubjectKindV1::LoaderMeshPrimitiveEntity,
9082                                creation: if load_meshes {
9083                                    ImporterSubjectCreationV1::Created
9084                                } else {
9085                                    ImporterSubjectCreationV1::SuppressedBySetting
9086                                },
9087                                domain: TransformScaleDomainV1::Local,
9088                                classification: load_meshes
9089                                    .then_some(LinearTransformClassification::UnitOrthonormal),
9090                            }),
9091                        )
9092                    } else {
9093                        unavailable(
9094                            scope,
9095                            vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9096                        )
9097                    });
9098                }
9099            }
9100        }
9101    }
9102
9103    for selector in provenance.rule_inputs().runtime_node_selectors() {
9104        if expected.len() >= candidate_capacity {
9105            break;
9106        }
9107        if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete
9108            || !inventory.is_some_and(|inventory| {
9109                inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9110                    && inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9111            })
9112        {
9113            expected.push(unavailable(
9114                unit_scale_scope(
9115                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9116                    Some(format!("selector:{selector}")),
9117                ),
9118                unit_scale_unavailable_reasons(
9119                    PredictionUnavailableReasonV2::RawSourceIncomplete,
9120                    dependency_complete,
9121                ),
9122            ));
9123            continue;
9124        }
9125        let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9126            node.name
9127                .as_deref()
9128                .is_some_and(|name| crate::config::glob_match(selector, name))
9129        });
9130        let first = matches.next();
9131        let second = matches.next();
9132        if first.is_none() || second.is_some() {
9133            expected.push(unavailable(
9134                unit_scale_scope(
9135                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9136                    Some(format!("selector:{selector}")),
9137                ),
9138                unit_scale_unavailable_reasons(
9139                    if first.is_none() {
9140                        PredictionUnavailableReasonV2::SourceSelectorNoMatch
9141                    } else {
9142                        PredictionUnavailableReasonV2::SourceSelectorAmbiguous
9143                    },
9144                    dependency_complete,
9145                ),
9146            ));
9147            continue;
9148        }
9149        let node = first.expect("one selected source node was established");
9150        let inventory = inventory.expect("selected inventory was proven complete");
9151        let mut reachable_scenes = Vec::new();
9152        let plan = reachability_plans.get(selector)?;
9153        let reachability_unavailable =
9154            match plan {
9155                UnitScaleSelectedReachabilityPlan::Refused {
9156                    node_index: cached_node_index,
9157                } if *cached_node_index == node.node_index as u64 => true,
9158                UnitScaleSelectedReachabilityPlan::Complete {
9159                    node_index: cached_node_index,
9160                    scene_witnesses,
9161                } if *cached_node_index == node.node_index as u64 => {
9162                    reachable_scenes.extend(
9163                        inventory.scenes().rows().iter().filter(|scene| {
9164                            scene_witnesses.contains_key(&scene.source_scene_index())
9165                        }),
9166                    );
9167                    false
9168                }
9169                UnitScaleSelectedReachabilityPlan::Refused { .. }
9170                | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9171            };
9172        if reachability_unavailable {
9173            expected.push(unavailable(
9174                unit_scale_scope(
9175                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9176                    Some(format!("selector:{selector}")),
9177                ),
9178                unit_scale_unavailable_reasons(
9179                    PredictionUnavailableReasonV2::custom(
9180                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9181                    )
9182                    .expect("static reason is valid"),
9183                    dependency_complete,
9184                ),
9185            ));
9186            continue;
9187        }
9188        let reason = unit_scale_selected_ancestry_reason(&nodes, node.node_index).or_else(|| {
9189            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9190                || node.rest_world_matrix.is_none())
9191            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9192        });
9193        let mut reachable_found = false;
9194        for scene in reachable_scenes {
9195            reachable_found = true;
9196            if expected.len() >= candidate_capacity {
9197                break;
9198            }
9199            let scope = unit_scale_scope(
9200                ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9201                Some(format!(
9202                    "selector:{selector}:source_scene:{}:source_node:{}",
9203                    scene.source_scene_index(),
9204                    node.node_index,
9205                )),
9206            );
9207            expected.push(if let Some(reason) = reason.clone() {
9208                unavailable(
9209                    scope,
9210                    unit_scale_unavailable_reasons(reason, dependency_complete),
9211                )
9212            } else if dependency_complete {
9213                available(
9214                    scope,
9215                    EngineMachineResultV1::TransformScale(TransformScaleResultV1 {
9216                        subject_kind: TransformScaleSubjectKindV1::SelectedSourceNode,
9217                        creation: ImporterSubjectCreationV1::Created,
9218                        domain: TransformScaleDomainV1::LoaderRootToSubject,
9219                        classification: Some(node.rest_world_linear.classification),
9220                    }),
9221                )
9222            } else {
9223                unavailable(
9224                    scope,
9225                    vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9226                )
9227            });
9228        }
9229        if !reachable_found && expected.len() < candidate_capacity {
9230            expected.push(unavailable(
9231                unit_scale_scope(
9232                    ENGINE_UNIT_SCALE_SELECTED_SCOPE,
9233                    Some(format!("selector:{selector}")),
9234                ),
9235                unit_scale_unavailable_reasons(
9236                    PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9237                        .expect("static reason is valid"),
9238                    dependency_complete,
9239                ),
9240            ));
9241        }
9242    }
9243    Some(expected)
9244}
9245
9246fn unit_scale_exact_raw_row_references(
9247    basis: &EnginePredictionBasisV4,
9248    expected: &[RawSceneAttachmentBasisReferenceV1],
9249) -> bool {
9250    let actual = basis
9251        .references()
9252        .iter()
9253        .filter_map(|reference| match reference {
9254            PredictionBasisReferenceV4::RawSceneAttachment(reference)
9255                if !matches!(
9256                    reference,
9257                    RawSceneAttachmentBasisReferenceV1::Coverage { .. }
9258                ) =>
9259            {
9260                Some(reference)
9261            }
9262            _ => None,
9263        })
9264        .collect::<Vec<_>>();
9265    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9266}
9267
9268fn unit_scale_mesh_scope_keys(subject: &str) -> Option<(u64, u64, u64, u64)> {
9269    let values = subject
9270        .strip_prefix("source_scene:")?
9271        .split(':')
9272        .collect::<Vec<_>>();
9273    if values.len() != 7
9274        || values[1] != "source_node"
9275        || values[3] != "source_mesh"
9276        || values[5] != "source_primitive"
9277    {
9278        return None;
9279    }
9280    Some((
9281        values[0].parse().ok()?,
9282        values[2].parse().ok()?,
9283        values[4].parse().ok()?,
9284        values[6].parse().ok()?,
9285    ))
9286}
9287
9288fn unit_scale_expected_mesh_raw_rows(
9289    subject: &str,
9290    mesh_plan: &CurrentUnitScaleMeshPlan,
9291) -> Option<Vec<RawSceneAttachmentBasisReferenceV1>> {
9292    let (source_scene_index, source_node_index, source_mesh_index, source_primitive_index) =
9293        unit_scale_mesh_scope_keys(subject)?;
9294    let CurrentUnitScaleMeshPlan::Detailed(rows) = mesh_plan else {
9295        return None;
9296    };
9297    let row = rows.iter().find(|row| {
9298        row.source_scene_index == source_scene_index
9299            && row.source_node_index == source_node_index
9300            && row.source_mesh_index == source_mesh_index
9301            && row.source_primitive_index == source_primitive_index
9302    })?;
9303    Some(vec![
9304        RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9305        RawSceneAttachmentBasisReferenceV1::SceneRoot {
9306            source_scene_index,
9307            source_root_ordinal: row.source_root_ordinal,
9308            source_node_index: row.root_node_index,
9309        },
9310        RawSceneAttachmentBasisReferenceV1::NodeMeshAttachmentRow {
9311            source_node_index,
9312            source_mesh_index,
9313        },
9314        RawSceneAttachmentBasisReferenceV1::MeshPrimitiveRow {
9315            source_mesh_index,
9316            source_primitive_index,
9317        },
9318    ])
9319}
9320
9321fn unit_scale_selected_scope_keys<'a>(
9322    subject: &str,
9323    selectors: &'a [String],
9324) -> Option<(&'a str, Option<(u64, u64)>)> {
9325    for selector in selectors {
9326        let prefix = format!("selector:{selector}");
9327        if subject == prefix {
9328            return Some((selector, None));
9329        }
9330        let Some(values) = subject
9331            .strip_prefix(&prefix)
9332            .and_then(|suffix| suffix.strip_prefix(":source_scene:"))
9333        else {
9334            continue;
9335        };
9336        let values = values.split(':').collect::<Vec<_>>();
9337        if values.len() == 3 && values[1] == "source_node" {
9338            return Some((
9339                selector,
9340                Some((values[0].parse().ok()?, values[2].parse().ok()?)),
9341            ));
9342        }
9343    }
9344    None
9345}
9346
9347fn unit_scale_classification_name(value: LinearTransformClassification) -> &'static str {
9348    match value {
9349        LinearTransformClassification::UnitOrthonormal => "unit_orthonormal",
9350        LinearTransformClassification::UniformScaled => "uniform_scaled",
9351        LinearTransformClassification::NonUniform => "non_uniform",
9352        LinearTransformClassification::Sheared => "sheared",
9353        LinearTransformClassification::Reflected => "reflected",
9354        LinearTransformClassification::Singular => "singular",
9355        LinearTransformClassification::NonFinite => "non_finite",
9356    }
9357}
9358
9359fn unit_scale_raw_source_node_reference(
9360    source_index: u64,
9361    field: &str,
9362    value: PredictionScalarV1,
9363) -> Option<RawSourceBasisReferenceV1> {
9364    RawSourceBasisReferenceV1::from_wire(
9365        RawSourceDomainV1::SourceNode,
9366        RawSourceKeyV1::SourceSkeleton {
9367            row_kind: SourceSkeletonRowKindV1::SourceNode,
9368            source_index,
9369        },
9370        RawSourceFieldIdV1::new(field).ok()?,
9371        value,
9372    )
9373    .ok()
9374}
9375
9376fn unit_scale_exact_raw_source_references(
9377    basis: &EnginePredictionBasisV4,
9378    expected: &[RawSourceBasisReferenceV1],
9379) -> bool {
9380    let actual = basis
9381        .references()
9382        .iter()
9383        .filter_map(|reference| match reference {
9384            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9385                PredictionBasisReferenceV1::RawSource { reference },
9386            )) => Some(reference),
9387            _ => None,
9388        })
9389        .collect::<Vec<_>>();
9390    actual.len() == expected.len() && actual.iter().all(|reference| expected.contains(reference))
9391}
9392
9393fn unit_scale_selected_authored_kind(
9394    basis: &EnginePredictionBasisV4,
9395    source_index: u64,
9396) -> Option<&str> {
9397    let mut values = basis.references().iter().filter_map(|reference| {
9398        let PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9399            PredictionBasisReferenceV1::RawSource { reference },
9400        )) = reference
9401        else {
9402            return None;
9403        };
9404        if reference.domain() != RawSourceDomainV1::SourceNode
9405            || reference.key()
9406                != &(RawSourceKeyV1::SourceSkeleton {
9407                    row_kind: SourceSkeletonRowKindV1::SourceNode,
9408                    source_index,
9409                })
9410            || reference.field().as_str() != "local_rest.kind"
9411        {
9412            return None;
9413        }
9414        match reference.value() {
9415            PredictionScalarV1::Token { value } if matches!(value.as_str(), "trs" | "matrix") => {
9416                Some(value.as_str())
9417            }
9418            _ => None,
9419        }
9420    });
9421    let value = values.next()?;
9422    values.next().is_none().then_some(value)
9423}
9424
9425fn unit_scale_selected_expected_reasons(
9426    primary: Option<PredictionUnavailableReasonV2>,
9427    dependency_complete: bool,
9428) -> Vec<PredictionUnavailableReasonV2> {
9429    match primary {
9430        Some(reason) => unit_scale_unavailable_reasons(reason, dependency_complete),
9431        None if dependency_complete => Vec::new(),
9432        None => vec![PredictionUnavailableReasonV2::DependencyClosureIncomplete],
9433    }
9434}
9435
9436struct CurrentUnitScaleSelectedEvidence {
9437    raw_source_references: Vec<RawSourceBasisReferenceV1>,
9438    reasons: Vec<PredictionUnavailableReasonV2>,
9439}
9440
9441fn unit_scale_expected_selected_raw_source_references(
9442    selector: &str,
9443    keys: Option<(u64, u64)>,
9444    basis: &EnginePredictionBasisV4,
9445    provenance: &PredictionProvenanceV4,
9446    measurements: &MeasurementContract,
9447    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9448) -> Option<CurrentUnitScaleSelectedEvidence> {
9449    let assets = measurements.assets();
9450    let dependency_complete = matches!(
9451        provenance.dependency_closure().coverage(),
9452        DependencyClosureCoverageV1::Complete
9453    );
9454    let inventory = provenance.raw_scene_attachment().inventory();
9455    let inventory_complete = inventory.is_some_and(|inventory| {
9456        inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete
9457            && inventory.source_skeleton().coverage() == RawSceneAttachmentCoverageV1::Complete
9458    });
9459    if assets.skeleton_source_coverage != SourceSkeletonCoverage::Complete || !inventory_complete {
9460        return Some(CurrentUnitScaleSelectedEvidence {
9461            raw_source_references: Vec::new(),
9462            reasons: unit_scale_selected_expected_reasons(
9463                Some(PredictionUnavailableReasonV2::RawSourceIncomplete),
9464                dependency_complete,
9465            ),
9466        });
9467    }
9468    let mut matches = assets.skeleton_nodes.iter().filter(|node| {
9469        node.name
9470            .as_deref()
9471            .is_some_and(|name| crate::config::glob_match(selector, name))
9472    });
9473    let first = matches.next();
9474    let second = matches.next();
9475    let name_reference = |node: &crate::measure::SkeletonNodeMeasurements| {
9476        unit_scale_raw_source_node_reference(
9477            node.node_index as u64,
9478            "name",
9479            node.name.as_ref().map_or(PredictionScalarV1::Null, |name| {
9480                PredictionScalarV1::text(name).expect("retained measurement text is bounded")
9481            }),
9482        )
9483    };
9484    if first.is_none() {
9485        return keys.is_none().then(|| CurrentUnitScaleSelectedEvidence {
9486            raw_source_references: Vec::new(),
9487            reasons: unit_scale_selected_expected_reasons(
9488                Some(PredictionUnavailableReasonV2::SourceSelectorNoMatch),
9489                dependency_complete,
9490            ),
9491        });
9492    }
9493    if let Some(second) = second {
9494        if keys.is_some() {
9495            return None;
9496        }
9497        return Some(CurrentUnitScaleSelectedEvidence {
9498            raw_source_references: vec![name_reference(first?)?, name_reference(second)?],
9499            reasons: unit_scale_selected_expected_reasons(
9500                Some(PredictionUnavailableReasonV2::SourceSelectorAmbiguous),
9501                dependency_complete,
9502            ),
9503        });
9504    }
9505    let node = first?;
9506    if keys.is_some_and(|(_, source_node_index)| source_node_index != node.node_index as u64) {
9507        return None;
9508    }
9509    let mut expected = vec![name_reference(node)?];
9510    let nodes = assets
9511        .skeleton_nodes
9512        .iter()
9513        .map(|node| (node.node_index, node))
9514        .collect::<BTreeMap<_, _>>();
9515    let plan = reachability_plans.get(selector)?;
9516    let reachability_unavailable = match plan {
9517        UnitScaleSelectedReachabilityPlan::Refused {
9518            node_index: cached_node_index,
9519        } if *cached_node_index == node.node_index as u64 => true,
9520        UnitScaleSelectedReachabilityPlan::Complete {
9521            node_index: cached_node_index,
9522            ..
9523        } if *cached_node_index == node.node_index as u64 => false,
9524        UnitScaleSelectedReachabilityPlan::Refused { .. }
9525        | UnitScaleSelectedReachabilityPlan::Complete { .. } => return None,
9526    };
9527    if reachability_unavailable {
9528        if keys.is_some() {
9529            return None;
9530        }
9531        return Some(CurrentUnitScaleSelectedEvidence {
9532            raw_source_references: expected,
9533            reasons: unit_scale_selected_expected_reasons(
9534                Some(
9535                    PredictionUnavailableReasonV2::custom(
9536                        ENGINE_UNIT_SCALE_SELECTED_REACHABILITY_UNAVAILABLE_REASON,
9537                    )
9538                    .expect("static reason is valid"),
9539                ),
9540                dependency_complete,
9541            ),
9542        });
9543    }
9544    let mut current = node.node_index;
9545    let mut seen = BTreeSet::new();
9546    let mut ancestry_reason = None;
9547    let mut ancestry_complete = false;
9548    for _ in 0..128 {
9549        if !seen.insert(current) {
9550            ancestry_reason = Some(
9551                PredictionUnavailableReasonV2::custom(
9552                    "animsmith:selected_node_ancestry_unavailable",
9553                )
9554                .expect("static reason is valid"),
9555            );
9556            break;
9557        }
9558        let Some(ancestry_node) = nodes.get(&current).copied() else {
9559            ancestry_reason = Some(
9560                PredictionUnavailableReasonV2::custom(
9561                    "animsmith:selected_node_ancestry_unavailable",
9562                )
9563                .expect("static reason is valid"),
9564            );
9565            break;
9566        };
9567        // The normalized measurement deliberately erases whether a non-finite
9568        // local rest was authored as TRS or as a matrix. The exact same-load
9569        // raw-source scalar in the basis retains that distinction per row.
9570        let retained_kind = unit_scale_selected_authored_kind(basis, current as u64)?;
9571        let local_kind = match ancestry_node.local_rest {
9572            SkeletonNodeLocalRestMeasurements::Trs { .. } if retained_kind == "trs" => "trs",
9573            SkeletonNodeLocalRestMeasurements::Matrix { .. } if retained_kind == "matrix" => {
9574                "matrix"
9575            }
9576            SkeletonNodeLocalRestMeasurements::Unavailable { .. } => retained_kind,
9577            _ => return None,
9578        };
9579        expected.push(unit_scale_raw_source_node_reference(
9580            current as u64,
9581            "local_rest.kind",
9582            PredictionScalarV1::token(local_kind).ok()?,
9583        )?);
9584        expected.push(unit_scale_raw_source_node_reference(
9585            current as u64,
9586            "parent_source_node_index",
9587            ancestry_node
9588                .parent_node_index
9589                .map_or(PredictionScalarV1::Null, |parent| {
9590                    PredictionScalarV1::UnsignedInteger {
9591                        value: parent as u64,
9592                    }
9593                }),
9594        )?);
9595        if local_kind == "matrix" {
9596            ancestry_reason = Some(
9597                PredictionUnavailableReasonV2::custom(
9598                    "animsmith:matrix_authored_selected_node_or_ancestry",
9599                )
9600                .expect("static reason is valid"),
9601            );
9602            break;
9603        }
9604        let Some(parent) = ancestry_node.parent_node_index else {
9605            ancestry_complete = true;
9606            break;
9607        };
9608        current = parent;
9609    }
9610    if ancestry_reason.is_none() && !ancestry_complete {
9611        ancestry_reason = Some(
9612            PredictionUnavailableReasonV2::custom("animsmith:selected_node_ancestry_unavailable")
9613                .expect("static reason is valid"),
9614        );
9615    }
9616    let primary_reason = if keys.is_none() {
9617        Some(
9618            PredictionUnavailableReasonV2::custom("animsmith:selected_node_unreachable")
9619                .expect("static reason is valid"),
9620        )
9621    } else {
9622        ancestry_reason.or_else(|| {
9623            (node.rest_world_linear.classification == LinearTransformClassification::NonFinite
9624                || node.rest_world_matrix.is_none())
9625            .then_some(PredictionUnavailableReasonV2::MeasurementUnavailable)
9626        })
9627    };
9628    Some(CurrentUnitScaleSelectedEvidence {
9629        raw_source_references: expected,
9630        reasons: unit_scale_selected_expected_reasons(primary_reason, dependency_complete),
9631    })
9632}
9633
9634fn unit_scale_exact_selected_evidence(
9635    selector: &str,
9636    keys: Option<(u64, u64)>,
9637    reasons: &[PredictionUnavailableReasonV2],
9638    basis: &EnginePredictionBasisV4,
9639    provenance: &PredictionProvenanceV4,
9640    measurements: &MeasurementContract,
9641    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9642) -> bool {
9643    let Some(expected) = unit_scale_expected_selected_raw_source_references(
9644        selector,
9645        keys,
9646        basis,
9647        provenance,
9648        measurements,
9649        reachability_plans,
9650    ) else {
9651        return false;
9652    };
9653    if reasons != expected.reasons
9654        || !unit_scale_exact_raw_source_references(basis, &expected.raw_source_references)
9655    {
9656        return false;
9657    }
9658    let measurement_references = basis
9659        .references()
9660        .iter()
9661        .filter_map(|reference| match reference {
9662            PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(
9663                PredictionBasisReferenceV1::Measurement {
9664                    schema,
9665                    pointer,
9666                    value,
9667                },
9668            )) => Some((*schema, pointer.as_str(), value)),
9669            _ => None,
9670        })
9671        .collect::<Vec<_>>();
9672    let Some((source_scene_index, source_node_index)) = keys else {
9673        return unit_scale_exact_raw_row_references(basis, &[])
9674            && measurement_references.is_empty();
9675    };
9676    let Some(inventory) = provenance.raw_scene_attachment().inventory() else {
9677        return false;
9678    };
9679    let Some(_scene) = inventory
9680        .scenes()
9681        .rows()
9682        .iter()
9683        .find(|scene| scene.source_scene_index() == source_scene_index)
9684    else {
9685        return false;
9686    };
9687    let witness = reachability_plans
9688        .get(selector)
9689        .filter(|plan| plan.node_index() == source_node_index)
9690        .and_then(|plan| match plan {
9691            UnitScaleSelectedReachabilityPlan::Complete {
9692                scene_witnesses, ..
9693            } => scene_witnesses.get(&source_scene_index).copied(),
9694            UnitScaleSelectedReachabilityPlan::Refused { .. } => None,
9695        });
9696    let Some((source_root_ordinal, source_node_index_at_root)) = witness else {
9697        return false;
9698    };
9699    if !unit_scale_exact_raw_row_references(
9700        basis,
9701        &[
9702            RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index },
9703            RawSceneAttachmentBasisReferenceV1::SceneRoot {
9704                source_scene_index,
9705                source_root_ordinal,
9706                source_node_index: source_node_index_at_root,
9707            },
9708        ],
9709    ) {
9710        return false;
9711    }
9712    let Some((ordinal, node)) = measurements
9713        .assets()
9714        .skeleton_nodes
9715        .iter()
9716        .enumerate()
9717        .find(|(_, node)| node.node_index as u64 == source_node_index)
9718    else {
9719        return measurement_references.is_empty();
9720    };
9721    let pointer =
9722        format!("/measurements/skeleton_nodes/{ordinal}/rest_world_linear/classification");
9723    matches!(
9724        measurement_references.as_slice(),
9725        [(
9726            MEASUREMENTS_V16_SCHEMA_ID,
9727            actual_pointer,
9728            PredictionScalarV1::Token { value },
9729        )] if *actual_pointer == pointer
9730            && value == unit_scale_classification_name(node.rest_world_linear.classification)
9731    )
9732}
9733
9734fn validate_current_engine_unit_scale_basis(
9735    scope: &EvaluationScope,
9736    basis: &EnginePredictionBasisV4,
9737    reasons: &[PredictionUnavailableReasonV2],
9738    provenance: &PredictionProvenanceV4,
9739    measurements: &MeasurementContract,
9740    mesh_plan: &CurrentUnitScaleMeshPlan,
9741    reachability_plans: &UnitScaleSelectedReachabilityPlans,
9742) -> bool {
9743    let v1 = |predicate: &dyn Fn(&PredictionBasisReferenceV1) -> bool| {
9744        basis.references().iter().any(|reference| {
9745            matches!(
9746                reference,
9747                PredictionBasisReferenceV4::V2(PredictionBasisReferenceV2::V1(reference))
9748                    if predicate(reference)
9749            )
9750        })
9751    };
9752    let fact = |expected: &str| {
9753        v1(
9754            &|reference| matches!(reference, PredictionBasisReferenceV1::ProfileFact { fact_id } if fact_id == expected),
9755        )
9756    };
9757    let setting = |expected: &str| {
9758        v1(
9759            &|reference| matches!(reference, PredictionBasisReferenceV1::ResolvedSetting { setting_id, .. } if setting_id == expected),
9760        )
9761    };
9762    let source = |expected: &str| {
9763        v1(
9764            &|reference| matches!(reference, PredictionBasisReferenceV1::PrimarySource { source_id } if source_id == expected),
9765        )
9766    };
9767    let raw_coverage = |expected: RawSceneAttachmentBasisDomainV1| {
9768        basis.references().iter().any(|reference| {
9769            matches!(
9770                reference,
9771                PredictionBasisReferenceV4::RawSceneAttachment(
9772                    RawSceneAttachmentBasisReferenceV1::Coverage { domain }
9773                ) if *domain == expected
9774            )
9775        })
9776    };
9777    let common_transform = source("bevy-gltf-loader-0.19.0-c6f634ca")
9778        && source("bevy-gltf-coordinate-conversion-0.19.0-c6f634ca")
9779        && fact("resulting_transform_scale")
9780        && setting("extension_handler_environment");
9781    match scope.code.as_str() {
9782        ENGINE_UNIT_SCALE_FILE_SCOPE => {
9783            unit_scale_exact_raw_row_references(basis, &[])
9784                && [
9785                    "application_world_unit_policy",
9786                    "importer_scale_conversion",
9787                    "physical_dimensions_preserved",
9788                    "source_to_target_unit_mapping",
9789                    "target_linear_unit",
9790                ]
9791                .into_iter()
9792                .all(fact)
9793                && [
9794                    "bevy-gltf-loader-0.19.0-c6f634ca",
9795                    "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
9796                    "khronos-gltf-2.0-coordinate-units",
9797                ]
9798                .into_iter()
9799                .all(source)
9800        }
9801        ENGINE_UNIT_SCALE_SCENE_SCOPE | ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE => {
9802            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_SCENE_INVENTORY_SCOPE {
9803                Some(Vec::new())
9804            } else {
9805                scope
9806                    .subject
9807                    .as_deref()
9808                    .and_then(|subject| subject.strip_prefix("source_scene:"))
9809                    .and_then(|index| index.parse::<u64>().ok())
9810                    .filter(|index| {
9811                        scope.subject.as_deref() == Some(&format!("source_scene:{index}"))
9812                    })
9813                    .map(|source_scene_index| {
9814                        vec![RawSceneAttachmentBasisReferenceV1::SceneRow { source_scene_index }]
9815                    })
9816            };
9817            expected_rows.as_deref().is_some_and(|expected_rows| {
9818                unit_scale_exact_raw_row_references(basis, expected_rows)
9819            }) && common_transform
9820                && setting("rotate_scene_entity")
9821                && (provenance.raw_scene_attachment().inventory().is_none()
9822                    || raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes))
9823        }
9824        ENGINE_UNIT_SCALE_MESH_SCOPE | ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE => {
9825            let expected_rows = if scope.code.as_str() == ENGINE_UNIT_SCALE_MESH_INVENTORY_SCOPE {
9826                Some(Vec::new())
9827            } else {
9828                scope
9829                    .subject
9830                    .as_deref()
9831                    .and_then(|subject| unit_scale_expected_mesh_raw_rows(subject, mesh_plan))
9832            };
9833            expected_rows.as_deref().is_some_and(|expected_rows| {
9834                unit_scale_exact_raw_row_references(basis, expected_rows)
9835            }) && common_transform
9836                && source("bevy-render-asset-usages-0.19.0-c6f634ca")
9837                && setting("load_meshes")
9838                && setting("rotate_meshes")
9839                && (provenance.raw_scene_attachment().inventory().is_none()
9840                    || [
9841                        RawSceneAttachmentBasisDomainV1::SourceSkeleton,
9842                        RawSceneAttachmentBasisDomainV1::Scenes,
9843                        RawSceneAttachmentBasisDomainV1::NodeMeshAttachments,
9844                        RawSceneAttachmentBasisDomainV1::MeshPrimitives,
9845                    ]
9846                    .into_iter()
9847                    .all(raw_coverage))
9848        }
9849        ENGINE_UNIT_SCALE_SELECTED_SCOPE => {
9850            let selected = scope.subject.as_deref().and_then(|subject| {
9851                unit_scale_selected_scope_keys(
9852                    subject,
9853                    provenance.rule_inputs().runtime_node_selectors(),
9854                )
9855            });
9856            let selector = selected.map(|(selector, _)| selector);
9857            common_transform
9858                && setting("rotate_scene_entity")
9859                && selected.is_some_and(|(selector, keys)| {
9860                    unit_scale_exact_selected_evidence(
9861                        selector,
9862                        keys,
9863                        reasons,
9864                        basis,
9865                        provenance,
9866                        measurements,
9867                        reachability_plans,
9868                    )
9869                })
9870                && selector.is_some_and(|selector| {
9871                    v1(&|reference| {
9872                        matches!(
9873                            reference,
9874                            PredictionBasisReferenceV1::ProjectField {
9875                                field_id,
9876                                value: PredictionScalarV1::Text { value }
9877                            } if field_id == "runtime_nodes.selector" && value == selector
9878                        )
9879                    })
9880                })
9881                && (provenance.raw_scene_attachment().inventory().is_none()
9882                    || (raw_coverage(RawSceneAttachmentBasisDomainV1::SourceSkeleton)
9883                        && raw_coverage(RawSceneAttachmentBasisDomainV1::Scenes)))
9884        }
9885        ENGINE_UNIT_SCALE_BUDGET_SCOPE => {
9886            unit_scale_exact_raw_row_references(basis, &[])
9887                && source("bevy-gltf-loader-0.19.0-c6f634ca")
9888        }
9889        _ => false,
9890    }
9891}
9892
9893fn validate_current_engine_unit_scale_prediction_v4(
9894    check_id: &str,
9895    selection: SelectionState,
9896    configuration: ConfigurationState,
9897    applicability: Applicability,
9898    prediction: Option<&EnginePredictionV4>,
9899    provenance: Option<&PredictionProvenanceV4>,
9900    measurements: &MeasurementContract,
9901) -> Result<(), PredictionContractError> {
9902    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9903        return Ok(());
9904    }
9905    let exact_profile = provenance.is_some_and(|provenance| {
9906        let selection = provenance.profile().selection();
9907        selection.family() == "bevy"
9908            && selection.profile_revision() == 2
9909            && selection.engine_version() == "0.19.0"
9910            && selection.importer() == "gltf-asset-loader"
9911            && provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9912            && provenance.profile().facts_identity().sha256()
9913                == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9914            && provenance.profile().facts_identity().bytes() == 4_783
9915            && matches!(
9916                provenance.source_format(),
9917                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9918            )
9919    });
9920    validate_current_engine_unit_scale_prediction_common(
9921        check_id,
9922        selection,
9923        configuration,
9924        applicability,
9925        prediction,
9926        provenance,
9927        measurements,
9928        exact_profile,
9929    )
9930}
9931
9932fn validate_current_engine_unit_scale_prediction_v5(
9933    check_id: &str,
9934    selection: SelectionState,
9935    configuration: ConfigurationState,
9936    applicability: Applicability,
9937    prediction: Option<&EnginePredictionV5>,
9938    provenance: Option<&PredictionProvenanceV5>,
9939    measurements: &MeasurementContract,
9940) -> Result<(), PredictionContractError> {
9941    let base = provenance.map(PredictionProvenanceV5::base);
9942    let exact_profile = base.is_some_and(|provenance| {
9943        let selection = provenance.profile().selection();
9944        let exact_identity = match selection.profile_revision() {
9945            2 => {
9946                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:2"
9947                    && provenance.profile().facts_identity().sha256()
9948                        == "bcd663e891b25029ecdf17e942f6fa93f71a8d0598fc8cb02bc7634749c34597"
9949                    && provenance.profile().facts_identity().bytes() == 4_783
9950            }
9951            3 => {
9952                provenance.profile().fact_bundle_urn() == "urn:animsmith:engine-profile:bevy:3"
9953                    && provenance.profile().facts_identity().sha256()
9954                        == "d532b00621bf06a2db2dedf896c19aae2c07b3b1873a1b05beade2252d7a89c5"
9955                    && provenance.profile().facts_identity().bytes() == 4_849
9956            }
9957            _ => false,
9958        };
9959        selection.family() == "bevy"
9960            && selection.engine_version() == "0.19.0"
9961            && selection.importer() == "gltf-asset-loader"
9962            && exact_identity
9963            && matches!(
9964                provenance.source_format(),
9965                SourceFormatV1::GltfJson | SourceFormatV1::Glb
9966            )
9967    });
9968    validate_current_engine_unit_scale_prediction_common(
9969        check_id,
9970        selection,
9971        configuration,
9972        applicability,
9973        prediction.map(EnginePredictionV5::base_prediction),
9974        base,
9975        measurements,
9976        exact_profile,
9977    )
9978}
9979
9980#[allow(clippy::too_many_arguments)]
9981fn validate_current_engine_unit_scale_prediction_common(
9982    check_id: &str,
9983    selection: SelectionState,
9984    configuration: ConfigurationState,
9985    applicability: Applicability,
9986    prediction: Option<&EnginePredictionV4>,
9987    provenance: Option<&PredictionProvenanceV4>,
9988    measurements: &MeasurementContract,
9989    exact_profile: bool,
9990) -> Result<(), PredictionContractError> {
9991    if check_id != ENGINE_UNIT_SCALE_CHECK_ID {
9992        return Ok(());
9993    }
9994    if applicability
9995        != if exact_profile {
9996            Applicability::Applicable
9997        } else {
9998            Applicability::NotApplicable
9999        }
10000    {
10001        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10002    }
10003    if !exact_profile {
10004        return if prediction.is_none() {
10005            Ok(())
10006        } else {
10007            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
10008        };
10009    }
10010    if selection != SelectionState::Selected || configuration != ConfigurationState::Enabled {
10011        return if prediction.is_none() {
10012            Ok(())
10013        } else {
10014            Err(PredictionContractError::EngineUnitScaleFacetMismatch)
10015        };
10016    }
10017    let provenance = provenance.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10018    let prediction = prediction.ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10019    let mesh_plan = current_unit_scale_mesh_plan(provenance, measurements);
10020    let reachability_plans = unit_scale_selected_reachability_plans(provenance, measurements);
10021    if prediction.facets().iter().any(|facet| {
10022        !validate_current_engine_unit_scale_basis(
10023            facet.scope(),
10024            facet.basis(),
10025            facet.reasons(),
10026            provenance,
10027            measurements,
10028            &mesh_plan,
10029            &reachability_plans,
10030        )
10031    }) {
10032        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10033    }
10034    let has_summary = prediction.facets().iter().any(|facet| {
10035        facet.scope().code.as_str() == ENGINE_UNIT_SCALE_BUDGET_SCOPE
10036            && facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded]
10037    });
10038    let retained = prediction
10039        .facets()
10040        .iter()
10041        .filter(|facet| facet.scope().code.as_str() != ENGINE_UNIT_SCALE_BUDGET_SCOPE)
10042        .collect::<Vec<_>>();
10043    let scene_facets = provenance
10044        .raw_scene_attachment()
10045        .inventory()
10046        .filter(|inventory| inventory.scenes().coverage() == RawSceneAttachmentCoverageV1::Complete)
10047        .map_or(1, |inventory| inventory.scenes().rows().len());
10048    let mesh_facets = match &mesh_plan {
10049        CurrentUnitScaleMeshPlan::Detailed(rows) => rows.len(),
10050        CurrentUnitScaleMeshPlan::CompleteEmpty
10051        | CurrentUnitScaleMeshPlan::Incomplete
10052        | CurrentUnitScaleMeshPlan::JoinOverflow => 1,
10053    };
10054    let selected_facets = current_unit_scale_selected_facet_count(&reachability_plans);
10055    let expected_count = 1usize
10056        .checked_add(scene_facets)
10057        .and_then(|count| count.checked_add(mesh_facets))
10058        .and_then(|count| count.checked_add(selected_facets))
10059        .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10060    if (!has_summary && expected_count > PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
10061        || (has_summary && retained.len() >= expected_count)
10062    {
10063        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10064    }
10065    let candidate_capacity = if has_summary {
10066        retained.len()
10067    } else {
10068        expected_count
10069    };
10070    let mut expected_retained = expected_current_engine_unit_scale_facets(
10071        provenance,
10072        measurements,
10073        &mesh_plan,
10074        candidate_capacity,
10075        &reachability_plans,
10076    )
10077    .ok_or(PredictionContractError::EngineUnitScaleFacetMismatch)?;
10078    if expected_retained.len() != candidate_capacity {
10079        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10080    }
10081    expected_retained.sort_by(|left, right| {
10082        left.scope
10083            .code
10084            .as_str()
10085            .cmp(right.scope.code.as_str())
10086            .then_with(|| left.scope.subject.cmp(&right.scope.subject))
10087    });
10088    if retained.len() != expected_retained.len()
10089        || retained
10090            .iter()
10091            .zip(&expected_retained)
10092            .any(|(actual, expected)| {
10093                actual.scope() != &expected.scope
10094                    || actual.result() != expected.result.as_ref()
10095                    || (actual.scope().code.as_str() != ENGINE_UNIT_SCALE_SELECTED_SCOPE
10096                        && actual.reasons() != expected.reasons)
10097            })
10098    {
10099        return Err(PredictionContractError::EngineUnitScaleFacetMismatch);
10100    }
10101    Ok(())
10102}
10103
10104const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
10105const ENGINE_CLIP_BOUNDARY_SOURCE_ID: &str = "unreal-animation-sequences-5.8";
10106const ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY: &str = "unreal";
10107const ENGINE_CLIP_BOUNDARY_PROFILE_REVISION: u32 = 1;
10108const ENGINE_CLIP_BOUNDARY_ENGINE_VERSION: &str = "5.8";
10109const ENGINE_CLIP_BOUNDARY_IMPORTER: &str = "fbx-importer";
10110const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256: &str =
10111    "e44ca461aee46312b8265446f08338b988b96abeab0f8f502f560da5f1cdf759";
10112const ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES: u64 = 2_169;
10113
10114fn current_engine_clip_boundary_profile_matches_v3(provenance: &PredictionProvenanceV3) -> bool {
10115    let selection = provenance.profile().selection();
10116    provenance.source_format() == SourceFormatV1::Fbx
10117        && provenance.raw_source().source_format() == SourceFormatV1::Fbx
10118        && selection.family() == ENGINE_CLIP_BOUNDARY_PROFILE_FAMILY
10119        && selection.profile_revision() == ENGINE_CLIP_BOUNDARY_PROFILE_REVISION
10120        && selection.engine_version() == ENGINE_CLIP_BOUNDARY_ENGINE_VERSION
10121        && selection.importer() == ENGINE_CLIP_BOUNDARY_IMPORTER
10122        && provenance.profile().facts_identity().sha256()
10123            == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
10124        && provenance.profile().facts_identity().bytes() == ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
10125        && matches!(
10126            provenance
10127                .profile()
10128                .fact(EngineFactIdV1::WholeEndFrameRequired)
10129                .map(|fact| fact.state()),
10130            Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
10131        )
10132        && provenance
10133            .profile()
10134            .source(ENGINE_CLIP_BOUNDARY_SOURCE_ID)
10135            .is_some()
10136}
10137
10138fn validate_current_engine_clip_boundary_applicability_v3(
10139    check_id: &str,
10140    applicability: Applicability,
10141    provenance: Option<&PredictionProvenanceV3>,
10142) -> Result<(), PredictionContractError> {
10143    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10144        return Ok(());
10145    }
10146    let expected = match provenance {
10147        Some(provenance)
10148            if current_engine_clip_boundary_profile_matches_v3(provenance)
10149                && !(provenance.raw_source().clips_coverage().state()
10150                    == RawSourceSetCoverageStateV1::Complete
10151                    && provenance
10152                        .raw_source()
10153                        .exact_source_timing()
10154                        .is_some_and(|timing| timing.clips().is_empty())) =>
10155        {
10156            Applicability::Applicable
10157        }
10158        _ => Applicability::NotApplicable,
10159    };
10160    if applicability != expected {
10161        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10162    }
10163    Ok(())
10164}
10165
10166/// Re-derive the frozen output-v14 clip-boundary rule from embedded V3
10167/// provenance. This keeps readback and producer construction from accepting a
10168/// merely well-shaped facet whose scope, basis, availability, reason, or
10169/// finding disagrees with the retained exact source timing.
10170fn validate_current_engine_clip_boundary_prediction_v3(
10171    check_id: &str,
10172    prediction: &EnginePredictionV3,
10173    provenance: &PredictionProvenanceV3,
10174    evaluated_scopes: &[EvaluationScope],
10175    finding_scopes: &[&EvaluationScope],
10176) -> Result<(), PredictionContractError> {
10177    if check_id != ENGINE_CLIP_BOUNDARY_CHECK_ID {
10178        return Ok(());
10179    }
10180
10181    if !current_engine_clip_boundary_profile_matches_v3(provenance) {
10182        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10183    }
10184
10185    let expected_rows = provenance.settings().clips().len();
10186    let timing = provenance.raw_source().exact_source_timing();
10187    if timing.is_some_and(|timing| timing.clips().len() != expected_rows) {
10188        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10189    }
10190    let inventory_incomplete =
10191        provenance.raw_source().clips_coverage().state() != RawSourceSetCoverageStateV1::Complete;
10192    let has_budget_summary = prediction.has_facet_budget_summary();
10193    let mut seen_rows = vec![false; expected_rows];
10194    let mut row_facets = 0usize;
10195    let mut inventory_facets = 0usize;
10196    let mut available_scopes = Vec::new();
10197    let mut expected_finding_scopes = Vec::new();
10198
10199    for facet in prediction.facets() {
10200        if facet.reasons() == [PredictionUnavailableReasonV2::FacetBudgetExceeded] {
10201            if facet.basis() != &engine_clip_boundary_inventory_basis(timing)? {
10202                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10203            }
10204            continue;
10205        }
10206        if facet.scope().code.as_str() == "engine_clip_boundary" {
10207            let Some(source_clip_index) = facet
10208                .scope()
10209                .subject
10210                .as_deref()
10211                .and_then(|subject| subject.strip_prefix("source_stack:"))
10212                .and_then(|index| index.parse::<usize>().ok())
10213            else {
10214                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10215            };
10216            if source_clip_index >= expected_rows
10217                || std::mem::replace(&mut seen_rows[source_clip_index], true)
10218            {
10219                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10220            }
10221            row_facets += 1;
10222            let expected_basis = engine_clip_boundary_stack_basis(timing, source_clip_index)?;
10223            if facet.basis() != &expected_basis {
10224                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10225            }
10226
10227            let exact = timing.and_then(|timing| {
10228                let declared = matches!(
10229                    timing.declared_time_mode().state(),
10230                    ExactSourceTimingObservationStateWireV1::Observed(_)
10231                );
10232                let period = match timing.frame_period().state() {
10233                    ExactSourceTimingObservationStateWireV1::Observed(period) => {
10234                        Some(period.units_per_frame())
10235                    }
10236                    _ => None,
10237                };
10238                let end = match timing.clips()[source_clip_index]
10239                    .source_time_range()
10240                    .state()
10241                {
10242                    ExactSourceTimingObservationStateWireV1::Observed(range) => {
10243                        Some(range.end_units())
10244                    }
10245                    _ => None,
10246                };
10247                declared.then_some(())?;
10248                Some((period?, end?))
10249            });
10250            match exact {
10251                Some((period, end)) => {
10252                    if facet.state() != EnginePredictionFacetStateV1::Available
10253                        || !facet.reasons().is_empty()
10254                    {
10255                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10256                    }
10257                    available_scopes.push(facet.scope());
10258                    if end.rem_euclid(period) != 0 {
10259                        expected_finding_scopes.push(facet.scope());
10260                    }
10261                }
10262                None => {
10263                    let expected_reasons =
10264                        engine_clip_boundary_unavailable_reasons(timing, source_clip_index)?;
10265                    if facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10266                        || facet.reasons() != expected_reasons
10267                    {
10268                        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10269                    }
10270                }
10271            }
10272        } else if facet.scope().code.as_str() == "engine_clip_boundary_inventory"
10273            && facet.scope().subject.is_none()
10274        {
10275            inventory_facets += 1;
10276            if inventory_facets != 1
10277                || !inventory_incomplete
10278                || facet.state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
10279                || facet.reasons() != [PredictionUnavailableReasonV2::RawSourceIncomplete]
10280                || facet.basis() != &engine_clip_boundary_inventory_basis(timing)?
10281            {
10282                return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10283            }
10284        } else {
10285            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10286        }
10287    }
10288
10289    if seen_rows[..row_facets].iter().any(|seen| !seen)
10290        || seen_rows[row_facets..].iter().any(|seen| *seen)
10291    {
10292        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10293    }
10294    let candidate_facets = row_facets + inventory_facets;
10295    let expected_demand = expected_rows + usize::from(inventory_incomplete);
10296    if has_budget_summary {
10297        if candidate_facets >= expected_demand
10298            || inventory_facets != usize::from(inventory_incomplete && candidate_facets != 0)
10299        {
10300            return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10301        }
10302    } else if row_facets != expected_rows || inventory_facets != usize::from(inventory_incomplete) {
10303        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10304    }
10305    if evaluated_scopes.len() != available_scopes.len()
10306        || evaluated_scopes
10307            .iter()
10308            .any(|scope| !available_scopes.contains(&scope))
10309    {
10310        return Err(PredictionContractError::EngineClipBoundaryFacetMismatch);
10311    }
10312    if finding_scopes.len() != expected_finding_scopes.len()
10313        || expected_finding_scopes.iter().any(|expected| {
10314            finding_scopes
10315                .iter()
10316                .filter(|actual| **actual == *expected)
10317                .count()
10318                != 1
10319        })
10320    {
10321        return Err(PredictionContractError::EngineClipBoundaryFindingMismatch);
10322    }
10323    Ok(())
10324}
10325
10326fn engine_clip_boundary_common_basis()
10327-> Result<Vec<PredictionBasisReferenceV2>, PredictionContractError> {
10328    Ok(vec![
10329        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::profile_fact(
10330            "whole_end_frame_required",
10331        )?),
10332        PredictionBasisReferenceV2::v1(PredictionBasisReferenceV1::primary_source(
10333            ENGINE_CLIP_BOUNDARY_SOURCE_ID,
10334        )?),
10335    ])
10336}
10337
10338fn engine_clip_boundary_exact_reference(
10339    binding: &ExactSourceTimingBindingV1,
10340    domain: ExactSourceTimingDomainV1,
10341    key: ExactSourceTimingKeyV1,
10342    field: &'static str,
10343) -> Result<PredictionBasisReferenceV2, PredictionContractError> {
10344    Ok(PredictionBasisReferenceV2::exact_source_timing(
10345        ExactSourceTimingBasisReferenceV1::from_binding(
10346            domain,
10347            key,
10348            RawSourceFieldIdV1::new(field)?,
10349            binding,
10350        )?,
10351    ))
10352}
10353
10354fn engine_clip_boundary_stack_basis(
10355    timing: Option<&ExactSourceTimingBindingV1>,
10356    source_clip_index: usize,
10357) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10358    let mut references = engine_clip_boundary_common_basis()?;
10359    let Some(timing) = timing else {
10360        return EnginePredictionBasisV2::new(references);
10361    };
10362    let stack_key = ExactSourceTimingKeyV1::Clip {
10363        source_clip_index: source_clip_index as u64,
10364    };
10365    for (domain, key, field) in [
10366        (
10367            ExactSourceTimingDomainV1::Document,
10368            ExactSourceTimingKeyV1::Document,
10369            "declared_time_mode.state",
10370        ),
10371        (
10372            ExactSourceTimingDomainV1::Document,
10373            ExactSourceTimingKeyV1::Document,
10374            "frame_period.state",
10375        ),
10376        (
10377            ExactSourceTimingDomainV1::Clip,
10378            stack_key.clone(),
10379            "source_time_range.state",
10380        ),
10381    ] {
10382        references.push(engine_clip_boundary_exact_reference(
10383            timing, domain, key, field,
10384        )?);
10385    }
10386    if matches!(
10387        timing.declared_time_mode().state(),
10388        ExactSourceTimingObservationStateWireV1::Observed(_)
10389    ) {
10390        references.push(engine_clip_boundary_exact_reference(
10391            timing,
10392            ExactSourceTimingDomainV1::Document,
10393            ExactSourceTimingKeyV1::Document,
10394            "declared_time_mode.value.time_mode",
10395        )?);
10396    }
10397    if matches!(
10398        timing.frame_period().state(),
10399        ExactSourceTimingObservationStateWireV1::Observed(_)
10400    ) {
10401        references.push(engine_clip_boundary_exact_reference(
10402            timing,
10403            ExactSourceTimingDomainV1::Document,
10404            ExactSourceTimingKeyV1::Document,
10405            "frame_period.value.units_per_frame",
10406        )?);
10407    }
10408    if matches!(
10409        timing.clips()[source_clip_index]
10410            .source_time_range()
10411            .state(),
10412        ExactSourceTimingObservationStateWireV1::Observed(_)
10413    ) {
10414        references.push(engine_clip_boundary_exact_reference(
10415            timing,
10416            ExactSourceTimingDomainV1::Clip,
10417            stack_key,
10418            "source_time_range.value.end_units",
10419        )?);
10420    }
10421    EnginePredictionBasisV2::new(references)
10422}
10423
10424fn engine_clip_boundary_inventory_basis(
10425    timing: Option<&ExactSourceTimingBindingV1>,
10426) -> Result<EnginePredictionBasisV2, PredictionContractError> {
10427    let mut references = engine_clip_boundary_common_basis()?;
10428    if let Some(timing) = timing {
10429        for field in ["clip_coverage.state", "clip_coverage.reason"] {
10430            references.push(engine_clip_boundary_exact_reference(
10431                timing,
10432                ExactSourceTimingDomainV1::Document,
10433                ExactSourceTimingKeyV1::Document,
10434                field,
10435            )?);
10436        }
10437    }
10438    EnginePredictionBasisV2::new(references)
10439}
10440
10441fn engine_clip_boundary_unavailable_reasons(
10442    timing: Option<&ExactSourceTimingBindingV1>,
10443    source_clip_index: usize,
10444) -> Result<Vec<PredictionUnavailableReasonV2>, PredictionContractError> {
10445    let Some(timing) = timing else {
10446        return Ok(vec![PredictionUnavailableReasonV2::custom(
10447            "animsmith:exact_source_timing_unavailable",
10448        )?]);
10449    };
10450    let mut reasons = Vec::new();
10451    if !matches!(
10452        timing.declared_time_mode().state(),
10453        ExactSourceTimingObservationStateWireV1::Observed(_)
10454    ) {
10455        reasons.push(PredictionUnavailableReasonV2::custom(
10456            "animsmith:source_declared_time_mode_unavailable",
10457        )?);
10458    }
10459    if !matches!(
10460        timing.frame_period().state(),
10461        ExactSourceTimingObservationStateWireV1::Observed(_)
10462    ) {
10463        reasons.push(PredictionUnavailableReasonV2::custom(
10464            "animsmith:source_frame_period_unavailable",
10465        )?);
10466    }
10467    if !matches!(
10468        timing.clips()[source_clip_index]
10469            .source_time_range()
10470            .state(),
10471        ExactSourceTimingObservationStateWireV1::Observed(_)
10472    ) {
10473        reasons.push(PredictionUnavailableReasonV2::custom(
10474            "animsmith:source_clip_time_range_unavailable",
10475        )?);
10476    }
10477    reasons.sort_by(|left, right| left.as_str().cmp(right.as_str()));
10478    Ok(reasons)
10479}
10480
10481impl MeasurementReportInput {
10482    /// Read one report through the immutable output-v11 byte bound before
10483    /// UTF-8 or JSON parsing.
10484    ///
10485    /// The JSON parser receives at most [`OUTPUT_V11_MAX_REPORT_BYTES`] bytes
10486    /// and retains its recursion limit. This function never performs an
10487    /// unbounded `read_to_end` or constructs a generic JSON value.
10488    ///
10489    /// # Errors
10490    ///
10491    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic contract
10492    /// validation remains in [`Self::into_files`].
10493    pub fn read_from(reader: impl Read) -> Result<Self, MeasurementReportReadError> {
10494        Self::read_from_with_limit(reader, OUTPUT_V11_MAX_REPORT_BYTES)
10495    }
10496
10497    fn read_from_with_limit(
10498        reader: impl Read,
10499        limit: u64,
10500    ) -> Result<Self, MeasurementReportReadError> {
10501        let mut bounded = reader.take(limit + 1);
10502        let mut bytes = Vec::new();
10503        bounded
10504            .read_to_end(&mut bytes)
10505            .map_err(|source| MeasurementReportReadError::Io { source })?;
10506        if bytes.len() as u64 > limit {
10507            return Err(MeasurementReportReadError::ReportTooLarge { limit });
10508        }
10509        serde_json::from_slice(&bytes)
10510            .map_err(|source| MeasurementReportReadError::InvalidJson { source })
10511    }
10512
10513    /// Number of file records present before nested record validation.
10514    ///
10515    /// Returns `None` when the report omitted its file array. Consumers can
10516    /// retain this count while [`MeasurementReportInput::into_files`] performs
10517    /// full validation, then apply their own cardinality and error policy.
10518    pub fn file_count(&self) -> Option<usize> {
10519        self.files.as_ref().map(Vec::len)
10520    }
10521
10522    /// Validate current output/measurement identities and recover every file's
10523    /// complete measurement record from a `measure` or `lint` report.
10524    ///
10525    /// File order is preserved. Empty and multi-file reports are accepted so
10526    /// callers can apply their own cardinality policy.
10527    ///
10528    /// # Errors
10529    ///
10530    /// Returns a typed error for a missing or unsupported identity, command,
10531    /// file shape, nested measurement contract, or measurement payload.
10532    pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
10533        #[derive(Clone, Copy, PartialEq, Eq)]
10534        enum ReaderRevision {
10535            V11,
10536            V12,
10537            V13,
10538            V14,
10539            V15,
10540            V16,
10541            V17,
10542            V18,
10543            V19,
10544        }
10545
10546        let revision = match self.schema_version {
10547            Some(OUTPUT_V11_SCHEMA_VERSION) => ReaderRevision::V11,
10548            Some(OUTPUT_V12_SCHEMA_VERSION) => ReaderRevision::V12,
10549            Some(OUTPUT_V13_SCHEMA_VERSION) => ReaderRevision::V13,
10550            Some(OUTPUT_V14_SCHEMA_VERSION) => ReaderRevision::V14,
10551            Some(OUTPUT_V15_SCHEMA_VERSION) => ReaderRevision::V15,
10552            Some(OUTPUT_V16_SCHEMA_VERSION) => ReaderRevision::V16,
10553            Some(OUTPUT_V17_SCHEMA_VERSION) => ReaderRevision::V17,
10554            Some(OUTPUT_V18_SCHEMA_VERSION) => ReaderRevision::V18,
10555            Some(OUTPUT_SCHEMA_VERSION) => ReaderRevision::V19,
10556            Some(found) => {
10557                return Err(MeasurementReportError::UnsupportedOutputVersion { found });
10558            }
10559            None => return Err(MeasurementReportError::MissingOutputVersion),
10560        };
10561        let expected_schema = match revision {
10562            ReaderRevision::V11 => OUTPUT_V11_SCHEMA_ID,
10563            ReaderRevision::V12 => OUTPUT_V12_SCHEMA_ID,
10564            ReaderRevision::V13 => OUTPUT_V13_SCHEMA_ID,
10565            ReaderRevision::V14 => OUTPUT_V14_SCHEMA_ID,
10566            ReaderRevision::V15 => OUTPUT_V15_SCHEMA_ID,
10567            ReaderRevision::V16 => OUTPUT_V16_SCHEMA_ID,
10568            ReaderRevision::V17 => OUTPUT_V17_SCHEMA_ID,
10569            ReaderRevision::V18 => OUTPUT_V18_SCHEMA_ID,
10570            ReaderRevision::V19 => OUTPUT_SCHEMA_ID,
10571        };
10572        if self.schema.as_deref() != Some(expected_schema) {
10573            return Err(MeasurementReportError::WrongOutputIdentity);
10574        }
10575        let command = match self.command.as_deref() {
10576            Some(command @ ("measure" | "lint")) => command,
10577            Some(command) => {
10578                return Err(MeasurementReportError::UnsupportedCommand {
10579                    command: command.to_owned(),
10580                });
10581            }
10582            None => return Err(MeasurementReportError::MissingCommand),
10583        };
10584        if let Some(field) = self.extra.keys().next() {
10585            return Err(MeasurementReportError::UnknownOutputField {
10586                field: field.clone(),
10587            });
10588        }
10589        if self._tool.is_none() {
10590            return Err(MeasurementReportError::MissingTool);
10591        }
10592        // The V11 reader retains the same summary obligation as the released
10593        // V1 contract; only its prediction attachment identity differs.
10594        validate_prediction_summary_presence(command, self.summary.as_ref())?;
10595        let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
10596        if files.len() > OUTPUT_V11_MAX_FILES {
10597            return Err(MeasurementReportError::TooManyFiles {
10598                found: files.len(),
10599                limit: OUTPUT_V11_MAX_FILES,
10600            });
10601        }
10602        let mut available = 0usize;
10603        let mut unavailable = 0usize;
10604        let mut decoded_files = Vec::with_capacity(files.len());
10605        for (file_index, raw) in files.into_iter().enumerate() {
10606            let file = if revision == ReaderRevision::V11 {
10607                let file = decode_legacy_v11_file(command, file_index, &raw)?;
10608                let (file_available, file_unavailable) =
10609                    validate_legacy_v11_prediction_phase_file(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::V14 {
10618                let file = decode_prediction_phase_file_v14(command, file_index, &raw)?;
10619                let (file_available, file_unavailable) =
10620                    validate_prediction_phase_file_v14(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 revision == ReaderRevision::V15 {
10629                let file = decode_prediction_phase_file_v15(command, file_index, &raw)?;
10630                let (file_available, file_unavailable) =
10631                    validate_prediction_phase_file_v15(command, file_index, &file)?;
10632                available = available
10633                    .checked_add(file_available)
10634                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10635                unavailable = unavailable
10636                    .checked_add(file_unavailable)
10637                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10638                file
10639            } else if revision == ReaderRevision::V16 {
10640                let file = decode_prediction_phase_file_v16(command, file_index, &raw)?;
10641                let (file_available, file_unavailable) =
10642                    validate_prediction_phase_file_v16(command, file_index, &file)?;
10643                available = available
10644                    .checked_add(file_available)
10645                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10646                unavailable = unavailable
10647                    .checked_add(file_unavailable)
10648                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10649                file
10650            } else if matches!(
10651                revision,
10652                ReaderRevision::V17 | ReaderRevision::V18 | ReaderRevision::V19
10653            ) {
10654                let file = decode_prediction_phase_file_v17(command, file_index, &raw)?;
10655                let (file_available, file_unavailable) =
10656                    validate_prediction_phase_file_v17(command, file_index, &file)?;
10657                available = available
10658                    .checked_add(file_available)
10659                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10660                unavailable = unavailable
10661                    .checked_add(file_unavailable)
10662                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10663                file
10664            } else {
10665                let expected_measurement_schema = if revision == ReaderRevision::V12 {
10666                    MEASUREMENTS_V15_SCHEMA_ID
10667                } else {
10668                    MEASUREMENTS_V16_SCHEMA_ID
10669                };
10670                let file = decode_prediction_phase_file(
10671                    command,
10672                    file_index,
10673                    &raw,
10674                    expected_measurement_schema,
10675                )?;
10676                let (file_available, file_unavailable) = validate_prediction_phase_file(
10677                    command,
10678                    file_index,
10679                    &file,
10680                    expected_measurement_schema,
10681                )?;
10682                available = available
10683                    .checked_add(file_available)
10684                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10685                unavailable = unavailable
10686                    .checked_add(file_unavailable)
10687                    .ok_or(MeasurementReportError::PredictionFacetSummaryMismatch)?;
10688                file
10689            };
10690            decoded_files.push(file);
10691        }
10692        validate_prediction_summary(command, self.summary.as_ref(), available, unavailable)?;
10693        let parsed = decoded_files
10694            .into_iter()
10695            .enumerate()
10696            .map(|(file_index, file)| {
10697                let path = file.path.ok_or_else(|| {
10698                    MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
10699                })?;
10700                let input = file.input.ok_or_else(|| {
10701                    MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
10702                })?;
10703                let sha256 = input.sha256.ok_or_else(|| {
10704                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
10705                })?;
10706                if sha256.len() != 64
10707                    || !sha256
10708                        .bytes()
10709                        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
10710                {
10711                    return Err(MeasurementReportError::file(
10712                        file_index,
10713                        MeasurementFileError::InvalidSha256,
10714                    ));
10715                }
10716                let bytes = input.bytes.ok_or_else(|| {
10717                    MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
10718                })?;
10719                let measurements = file.measurements.ok_or_else(|| {
10720                    MeasurementReportError::file(
10721                        file_index,
10722                        MeasurementFileError::MissingMeasurements,
10723                    )
10724                })?;
10725                let measurements = decode_measurement_payload(
10726                    &measurements,
10727                    matches!(
10728                        revision,
10729                        ReaderRevision::V13
10730                            | ReaderRevision::V14
10731                            | ReaderRevision::V15
10732                            | ReaderRevision::V16
10733                            | ReaderRevision::V17
10734                            | ReaderRevision::V18
10735                            | ReaderRevision::V19
10736                    ),
10737                )
10738                .map_err(|source| {
10739                    MeasurementReportError::file(
10740                        file_index,
10741                        MeasurementFileError::InvalidMeasurementsShape {
10742                            reason: source.to_string(),
10743                        },
10744                    )
10745                })?;
10746                let (expected_measurement_version, expected_measurement_schema) = match revision {
10747                    ReaderRevision::V11 | ReaderRevision::V12 => {
10748                        (MEASUREMENTS_V15_SCHEMA_VERSION, MEASUREMENTS_V15_SCHEMA_ID)
10749                    }
10750                    ReaderRevision::V13
10751                    | ReaderRevision::V14
10752                    | ReaderRevision::V15
10753                    | ReaderRevision::V16
10754                    | ReaderRevision::V17 => {
10755                        (MEASUREMENTS_V16_SCHEMA_VERSION, MEASUREMENTS_V16_SCHEMA_ID)
10756                    }
10757                    ReaderRevision::V18 => {
10758                        (MEASUREMENTS_V17_SCHEMA_VERSION, MEASUREMENTS_V17_SCHEMA_ID)
10759                    }
10760                    ReaderRevision::V19 => (MEASUREMENTS_SCHEMA_VERSION, MEASUREMENTS_SCHEMA_ID),
10761                };
10762                match measurements.schema_version {
10763                    Some(found) if found == expected_measurement_version => {}
10764                    Some(found) => {
10765                        return Err(MeasurementReportError::file(
10766                            file_index,
10767                            MeasurementFileError::UnsupportedMeasurementVersion {
10768                                found,
10769                                expected: expected_measurement_version,
10770                            },
10771                        ));
10772                    }
10773                    None => {
10774                        return Err(MeasurementReportError::file(
10775                            file_index,
10776                            MeasurementFileError::MissingMeasurementVersion,
10777                        ));
10778                    }
10779                }
10780                if measurements.schema.as_deref() != Some(expected_measurement_schema) {
10781                    return Err(MeasurementReportError::file(
10782                        file_index,
10783                        MeasurementFileError::WrongMeasurementIdentity {
10784                            expected: expected_measurement_schema,
10785                        },
10786                    ));
10787                }
10788                let clips = measurements.clips.ok_or_else(|| {
10789                    MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
10790                })?;
10791                let material_resource_coverage =
10792                    measurements.material_resource_coverage.ok_or_else(|| {
10793                        MeasurementReportError::file(
10794                            file_index,
10795                            MeasurementFileError::MissingMaterialResourceCoverage,
10796                        )
10797                    })?;
10798                let material_definitions = measurements.material_definitions.ok_or_else(|| {
10799                    MeasurementReportError::file(
10800                        file_index,
10801                        MeasurementFileError::MissingMaterialDefinitions,
10802                    )
10803                })?;
10804                let textures = measurements.textures.ok_or_else(|| {
10805                    MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
10806                })?;
10807                let images = measurements.images.ok_or_else(|| {
10808                    MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
10809                })?;
10810                let skeleton_source_coverage =
10811                    measurements.skeleton_source_coverage.ok_or_else(|| {
10812                        MeasurementReportError::file(
10813                            file_index,
10814                            MeasurementFileError::MissingSkeletonSourceCoverage,
10815                        )
10816                    })?;
10817                let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
10818                    MeasurementReportError::file(
10819                        file_index,
10820                        MeasurementFileError::MissingSkeletonNodes,
10821                    )
10822                })?;
10823                let skeleton_nodes = skeleton_nodes
10824                    .into_iter()
10825                    .enumerate()
10826                    .map(|(offset, node)| match node {
10827                        SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
10828                        SkeletonNodeMeasurementInput::Earlier { .. } => {
10829                            Err(MeasurementReportError::file(
10830                                file_index,
10831                                MeasurementFileError::InvalidMeasurements {
10832                                    source: MeasurementContractError::InvalidStructure {
10833                                        path: format!("skeleton_nodes[{offset}]"),
10834                                        reason: "uses a shape from an earlier measurement contract"
10835                                            .into(),
10836                                    },
10837                                },
10838                            ))
10839                        }
10840                    })
10841                    .collect::<Result<Vec<_>, _>>()?;
10842                let skins = measurements.skins.ok_or_else(|| {
10843                    MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
10844                })?;
10845                let skins = skins
10846                    .into_iter()
10847                    .enumerate()
10848                    .map(|(offset, skin)| match skin {
10849                        SkinMeasurementInput::Current(skin) => Ok(*skin),
10850                        SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
10851                            file_index,
10852                            MeasurementFileError::InvalidMeasurements {
10853                                source: MeasurementContractError::InvalidStructure {
10854                                    path: format!("skins[{offset}]"),
10855                                    reason: "uses a shape from an earlier measurement contract"
10856                                        .into(),
10857                                },
10858                            },
10859                        )),
10860                    })
10861                    .collect::<Result<Vec<_>, _>>()?;
10862                let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
10863                    MeasurementReportError::file(
10864                        file_index,
10865                        MeasurementFileError::MissingMeshDefinitions,
10866                    )
10867                })?;
10868                let node_instances = measurements.node_instances.ok_or_else(|| {
10869                    MeasurementReportError::file(
10870                        file_index,
10871                        MeasurementFileError::MissingNodeInstances,
10872                    )
10873                })?;
10874                let scenes = measurements.scenes.ok_or_else(|| {
10875                    MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
10876                })?;
10877                let assets = AssetMeasurements {
10878                    material_resource_coverage,
10879                    material_definitions,
10880                    textures,
10881                    images,
10882                    skeleton_source_coverage,
10883                    skeleton_nodes,
10884                    skins,
10885                    mesh_definitions,
10886                    node_instances,
10887                    scenes,
10888                    default_scene_index: measurements.default_scene_index,
10889                };
10890                let measurements = match revision {
10891                    ReaderRevision::V11 | ReaderRevision::V12 => {
10892                        MeasurementContract::historical_v15(clips, assets)
10893                    }
10894                    ReaderRevision::V13
10895                    | ReaderRevision::V14
10896                    | ReaderRevision::V15
10897                    | ReaderRevision::V16
10898                    | ReaderRevision::V17 => MeasurementContract::historical_v16(clips, assets),
10899                    ReaderRevision::V18 => MeasurementContract::historical_v17(clips, assets),
10900                    ReaderRevision::V19 => MeasurementContract::new(clips, assets),
10901                }
10902                .map_err(|source| {
10903                    MeasurementReportError::file(
10904                        file_index,
10905                        MeasurementFileError::InvalidMeasurements { source },
10906                    )
10907                })?;
10908                let prediction_measurements =
10909                    if matches!(revision, ReaderRevision::V18 | ReaderRevision::V19) {
10910                        measurements.prediction_v16_projection().map_err(|source| {
10911                            MeasurementReportError::file(
10912                                file_index,
10913                                MeasurementFileError::InvalidMeasurements { source },
10914                            )
10915                        })?
10916                    } else {
10917                        measurements.clone()
10918                    };
10919                if revision == ReaderRevision::V15 {
10920                    let provenance = file.prediction_provenance_v4.as_present();
10921                    for (check_index, check) in file
10922                        .checks_v3
10923                        .as_deref()
10924                        .unwrap_or_default()
10925                        .iter()
10926                        .enumerate()
10927                    {
10928                        validate_current_engine_unit_scale_prediction_v4(
10929                            &check.check_id,
10930                            check.selection,
10931                            check.configuration,
10932                            check.applicability,
10933                            None,
10934                            None,
10935                            &prediction_measurements,
10936                        )
10937                        .map_err(|source| {
10938                            MeasurementReportError::file(
10939                                file_index,
10940                                MeasurementFileError::InvalidPrediction {
10941                                    check_index,
10942                                    source,
10943                                },
10944                            )
10945                        })?;
10946                    }
10947                    for (check_index, check) in file
10948                        .checks_v4
10949                        .as_deref()
10950                        .unwrap_or_default()
10951                        .iter()
10952                        .enumerate()
10953                    {
10954                        validate_current_engine_unit_scale_prediction_v4(
10955                            &check.check_id,
10956                            check.selection,
10957                            check.configuration,
10958                            check.applicability,
10959                            check.prediction.as_ref(),
10960                            provenance,
10961                            &prediction_measurements,
10962                        )
10963                        .map_err(|source| {
10964                            MeasurementReportError::file(
10965                                file_index,
10966                                MeasurementFileError::InvalidPrediction {
10967                                    check_index,
10968                                    source,
10969                                },
10970                            )
10971                        })?;
10972                    }
10973                }
10974                if matches!(
10975                    revision,
10976                    ReaderRevision::V16
10977                        | ReaderRevision::V17
10978                        | ReaderRevision::V18
10979                        | ReaderRevision::V19
10980                ) && !matches!(file.prediction_provenance_v5, RequiredNullable::Missing)
10981                {
10982                    let provenance = file.prediction_provenance_v5.as_present();
10983                    for (check_index, check) in file
10984                        .checks_v5
10985                        .as_deref()
10986                        .unwrap_or_default()
10987                        .iter()
10988                        .enumerate()
10989                    {
10990                        validate_current_engine_unit_scale_prediction_v5(
10991                            &check.check_id,
10992                            check.selection,
10993                            check.configuration,
10994                            check.applicability,
10995                            check.prediction.as_ref(),
10996                            provenance,
10997                            &prediction_measurements,
10998                        )
10999                        .map_err(|source| {
11000                            MeasurementReportError::file(
11001                                file_index,
11002                                MeasurementFileError::InvalidPrediction {
11003                                    check_index,
11004                                    source,
11005                                },
11006                            )
11007                        })?;
11008                    }
11009                }
11010                if matches!(
11011                    revision,
11012                    ReaderRevision::V17 | ReaderRevision::V18 | ReaderRevision::V19
11013                ) && !matches!(file.prediction_provenance_v6, RequiredNullable::Missing)
11014                {
11015                    let provenance = file.prediction_provenance_v6.as_present();
11016                    let rig = file.rig_v17.as_ref().ok_or_else(|| {
11017                        MeasurementReportError::file(
11018                            file_index,
11019                            MeasurementFileError::InvalidFileShape {
11020                                reason: "current role-policy rig evidence was not retained".into(),
11021                            },
11022                        )
11023                    })?;
11024                    for (check_index, check) in file
11025                        .checks_v6
11026                        .as_deref()
11027                        .unwrap_or_default()
11028                        .iter()
11029                        .enumerate()
11030                    {
11031                        validate_current_engine_root_motion_prediction_v6(
11032                            &check.check_id,
11033                            check.selection,
11034                            check.configuration,
11035                            check.applicability,
11036                            check.prediction.as_ref(),
11037                            provenance,
11038                            &check.findings,
11039                            rig,
11040                            &prediction_measurements,
11041                        )
11042                        .map_err(|source| {
11043                            MeasurementReportError::file(
11044                                file_index,
11045                                MeasurementFileError::InvalidPrediction {
11046                                    check_index,
11047                                    source,
11048                                },
11049                            )
11050                        })?;
11051                    }
11052                }
11053                Ok((
11054                    MeasurementReportFile {
11055                        path,
11056                        input: InputIdentity { sha256, bytes },
11057                        measurements,
11058                    },
11059                    (
11060                        file.checks.unwrap_or_default(),
11061                        file.legacy_checks.unwrap_or_default(),
11062                        file.checks_v3.unwrap_or_default(),
11063                        file.checks_v4.unwrap_or_default(),
11064                        file.checks_v5.unwrap_or_default(),
11065                        file.checks_v6.unwrap_or_default(),
11066                    ),
11067                ))
11068            })
11069            .collect::<Result<Vec<_>, _>>()?;
11070
11071        // Measurement-dependent basis pointers are deliberately resolved only
11072        // after every file's complete, version-routed measurements contract has passed.
11073        for (
11074            file_index,
11075            (file, (checks, legacy_checks, checks_v3, checks_v4, checks_v5, checks_v6)),
11076        ) in parsed.iter().enumerate()
11077        {
11078            let prediction_measurements =
11079                if matches!(revision, ReaderRevision::V18 | ReaderRevision::V19) {
11080                    file.measurements
11081                        .prediction_v16_projection()
11082                        .map_err(|source| {
11083                            MeasurementReportError::file(
11084                                file_index,
11085                                MeasurementFileError::InvalidMeasurements { source },
11086                            )
11087                        })?
11088                } else {
11089                    file.measurements.clone()
11090                };
11091            validate_measurement_references_batch_v4(
11092                &prediction_measurements,
11093                checks_v6
11094                    .iter()
11095                    .enumerate()
11096                    .filter_map(|(check_index, check)| {
11097                        check
11098                            .prediction
11099                            .as_ref()
11100                            .map(|prediction| (check_index, prediction.base_prediction()))
11101                    }),
11102            )
11103            .map_err(|error| {
11104                MeasurementReportError::file(
11105                    file_index,
11106                    MeasurementFileError::InvalidPrediction {
11107                        check_index: error.prediction_index,
11108                        source: error.source,
11109                    },
11110                )
11111            })?;
11112            validate_measurement_references_batch_v4(
11113                &prediction_measurements,
11114                checks_v5
11115                    .iter()
11116                    .enumerate()
11117                    .filter_map(|(check_index, check)| {
11118                        check
11119                            .prediction
11120                            .as_ref()
11121                            .map(|prediction| (check_index, prediction.base_prediction()))
11122                    }),
11123            )
11124            .map_err(|error| {
11125                MeasurementReportError::file(
11126                    file_index,
11127                    MeasurementFileError::InvalidPrediction {
11128                        check_index: error.prediction_index,
11129                        source: error.source,
11130                    },
11131                )
11132            })?;
11133            validate_measurement_references_batch_v4(
11134                &prediction_measurements,
11135                checks_v4
11136                    .iter()
11137                    .enumerate()
11138                    .filter_map(|(check_index, check)| {
11139                        check
11140                            .prediction
11141                            .as_ref()
11142                            .map(|prediction| (check_index, prediction))
11143                    }),
11144            )
11145            .map_err(|error| {
11146                MeasurementReportError::file(
11147                    file_index,
11148                    MeasurementFileError::InvalidPrediction {
11149                        check_index: error.prediction_index,
11150                        source: error.source,
11151                    },
11152                )
11153            })?;
11154            validate_measurement_references_batch_v3(
11155                &prediction_measurements,
11156                checks_v3
11157                    .iter()
11158                    .enumerate()
11159                    .filter_map(|(check_index, check)| {
11160                        check
11161                            .prediction
11162                            .as_ref()
11163                            .map(|prediction| (check_index, prediction))
11164                    }),
11165            )
11166            .map_err(|error| {
11167                MeasurementReportError::file(
11168                    file_index,
11169                    MeasurementFileError::InvalidPrediction {
11170                        check_index: error.prediction_index,
11171                        source: error.source,
11172                    },
11173                )
11174            })?;
11175            validate_measurement_references_batch_v2(
11176                &prediction_measurements,
11177                checks
11178                    .iter()
11179                    .enumerate()
11180                    .filter_map(|(check_index, check)| {
11181                        check
11182                            .prediction
11183                            .as_ref()
11184                            .map(|prediction| (check_index, prediction))
11185                    }),
11186            )
11187            .map_err(|error| {
11188                MeasurementReportError::file(
11189                    file_index,
11190                    MeasurementFileError::InvalidPrediction {
11191                        check_index: error.prediction_index,
11192                        source: error.source,
11193                    },
11194                )
11195            })?;
11196            validate_measurement_references_batch(
11197                &prediction_measurements,
11198                legacy_checks
11199                    .iter()
11200                    .enumerate()
11201                    .filter_map(|(check_index, check)| {
11202                        check
11203                            .prediction
11204                            .as_ref()
11205                            .map(|prediction| (check_index, prediction))
11206                    }),
11207            )
11208            .map_err(|error| {
11209                MeasurementReportError::file(
11210                    file_index,
11211                    MeasurementFileError::InvalidPrediction {
11212                        check_index: error.prediction_index,
11213                        source: error.source,
11214                    },
11215                )
11216            })?;
11217        }
11218        Ok(parsed.into_iter().map(|(file, _)| file).collect())
11219    }
11220}
11221
11222#[cfg(test)]
11223mod measurement_report_input_tests {
11224    use std::collections::BTreeMap;
11225
11226    use super::*;
11227    use crate::engine_contract::{
11228        EngineClipSettingsV1, EngineConversionControlV1, EngineCoordinateBasisV1, EngineFactIdV1,
11229        EngineFactStateV1, EngineFactValueV1, EngineForwardAxisV1, EngineHandednessV1,
11230        EngineLinearUnitV1, EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1,
11231        EngineUpAxisV1, ResolvedEngineProfileV1, ResolvedEngineSettingsCoverageV2,
11232        ResolvedEngineSettingsV1, ResolvedEngineSettingsV2, ResolvedEngineSettingsWorkV2,
11233    };
11234    use crate::evaluation::{CheckOutput, EvaluationScope, EvaluationScopeCode};
11235    use crate::measure::{
11236        AssetMeasurements, ImageMeasurements, MeshDefinitionMeasurements, PrimitiveMeasurements,
11237    };
11238    use crate::prediction::{
11239        EngineMachineResultV1, EnginePredictionBasisV1, EnginePredictionBasisV2,
11240        EnginePredictionBasisV4, EnginePredictionFacetV1, EnginePredictionFacetV2,
11241        EnginePredictionFacetV3, EnginePredictionFacetV4, EnginePredictionV1, EnginePredictionV2,
11242        EnginePredictionV3, EnginePredictionV4, PredictionBasisReferenceV1,
11243        PredictionBasisReferenceV2, PredictionBasisReferenceV4, PredictionProvenanceIdentityV4,
11244        PredictionScalarV1, PredictionUnavailableReasonV1, PredictionUnavailableReasonV2,
11245        RawSourceBindingV1, RawSourceBindingV2, UnitMappingResultV1,
11246    };
11247    use crate::source_facts::SourceFormatV1;
11248    use crate::{
11249        DependencyClosureV1, Document, Finding, ImageSourceKind, ImageUnavailableReason,
11250        MaterialResourceCoverage, ResolvedRoles,
11251    };
11252
11253    fn prediction_test_profile() -> ResolvedEngineProfileV1 {
11254        let all_fact_ids = [
11255            EngineFactIdV1::AcceptedInputs,
11256            EngineFactIdV1::AnimationAddressability,
11257            EngineFactIdV1::AnimationChannelHandling,
11258            EngineFactIdV1::AnimationTargetAddressability,
11259            EngineFactIdV1::AxisConversionControl,
11260            EngineFactIdV1::ConstructHandling,
11261            EngineFactIdV1::ExactAxisConversion,
11262            EngineFactIdV1::ExtensionHandling,
11263            EngineFactIdV1::ResultingHierarchyScale,
11264            EngineFactIdV1::RootMotionAddressability,
11265            EngineFactIdV1::TargetCoordinateBasis,
11266            EngineFactIdV1::TargetLinearUnit,
11267            EngineFactIdV1::UnitConversionControl,
11268            EngineFactIdV1::WholeEndFrameRequired,
11269        ];
11270        let facts = all_fact_ids
11271            .into_iter()
11272            .map(|id| {
11273                let state = if id == EngineFactIdV1::AcceptedInputs {
11274                    EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11275                        SourceFormatV1::Glb,
11276                    ]))
11277                } else {
11278                    EngineFactStateV1::Unknown
11279                };
11280                EngineProfileFactV1::new(id, state)
11281            })
11282            .collect();
11283        ResolvedEngineProfileV1::new(
11284            EngineProfileSelectionV1::new("test", 1, "1", "test-importer").unwrap(),
11285            "urn:animsmith:engine-profile:test:1",
11286            facts,
11287            vec![],
11288            vec![
11289                EnginePrimarySourceV1::new(
11290                    "test-source",
11291                    "1",
11292                    "https://example.invalid/test",
11293                    "2026-08-20",
11294                    vec![EngineFactIdV1::AcceptedInputs],
11295                    vec![],
11296                )
11297                .unwrap(),
11298            ],
11299        )
11300        .unwrap()
11301    }
11302
11303    fn prediction_test_provenance_v2() -> PredictionProvenanceV2 {
11304        let raw: RawSourceBindingV1 = serde_json::from_value(serde_json::json!({
11305            "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11306            "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11307            "source_format": "glb",
11308            "linear_unit": {
11309                "state": "observed", "value": 1.0, "disposition": "preserved",
11310                "provenance": {"kind": "format_defined"}
11311            },
11312            "coordinate_basis": {
11313                "state": "observed",
11314                "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11315                "disposition": "preserved", "provenance": {"kind": "format_defined"}
11316            },
11317            "frames_per_second": {
11318                "state": "observed", "value": 30.0, "disposition": "preserved",
11319                "provenance": {"kind": "format_defined"}
11320            },
11321            "clips_coverage": {"state": "complete"},
11322            "constructs_coverage": {"state": "complete"},
11323            "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11324            "source_skeleton_coverage": "unavailable",
11325            "work": {
11326                "inspected_rows": 0, "retained_rows": 0,
11327                "retained_text_bytes": 0, "max_traversal_depth": 0
11328            }
11329        }))
11330        .unwrap();
11331        let closure = DependencyClosureV1::unavailable(raw.primary_input().clone());
11332        let profile = prediction_test_profile();
11333        let settings = ResolvedEngineSettingsV2::new(
11334            &profile,
11335            vec![],
11336            vec![],
11337            ResolvedEngineSettingsCoverageV2::complete(),
11338            ResolvedEngineSettingsWorkV2::new(0, 0, 0),
11339        )
11340        .unwrap();
11341        PredictionProvenanceV2::new(profile, SourceFormatV1::Glb, settings, raw, closure).unwrap()
11342    }
11343
11344    fn prediction_test_provenance() -> PredictionProvenanceV3 {
11345        let prior = prediction_test_provenance_v2();
11346        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11347            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11348            "source_facts": prior.raw_source(),
11349            "exact_source_timing": null
11350        }))
11351        .unwrap();
11352        PredictionProvenanceV3::new(
11353            prior.profile().clone(),
11354            prior.source_format(),
11355            prior.settings().clone(),
11356            raw,
11357            prior.dependency_closure().clone(),
11358        )
11359        .unwrap()
11360    }
11361
11362    fn partial_engine_provenance() -> PredictionProvenanceV3 {
11363        let complete = prediction_test_provenance();
11364        let mut raw_wire = serde_json::to_value(complete.raw_source().source_facts()).unwrap();
11365        raw_wire["clips_coverage"] = serde_json::json!({
11366            "state": "partial",
11367            "reason": "projection_budget_exceeded"
11368        });
11369        let raw: RawSourceBindingV2 = serde_json::from_value(serde_json::json!({
11370            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11371            "source_facts": raw_wire,
11372            "exact_source_timing": null
11373        }))
11374        .unwrap();
11375        let clips = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
11376            .map(|index| EngineClipSettingsV1::new(format!("clip-{index:04}"), Vec::new()).unwrap())
11377            .collect();
11378        let settings = ResolvedEngineSettingsV2::new(
11379            complete.profile(),
11380            Vec::new(),
11381            clips,
11382            ResolvedEngineSettingsCoverageV2::actual_clip_rows_exceeded(),
11383            ResolvedEngineSettingsWorkV2::new(4_097, 4_096, 4_096),
11384        )
11385        .unwrap();
11386        PredictionProvenanceV3::new(
11387            complete.profile().clone(),
11388            complete.source_format(),
11389            settings,
11390            raw,
11391            complete.dependency_closure().clone(),
11392        )
11393        .unwrap()
11394    }
11395
11396    fn prediction_test_measurements() -> MeasurementContract {
11397        MeasurementContract::new(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11398    }
11399
11400    fn prediction_measurements_with_rotation_facts() -> MeasurementContract {
11401        let assets: AssetMeasurements = serde_json::from_value(serde_json::json!({
11402            "material_resource_coverage": "unavailable",
11403            "material_definitions": [], "textures": [], "images": [],
11404            "skeleton_source_coverage": "complete",
11405            "skeleton_nodes": [{
11406                "node_index": 0,
11407                "scene_root_indices": [],
11408                "local_rest": {
11409                    "kind": "trs",
11410                    "translation_parent_space_m": [0.0, 0.0, 0.0],
11411                    "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
11412                    "scale": [1.0, 1.0, 1.0]
11413                },
11414                "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],
11415                "rest_world_translation_m": [0.0, 0.0, 0.0],
11416                "rest_world_linear": {
11417                    "classification": "unit_orthonormal",
11418                    "axis_lengths": [1.0, 1.0, 1.0],
11419                    "determinant": 1.0,
11420                    "orientation": "positive",
11421                    "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
11422                    "uniform_scale": 1.0
11423                }
11424            }],
11425            "skins": [{
11426                "skin_index": 0,
11427                "joints": [{
11428                    "joint_index": 0, "node_index": 0,
11429                    "joint_bind_to_mesh": {
11430                        "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],
11431                        "inversion_quality": { "reciprocal_condition_number_inf": 1.0 },
11432                        "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],
11433                        "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 }
11434                    },
11435                    "mesh_bind_world": {
11436                        "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],
11437                        "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],
11438                        "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 }
11439                    }
11440                }],
11441                "joint_bind_linear_summary": { "classification": "consistent_uniform", "joint_count": 1, "available_joint_count": 1, "unavailable_joint_count": 0, "consistent_uniform_scale": 1.0 },
11442                "inverse_bind_accessor": {
11443                    "status": "available", "declared_count": 1,
11444                    "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]]
11445                },
11446                "attachments": [{ "node_index": 0 }]
11447            }],
11448            "mesh_definitions": [], "node_instances": [], "scenes": []
11449        }))
11450        .expect("complete skeleton and skin fixture");
11451        MeasurementContract::new(BTreeMap::new(), assets).expect("current rotation facts")
11452    }
11453
11454    fn prediction_test_measurements_v16() -> MeasurementContract {
11455        MeasurementContract::historical_v16(BTreeMap::new(), AssetMeasurements::default()).unwrap()
11456    }
11457
11458    fn loop_projection_measurements(
11459        availability: &[MeasurementAvailability],
11460    ) -> MeasurementContract {
11461        let bones = availability
11462            .iter()
11463            .enumerate()
11464            .map(|(index, availability)| match availability {
11465                MeasurementAvailability::Measured => serde_json::json!({
11466                    "bone_index": index,
11467                    "bone_name": format!("bone-{index}"),
11468                    "availability": "measured",
11469                    "position_delta_m": index as f64,
11470                    "rotation_delta_deg": index as f64,
11471                    "seam_velocity_delta_mps": index as f64,
11472                    "seam_angular_velocity_delta_degps": index as f64,
11473                }),
11474                MeasurementAvailability::Unavailable => serde_json::json!({
11475                    "bone_index": index,
11476                    "bone_name": format!("bone-{index}"),
11477                    "availability": "unavailable",
11478                }),
11479                MeasurementAvailability::NotApplicable => {
11480                    panic!("loop-continuity rows are always applicable")
11481                }
11482            })
11483            .collect::<Vec<_>>();
11484        let clip: ClipMeasurements = serde_json::from_value(serde_json::json!({
11485            "duration_s": 1.0,
11486            "frame_count": 3,
11487            "animated_bones": [],
11488            "bone_channels": [],
11489            "bone_rotation_range_deg": {},
11490            "loop_continuity": { "bones": bones },
11491            "loop_continuity_availability": "measured",
11492            "loop_endpoint_mode_availability": "not_applicable",
11493            "frame_grid_availability": "not_applicable",
11494            "loop_seam_ratio_availability": "not_applicable",
11495            "gait_availability": "not_applicable",
11496            "root_trajectory_availability": "not_applicable",
11497            "speed_mps_availability": "not_applicable"
11498        }))
11499        .unwrap();
11500        MeasurementContract::new(
11501            BTreeMap::from([("loop".into(), clip)]),
11502            AssetMeasurements::default(),
11503        )
11504        .unwrap()
11505    }
11506
11507    fn measure_wire(measurements: MeasurementContract) -> serde_json::Value {
11508        let file = MeasureFileReport::new(
11509            "test.glb",
11510            InputIdentity::from_bytes(&[]),
11511            prediction_test_rig(),
11512            measurements,
11513        )
11514        .unwrap();
11515        let envelope =
11516            MeasureEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11517                .unwrap();
11518        let wire = serde_json::to_value(envelope).unwrap();
11519        serde_json::from_value::<MeasurementReportInput>(wire.clone())
11520            .unwrap()
11521            .into_files()
11522            .expect("current measurement fixture reads back");
11523        wire
11524    }
11525
11526    fn primitive_measurement_contract() -> MeasurementContract {
11527        let mut assets = AssetMeasurements::default();
11528        assets.mesh_definitions.push(MeshDefinitionMeasurements {
11529            mesh_index: 0,
11530            name: "mesh".into(),
11531            primitives: Some(vec![
11532                PrimitiveMeasurements {
11533                    primitive_index: 1,
11534                    material_index: Some(7),
11535                    vertex_count: 2,
11536                    finite_vertex_count: 1,
11537                    geometry_aabb: Some(Aabb {
11538                        min: [-2.0, 1.0, 0.0],
11539                        max: [-2.0, 1.0, 0.0],
11540                    }),
11541                    geometry_centroid: Some([-2.0, 1.0, 0.0]),
11542                },
11543                PrimitiveMeasurements {
11544                    primitive_index: 3,
11545                    material_index: None,
11546                    vertex_count: 2,
11547                    finite_vertex_count: 2,
11548                    geometry_aabb: Some(Aabb {
11549                        min: [4.0, 3.0, 0.0],
11550                        max: [6.0, 3.0, 0.0],
11551                    }),
11552                    geometry_centroid: Some([5.0, 3.0, 0.0]),
11553                },
11554            ]),
11555            vertex_count: 4,
11556            geometry_aabb: Some(Aabb {
11557                min: [-2.0, 1.0, 0.0],
11558                max: [6.0, 3.0, 0.0],
11559            }),
11560            geometry_centroid: Some([8.0 / 3.0, 7.0 / 3.0, 0.0]),
11561            max_joints_per_vertex: 0,
11562            weight_sum_min: None,
11563            weight_sum_max: None,
11564            additional_influence_sets: Vec::new(),
11565        });
11566        MeasurementContract::new(BTreeMap::new(), assets).unwrap()
11567    }
11568
11569    fn prediction_test_rig() -> RigInfo {
11570        RigInfo::from_resolved(&Document::default(), &ResolvedRoles::default()).unwrap()
11571    }
11572
11573    fn basis_v2(basis: EnginePredictionBasisV1) -> EnginePredictionBasisV2 {
11574        EnginePredictionBasisV2::new(
11575            basis
11576                .references()
11577                .iter()
11578                .cloned()
11579                .map(PredictionBasisReferenceV2::v1)
11580                .collect(),
11581        )
11582        .unwrap()
11583    }
11584
11585    fn unavailable_facet(
11586        subject: String,
11587        basis: EnginePredictionBasisV1,
11588    ) -> EnginePredictionFacetV3 {
11589        EnginePredictionFacetV3::required_unavailable(
11590            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit"))
11591                .subject(subject),
11592            basis_v2(basis),
11593            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
11594        )
11595        .unwrap()
11596    }
11597
11598    fn unavailable_check(
11599        check_id: &'static str,
11600        provenance: &PredictionProvenanceV3,
11601        facets: Vec<EnginePredictionFacetV3>,
11602    ) -> CheckEvaluation {
11603        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
11604        CheckEvaluation::evaluated(
11605            check_id,
11606            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11607                .with_engine_prediction_v3(prediction),
11608        )
11609        .unwrap()
11610    }
11611
11612    fn unavailable_check_v2(
11613        check_id: &'static str,
11614        provenance: &PredictionProvenanceV2,
11615        facets: Vec<EnginePredictionFacetV2>,
11616    ) -> CheckEvaluation {
11617        let prediction = EnginePredictionV2::new(provenance.identity().clone(), facets).unwrap();
11618        CheckEvaluation::evaluated(
11619            check_id,
11620            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
11621                .with_engine_prediction_v2(prediction),
11622        )
11623        .unwrap()
11624    }
11625
11626    fn lint_file(
11627        provenance: &PredictionProvenanceV3,
11628        checks: Vec<CheckEvaluation>,
11629    ) -> Result<LintFileReport, OutputContractError> {
11630        LintFileReport::new(
11631            "limit.glb",
11632            provenance.raw_source().primary_input().clone(),
11633            prediction_test_rig(),
11634            Some(provenance.clone()),
11635            checks,
11636            prediction_test_measurements_v16(),
11637        )
11638    }
11639
11640    #[test]
11641    fn output_v15_rejects_mixed_v3_provenance_and_v4_predictions() {
11642        let provenance = prediction_test_provenance();
11643        let identity: PredictionProvenanceIdentityV4 =
11644            serde_json::from_value(serde_json::to_value(provenance.identity()).unwrap()).unwrap();
11645        let basis = EnginePredictionBasisV4::new(vec![PredictionBasisReferenceV4::v2(
11646            PredictionBasisReferenceV2::v1(
11647                PredictionBasisReferenceV1::profile_fact("accepted_inputs").unwrap(),
11648            ),
11649        )])
11650        .unwrap();
11651        let scope = EvaluationScope::new(EvaluationScopeCode::custom("acme:v4-mixed"));
11652        let facet = EnginePredictionFacetV4::available(
11653            scope.clone(),
11654            basis,
11655            EngineMachineResultV1::UnitMapping(
11656                UnitMappingResultV1::gltf_to_engine_world_length_unit(),
11657            ),
11658        )
11659        .unwrap();
11660        let prediction = EnginePredictionV4::new(identity, vec![facet]).unwrap();
11661        let check = CheckEvaluation::evaluated(
11662            "acme-v4-mixed",
11663            CheckOutput::from_coverage(vec![], vec![scope], vec![])
11664                .with_engine_prediction_v4(prediction),
11665        )
11666        .unwrap();
11667        assert!(matches!(
11668            lint_file(&provenance, vec![check]),
11669            Err(OutputContractError::PredictionRevisionMismatch)
11670        ));
11671    }
11672
11673    #[test]
11674    fn output_v15_preserves_prediction_without_provenance_error_precedence() {
11675        let provenance = prediction_test_provenance();
11676        let check = unavailable_check(
11677            "acme-v3-without-provenance",
11678            &provenance,
11679            vec![unavailable_facet(
11680                "row".to_owned(),
11681                EnginePredictionBasisV1::new(Vec::new()).unwrap(),
11682            )],
11683        );
11684        let error = LintFileReport::new(
11685            "without-provenance.glb",
11686            provenance.raw_source().primary_input().clone(),
11687            prediction_test_rig(),
11688            None,
11689            vec![check],
11690            prediction_test_measurements_v16(),
11691        )
11692        .unwrap_err();
11693        assert_eq!(error, OutputContractError::PredictionWithoutProvenance);
11694    }
11695
11696    #[test]
11697    fn output_v15_rejects_active_unit_scale_without_v4_provenance() {
11698        let check = CheckEvaluation::evaluated(
11699            ENGINE_UNIT_SCALE_CHECK_ID,
11700            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
11701        )
11702        .unwrap();
11703        let error = LintFileReport::new_v4(
11704            "unit-scale-without-provenance.glb",
11705            InputIdentity::from_bytes(&[]),
11706            prediction_test_rig(),
11707            None,
11708            vec![check],
11709            prediction_test_measurements_v16(),
11710        )
11711        .unwrap_err();
11712        assert_eq!(
11713            error,
11714            OutputContractError::InvalidPrediction(
11715                PredictionContractError::EngineUnitScaleFacetMismatch
11716            )
11717        );
11718    }
11719
11720    fn validated_lint_wire(
11721        provenance: &PredictionProvenanceV3,
11722        checks: Vec<CheckEvaluation>,
11723    ) -> serde_json::Value {
11724        let file = lint_file(provenance, checks).expect("producer accepts exact N");
11725        let envelope =
11726            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
11727                .unwrap();
11728        let wire = serde_json::to_value(envelope).unwrap();
11729        let read: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
11730        read.into_files().expect("reader accepts exact N");
11731        wire
11732    }
11733
11734    fn lint_read_error(wire: serde_json::Value) -> MeasurementReportError {
11735        let read: MeasurementReportInput = serde_json::from_value(wire).unwrap();
11736        read.into_files().expect_err("reader must reject N+1")
11737    }
11738
11739    fn clip_boundary_profile() -> ResolvedEngineProfileV1 {
11740        let all_fact_ids = [
11741            EngineFactIdV1::AcceptedInputs,
11742            EngineFactIdV1::AnimationAddressability,
11743            EngineFactIdV1::AnimationChannelHandling,
11744            EngineFactIdV1::AnimationTargetAddressability,
11745            EngineFactIdV1::AxisConversionControl,
11746            EngineFactIdV1::ConstructHandling,
11747            EngineFactIdV1::ExactAxisConversion,
11748            EngineFactIdV1::ExtensionHandling,
11749            EngineFactIdV1::ResultingHierarchyScale,
11750            EngineFactIdV1::RootMotionAddressability,
11751            EngineFactIdV1::TargetCoordinateBasis,
11752            EngineFactIdV1::TargetLinearUnit,
11753            EngineFactIdV1::UnitConversionControl,
11754            EngineFactIdV1::WholeEndFrameRequired,
11755        ];
11756        let facts = all_fact_ids
11757            .into_iter()
11758            .map(|id| {
11759                let state = match id {
11760                    EngineFactIdV1::AcceptedInputs => {
11761                        EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
11762                            SourceFormatV1::Fbx,
11763                        ]))
11764                    }
11765                    EngineFactIdV1::TargetCoordinateBasis => EngineFactStateV1::Known(
11766                        EngineFactValueV1::CoordinateBasis(EngineCoordinateBasisV1 {
11767                            handedness: EngineHandednessV1::Left,
11768                            up_axis: EngineUpAxisV1::Z,
11769                            forward_axis: EngineForwardAxisV1::PositiveX,
11770                        }),
11771                    ),
11772                    EngineFactIdV1::TargetLinearUnit => EngineFactStateV1::Known(
11773                        EngineFactValueV1::LinearUnit(EngineLinearUnitV1::Centimetre),
11774                    ),
11775                    EngineFactIdV1::UnitConversionControl
11776                    | EngineFactIdV1::AxisConversionControl => {
11777                        EngineFactStateV1::Known(EngineFactValueV1::ConversionControl(
11778                            EngineConversionControlV1::ImporterOption,
11779                        ))
11780                    }
11781                    EngineFactIdV1::WholeEndFrameRequired => {
11782                        EngineFactStateV1::Known(EngineFactValueV1::Boolean(true))
11783                    }
11784                    _ => EngineFactStateV1::Unknown,
11785                };
11786                EngineProfileFactV1::new(id, state)
11787            })
11788            .collect();
11789        ResolvedEngineProfileV1::new(
11790            EngineProfileSelectionV1::new("unreal", 1, "5.8", "fbx-importer").unwrap(),
11791            "urn:animsmith:engine-profile:unreal:1",
11792            facts,
11793            vec![],
11794            vec![
11795                EnginePrimarySourceV1::new(
11796                    ENGINE_CLIP_BOUNDARY_SOURCE_ID,
11797                    "5.8",
11798                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
11799                    "2026-08-20",
11800                    vec![EngineFactIdV1::WholeEndFrameRequired],
11801                    vec![],
11802                )
11803                .unwrap(),
11804                EnginePrimarySourceV1::new(
11805                    "unreal-coordinate-system-5.8",
11806                    "5.8",
11807                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
11808                    "2026-08-20",
11809                    vec![EngineFactIdV1::TargetCoordinateBasis],
11810                    vec![],
11811                )
11812                .unwrap(),
11813                EnginePrimarySourceV1::new(
11814                    "unreal-fbx-import-options-5.8",
11815                    "5.8",
11816                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
11817                    "2026-08-20",
11818                    vec![
11819                        EngineFactIdV1::AcceptedInputs,
11820                        EngineFactIdV1::UnitConversionControl,
11821                        EngineFactIdV1::AxisConversionControl,
11822                    ],
11823                    vec![],
11824                )
11825                .unwrap(),
11826                EnginePrimarySourceV1::new(
11827                    "unreal-units-5.8",
11828                    "5.8",
11829                    "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
11830                    "2026-08-20",
11831                    vec![EngineFactIdV1::TargetLinearUnit],
11832                    vec![],
11833                )
11834                .unwrap(),
11835            ],
11836        )
11837        .unwrap()
11838    }
11839
11840    fn exact_observed(value: serde_json::Value, kind: &'static str) -> serde_json::Value {
11841        serde_json::json!({
11842            "state": {"kind": "observed", "value": value},
11843            "disposition": "preserved",
11844            "provenance": {"kind": kind}
11845        })
11846    }
11847
11848    fn clip_boundary_raw_wire(unavailable_last: bool) -> serde_json::Value {
11849        let last_range = if unavailable_last {
11850            serde_json::json!({
11851                "state": {"kind": "unavailable", "value": "malformed"},
11852                "disposition": "baked",
11853                "provenance": null
11854            })
11855        } else {
11856            exact_observed(
11857                serde_json::json!({
11858                    "selection": "primary", "begin_units": 0, "end_units": 9_408_000
11859                }),
11860                "parser_projected",
11861            )
11862        };
11863        serde_json::json!({
11864            "schema": crate::RAW_SOURCE_FACTS_V2_ID,
11865            "source_facts": {
11866                "schema": crate::RAW_SOURCE_FACTS_V1_ID,
11867                "primary_input": {"sha256": "00".repeat(32), "bytes": 0},
11868                "source_format": "fbx",
11869                "linear_unit": {
11870                    "state": "observed", "value": 0.01, "disposition": "preserved",
11871                    "provenance": {"kind": "format_defined"}
11872                },
11873                "coordinate_basis": {
11874                    "state": "observed",
11875                    "value": {"right": "positive_x", "up": "positive_y", "forward": "positive_z"},
11876                    "disposition": "preserved", "provenance": {"kind": "format_defined"}
11877                },
11878                "frames_per_second": {
11879                    "state": "observed", "value": 30.0, "disposition": "preserved",
11880                    "provenance": {"kind": "format_defined"}
11881                },
11882                "clips_coverage": {"state": "complete"},
11883                "constructs_coverage": {"state": "complete"},
11884                "resources_coverage": {"state": "unavailable", "reason": "parser_unavailable"},
11885                "source_skeleton_coverage": "unavailable",
11886                "work": {
11887                    "inspected_rows": 3, "retained_rows": 3,
11888                    "retained_text_bytes": 0, "max_traversal_depth": 0
11889                }
11890            },
11891            "exact_source_timing": {
11892                "schema": crate::EXACT_SOURCE_TIMING_V1_ID,
11893                "time_basis": exact_observed(
11894                    serde_json::json!({"units_per_second": 141_120_000}),
11895                    "format_defined"
11896                ),
11897                "declared_time_mode": exact_observed(
11898                    serde_json::json!("fps30"), "source_declared"
11899                ),
11900                "effective_time_mode": exact_observed(
11901                    serde_json::json!("fps30"), "parser_projected"
11902                ),
11903                "declared_custom_frame_rate": exact_observed(
11904                    serde_json::json!({"binary64_bits": 30.0_f64.to_bits()}),
11905                    "source_declared"
11906                ),
11907                "frame_period": exact_observed(
11908                    serde_json::json!({"units_per_frame": 4_704_000}),
11909                    "derived_from_source"
11910                ),
11911                "declared_time_protocol": exact_observed(
11912                    serde_json::json!("default"), "source_declared"
11913                ),
11914                "effective_time_protocol": exact_observed(
11915                    serde_json::json!("default"), "parser_projected"
11916                ),
11917                "clip_coverage": {"state": "complete"},
11918                "clips": [
11919                    {
11920                        "source_clip_index": 0,
11921                        "source_time_range": exact_observed(
11922                            serde_json::json!({
11923                                "selection": "primary", "begin_units": 0,
11924                                "end_units": 4_704_000
11925                            }),
11926                            "parser_projected"
11927                        )
11928                    },
11929                    {
11930                        "source_clip_index": 1,
11931                        "source_time_range": exact_observed(
11932                            serde_json::json!({
11933                                "selection": "primary", "begin_units": 0,
11934                                "end_units": 4_704_001
11935                            }),
11936                            "parser_projected"
11937                        )
11938                    },
11939                    {"source_clip_index": 2, "source_time_range": last_range}
11940                ]
11941            }
11942        })
11943    }
11944
11945    fn clip_boundary_provenance(unavailable_last: bool) -> PredictionProvenanceV3 {
11946        let raw: RawSourceBindingV2 =
11947            serde_json::from_value(clip_boundary_raw_wire(unavailable_last)).unwrap();
11948        let profile = clip_boundary_profile();
11949        let clips = (0..3)
11950            .map(|index| EngineClipSettingsV1::new(format!("stack-{index}"), vec![]).unwrap())
11951            .collect();
11952        let settings = ResolvedEngineSettingsV2::new(
11953            &profile,
11954            vec![],
11955            clips,
11956            ResolvedEngineSettingsCoverageV2::complete(),
11957            ResolvedEngineSettingsWorkV2::new(3, 3, 3),
11958        )
11959        .unwrap();
11960        PredictionProvenanceV3::new(
11961            profile,
11962            SourceFormatV1::Fbx,
11963            settings,
11964            raw.clone(),
11965            DependencyClosureV1::unavailable(raw.primary_input().clone()),
11966        )
11967        .unwrap()
11968    }
11969
11970    fn clip_boundary_provenance_with_settings(
11971        provenance: &PredictionProvenanceV3,
11972        profile: ResolvedEngineProfileV1,
11973        clips: Vec<EngineClipSettingsV1>,
11974    ) -> PredictionProvenanceV3 {
11975        let retained = clips.len();
11976        let settings = ResolvedEngineSettingsV2::new(
11977            &profile,
11978            vec![],
11979            clips,
11980            ResolvedEngineSettingsCoverageV2::complete(),
11981            ResolvedEngineSettingsWorkV2::new(retained, retained, retained),
11982        )
11983        .unwrap();
11984        PredictionProvenanceV3::new(
11985            profile,
11986            provenance.source_format(),
11987            settings,
11988            provenance.raw_source().clone(),
11989            provenance.dependency_closure().clone(),
11990        )
11991        .unwrap()
11992    }
11993
11994    fn altered_clip_boundary_source_profile(
11995        provenance: &PredictionProvenanceV3,
11996    ) -> ResolvedEngineProfileV1 {
11997        let sources = provenance
11998            .profile()
11999            .primary_sources()
12000            .iter()
12001            .map(|source| {
12002                let url = if source.id() == ENGINE_CLIP_BOUNDARY_SOURCE_ID {
12003                    format!("{}#altered", source.url())
12004                } else {
12005                    source.url().to_owned()
12006                };
12007                EnginePrimarySourceV1::new(
12008                    source.id(),
12009                    source.target_version(),
12010                    url,
12011                    source.verified_on(),
12012                    source.supported_fact_ids().to_vec(),
12013                    source.supported_setting_ids().to_vec(),
12014                )
12015                .unwrap()
12016            })
12017            .collect();
12018        ResolvedEngineProfileV1::new(
12019            provenance.profile().selection().clone(),
12020            provenance.profile().fact_bundle_urn(),
12021            provenance.profile().facts().to_vec(),
12022            provenance.profile().setting_descriptors().to_vec(),
12023            sources,
12024        )
12025        .unwrap()
12026    }
12027
12028    fn clip_boundary_scope(source_clip_index: usize) -> EvaluationScope {
12029        EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
12030            .subject(format!("source_stack:{source_clip_index}"))
12031    }
12032
12033    struct InapplicableClipBoundaryCheck;
12034
12035    impl crate::Check for InapplicableClipBoundaryCheck {
12036        fn id(&self) -> &'static str {
12037            ENGINE_CLIP_BOUNDARY_CHECK_ID
12038        }
12039
12040        fn applicability(&self, _ctx: &crate::CheckCtx<'_>) -> Applicability {
12041            Applicability::NotApplicable
12042        }
12043
12044        fn evaluate(&self, _ctx: &crate::CheckCtx<'_>) -> CheckOutput {
12045            panic!("an inapplicable check must not be evaluated")
12046        }
12047    }
12048
12049    fn inapplicable_clip_boundary_check() -> CheckEvaluation {
12050        let document = Document::default();
12051        let grids = crate::MetricGrids::new(&document);
12052        let roles = ResolvedRoles::default();
12053        let config = crate::Config::default();
12054        let context = crate::CheckCtx::new(&grids, &roles, &config);
12055        let checks: Vec<Box<dyn crate::Check>> = vec![Box::new(InapplicableClipBoundaryCheck)];
12056        crate::evaluate_checks(&context, &checks, crate::CheckSelection::All)
12057            .unwrap()
12058            .pop()
12059            .unwrap()
12060    }
12061
12062    fn clip_boundary_check(
12063        provenance: &PredictionProvenanceV3,
12064        unavailable_last: bool,
12065        first_basis: Option<EnginePredictionBasisV2>,
12066    ) -> CheckEvaluation {
12067        let timing = provenance.raw_source().exact_source_timing();
12068        let scopes = (0..3).map(clip_boundary_scope).collect::<Vec<_>>();
12069        let mut facets = Vec::new();
12070        for (index, scope) in scopes.iter().cloned().enumerate() {
12071            let basis = if index == 0 {
12072                first_basis
12073                    .clone()
12074                    .unwrap_or_else(|| engine_clip_boundary_stack_basis(timing, index).unwrap())
12075            } else {
12076                engine_clip_boundary_stack_basis(timing, index).unwrap()
12077            };
12078            if unavailable_last && index == 2 {
12079                facets.push(
12080                    EnginePredictionFacetV3::required_unavailable(
12081                        scope,
12082                        basis,
12083                        engine_clip_boundary_unavailable_reasons(timing, index).unwrap(),
12084                    )
12085                    .unwrap(),
12086                );
12087            } else {
12088                facets.push(EnginePredictionFacetV3::available(scope, basis).unwrap());
12089            }
12090        }
12091        let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets).unwrap();
12092        let finding = Finding::new(
12093            ENGINE_CLIP_BOUNDARY_CHECK_ID,
12094            Severity::Warning,
12095            "fractional exact source clip end",
12096        )
12097        .prediction_scope(scopes[1].clone());
12098        let evaluated_scopes = if unavailable_last {
12099            scopes[..2].to_vec()
12100        } else {
12101            scopes
12102        };
12103        CheckEvaluation::evaluated(
12104            ENGINE_CLIP_BOUNDARY_CHECK_ID,
12105            CheckOutput::from_coverage(vec![finding], evaluated_scopes, vec![])
12106                .with_engine_prediction_v3(prediction),
12107        )
12108        .unwrap()
12109    }
12110
12111    fn clip_boundary_lint_wire(unavailable_last: bool) -> serde_json::Value {
12112        let provenance = clip_boundary_provenance(unavailable_last);
12113        let check = clip_boundary_check(&provenance, unavailable_last, None);
12114        let file = LintFileReport::new(
12115            "test.fbx",
12116            provenance.raw_source().primary_input().clone(),
12117            prediction_test_rig(),
12118            Some(provenance),
12119            vec![check],
12120            prediction_test_measurements_v16(),
12121        )
12122        .unwrap();
12123        let envelope =
12124            LintEnvelope::new(ToolInfo::animsmith(ToolSource::new(None, None)), vec![file])
12125                .unwrap();
12126        let wire = serde_json::to_value(envelope).unwrap();
12127        serde_json::from_value::<MeasurementReportInput>(wire.clone())
12128            .unwrap()
12129            .into_files()
12130            .unwrap();
12131        wire
12132    }
12133
12134    fn assert_clip_boundary_read_error(wire: serde_json::Value, expected: PredictionContractError) {
12135        assert_eq!(
12136            lint_read_error(wire),
12137            MeasurementReportError::File {
12138                file_index: 0,
12139                source: MeasurementFileError::InvalidPrediction {
12140                    check_index: 0,
12141                    source: expected,
12142                },
12143            }
12144        );
12145    }
12146
12147    #[test]
12148    fn exact_source_raw_source_v2_observed_values_round_trip_and_reject_hostile_mutations() {
12149        let wire = clip_boundary_raw_wire(false);
12150        let binding: RawSourceBindingV2 = serde_json::from_value(wire.clone()).unwrap();
12151        assert_eq!(serde_json::to_value(binding).unwrap(), wire);
12152
12153        let mut invalid_value = wire.clone();
12154        invalid_value["exact_source_timing"]["frame_period"]["state"]["value"]["units_per_frame"] =
12155            serde_json::json!(0);
12156        assert_eq!(
12157            serde_json::from_value::<RawSourceBindingV2>(invalid_value)
12158                .unwrap_err()
12159                .to_string(),
12160            PredictionContractError::ExactSourceTimingValueMismatch.to_string()
12161        );
12162
12163        let mut invalid_coverage = wire.clone();
12164        invalid_coverage["exact_source_timing"]["clip_coverage"] = serde_json::json!({
12165            "state": "partial", "reason": "projection_budget_exceeded"
12166        });
12167        assert_eq!(
12168            serde_json::from_value::<RawSourceBindingV2>(invalid_coverage)
12169                .unwrap_err()
12170                .to_string(),
12171            PredictionContractError::ExactSourceTimingCoverageMismatch.to_string()
12172        );
12173
12174        let mut invalid_prefix = wire;
12175        invalid_prefix["exact_source_timing"]["clips"][1]["source_clip_index"] =
12176            serde_json::json!(2);
12177        assert_eq!(
12178            serde_json::from_value::<RawSourceBindingV2>(invalid_prefix)
12179                .unwrap_err()
12180                .to_string(),
12181            PredictionContractError::ExactSourceTimingClipPrefixMismatch.to_string()
12182        );
12183    }
12184
12185    #[test]
12186    fn clip_boundary_v3_readback_rejects_scope_basis_reason_and_finding_mutations() {
12187        let wire = clip_boundary_lint_wire(false);
12188
12189        let mut wrong_scope = wire.clone();
12190        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["subject"] =
12191            serde_json::json!("source_stack:9");
12192        wrong_scope["files"][0]["checks"][0]["evaluated_scopes"][0]["subject"] =
12193            serde_json::json!("source_stack:9");
12194        assert_clip_boundary_read_error(
12195            wrong_scope,
12196            PredictionContractError::EngineClipBoundaryFacetMismatch,
12197        );
12198
12199        let mut wrong_basis = wire.clone();
12200        wrong_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"] =
12201            serde_json::to_value(
12202                EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap(),
12203            )
12204            .unwrap();
12205        assert_clip_boundary_read_error(
12206            wrong_basis,
12207            PredictionContractError::EngineClipBoundaryFacetMismatch,
12208        );
12209
12210        let mut missing_finding = wire;
12211        missing_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([]);
12212        assert_clip_boundary_read_error(
12213            missing_finding,
12214            PredictionContractError::EngineClipBoundaryFindingMismatch,
12215        );
12216
12217        let mut wrong_reason = clip_boundary_lint_wire(true);
12218        wrong_reason["files"][0]["checks"][0]["prediction"]["facets"][2]["reasons"] =
12219            serde_json::json!(["animsmith:source_frame_period_unavailable"]);
12220        assert_clip_boundary_read_error(
12221            wrong_reason,
12222            PredictionContractError::EngineClipBoundaryFacetMismatch,
12223        );
12224    }
12225
12226    #[test]
12227    fn clip_boundary_v3_rederives_applicability_for_producer_and_readback() {
12228        let provenance = clip_boundary_provenance(false);
12229        assert!(matches!(
12230            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12231            Err(OutputContractError::InvalidPrediction(
12232                PredictionContractError::EngineClipBoundaryFacetMismatch
12233            ))
12234        ));
12235
12236        let mut wire = clip_boundary_lint_wire(false);
12237        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12238        check.insert(
12239            "applicability".to_owned(),
12240            serde_json::json!("not_applicable"),
12241        );
12242        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12243        check.insert("findings".to_owned(), serde_json::json!([]));
12244        check.remove("evaluated_scopes");
12245        check.remove("gaps");
12246        check.remove("prediction");
12247        assert_clip_boundary_read_error(
12248            wire,
12249            PredictionContractError::EngineClipBoundaryFacetMismatch,
12250        );
12251    }
12252
12253    #[test]
12254    fn clip_boundary_v3_applicability_uses_raw_exact_stack_inventory() {
12255        let original = clip_boundary_provenance(false);
12256        let provenance = clip_boundary_provenance_with_settings(
12257            &original,
12258            original.profile().clone(),
12259            Vec::new(),
12260        );
12261        assert_eq!(
12262            provenance
12263                .raw_source()
12264                .exact_source_timing()
12265                .unwrap()
12266                .clips()
12267                .len(),
12268            3
12269        );
12270        assert!(provenance.settings().clips().is_empty());
12271        assert!(matches!(
12272            lint_file(&provenance, vec![inapplicable_clip_boundary_check()]),
12273            Err(OutputContractError::InvalidPrediction(
12274                PredictionContractError::EngineClipBoundaryFacetMismatch
12275            ))
12276        ));
12277
12278        let mut wire = clip_boundary_lint_wire(false);
12279        wire["files"][0]["prediction_provenance"] = serde_json::to_value(provenance).unwrap();
12280        let check = wire["files"][0]["checks"][0].as_object_mut().unwrap();
12281        check.insert(
12282            "applicability".to_owned(),
12283            serde_json::json!("not_applicable"),
12284        );
12285        check.insert("evaluation".to_owned(), serde_json::json!("not_evaluated"));
12286        check.insert("findings".to_owned(), serde_json::json!([]));
12287        check.remove("evaluated_scopes");
12288        check.remove("gaps");
12289        check.remove("prediction");
12290        assert_clip_boundary_read_error(
12291            wire,
12292            PredictionContractError::EngineClipBoundaryFacetMismatch,
12293        );
12294    }
12295
12296    #[test]
12297    fn clip_boundary_v3_binds_the_frozen_unreal_profile_identity() {
12298        let original = clip_boundary_provenance(false);
12299        assert_eq!(
12300            original.profile().facts_identity().sha256(),
12301            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_SHA256
12302        );
12303        assert_eq!(
12304            original.profile().facts_identity().bytes(),
12305            ENGINE_CLIP_BOUNDARY_PROFILE_FACTS_BYTES
12306        );
12307        let altered_profile = altered_clip_boundary_source_profile(&original);
12308        assert_ne!(
12309            altered_profile.facts_identity(),
12310            original.profile().facts_identity()
12311        );
12312        let altered = clip_boundary_provenance_with_settings(
12313            &original,
12314            altered_profile,
12315            original.settings().clips().to_vec(),
12316        );
12317        let altered_check = clip_boundary_check(&altered, false, None);
12318        assert!(matches!(
12319            lint_file(&altered, vec![altered_check.clone()]),
12320            Err(OutputContractError::InvalidPrediction(
12321                PredictionContractError::EngineClipBoundaryFacetMismatch
12322            ))
12323        ));
12324
12325        let mut wire = clip_boundary_lint_wire(false);
12326        wire["files"][0]["prediction_provenance"] = serde_json::to_value(altered).unwrap();
12327        wire["files"][0]["checks"][0] = serde_json::to_value(altered_check).unwrap();
12328        assert_clip_boundary_read_error(
12329            wire,
12330            PredictionContractError::EngineClipBoundaryFacetMismatch,
12331        );
12332    }
12333
12334    #[test]
12335    fn clip_boundary_v3_producer_rejects_incomplete_exact_basis() {
12336        let provenance = clip_boundary_provenance(false);
12337        let incomplete_basis =
12338            EnginePredictionBasisV2::new(engine_clip_boundary_common_basis().unwrap()).unwrap();
12339        let check = clip_boundary_check(&provenance, false, Some(incomplete_basis));
12340        assert!(matches!(
12341            lint_file(&provenance, vec![check]),
12342            Err(OutputContractError::InvalidPrediction(
12343                PredictionContractError::EngineClipBoundaryFacetMismatch
12344            ))
12345        ));
12346    }
12347
12348    fn prediction_with_retained_text(
12349        provenance: &PredictionProvenanceV3,
12350        retained_text: usize,
12351    ) -> EnginePredictionV3 {
12352        const FIELD_ID_BYTES: usize = 16;
12353        const MAX_VALUE_BYTES: usize = crate::PREDICTION_V1_MAX_TEXT_BYTES;
12354        let fixed = "test:prediction-limit".len()
12355            + PredictionUnavailableReasonV2::ProjectIntentUnavailable
12356                .as_str()
12357                .len();
12358        let remaining = retained_text.checked_sub(fixed).unwrap();
12359        let full_row = FIELD_ID_BYTES + MAX_VALUE_BYTES;
12360        let full_rows = remaining / full_row;
12361        let remainder = remaining % full_row;
12362        let (full_rows, tail_lengths) = if remainder == 0 {
12363            (full_rows, Vec::new())
12364        } else if remainder >= FIELD_ID_BYTES {
12365            (full_rows, vec![remainder - FIELD_ID_BYTES])
12366        } else {
12367            (
12368                full_rows - 1,
12369                vec![0, MAX_VALUE_BYTES - FIELD_ID_BYTES + remainder],
12370            )
12371        };
12372        let mut references = Vec::with_capacity(full_rows + tail_lengths.len());
12373        for index in 0..full_rows {
12374            references.push(
12375                PredictionBasisReferenceV1::project_field(
12376                    format!("f{index:015}"),
12377                    PredictionScalarV1::text("x".repeat(MAX_VALUE_BYTES)).unwrap(),
12378                )
12379                .unwrap(),
12380            );
12381        }
12382        for length in tail_lengths {
12383            let index = references.len();
12384            references.push(
12385                PredictionBasisReferenceV1::project_field(
12386                    format!("f{index:015}"),
12387                    PredictionScalarV1::text("x".repeat(length)).unwrap(),
12388                )
12389                .unwrap(),
12390            );
12391        }
12392        let basis = EnginePredictionBasisV1::new(references).unwrap();
12393        let facet = EnginePredictionFacetV3::required_unavailable(
12394            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-limit")),
12395            basis_v2(basis),
12396            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12397        )
12398        .unwrap();
12399        let prediction =
12400            EnginePredictionV3::new(provenance.identity().clone(), vec![facet]).unwrap();
12401        assert_eq!(prediction.retained_text_bytes().unwrap(), retained_text);
12402        prediction
12403    }
12404
12405    #[test]
12406    fn report_reader_enforces_the_byte_cap_before_json_parsing() {
12407        let bytes = br#"{"schema_version":10,"tool":{}}"#;
12408        let report =
12409            MeasurementReportInput::read_from_with_limit(bytes.as_slice(), bytes.len() as u64)
12410                .expect("exact N must parse");
12411        assert_eq!(report.schema_version, Some(10));
12412
12413        assert!(matches!(
12414            MeasurementReportInput::read_from_with_limit(
12415                bytes.as_slice(),
12416                bytes.len() as u64 - 1,
12417            ),
12418            Err(MeasurementReportReadError::ReportTooLarge { limit })
12419                if limit == bytes.len() as u64 - 1
12420        ));
12421    }
12422
12423    #[test]
12424    fn prediction_facet_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12425        let provenance = prediction_test_provenance();
12426        let empty_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12427        let facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE)
12428            .map(|index| unavailable_facet(format!("facet-{index:04}"), empty_basis.clone()))
12429            .collect();
12430        let at_limit = unavailable_check("test:facet-limit", &provenance, facets);
12431        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12432        let extra = unavailable_check(
12433            "test:facet-extra",
12434            &provenance,
12435            vec![unavailable_facet("facet-extra".into(), empty_basis)],
12436        );
12437
12438        assert_eq!(
12439            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12440            OutputContractError::TooManyPredictionFacets {
12441                found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12442                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12443            }
12444        );
12445
12446        wire["files"][0]["checks"]
12447            .as_array_mut()
12448            .unwrap()
12449            .push(serde_json::to_value(extra).unwrap());
12450        assert_eq!(
12451            lint_read_error(wire),
12452            MeasurementReportError::File {
12453                file_index: 0,
12454                source: MeasurementFileError::TooManyPredictionFacets {
12455                    found: PREDICTION_V1_MAX_FACETS_PER_FILE + 1,
12456                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12457                },
12458            }
12459        );
12460    }
12461
12462    #[test]
12463    fn v2_budget_summary_is_canonical_and_requires_an_exhausted_file_budget() {
12464        let provenance = prediction_test_provenance();
12465        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12466        let mut facets = (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12467            .map(|index| unavailable_facet(format!("facet-{index:04}"), basis.clone()))
12468            .collect::<Vec<_>>();
12469        facets.push(
12470            EnginePredictionFacetV3::required_unavailable(
12471                EvaluationScope::new(EvaluationScopeCode::custom("test:budget:facet-budget")),
12472                basis_v2(basis),
12473                vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12474            )
12475            .unwrap(),
12476        );
12477        let check = unavailable_check("test:budget", &provenance, facets);
12478        let wire = validated_lint_wire(&provenance, vec![check]);
12479        let facets = wire["files"][0]["checks"][0]["prediction"]["facets"]
12480            .as_array()
12481            .unwrap();
12482        let summary_index = facets
12483            .iter()
12484            .position(|facet| facet["reasons"] == serde_json::json!(["facet_budget_exceeded"]))
12485            .unwrap();
12486
12487        let mut wrong_scope = wire.clone();
12488        wrong_scope["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["code"] =
12489            serde_json::json!("test:wrong:facet-budget");
12490        assert!(matches!(
12491            lint_read_error(wrong_scope),
12492            MeasurementReportError::File {
12493                source: MeasurementFileError::InvalidPrediction {
12494                    source: PredictionContractError::InvalidFacetBudgetSummary,
12495                    ..
12496                },
12497                ..
12498            }
12499        ));
12500
12501        let mut subject = wire.clone();
12502        subject["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["scope"]["subject"] =
12503            serde_json::json!("forged");
12504        assert!(matches!(
12505            lint_read_error(subject),
12506            MeasurementReportError::File {
12507                source: MeasurementFileError::InvalidPrediction {
12508                    source: PredictionContractError::InvalidFacetBudgetSummary,
12509                    ..
12510                },
12511                ..
12512            }
12513        ));
12514
12515        let mut available = wire.clone();
12516        available["files"][0]["checks"][0]["prediction"]["facets"][summary_index]["state"] =
12517            serde_json::json!("available");
12518        let available_error = lint_read_error(available);
12519        assert!(
12520            matches!(
12521                available_error,
12522                MeasurementReportError::File {
12523                    source: MeasurementFileError::InvalidPrediction {
12524                        source: PredictionContractError::AvailableBasisEmpty,
12525                        ..
12526                    },
12527                    ..
12528                }
12529            ),
12530            "unexpected available mutation: {available_error:?}"
12531        );
12532
12533        let mut duplicate = wire.clone();
12534        let duplicate_summary =
12535            duplicate["files"][0]["checks"][0]["prediction"]["facets"][summary_index].clone();
12536        duplicate["files"][0]["checks"][0]["prediction"]["facets"]
12537            [if summary_index == 0 { 1 } else { 0 }] = duplicate_summary;
12538        assert!(matches!(
12539            lint_read_error(duplicate),
12540            MeasurementReportError::File {
12541                source: MeasurementFileError::InvalidPrediction {
12542                    source: PredictionContractError::DuplicateFacetScope,
12543                    ..
12544                },
12545                ..
12546            }
12547        ));
12548
12549        let mut under_full = wire;
12550        under_full["files"][0]["checks"][0]["prediction"]["facets"]
12551            .as_array_mut()
12552            .unwrap()
12553            .remove(if summary_index == 0 { 1 } else { 0 });
12554        under_full["summary"]["prediction_facets"]["required_prediction_unavailable"] =
12555            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE - 1);
12556        assert_eq!(
12557            lint_read_error(under_full),
12558            MeasurementReportError::File {
12559                file_index: 0,
12560                source: MeasurementFileError::FacetBudgetSummaryWithoutExhaustedFileBudget {
12561                    found: PREDICTION_V1_MAX_FACETS_PER_FILE - 1,
12562                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
12563                },
12564            }
12565        );
12566    }
12567
12568    #[test]
12569    fn partial_engine_inventory_can_be_replaced_by_its_budget_summary() {
12570        let provenance = partial_engine_provenance();
12571        let summary = EnginePredictionFacetV3::required_unavailable(
12572            EvaluationScope::new(EvaluationScopeCode::custom(
12573                "engine-addressability:facet-budget",
12574            )),
12575            basis_v2(EnginePredictionBasisV1::new(Vec::new()).unwrap()),
12576            vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
12577        )
12578        .unwrap();
12579        let engine = unavailable_check("engine-addressability", &provenance, vec![summary]);
12580        let filler_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12581        let filler = unavailable_check(
12582            "test:filler",
12583            &provenance,
12584            (0..PREDICTION_V1_MAX_FACETS_PER_FILE - 1)
12585                .map(|index| unavailable_facet(format!("filler-{index:04}"), filler_basis.clone()))
12586                .collect(),
12587        );
12588        // A capacity-zero incomplete inventory is represented by the one
12589        // canonical summary while other rules consume the retained slots.
12590        let wire = validated_lint_wire(&provenance, vec![engine, filler]);
12591        assert_eq!(
12592            wire["summary"]["prediction_facets"]["required_prediction_unavailable"],
12593            serde_json::json!(PREDICTION_V1_MAX_FACETS_PER_FILE)
12594        );
12595    }
12596
12597    #[test]
12598    fn engine_addressability_inventory_reasons_follow_raw_and_settings_coverage() {
12599        let partial = partial_engine_provenance();
12600        let basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
12601        let inventory = EnginePredictionFacetV3::required_unavailable(
12602            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12603            basis_v2(basis.clone()),
12604            vec![
12605                PredictionUnavailableReasonV2::RawSourceIncomplete,
12606                PredictionUnavailableReasonV2::ResolvedSettingsOverflow,
12607            ],
12608        )
12609        .unwrap();
12610        let check = unavailable_check("engine-addressability", &partial, vec![inventory]);
12611        let mut wire = validated_lint_wire(&partial, vec![check]);
12612        wire["files"][0]["checks"][0]["prediction"]["facets"][0]["reasons"] =
12613            serde_json::json!(["raw_source_incomplete"]);
12614        assert!(matches!(
12615            lint_read_error(wire),
12616            MeasurementReportError::File {
12617                source: MeasurementFileError::InvalidPrediction {
12618                    source: PredictionContractError::EngineAddressabilityInventoryReasonsMismatch,
12619                    ..
12620                },
12621                ..
12622            }
12623        ));
12624
12625        let complete = prediction_test_provenance();
12626        let forged = EnginePredictionFacetV3::required_unavailable(
12627            EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
12628            basis_v2(basis),
12629            vec![PredictionUnavailableReasonV2::ResolvedSettingsOverflow],
12630        )
12631        .unwrap();
12632        assert!(matches!(
12633            lint_file(
12634                &complete,
12635                vec![unavailable_check(
12636                    "engine-addressability",
12637                    &complete,
12638                    vec![forged]
12639                )],
12640            ),
12641            Err(OutputContractError::InvalidPrediction(
12642                PredictionContractError::EngineAddressabilityInventoryReasonsMismatch
12643            ))
12644        ));
12645    }
12646
12647    #[test]
12648    fn engine_addressability_rejects_a_non_addressability_available_facet_prefix() {
12649        let mut wire = clip_boundary_lint_wire(false);
12650        let check = &mut wire["files"][0]["checks"][0];
12651        check["check_id"] = serde_json::json!("engine-addressability");
12652        for (index, facet) in check["prediction"]["facets"]
12653            .as_array_mut()
12654            .unwrap()
12655            .iter_mut()
12656            .enumerate()
12657        {
12658            facet["scope"]["code"] = serde_json::json!("animation_asset_label");
12659            facet["scope"]["subject"] = serde_json::json!(format!("Animation{index}"));
12660        }
12661        for (index, scope) in check["evaluated_scopes"]
12662            .as_array_mut()
12663            .unwrap()
12664            .iter_mut()
12665            .enumerate()
12666        {
12667            scope["code"] = serde_json::json!("animation_asset_label");
12668            scope["subject"] = serde_json::json!(format!("Animation{index}"));
12669        }
12670        check["findings"][0]["check_id"] = serde_json::json!("engine-addressability");
12671        check["findings"][0]["prediction_scope"]["code"] =
12672            serde_json::json!("animation_asset_label");
12673        check["findings"][0]["prediction_scope"]["subject"] = serde_json::json!("Animation1");
12674
12675        assert!(matches!(
12676            lint_read_error(wire),
12677            MeasurementReportError::File {
12678                source: MeasurementFileError::InvalidPrediction {
12679                    source: PredictionContractError::EngineAddressabilityFacetPrefixMismatch,
12680                    ..
12681                },
12682                ..
12683            }
12684        ));
12685    }
12686
12687    #[test]
12688    fn prediction_basis_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12689        let provenance = prediction_test_provenance();
12690        let basis = EnginePredictionBasisV1::new(
12691            (0..crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET)
12692                .map(|index| {
12693                    PredictionBasisReferenceV1::project_field(
12694                        format!("project.field.{index:04}"),
12695                        PredictionScalarV1::Null,
12696                    )
12697                    .unwrap()
12698                })
12699                .collect(),
12700        )
12701        .unwrap();
12702        let facet_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
12703            / crate::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
12704        let facets = (0..facet_count)
12705            .map(|index| unavailable_facet(format!("basis-{index:02}"), basis.clone()))
12706            .collect();
12707        let at_limit = unavailable_check("test:basis-limit", &provenance, facets);
12708        let mut wire = validated_lint_wire(&provenance, vec![at_limit.clone()]);
12709        let extra_basis = EnginePredictionBasisV1::new(vec![
12710            PredictionBasisReferenceV1::project_field("project.extra", PredictionScalarV1::Null)
12711                .unwrap(),
12712        ])
12713        .unwrap();
12714        let extra = unavailable_check(
12715            "test:basis-extra",
12716            &provenance,
12717            vec![unavailable_facet("basis-extra".into(), extra_basis)],
12718        );
12719
12720        assert_eq!(
12721            lint_file(&provenance, vec![at_limit, extra.clone()]).unwrap_err(),
12722            OutputContractError::TooManyPredictionBasisReferences {
12723                found: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE + 1,
12724                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
12725            }
12726        );
12727
12728        wire["files"][0]["checks"]
12729            .as_array_mut()
12730            .unwrap()
12731            .push(serde_json::to_value(extra).unwrap());
12732        *wire["files"][0]["checks"]
12733            .as_array_mut()
12734            .unwrap()
12735            .last_mut()
12736            .unwrap()
12737            .get_mut("prediction")
12738            .unwrap()
12739            .get_mut("facets")
12740            .and_then(serde_json::Value::as_array_mut)
12741            .and_then(|facets| facets.first_mut())
12742            .and_then(|facet| facet.get_mut("basis"))
12743            .and_then(|basis| basis.get_mut("references"))
12744            .and_then(serde_json::Value::as_array_mut)
12745            .and_then(|references| references.first_mut())
12746            .unwrap() = serde_json::Value::Null;
12747        assert!(matches!(
12748            lint_read_error(wire),
12749            MeasurementReportError::File {
12750                file_index: 0,
12751                source: MeasurementFileError::TooManyPredictionBasisReferences { .. },
12752            }
12753        ));
12754    }
12755
12756    #[test]
12757    fn prediction_text_file_bound_accepts_n_and_rejects_n_plus_one_on_write_and_read() {
12758        let provenance = prediction_test_provenance();
12759        let provenance_text = provenance.retained_text_bytes().unwrap();
12760        let at_limit_prediction = prediction_with_retained_text(
12761            &provenance,
12762            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE - provenance_text,
12763        );
12764        let at_limit = CheckEvaluation::evaluated(
12765            "test:text-limit",
12766            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12767                .with_engine_prediction_v3(at_limit_prediction),
12768        )
12769        .unwrap();
12770        let mut wire = validated_lint_wire(&provenance, vec![at_limit]);
12771
12772        let above_limit_prediction = prediction_with_retained_text(
12773            &provenance,
12774            PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1 - provenance_text,
12775        );
12776        let above_limit = CheckEvaluation::evaluated(
12777            "test:text-limit",
12778            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
12779                .with_engine_prediction_v3(above_limit_prediction),
12780        )
12781        .unwrap();
12782        let above_limit_wire = serde_json::to_value(&above_limit).unwrap();
12783        assert_eq!(
12784            lint_file(&provenance, vec![above_limit]).unwrap_err(),
12785            OutputContractError::TooMuchPredictionText {
12786                found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12787                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12788            }
12789        );
12790
12791        wire["files"][0]["checks"][0] = above_limit_wire;
12792        assert_eq!(
12793            lint_read_error(wire),
12794            MeasurementReportError::File {
12795                file_index: 0,
12796                source: MeasurementFileError::TooMuchPredictionText {
12797                    found: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE + 1,
12798                    limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
12799                },
12800            }
12801        );
12802    }
12803
12804    fn reader_error(wire: serde_json::Value) -> MeasurementReportError {
12805        serde_json::from_value::<MeasurementReportInput>(wire)
12806            .expect("outer v11 shape remains valid")
12807            .into_files()
12808            .expect_err("mutated report must fail")
12809    }
12810
12811    fn empty_check(check_id: &'static str) -> CheckEvaluation {
12812        CheckEvaluation::evaluated(
12813            check_id,
12814            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
12815        )
12816        .unwrap()
12817    }
12818
12819    #[test]
12820    fn staged_reader_rejects_unknown_root_file_and_check_fields() {
12821        let provenance = prediction_test_provenance();
12822        let wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12823
12824        let mut root = wire.clone();
12825        root["unknown_root"] = serde_json::json!(true);
12826        let bytes = serde_json::to_vec(&root).unwrap();
12827        assert_eq!(
12828            MeasurementReportInput::read_from(bytes.as_slice())
12829                .expect("unknown root fields are retained through the staged read")
12830                .into_files()
12831                .unwrap_err(),
12832            MeasurementReportError::UnknownOutputField {
12833                field: "unknown_root".into(),
12834            }
12835        );
12836
12837        let mut missing_tool = wire.clone();
12838        missing_tool.as_object_mut().unwrap().remove("tool");
12839        assert_eq!(
12840            reader_error(missing_tool),
12841            MeasurementReportError::MissingTool
12842        );
12843
12844        let bare = br#"{"walk":true}"#;
12845        assert_eq!(
12846            MeasurementReportInput::read_from(bare.as_slice())
12847                .expect("unknown root fields remain staged until header validation")
12848                .into_files()
12849                .unwrap_err(),
12850            MeasurementReportError::MissingOutputVersion,
12851        );
12852
12853        let unsupported = br#"{"schema_version":9,"walk":true}"#;
12854        assert_eq!(
12855            MeasurementReportInput::read_from(unsupported.as_slice())
12856                .expect("unknown root fields remain staged until header validation")
12857                .into_files()
12858                .unwrap_err(),
12859            MeasurementReportError::UnsupportedOutputVersion { found: 9 },
12860        );
12861
12862        let mut file = wire.clone();
12863        file["files"][0]["unknown_file"] = serde_json::json!(true);
12864        assert!(matches!(
12865            reader_error(file),
12866            MeasurementReportError::File {
12867                file_index: 0,
12868                source: MeasurementFileError::InvalidFileShape { reason },
12869            } if reason.contains("unknown field `unknown_file`")
12870        ));
12871
12872        let mut check = wire;
12873        check["files"][0]["checks"][0]["unknown_check"] = serde_json::json!(true);
12874        assert!(matches!(
12875            reader_error(check),
12876            MeasurementReportError::File {
12877                file_index: 0,
12878                source: MeasurementFileError::InvalidPredictionShape {
12879                    check_index: 0,
12880                    reason,
12881                },
12882            } if reason.contains("unknown field `unknown_check`")
12883        ));
12884
12885        let provenance = prediction_test_provenance();
12886        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12887        summary["summary"]["prediction_facets"]["unknown_prediction_total"] = serde_json::json!(0);
12888        let bytes = serde_json::to_vec(&summary).unwrap();
12889        assert!(matches!(
12890            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12891            MeasurementReportReadError::InvalidJson { source }
12892                if source.to_string().contains("unknown field `unknown_prediction_total`")
12893        ));
12894
12895        let provenance = prediction_test_provenance();
12896        let mut summary = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12897        summary["summary"]["unknown_summary"] = serde_json::json!(0);
12898        let bytes = serde_json::to_vec(&summary).unwrap();
12899        assert!(matches!(
12900            MeasurementReportInput::read_from(bytes.as_slice()).unwrap_err(),
12901            MeasurementReportReadError::InvalidJson { source }
12902                if source.to_string().contains("unknown field `unknown_summary`")
12903        ));
12904    }
12905
12906    #[test]
12907    fn staged_reader_preserves_typed_prediction_semantic_errors() {
12908        let provenance = prediction_test_provenance();
12909        let mut provenance_wire = validated_lint_wire(&provenance, Vec::new());
12910        provenance_wire["files"][0]["prediction_provenance"]["schema"] =
12911            serde_json::json!("urn:changed");
12912        assert!(matches!(
12913            reader_error(provenance_wire),
12914            MeasurementReportError::File {
12915                file_index: 0,
12916                source: MeasurementFileError::InvalidPredictionProvenance { .. },
12917            }
12918        ));
12919
12920        let basis = EnginePredictionBasisV1::new(vec![
12921            PredictionBasisReferenceV1::project_field(
12922                "test:project",
12923                PredictionScalarV1::Boolean { value: true },
12924            )
12925            .unwrap(),
12926        ])
12927        .unwrap();
12928        let facet = EnginePredictionFacetV3::required_unavailable(
12929            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
12930            basis_v2(basis),
12931            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
12932        )
12933        .unwrap();
12934        let prediction_wire = validated_lint_wire(
12935            &provenance,
12936            vec![unavailable_check("test:reader", &provenance, vec![facet])],
12937        );
12938
12939        let mut wrong_emitter = prediction_wire.clone();
12940        wrong_emitter["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12941            serde_json::json!("member_existence");
12942        assert!(matches!(
12943            reader_error(wrong_emitter),
12944            MeasurementReportError::File {
12945                file_index: 0,
12946                source: MeasurementFileError::InvalidPredictionLifecycle {
12947                    check_index: 0,
12948                    reason: "prediction facet scope code is invalid for its parent check",
12949                },
12950            }
12951        ));
12952
12953        let mut empty_scope = prediction_wire.clone();
12954        empty_scope["files"][0]["checks"][0]["prediction"]["facets"][0]["scope"]["code"] =
12955            serde_json::json!("");
12956        assert!(matches!(
12957            reader_error(empty_scope),
12958            MeasurementReportError::File {
12959                file_index: 0,
12960                source: MeasurementFileError::InvalidPrediction { check_index: 0, .. },
12961            }
12962        ));
12963
12964        let mut prediction_wire = prediction_wire;
12965        prediction_wire["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["identity"]
12966            ["bytes"] = serde_json::json!(0);
12967        assert!(matches!(
12968            reader_error(prediction_wire),
12969            MeasurementReportError::File {
12970                file_index: 0,
12971                source: MeasurementFileError::InvalidPrediction {
12972                    check_index: 0,
12973                    source: PredictionContractError::IdentityMismatch {
12974                        contract: "engine prediction basis v2",
12975                    },
12976                },
12977            }
12978        ));
12979    }
12980
12981    #[test]
12982    fn staged_reader_uses_the_authoritative_check_lifecycle_without_prediction() {
12983        let provenance = prediction_test_provenance();
12984        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
12985
12986        for (field, state) in [
12987            ("selection", "unselected"),
12988            ("configuration", "disabled"),
12989            ("applicability", "not_applicable"),
12990        ] {
12991            let mut inactive = base.clone();
12992            inactive["files"][0]["checks"][0][field] = serde_json::json!(state);
12993            assert!(matches!(
12994                reader_error(inactive),
12995                MeasurementReportError::File {
12996                    file_index: 0,
12997                    source: MeasurementFileError::InvalidPredictionLifecycle {
12998                        check_index: 0,
12999                        reason: "evaluation does not match completed and missing prediction work",
13000                    },
13001                }
13002            ));
13003        }
13004
13005        let mut inactive = base.clone();
13006        inactive["files"][0]["checks"][0]["selection"] = serde_json::json!("unselected");
13007        inactive["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
13008        serde_json::from_value::<MeasurementReportInput>(inactive)
13009            .unwrap()
13010            .into_files()
13011            .expect("empty inactive record is valid");
13012
13013        let mut not_evaluated = base.clone();
13014        not_evaluated["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
13015            "code": "test:missing",
13016            "message": "missing",
13017        }]);
13018        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
13019        serde_json::from_value::<MeasurementReportInput>(not_evaluated.clone())
13020            .unwrap()
13021            .into_files()
13022            .expect("missing-only active record derives not_evaluated");
13023        not_evaluated["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
13024        assert!(matches!(
13025            reader_error(not_evaluated),
13026            MeasurementReportError::File {
13027                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13028                ..
13029            }
13030        ));
13031
13032        let mut partial = base.clone();
13033        partial["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
13034            "code": "test:missing",
13035            "message": "missing",
13036        }]);
13037        partial["files"][0]["checks"][0]["evaluated_scopes"] =
13038            serde_json::json!([{ "code": "test:completed" }]);
13039        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13040        serde_json::from_value::<MeasurementReportInput>(partial.clone())
13041            .unwrap()
13042            .into_files()
13043            .expect("mixed active record derives partial");
13044        partial["files"][0]["checks"][0]["evaluation"] = serde_json::json!("complete");
13045        assert!(matches!(
13046            reader_error(partial),
13047            MeasurementReportError::File {
13048                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13049                ..
13050            }
13051        ));
13052
13053        let mut wrong_complete = base;
13054        wrong_complete["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13055        assert!(matches!(
13056            reader_error(wrong_complete),
13057            MeasurementReportError::File {
13058                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13059                ..
13060            }
13061        ));
13062    }
13063
13064    #[test]
13065    fn staged_reader_rejects_invalid_scope_gap_and_finding_shapes() {
13066        let provenance = prediction_test_provenance();
13067        let base = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
13068
13069        let mut empty_scope = base.clone();
13070        empty_scope["files"][0]["checks"][0]["evaluated_scopes"] =
13071            serde_json::json!([{ "code": "" }]);
13072        assert!(matches!(
13073            reader_error(empty_scope),
13074            MeasurementReportError::File {
13075                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13076                ..
13077            }
13078        ));
13079
13080        let mut malformed_gap = base.clone();
13081        malformed_gap["files"][0]["checks"][0]["gaps"] = serde_json::json!([{
13082            "code": "",
13083            "message": "missing",
13084        }]);
13085        malformed_gap["files"][0]["checks"][0]["evaluation"] = serde_json::json!("not_evaluated");
13086        assert!(matches!(
13087            reader_error(malformed_gap),
13088            MeasurementReportError::File {
13089                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13090                ..
13091            }
13092        ));
13093
13094        let mut incomplete_finding = base;
13095        incomplete_finding["files"][0]["checks"][0]["findings"] = serde_json::json!([{
13096            "check_id": "test:reader",
13097        }]);
13098        assert!(matches!(
13099            reader_error(incomplete_finding),
13100            MeasurementReportError::File {
13101                source: MeasurementFileError::InvalidPredictionShape { check_index: 0, .. },
13102                ..
13103            }
13104        ));
13105    }
13106
13107    #[test]
13108    fn staged_reader_stops_at_the_first_files_lifecycle_failure() {
13109        let provenance = prediction_test_provenance();
13110        let mut wire = validated_lint_wire(&provenance, vec![empty_check("test:reader")]);
13111        let mut later_file = wire["files"][0].clone();
13112        later_file["prediction_provenance"]["schema"] = serde_json::json!("urn:changed");
13113        wire["files"].as_array_mut().unwrap().push(later_file);
13114        wire["summary"]["files"] = serde_json::json!(2);
13115        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13116
13117        assert!(matches!(
13118            reader_error(wire),
13119            MeasurementReportError::File {
13120                file_index: 0,
13121                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13122            }
13123        ));
13124    }
13125
13126    #[test]
13127    fn staged_reader_stops_at_the_first_checks_lifecycle_failure() {
13128        let provenance = prediction_test_provenance();
13129        let basis = EnginePredictionBasisV1::new(vec![
13130            PredictionBasisReferenceV1::project_field(
13131                "test:project",
13132                PredictionScalarV1::Boolean { value: true },
13133            )
13134            .unwrap(),
13135        ])
13136        .unwrap();
13137        let facet = EnginePredictionFacetV3::required_unavailable(
13138            EvaluationScope::new(EvaluationScopeCode::custom("test:prediction")),
13139            basis_v2(basis),
13140            vec![PredictionUnavailableReasonV2::ProjectIntentUnavailable],
13141        )
13142        .unwrap();
13143        let mut wire = validated_lint_wire(
13144            &provenance,
13145            vec![
13146                empty_check("test:first"),
13147                unavailable_check("test:second", &provenance, vec![facet]),
13148            ],
13149        );
13150        wire["files"][0]["checks"][0]["evaluation"] = serde_json::json!("partial");
13151        wire["files"][0]["checks"][1]["unknown"] = serde_json::json!(true);
13152
13153        assert!(matches!(
13154            reader_error(wire),
13155            MeasurementReportError::File {
13156                file_index: 0,
13157                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
13158            }
13159        ));
13160    }
13161
13162    #[test]
13163    fn v11_nested_version_is_rejected_before_current_shape_decode() {
13164        let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
13165            "schema_version": OUTPUT_SCHEMA_VERSION,
13166            "schema": OUTPUT_SCHEMA_ID,
13167            "tool": {},
13168            "command": "measure",
13169            "files": [{
13170                "path": "measurements-v11.json",
13171                "input": { "sha256": "0".repeat(64), "bytes": 0 },
13172                "rig": {},
13173                "measurements": {
13174                    "schema_version": 11,
13175                    "schema": "urn:animsmith:schema:measurements:11",
13176                    "skeleton_nodes": [{
13177                        "node_index": 0,
13178                        "scene_root_indices": [],
13179                        "local_rest": {
13180                            "kind": "trs",
13181                            "translation_m": [0.0, 0.0, 0.0],
13182                            "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
13183                            "scale": [1.0, 1.0, 1.0]
13184                        },
13185                        "rest_world_matrix": [
13186                            1.0, 0.0, 0.0, 0.0,
13187                            0.0, 1.0, 0.0, 0.0,
13188                            0.0, 0.0, 1.0, 0.0,
13189                            0.0, 0.0, 0.0, 1.0
13190                        ]
13191                    }],
13192                    "skins": [{ "skin_index": 0 }]
13193                }
13194            }]
13195        }))
13196        .expect("unsupported payload shapes remain decodable for version rejection");
13197
13198        assert!(matches!(
13199            report.into_files(),
13200            Err(MeasurementReportError::File {
13201                file_index: 0,
13202                source: MeasurementFileError::UnsupportedMeasurementVersion {
13203                    found: 11,
13204                    expected: MEASUREMENTS_SCHEMA_VERSION,
13205                },
13206            })
13207        ));
13208    }
13209
13210    #[test]
13211    fn current_v18_primitive_mutations_fail_closed_on_readback() {
13212        let wire = measure_wire(primitive_measurement_contract());
13213        let primitive = &wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0];
13214        assert_eq!(primitive["primitive_index"], serde_json::json!(1));
13215        assert_eq!(primitive["material_index"], serde_json::json!(7));
13216        assert_eq!(
13217            wire["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["material_index"],
13218            serde_json::Value::Null
13219        );
13220
13221        let mut missing_material = wire.clone();
13222        missing_material["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]
13223            .as_object_mut()
13224            .unwrap()
13225            .remove("material_index");
13226        assert!(matches!(
13227            reader_error(missing_material),
13228            MeasurementReportError::File {
13229                source: MeasurementFileError::InvalidMeasurementsShape { .. },
13230                ..
13231            }
13232        ));
13233
13234        let mut mutations = Vec::new();
13235        let mut missing = wire.clone();
13236        missing["files"][0]["measurements"]["mesh_definitions"][0]
13237            .as_object_mut()
13238            .unwrap()
13239            .remove("primitives");
13240        mutations.push(missing);
13241
13242        let mut duplicate_index = wire.clone();
13243        duplicate_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13244            serde_json::json!(1);
13245        mutations.push(duplicate_index);
13246
13247        let mut decreasing_index = wire.clone();
13248        decreasing_index["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][1]["primitive_index"] =
13249            serde_json::json!(0);
13250        mutations.push(decreasing_index);
13251
13252        let mut finite_over = wire.clone();
13253        finite_over["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13254            serde_json::json!(3);
13255        mutations.push(finite_over);
13256
13257        let mut zero_with_facts = wire.clone();
13258        zero_with_facts["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["finite_vertex_count"] =
13259            serde_json::json!(0);
13260        mutations.push(zero_with_facts);
13261
13262        let mut positive_without_centroid = wire.clone();
13263        positive_without_centroid["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13264            [0]
13265        .as_object_mut()
13266        .unwrap()
13267        .remove("geometry_centroid");
13268        mutations.push(positive_without_centroid);
13269
13270        let mut centroid_outside_aabb = wire.clone();
13271        centroid_outside_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13272            ["geometry_centroid"] = serde_json::json!([-3.0, 1.0, 0.0]);
13273        mutations.push(centroid_outside_aabb);
13274
13275        let mut missing_mesh_geometry = wire.clone();
13276        missing_mesh_geometry["files"][0]["measurements"]["mesh_definitions"][0]
13277            .as_object_mut()
13278            .unwrap()
13279            .remove("geometry_centroid");
13280        mutations.push(missing_mesh_geometry);
13281
13282        let mut wrong_mesh_aabb = wire.clone();
13283        wrong_mesh_aabb["files"][0]["measurements"]["mesh_definitions"][0]["geometry_aabb"]["max"]
13284            [0] = serde_json::json!(7.0);
13285        mutations.push(wrong_mesh_aabb);
13286
13287        let mut wrong_mesh_centroid = wire.clone();
13288        wrong_mesh_centroid["files"][0]["measurements"]["mesh_definitions"][0]["geometry_centroid"] =
13289            serde_json::json!([3.0, 7.0 / 3.0, 0.0]);
13290        mutations.push(wrong_mesh_centroid);
13291
13292        let mut wrong_sum = wire.clone();
13293        wrong_sum["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13294            serde_json::json!(3);
13295        mutations.push(wrong_sum);
13296
13297        let mut finite_sum_overflow = wire.clone();
13298        for primitive in
13299            finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["primitives"]
13300                .as_array_mut()
13301                .unwrap()
13302        {
13303            primitive["vertex_count"] = serde_json::json!(u64::MAX);
13304            primitive["finite_vertex_count"] = serde_json::json!(u64::MAX);
13305        }
13306        finite_sum_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13307            serde_json::json!(u64::MAX);
13308        mutations.push(finite_sum_overflow);
13309
13310        for mutation in mutations {
13311            assert!(matches!(
13312                reader_error(mutation),
13313                MeasurementReportError::File {
13314                    source: MeasurementFileError::InvalidMeasurements { .. },
13315                    ..
13316                }
13317            ));
13318        }
13319
13320        let mut unknown_root = wire.clone();
13321        unknown_root["files"][0]["measurements"]
13322            .as_object_mut()
13323            .unwrap()
13324            .insert("bogus".into(), serde_json::json!(true));
13325        let mut unknown_mesh = wire.clone();
13326        unknown_mesh["files"][0]["measurements"]["mesh_definitions"][0]
13327            .as_object_mut()
13328            .unwrap()
13329            .insert("bogus".into(), serde_json::json!(true));
13330        let mut unknown_primitive = wire.clone();
13331        unknown_primitive["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13332            .as_object_mut()
13333            .unwrap()
13334            .insert("bogus".into(), serde_json::json!(true));
13335        let mut unknown_aabb = wire;
13336        unknown_aabb["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]
13337            ["geometry_aabb"]
13338            .as_object_mut()
13339            .unwrap()
13340            .insert("bogus".into(), serde_json::json!(true));
13341        for mutation in [unknown_root, unknown_mesh, unknown_primitive, unknown_aabb] {
13342            assert!(matches!(
13343                reader_error(mutation),
13344                MeasurementReportError::File {
13345                    source: MeasurementFileError::InvalidMeasurementsShape { reason },
13346                    ..
13347                } if reason.contains("unknown field `bogus`")
13348            ));
13349        }
13350    }
13351
13352    #[test]
13353    fn current_v18_material_indices_follow_resource_coverage() {
13354        let unavailable = measure_wire(primitive_measurement_contract());
13355        serde_json::from_value::<MeasurementReportInput>(unavailable.clone())
13356            .unwrap()
13357            .into_files()
13358            .expect("unavailable inventory may retain a source material index");
13359
13360        let mut complete_out_of_range = unavailable.clone();
13361        complete_out_of_range["files"][0]["measurements"]["material_resource_coverage"] =
13362            serde_json::json!("complete");
13363        assert!(matches!(
13364            reader_error(complete_out_of_range),
13365            MeasurementReportError::File {
13366                source: MeasurementFileError::InvalidMeasurements { .. },
13367                ..
13368            }
13369        ));
13370
13371        let mut complete_in_range = unavailable;
13372        complete_in_range["files"][0]["measurements"]["material_resource_coverage"] =
13373            serde_json::json!("complete");
13374        complete_in_range["files"][0]["measurements"]["material_definitions"] = serde_json::json!([{
13375            "material_index": 0,
13376            "name": null,
13377            "texture_bindings": []
13378        }]);
13379        complete_in_range["files"][0]["measurements"]["mesh_definitions"][0]["primitives"][0]["material_index"] =
13380            serde_json::json!(0);
13381        serde_json::from_value::<MeasurementReportInput>(complete_in_range)
13382            .unwrap()
13383            .into_files()
13384            .expect("complete inventory accepts an in-range primitive material index");
13385    }
13386
13387    #[test]
13388    fn current_v18_leading_magic_is_bounded_reason_specific_hex() {
13389        let mut assets = AssetMeasurements {
13390            material_resource_coverage: MaterialResourceCoverage::Complete,
13391            ..AssetMeasurements::default()
13392        };
13393        assets.images.push(ImageMeasurements {
13394            image_index: 0,
13395            name: None,
13396            source_kind: ImageSourceKind::Embedded,
13397            declared_mime_type: None,
13398            detected_container: None,
13399            leading_magic_hex: Some("00ff10".into()),
13400            width: None,
13401            height: None,
13402            channel_count: None,
13403            decoded_color_type: None,
13404            unavailable_reason: Some(ImageUnavailableReason::UnsupportedContainer),
13405        });
13406        let wire = measure_wire(MeasurementContract::new(BTreeMap::new(), assets).unwrap());
13407
13408        let mut unknown_image = wire.clone();
13409        unknown_image["files"][0]["measurements"]["images"][0]
13410            .as_object_mut()
13411            .unwrap()
13412            .insert("bogus".into(), serde_json::json!(true));
13413        assert!(matches!(
13414            reader_error(unknown_image),
13415            MeasurementReportError::File {
13416                source: MeasurementFileError::InvalidMeasurementsShape { reason },
13417                ..
13418            } if reason.contains("unknown field `bogus`")
13419        ));
13420
13421        for magic in ["", "0", "0F", "00ff00112233445566778899aabbccdde"] {
13422            let mut mutation = wire.clone();
13423            mutation["files"][0]["measurements"]["images"][0]["leading_magic_hex"] =
13424                serde_json::json!(magic);
13425            assert!(matches!(
13426                reader_error(mutation),
13427                MeasurementReportError::File {
13428                    source: MeasurementFileError::InvalidMeasurements { .. },
13429                    ..
13430                }
13431            ));
13432        }
13433
13434        let mut wrong_reason = wire;
13435        wrong_reason["files"][0]["measurements"]["images"][0]["unavailable_reason"] =
13436            serde_json::json!("resource_limit");
13437        assert!(matches!(
13438            reader_error(wrong_reason),
13439            MeasurementReportError::File {
13440                source: MeasurementFileError::InvalidMeasurements { .. },
13441                ..
13442            }
13443        ));
13444    }
13445
13446    #[test]
13447    fn v12_v15_round_trips_without_inventing_primitive_rows() {
13448        let current_provenance = prediction_test_provenance_v2();
13449        let historical_provenance = current_provenance.clone().historical_v15_for_test();
13450        let basis =
13451            EnginePredictionBasisV1::new_v16(vec![PredictionBasisReferenceV1::measurement_v16(
13452                crate::MeasurementPointerV1::new("/measurements/schema_version").unwrap(),
13453                PredictionScalarV1::UnsignedInteger { value: 15 },
13454            )])
13455            .unwrap();
13456        let facet = EnginePredictionFacetV2::required_unavailable(
13457            EvaluationScope::new(EvaluationScopeCode::custom("test:v12")),
13458            basis,
13459            vec![PredictionUnavailableReasonV2::MeasurementUnavailable],
13460        )
13461        .unwrap();
13462        let current_check =
13463            unavailable_check_v2("test:v12", &current_provenance, vec![facet.clone()]);
13464        let prediction =
13465            EnginePredictionV2::new(current_provenance.identity().clone(), vec![facet])
13466                .unwrap()
13467                .historical_v15_for_test(historical_provenance.identity().clone());
13468        let mut check_wire = serde_json::to_value(current_check).unwrap();
13469        check_wire["prediction"] = serde_json::to_value(prediction).unwrap();
13470        let mut historical_assets = AssetMeasurements::default();
13471        historical_assets
13472            .mesh_definitions
13473            .push(MeshDefinitionMeasurements {
13474                mesh_index: 0,
13475                name: "legacy-mesh".into(),
13476                primitives: None,
13477                vertex_count: 0,
13478                geometry_aabb: None,
13479                geometry_centroid: None,
13480                max_joints_per_vertex: 0,
13481                weight_sum_min: None,
13482                weight_sum_max: None,
13483                additional_influence_sets: Vec::new(),
13484            });
13485        let historical_measurements =
13486            MeasurementContract::historical_v15(BTreeMap::new(), historical_assets).unwrap();
13487        let wire = serde_json::json!({
13488            "schema_version": OUTPUT_V12_SCHEMA_VERSION,
13489            "schema": OUTPUT_V12_SCHEMA_ID,
13490            "tool": {},
13491            "command": "lint",
13492            "summary": {"prediction_facets": {
13493                "available": 0,
13494                "required_prediction_unavailable": 1
13495            }},
13496            "files": [{
13497                "path": "historical-v12.glb",
13498                "input": {"sha256": "00".repeat(32), "bytes": 0},
13499                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13500                "measurements": serde_json::to_value(historical_measurements).unwrap(),
13501                "prediction_provenance": serde_json::to_value(historical_provenance).unwrap(),
13502                "checks": [check_wire]
13503            }]
13504        });
13505
13506        let mut historical_with_unknowns = wire.clone();
13507        historical_with_unknowns["files"][0]["measurements"]
13508            .as_object_mut()
13509            .unwrap()
13510            .insert("future_root_field".into(), serde_json::json!(true));
13511        historical_with_unknowns["files"][0]["measurements"]["mesh_definitions"][0]
13512            .as_object_mut()
13513            .unwrap()
13514            .insert("future_mesh_field".into(), serde_json::json!(true));
13515        serde_json::from_value::<MeasurementReportInput>(historical_with_unknowns)
13516            .unwrap()
13517            .into_files()
13518            .expect("historical measurements-v15 retains permissive unknown-field readback");
13519
13520        let mut smuggled_primitives = wire.clone();
13521        smuggled_primitives["files"][0]["measurements"]["mesh_definitions"][0]["primitives"] =
13522            serde_json::json!([]);
13523        assert!(matches!(
13524            serde_json::from_value::<MeasurementReportInput>(smuggled_primitives)
13525                .unwrap()
13526                .into_files(),
13527            Err(MeasurementReportError::File {
13528                source: MeasurementFileError::InvalidMeasurements { source },
13529                ..
13530            }) if source.to_string().contains("measurements-v15 cannot carry per-primitive evidence")
13531        ));
13532
13533        let mut smuggled_magic = wire.clone();
13534        smuggled_magic["files"][0]["measurements"]["material_resource_coverage"] =
13535            serde_json::json!("complete");
13536        smuggled_magic["files"][0]["measurements"]["images"] = serde_json::json!([{
13537            "image_index": 0,
13538            "name": null,
13539            "source_kind": "embedded",
13540            "declared_mime_type": null,
13541            "detected_container": null,
13542            "leading_magic_hex": "00",
13543            "width": null,
13544            "height": null,
13545            "channel_count": null,
13546            "decoded_color_type": null,
13547            "unavailable_reason": "unsupported_container"
13548        }]);
13549        assert!(matches!(
13550            serde_json::from_value::<MeasurementReportInput>(smuggled_magic)
13551                .unwrap()
13552                .into_files(),
13553            Err(MeasurementReportError::File {
13554                source: MeasurementFileError::InvalidMeasurements { source },
13555                ..
13556            }) if source.to_string().contains("measurements-v15 cannot carry leading-magic evidence")
13557        ));
13558
13559        let files = serde_json::from_value::<MeasurementReportInput>(wire.clone())
13560            .unwrap()
13561            .into_files()
13562            .unwrap();
13563        let readback = serde_json::to_value(files[0].measurements()).unwrap();
13564        assert_eq!(readback["schema_version"], serde_json::json!(15));
13565        assert_eq!(
13566            readback["schema"],
13567            serde_json::json!(MEASUREMENTS_V15_SCHEMA_ID)
13568        );
13569        assert_eq!(readback["mesh_definitions"].as_array().unwrap().len(), 1);
13570        assert!(readback["mesh_definitions"][0].get("primitives").is_none());
13571
13572        let mut historical_max = wire.clone();
13573        historical_max["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13574            serde_json::json!(u32::MAX);
13575        serde_json::from_value::<MeasurementReportInput>(historical_max)
13576            .unwrap()
13577            .into_files()
13578            .expect("measurements-v15 retains its historical inclusive u32 maximum");
13579
13580        let mut historical_overflow = wire.clone();
13581        historical_overflow["files"][0]["measurements"]["mesh_definitions"][0]["vertex_count"] =
13582            serde_json::json!(u64::from(u32::MAX) + 1);
13583        assert!(matches!(
13584            lint_read_error(historical_overflow),
13585            MeasurementReportError::File {
13586                source: MeasurementFileError::InvalidMeasurements { .. },
13587                ..
13588            }
13589        ));
13590
13591        let mut v16_basis = wire;
13592        v16_basis["files"][0]["checks"][0]["prediction"]["facets"][0]["basis"]["references"][0]["schema"] =
13593            serde_json::json!(MEASUREMENTS_SCHEMA_ID);
13594        assert!(matches!(
13595            lint_read_error(v16_basis),
13596            MeasurementReportError::File {
13597                source: MeasurementFileError::InvalidPrediction {
13598                    source: PredictionContractError::InvalidSchema {
13599                        field: "basis.measurement.schema",
13600                        expected: MEASUREMENTS_V15_SCHEMA_ID,
13601                        ..
13602                    },
13603                    ..
13604                },
13605                ..
13606            }
13607        ));
13608    }
13609
13610    #[test]
13611    fn adjacent_output_revisions_reject_each_others_nested_measurements() {
13612        let current = measure_wire(prediction_test_measurements());
13613        let mut v12_with_v18 = current.clone();
13614        v12_with_v18["schema_version"] = serde_json::json!(OUTPUT_V12_SCHEMA_VERSION);
13615        v12_with_v18["schema"] = serde_json::json!(OUTPUT_V12_SCHEMA_ID);
13616        assert!(matches!(
13617            reader_error(v12_with_v18),
13618            MeasurementReportError::File {
13619                source: MeasurementFileError::UnsupportedMeasurementVersion {
13620                    found: 18,
13621                    expected: MEASUREMENTS_V15_SCHEMA_VERSION,
13622                },
13623                ..
13624            }
13625        ));
13626
13627        let historical =
13628            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13629                .unwrap();
13630        let mut v13_with_v15 = current;
13631        v13_with_v15["schema_version"] = serde_json::json!(OUTPUT_V13_SCHEMA_VERSION);
13632        v13_with_v15["schema"] = serde_json::json!(OUTPUT_V13_SCHEMA_ID);
13633        v13_with_v15["files"][0]["measurements"] = serde_json::to_value(historical).unwrap();
13634        assert!(matches!(
13635            reader_error(v13_with_v15),
13636            MeasurementReportError::File {
13637                source: MeasurementFileError::UnsupportedMeasurementVersion {
13638                    found: 15,
13639                    expected: MEASUREMENTS_V16_SCHEMA_VERSION,
13640                },
13641                ..
13642            }
13643        ));
13644    }
13645
13646    #[test]
13647    fn historical_output_v17_writer_projects_current_measurements_to_v16() {
13648        let report = LintFileReportV17::new(
13649            "historical.glb",
13650            InputIdentity::from_bytes(&[]),
13651            prediction_test_rig(),
13652            None,
13653            Vec::new(),
13654            prediction_test_measurements(),
13655        )
13656        .unwrap();
13657        assert_eq!(
13658            report.measurements().schema_version,
13659            MEASUREMENTS_V16_SCHEMA_VERSION
13660        );
13661        assert_eq!(report.measurements().schema, MEASUREMENTS_V16_SCHEMA_ID);
13662    }
13663
13664    #[test]
13665    fn current_v19_writers_refuse_measurements_v16_at_file_and_envelope_boundaries() {
13666        let mismatch = OutputContractError::CurrentMeasurementContractMismatch {
13667            output_version: OUTPUT_SCHEMA_VERSION,
13668            found_version: MEASUREMENTS_V16_SCHEMA_VERSION,
13669            found_schema: MEASUREMENTS_V16_SCHEMA_ID.into(),
13670        };
13671        assert_eq!(
13672            MeasureFileReport::new(
13673                "historical.glb",
13674                InputIdentity::from_bytes(&[]),
13675                prediction_test_rig(),
13676                prediction_test_measurements_v16(),
13677            )
13678            .unwrap_err(),
13679            mismatch
13680        );
13681        assert_eq!(
13682            LintFileReportV19::new(
13683                "historical.glb",
13684                InputIdentity::from_bytes(&[]),
13685                prediction_test_rig(),
13686                None,
13687                Vec::new(),
13688                prediction_test_measurements_v16(),
13689            )
13690            .unwrap_err(),
13691            mismatch
13692        );
13693
13694        let malformed_measure = MeasureFileReport {
13695            evidence: FileEvidence::new(
13696                "historical.glb",
13697                InputIdentity::from_bytes(&[]),
13698                prediction_test_rig(),
13699                prediction_test_measurements_v16(),
13700            ),
13701        };
13702        assert_eq!(
13703            MeasureEnvelope::new(
13704                ToolInfo::animsmith(ToolSource::new(None, None)),
13705                vec![malformed_measure],
13706            )
13707            .unwrap_err(),
13708            mismatch
13709        );
13710        let malformed_lint = LintFileReportV19 {
13711            evidence: FileEvidence::new(
13712                "historical.glb",
13713                InputIdentity::from_bytes(&[]),
13714                prediction_test_rig(),
13715                prediction_test_measurements_v16(),
13716            ),
13717            prediction_provenance: None,
13718            checks: Vec::new(),
13719        };
13720        assert_eq!(
13721            LintEnvelopeV19::new(
13722                ToolInfo::animsmith(ToolSource::new(None, None)),
13723                vec![malformed_lint],
13724            )
13725            .unwrap_err(),
13726            mismatch
13727        );
13728    }
13729
13730    #[test]
13731    fn v17_to_v16_projection_is_total_and_used_by_prediction_and_historical_writers() {
13732        let fully_measured = loop_projection_measurements(&[
13733            MeasurementAvailability::Measured,
13734            MeasurementAvailability::Measured,
13735        ]);
13736        let projected = fully_measured.prediction_v16_projection().unwrap();
13737        assert_eq!(projected.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13738        let continuity = projected.clips()["loop"]
13739            .loop_continuity
13740            .as_ref()
13741            .expect("fully measured rows survive projection");
13742        assert_eq!(continuity.bones.len(), 2);
13743        let wire = serde_json::to_value(&projected).unwrap();
13744        assert!(
13745            wire["clips"]["loop"]["loop_continuity"]["bones"]
13746                .as_array()
13747                .unwrap()
13748                .iter()
13749                .all(|bone| bone.get("availability").is_none())
13750        );
13751
13752        // Historical prediction writers consume a V16 projection. Exercise
13753        // every new V18 rotation location with real skeleton and skin facts so
13754        // the projection cannot accidentally leak an exclusive V18 field.
13755        let rotation_facts = prediction_measurements_with_rotation_facts();
13756        let projected_rotation_facts = rotation_facts.prediction_v16_projection().unwrap();
13757        let projected_wire = serde_json::to_value(&projected_rotation_facts).unwrap();
13758        let projected_linear_paths = [
13759            &projected_wire["skeleton_nodes"][0]["rest_world_linear"],
13760            &projected_wire["skins"][0]["joints"][0]["joint_bind_to_mesh"]["linear"],
13761            &projected_wire["skins"][0]["joints"][0]["mesh_bind_world"]["linear"],
13762        ];
13763        assert!(
13764            projected_linear_paths
13765                .into_iter()
13766                .all(|linear| linear.get("rotation_xyzw").is_none()),
13767            "V16 projection must remove every V18-only rotation fact"
13768        );
13769        let historical_rotation_report = LintFileReport::new(
13770            "rotation-facts.glb",
13771            InputIdentity::from_bytes(&[]),
13772            prediction_test_rig(),
13773            None,
13774            Vec::new(),
13775            rotation_facts,
13776        )
13777        .expect("historical writer projects non-empty rotation facts");
13778        let historical_rotation_wire = serde_json::to_value(&historical_rotation_report).unwrap();
13779        assert_eq!(
13780            historical_rotation_report.measurements().schema_version,
13781            MEASUREMENTS_V16_SCHEMA_VERSION
13782        );
13783        let historical_linear_paths = [
13784            &historical_rotation_wire["measurements"]["skeleton_nodes"][0]["rest_world_linear"],
13785            &historical_rotation_wire["measurements"]["skins"][0]["joints"][0]["joint_bind_to_mesh"]
13786                ["linear"],
13787            &historical_rotation_wire["measurements"]["skins"][0]["joints"][0]["mesh_bind_world"]["linear"],
13788        ];
13789        assert!(
13790            historical_linear_paths
13791                .into_iter()
13792                .all(|linear| linear.get("rotation_xyzw").is_none()),
13793            "historical V15 writer must preserve the V16 measurement shape"
13794        );
13795        let historical_envelope = LintEnvelope::new(
13796            ToolInfo::animsmith(ToolSource::new(None, None)),
13797            vec![historical_rotation_report],
13798        )
13799        .expect("historical envelope accepts projected non-empty rotation facts");
13800        let historical_envelope_wire = serde_json::to_value(historical_envelope).unwrap();
13801        let historical_input: MeasurementReportInput =
13802            serde_json::from_value(historical_envelope_wire).unwrap();
13803        assert_eq!(historical_input.file_count(), Some(1));
13804        historical_input
13805            .into_files()
13806            .expect("historical reader accepts the projected V16 wire shape");
13807
13808        let partial = loop_projection_measurements(&[
13809            MeasurementAvailability::Measured,
13810            MeasurementAvailability::Unavailable,
13811        ]);
13812        let all_unavailable = loop_projection_measurements(&[
13813            MeasurementAvailability::Unavailable,
13814            MeasurementAvailability::Unavailable,
13815        ]);
13816        for measurements in [&partial, &all_unavailable] {
13817            let projected = measurements.prediction_v16_projection().unwrap();
13818            assert_eq!(
13819                projected.clips()["loop"].loop_continuity_availability,
13820                MeasurementAvailability::Unavailable
13821            );
13822            assert!(projected.clips()["loop"].loop_continuity.is_none());
13823        }
13824
13825        let current_v3 = LintFileReportV19::new(
13826            "v3.glb",
13827            InputIdentity::from_bytes(&[]),
13828            prediction_test_rig(),
13829            None,
13830            Vec::new(),
13831            fully_measured,
13832        )
13833        .unwrap();
13834        let current_v5 = LintFileReportV19::new_v5(
13835            "v5.glb",
13836            InputIdentity::from_bytes(&[]),
13837            prediction_test_rig(),
13838            None,
13839            Vec::new(),
13840            partial.clone(),
13841        )
13842        .unwrap();
13843        let current_v6 = LintFileReportV19::new_v6(
13844            "v6.glb",
13845            InputIdentity::from_bytes(&[]),
13846            prediction_test_rig(),
13847            None,
13848            Vec::new(),
13849            all_unavailable.clone(),
13850        )
13851        .unwrap();
13852        assert!(
13853            current_v3.measurements().clips()["loop"]
13854                .loop_continuity
13855                .is_some()
13856        );
13857        assert!(
13858            current_v5.measurements().clips()["loop"]
13859                .loop_continuity
13860                .is_some()
13861        );
13862        assert!(
13863            current_v6.measurements().clips()["loop"]
13864                .loop_continuity
13865                .is_some()
13866        );
13867
13868        let historical_v15 = LintFileReport::new(
13869            "v15.glb",
13870            InputIdentity::from_bytes(&[]),
13871            prediction_test_rig(),
13872            None,
13873            Vec::new(),
13874            partial.clone(),
13875        )
13876        .unwrap();
13877        let historical_v16 = LintFileReportV16::new(
13878            "v16.glb",
13879            InputIdentity::from_bytes(&[]),
13880            prediction_test_rig(),
13881            None,
13882            Vec::new(),
13883            partial.clone(),
13884        )
13885        .unwrap();
13886        let historical_v17 = LintFileReportV17::new(
13887            "v17.glb",
13888            InputIdentity::from_bytes(&[]),
13889            prediction_test_rig(),
13890            None,
13891            Vec::new(),
13892            partial,
13893        )
13894        .unwrap();
13895        for measurements in [
13896            historical_v15.measurements(),
13897            historical_v16.measurements(),
13898            historical_v17.measurements(),
13899        ] {
13900            assert_eq!(measurements.schema_version, MEASUREMENTS_V16_SCHEMA_VERSION);
13901            assert_eq!(
13902                measurements.clips()["loop"].loop_continuity_availability,
13903                MeasurementAvailability::Unavailable
13904            );
13905            assert!(measurements.clips()["loop"].loop_continuity.is_none());
13906        }
13907    }
13908
13909    #[test]
13910    fn legacy_output_v11_reader_dispatch_preserves_measurement_recovery() {
13911        let provenance = prediction_test_provenance_v2();
13912        let legacy = PredictionProvenanceV1::new(
13913            provenance.profile().clone(),
13914            provenance.source_format(),
13915            ResolvedEngineSettingsV1::new(provenance.profile(), Vec::new(), Vec::new()).unwrap(),
13916            provenance.raw_source().clone(),
13917            provenance.dependency_closure().clone(),
13918        )
13919        .unwrap()
13920        .historical_v15_for_test();
13921        let legacy_prediction = EnginePredictionV1::new(
13922            legacy.identity().clone(),
13923            vec![
13924                EnginePredictionFacetV1::required_unavailable(
13925                    EvaluationScope::new(EvaluationScopeCode::custom("test:legacy-v11")),
13926                    EnginePredictionBasisV1::new(Vec::new()).unwrap(),
13927                    vec![PredictionUnavailableReasonV1::ProjectIntentUnavailable],
13928                )
13929                .unwrap(),
13930            ],
13931        )
13932        .unwrap()
13933        .historical_v15_for_test(legacy.identity().clone());
13934        let legacy_measurements =
13935            MeasurementContract::historical_v15(BTreeMap::new(), AssetMeasurements::default())
13936                .unwrap();
13937        // This is an actual V11/V1 wire shape, deliberately constructed
13938        // without producing a V12 envelope and swapping only its header.
13939        let wire = serde_json::json!({
13940            "schema_version": OUTPUT_V11_SCHEMA_VERSION,
13941            "schema": OUTPUT_V11_SCHEMA_ID,
13942            "tool": {},
13943            "command": "lint",
13944            "summary": {
13945                "prediction_facets": {
13946                    "available": 0,
13947                    "required_prediction_unavailable": 1,
13948                },
13949            },
13950            "files": [{
13951                "path": "legacy-v11.glb",
13952                "input": { "sha256": "00".repeat(32), "bytes": 0 },
13953                "rig": serde_json::to_value(prediction_test_rig()).unwrap(),
13954                "measurements": serde_json::to_value(legacy_measurements).unwrap(),
13955                "prediction_provenance": serde_json::to_value(legacy.clone()).unwrap(),
13956                "checks": [{
13957                    "check_id": "test:legacy-v11",
13958                    "selection": "selected",
13959                    "configuration": "enabled",
13960                    "applicability": "applicable",
13961                    "evaluation": "not_evaluated",
13962                    "findings": [],
13963                    "evaluated_scopes": [],
13964                    "gaps": [],
13965                    "prediction": legacy_prediction,
13966                }],
13967            }],
13968        });
13969        let report: MeasurementReportInput = serde_json::from_value(wire.clone()).unwrap();
13970        assert_eq!(report.file_count(), Some(1));
13971        assert_eq!(report.into_files().unwrap().len(), 1);
13972
13973        let mut bad_provenance = wire.clone();
13974        bad_provenance["files"][0]["prediction_provenance"]["schema"] =
13975            serde_json::json!("urn:forged");
13976        assert!(matches!(
13977            lint_read_error(bad_provenance),
13978            MeasurementReportError::File {
13979                source: MeasurementFileError::InvalidPredictionProvenance { .. },
13980                ..
13981            }
13982        ));
13983
13984        let mut bad_prediction = wire.clone();
13985        bad_prediction["files"][0]["checks"] = serde_json::json!([{
13986            "check_id": "test:legacy-v11",
13987            "selection": "selected",
13988            "configuration": "enabled",
13989            "applicability": "applicable",
13990            "evaluation": "not_evaluated",
13991            "findings": [],
13992            "evaluated_scopes": [],
13993            "gaps": [],
13994            "prediction": { "schema": "urn:forged" }
13995        }]);
13996        assert!(matches!(
13997            lint_read_error(bad_prediction),
13998            MeasurementReportError::File {
13999                source: MeasurementFileError::InvalidPredictionShape { .. },
14000                ..
14001            }
14002        ));
14003
14004        let mut missing_provenance_before_malformed_prediction = wire.clone();
14005        missing_provenance_before_malformed_prediction["files"][0]["prediction_provenance"] =
14006            serde_json::Value::Null;
14007        missing_provenance_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
14008            serde_json::json!({ "schema": "urn:forged" });
14009        assert!(matches!(
14010            lint_read_error(missing_provenance_before_malformed_prediction),
14011            MeasurementReportError::File {
14012                source: MeasurementFileError::PredictionWithoutProvenance { check_index: 0 },
14013                ..
14014            }
14015        ));
14016
14017        let mut inactive_before_malformed_prediction = wire.clone();
14018        inactive_before_malformed_prediction["files"][0]["checks"][0]["selection"] =
14019            serde_json::json!("unselected");
14020        inactive_before_malformed_prediction["files"][0]["checks"][0]["prediction"] =
14021            serde_json::json!({ "schema": "urn:forged" });
14022        assert!(matches!(
14023            lint_read_error(inactive_before_malformed_prediction),
14024            MeasurementReportError::File {
14025                source: MeasurementFileError::InvalidPredictionLifecycle { check_index: 0, .. },
14026                ..
14027            }
14028        ));
14029
14030        // A V11 aggregate overflow is terminal at the check that creates it;
14031        // a malformed later check must never change that first error.
14032        let mut overbudget_before_later_malformed = wire.clone();
14033        let facet =
14034            overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"][0]
14035                .clone();
14036        overbudget_before_later_malformed["files"][0]["checks"][0]["prediction"]["facets"] =
14037            serde_json::Value::Array(
14038                std::iter::repeat_n(facet, PREDICTION_V1_MAX_FACETS_PER_FILE + 1).collect(),
14039            );
14040        overbudget_before_later_malformed["files"][0]["checks"]
14041            .as_array_mut()
14042            .unwrap()
14043            .push(serde_json::json!({ "check_id": 7 }));
14044        let precedence_error = lint_read_error(overbudget_before_later_malformed);
14045        assert!(
14046            matches!(
14047                precedence_error,
14048                MeasurementReportError::File {
14049                    source: MeasurementFileError::InvalidPrediction {
14050                        source: PredictionContractError::TooManyFacets { .. },
14051                        ..
14052                    },
14053                    ..
14054                },
14055            ),
14056            "{precedence_error:?}"
14057        );
14058    }
14059}
14060
14061#[derive(Debug, Clone, Serialize)]
14062struct FileEvidence {
14063    path: String,
14064    input: InputIdentity,
14065    rig: RigInfo,
14066    measurements: MeasurementContract,
14067}
14068
14069impl FileEvidence {
14070    fn new(
14071        path: impl Into<String>,
14072        input: InputIdentity,
14073        rig: RigInfo,
14074        measurements: MeasurementContract,
14075    ) -> Self {
14076        Self {
14077            path: path.into(),
14078            input,
14079            rig,
14080            measurements,
14081        }
14082    }
14083
14084    fn historical_v16(
14085        path: impl Into<String>,
14086        input: InputIdentity,
14087        rig: RigInfo,
14088        measurements: MeasurementContract,
14089    ) -> Result<Self, OutputContractError> {
14090        Ok(Self::new(
14091            path,
14092            input,
14093            rig,
14094            measurements.prediction_v16_projection()?,
14095        ))
14096    }
14097}
14098
14099/// One source file and its measurement-command evidence.
14100#[derive(Debug, Clone, Serialize)]
14101pub struct MeasureFileReport {
14102    #[serde(flatten)]
14103    evidence: FileEvidence,
14104}
14105
14106impl MeasureFileReport {
14107    /// Construct a measurement-command file report.
14108    ///
14109    /// # Errors
14110    ///
14111    /// Returns [`OutputContractError::CurrentMeasurementContractMismatch`] if
14112    /// the nested evidence is not the exact current measurements-v18 contract.
14113    pub fn new(
14114        path: impl Into<String>,
14115        input: InputIdentity,
14116        rig: RigInfo,
14117        measurements: MeasurementContract,
14118    ) -> Result<Self, OutputContractError> {
14119        require_measurements_v18(OUTPUT_SCHEMA_VERSION, &measurements)?;
14120        Ok(Self {
14121            evidence: FileEvidence::new(path, input, rig, measurements),
14122        })
14123    }
14124
14125    /// Display path supplied by the producer.
14126    pub fn path(&self) -> &str {
14127        &self.evidence.path
14128    }
14129
14130    /// Immutable identity of the source bytes used to produce this record.
14131    pub fn input(&self) -> &InputIdentity {
14132        &self.evidence.input
14133    }
14134
14135    /// Nested measurement evidence.
14136    pub fn measurements(&self) -> &MeasurementContract {
14137        &self.evidence.measurements
14138    }
14139}
14140
14141/// A producer attempted to construct output outside the immutable v11 contract.
14142#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14143#[non_exhaustive]
14144pub enum OutputContractError {
14145    /// One envelope carried too many file records.
14146    #[error("output contains {found} files, exceeding the v10 limit of {limit}")]
14147    TooManyFiles {
14148        /// Supplied file count.
14149        found: usize,
14150        /// Immutable v10 limit.
14151        limit: usize,
14152    },
14153    /// One lint file carried too many check records.
14154    #[error("lint file contains {found} checks, exceeding the v10 limit of {limit}")]
14155    TooManyChecks {
14156        /// Supplied check count.
14157        found: usize,
14158        /// Immutable v10 limit.
14159        limit: usize,
14160    },
14161    /// A check carried prediction evidence without its file-scoped authority.
14162    #[error("engine prediction requires non-null file prediction_provenance")]
14163    PredictionWithoutProvenance,
14164    /// A current output-v15 lint record attempted to attach historical prediction evidence.
14165    #[error("output-v15 lint cannot carry historical engine-prediction evidence")]
14166    HistoricalPredictionInV2Output,
14167    /// File provenance and check prediction used different contract revisions.
14168    #[error(
14169        "output-v15 prediction provenance and check attachments must use one correlated revision"
14170    )]
14171    PredictionRevisionMismatch,
14172    /// File and provenance primary-input identities differed.
14173    #[error("prediction provenance primary input does not match the lint file input")]
14174    PredictionPrimaryInputMismatch,
14175    /// Aggregate prediction facets exceeded the V1 file limit.
14176    #[error("lint file contains {found} prediction facets, exceeding the V1 limit of {limit}")]
14177    TooManyPredictionFacets {
14178        /// Supplied facet count.
14179        found: usize,
14180        /// Immutable V1 limit.
14181        limit: usize,
14182    },
14183    /// A V2 rule emitted a budget summary although the file did not consume
14184    /// every one of its shared prediction-facet slots.
14185    #[error("facet-budget summary requires exactly {limit} aggregate facets, found {found}")]
14186    FacetBudgetSummaryWithoutExhaustedFileBudget {
14187        /// Aggregate facet count.
14188        found: usize,
14189        /// Immutable shared file limit.
14190        limit: usize,
14191    },
14192    /// Aggregate basis references exceeded the V1 file limit.
14193    #[error(
14194        "lint file contains {found} prediction basis references, exceeding the V1 limit of {limit}"
14195    )]
14196    TooManyPredictionBasisReferences {
14197        /// Supplied reference count.
14198        found: usize,
14199        /// Immutable V1 limit.
14200        limit: usize,
14201    },
14202    /// Aggregate retained provenance/prediction text exceeded the V1 limit.
14203    #[error("lint file retains {found} prediction text bytes, exceeding the V1 limit of {limit}")]
14204    TooMuchPredictionText {
14205        /// Supplied UTF-8 byte count.
14206        found: usize,
14207        /// Immutable V1 limit.
14208        limit: usize,
14209    },
14210    /// Checked aggregate accounting overflowed.
14211    #[error("checked arithmetic overflow while validating output-v11 bounds")]
14212    ArithmeticOverflow,
14213    /// Nested prediction evidence violated its contract.
14214    #[error("invalid prediction evidence: {0}")]
14215    InvalidPrediction(#[from] PredictionContractError),
14216    /// Current measurements could not be projected into the immutable V16
14217    /// evidence view consumed by existing prediction contracts.
14218    #[error("invalid measurements-v16 prediction projection: {0}")]
14219    InvalidMeasurementProjection(#[from] MeasurementContractError),
14220    /// A historical output writer was paired with a different nested
14221    /// measurement contract.
14222    #[error(
14223        "output-v{output_version} requires measurements-v16, found version {found_version} with identity {found_schema}"
14224    )]
14225    HistoricalMeasurementContractMismatch {
14226        /// Historical outer output revision.
14227        output_version: u32,
14228        /// Supplied nested measurement revision.
14229        found_version: u32,
14230        /// Supplied nested measurement identity.
14231        found_schema: String,
14232    },
14233    /// A current output writer received a non-current nested measurement contract.
14234    #[error(
14235        "output-v{output_version} requires measurements-v18, found measurement schema_version {found_version} ({found_schema})"
14236    )]
14237    CurrentMeasurementContractMismatch {
14238        /// Current outer output revision.
14239        output_version: u32,
14240        /// Supplied nested measurement revision.
14241        found_version: u32,
14242        /// Supplied nested measurement identity.
14243        found_schema: String,
14244    },
14245}
14246
14247fn require_measurements_v18(
14248    output_version: u32,
14249    measurements: &MeasurementContract,
14250) -> Result<(), OutputContractError> {
14251    if measurements.schema_version == MEASUREMENTS_SCHEMA_VERSION
14252        && measurements.schema == MEASUREMENTS_SCHEMA_ID
14253    {
14254        Ok(())
14255    } else {
14256        Err(OutputContractError::CurrentMeasurementContractMismatch {
14257            output_version,
14258            found_version: measurements.schema_version,
14259            found_schema: measurements.schema.to_owned(),
14260        })
14261    }
14262}
14263
14264fn require_measurements_v16(
14265    output_version: u32,
14266    measurements: &MeasurementContract,
14267) -> Result<(), OutputContractError> {
14268    if measurements.schema_version == MEASUREMENTS_V16_SCHEMA_VERSION
14269        && measurements.schema == MEASUREMENTS_V16_SCHEMA_ID
14270    {
14271        Ok(())
14272    } else {
14273        Err(OutputContractError::HistoricalMeasurementContractMismatch {
14274            output_version,
14275            found_version: measurements.schema_version,
14276            found_schema: measurements.schema.to_owned(),
14277        })
14278    }
14279}
14280
14281#[derive(Debug, Clone, Serialize)]
14282struct EnvelopeHeader {
14283    schema_version: u32,
14284    schema: &'static str,
14285    tool: ToolInfo,
14286    command: &'static str,
14287}
14288
14289impl EnvelopeHeader {
14290    fn new(tool: ToolInfo, command: &'static str) -> Self {
14291        Self {
14292            schema_version: OUTPUT_SCHEMA_VERSION,
14293            schema: OUTPUT_SCHEMA_ID,
14294            tool,
14295            command,
14296        }
14297    }
14298}
14299
14300#[derive(Debug, Clone, Serialize)]
14301struct MeasureSummary {
14302    files: usize,
14303}
14304
14305#[derive(Debug, Clone, Default, Serialize)]
14306struct FindingSummary {
14307    error: usize,
14308    warning: usize,
14309    note: usize,
14310}
14311
14312impl FindingSummary {
14313    fn add(&mut self, severity: Severity) {
14314        match severity {
14315            Severity::Error => self.error += 1,
14316            Severity::Warning => self.warning += 1,
14317            Severity::Note => self.note += 1,
14318        }
14319    }
14320}
14321
14322#[derive(Debug, Clone, Default, Serialize)]
14323struct SelectionSummary {
14324    selected: usize,
14325    unselected: usize,
14326}
14327
14328#[derive(Debug, Clone, Default, Serialize)]
14329struct ConfigurationSummary {
14330    enabled: usize,
14331    disabled: usize,
14332}
14333
14334#[derive(Debug, Clone, Default, Serialize)]
14335struct ApplicabilitySummary {
14336    applicable: usize,
14337    not_applicable: usize,
14338}
14339
14340#[derive(Debug, Clone, Default, Serialize)]
14341struct EvaluationStateSummary {
14342    complete: usize,
14343    partial: usize,
14344    not_evaluated: usize,
14345}
14346
14347#[derive(Debug, Clone, Default, Serialize)]
14348struct CheckSummary {
14349    total: usize,
14350    selection: SelectionSummary,
14351    configuration: ConfigurationSummary,
14352    applicability: ApplicabilitySummary,
14353    evaluation: EvaluationStateSummary,
14354    gaps: usize,
14355}
14356
14357#[derive(Debug, Clone, Serialize)]
14358struct LintSummary {
14359    files: usize,
14360    findings: FindingSummary,
14361    checks: CheckSummary,
14362    prediction_facets: PredictionFacetSummary,
14363}
14364
14365#[derive(Debug, Clone, Default, Serialize)]
14366struct PredictionFacetSummary {
14367    available: usize,
14368    required_prediction_unavailable: usize,
14369}
14370
14371/// Current measure-command result envelope.
14372#[derive(Debug, Clone, Serialize)]
14373pub struct MeasureEnvelope {
14374    #[serde(flatten)]
14375    header: EnvelopeHeader,
14376    summary: MeasureSummary,
14377    files: Vec<MeasureFileReport>,
14378}
14379
14380impl MeasureEnvelope {
14381    /// Construct a schema-valid measurement envelope.
14382    pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Result<Self, OutputContractError> {
14383        if files.len() > OUTPUT_V11_MAX_FILES {
14384            return Err(OutputContractError::TooManyFiles {
14385                found: files.len(),
14386                limit: OUTPUT_V11_MAX_FILES,
14387            });
14388        }
14389        for file in &files {
14390            require_measurements_v18(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
14391        }
14392        Ok(Self {
14393            header: EnvelopeHeader::new(tool, "measure"),
14394            summary: MeasureSummary { files: files.len() },
14395            files,
14396        })
14397    }
14398}
14399
14400#[derive(Debug, Clone, Serialize)]
14401#[serde(untagged)]
14402#[allow(
14403    clippy::large_enum_variant,
14404    reason = "the internal correlated revision enum preserves value ownership for both immutable wire types"
14405)]
14406enum CurrentPredictionProvenance {
14407    V3(PredictionProvenanceV3),
14408    V4(PredictionProvenanceV4),
14409}
14410
14411/// Current output-v15 lint file evidence with correlated V3 or V4 prediction provenance.
14412#[derive(Debug, Clone, Serialize)]
14413pub struct LintFileReport {
14414    #[serde(flatten)]
14415    evidence: FileEvidence,
14416    prediction_provenance: Option<CurrentPredictionProvenance>,
14417    checks: Vec<CheckEvaluation>,
14418}
14419
14420impl LintFileReport {
14421    /// Construct a legacy-V3 lint file report inside output-v15.
14422    pub fn new(
14423        path: impl Into<String>,
14424        input: InputIdentity,
14425        rig: RigInfo,
14426        prediction_provenance: Option<PredictionProvenanceV3>,
14427        checks: Vec<CheckEvaluation>,
14428        measurements: MeasurementContract,
14429    ) -> Result<Self, OutputContractError> {
14430        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14431            return Err(OutputContractError::TooManyChecks {
14432                found: checks.len(),
14433                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14434            });
14435        }
14436        let report = Self {
14437            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14438            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V3),
14439            checks,
14440        };
14441        report.validate()?;
14442        Ok(report)
14443    }
14444
14445    /// V3 prediction provenance, or `None` for engine-neutral lint.
14446    pub const fn prediction_provenance(&self) -> Option<&PredictionProvenanceV3> {
14447        match self.prediction_provenance.as_ref() {
14448            Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14449            Some(CurrentPredictionProvenance::V4(_)) | None => None,
14450        }
14451    }
14452
14453    /// Construct a result-bearing V4 lint file report.
14454    pub fn new_v4(
14455        path: impl Into<String>,
14456        input: InputIdentity,
14457        rig: RigInfo,
14458        prediction_provenance: Option<PredictionProvenanceV4>,
14459        checks: Vec<CheckEvaluation>,
14460        measurements: MeasurementContract,
14461    ) -> Result<Self, OutputContractError> {
14462        if checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14463            return Err(OutputContractError::TooManyChecks {
14464                found: checks.len(),
14465                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14466            });
14467        }
14468        let report = Self {
14469            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14470            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenance::V4),
14471            checks,
14472        };
14473        report.validate()?;
14474        Ok(report)
14475    }
14476
14477    /// V4 prediction provenance, when this file uses revision 4.
14478    pub const fn prediction_provenance_v4(&self) -> Option<&PredictionProvenanceV4> {
14479        match self.prediction_provenance.as_ref() {
14480            Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14481            Some(CurrentPredictionProvenance::V3(_)) | None => None,
14482        }
14483    }
14484
14485    /// Display path supplied by the producer.
14486    pub fn path(&self) -> &str {
14487        &self.evidence.path
14488    }
14489
14490    /// Immutable identity of the source bytes used to produce this record.
14491    pub fn input(&self) -> &InputIdentity {
14492        &self.evidence.input
14493    }
14494
14495    /// Nested measurement evidence.
14496    pub fn measurements(&self) -> &MeasurementContract {
14497        &self.evidence.measurements
14498    }
14499
14500    /// Catalog-ordered check records.
14501    pub fn checks(&self) -> &[CheckEvaluation] {
14502        &self.checks
14503    }
14504
14505    fn validate(&self) -> Result<(), OutputContractError> {
14506        require_measurements_v16(OUTPUT_V15_SCHEMA_VERSION, &self.evidence.measurements)?;
14507        if let Some(provenance) = &self.prediction_provenance {
14508            let primary_input = match provenance {
14509                CurrentPredictionProvenance::V3(provenance) => {
14510                    provenance.validate()?;
14511                    provenance.raw_source().primary_input()
14512                }
14513                CurrentPredictionProvenance::V4(provenance) => {
14514                    provenance.validate()?;
14515                    provenance.raw_source().primary_input()
14516                }
14517            };
14518            if primary_input != &self.evidence.input {
14519                return Err(OutputContractError::PredictionPrimaryInputMismatch);
14520            }
14521        }
14522        let mut facets = 0usize;
14523        let mut references = 0usize;
14524        let mut has_facet_budget_summary = false;
14525        let mut text = self
14526            .prediction_provenance
14527            .as_ref()
14528            .map(|provenance| match provenance {
14529                CurrentPredictionProvenance::V3(provenance) => provenance.retained_text_bytes(),
14530                CurrentPredictionProvenance::V4(provenance) => provenance.retained_text_bytes(),
14531            })
14532            .transpose()?
14533            .unwrap_or(0);
14534        for check in &self.checks {
14535            if check.engine_prediction().is_some() || check.engine_prediction_v2().is_some() {
14536                return Err(OutputContractError::HistoricalPredictionInV2Output);
14537            }
14538            let provenance_revision_matches = matches!(
14539                (
14540                    &self.prediction_provenance,
14541                    check.engine_prediction_v3(),
14542                    check.engine_prediction_v4()
14543                ),
14544                (Some(CurrentPredictionProvenance::V3(_)), Some(_), None)
14545                    | (Some(CurrentPredictionProvenance::V4(_)), None, Some(_))
14546                    | (_, None, None)
14547            );
14548            if !provenance_revision_matches {
14549                if self.prediction_provenance.is_none()
14550                    && (check.engine_prediction_v3().is_some()
14551                        || check.engine_prediction_v4().is_some())
14552                {
14553                    return Err(OutputContractError::PredictionWithoutProvenance);
14554                }
14555                return Err(OutputContractError::PredictionRevisionMismatch);
14556            }
14557            let legacy_v3_provenance = match self.prediction_provenance.as_ref() {
14558                Some(CurrentPredictionProvenance::V3(provenance)) => Some(provenance),
14559                Some(CurrentPredictionProvenance::V4(_)) | None => None,
14560            };
14561            if !matches!(
14562                self.prediction_provenance.as_ref(),
14563                Some(CurrentPredictionProvenance::V4(_))
14564            ) {
14565                validate_current_engine_clip_boundary_applicability_v3(
14566                    check.check_id(),
14567                    check.applicability(),
14568                    legacy_v3_provenance,
14569                )?;
14570            }
14571            if check.check_id() == ENGINE_CLIP_BOUNDARY_CHECK_ID
14572                && check.selection() == SelectionState::Selected
14573                && check.configuration() == ConfigurationState::Enabled
14574                && check.applicability() == Applicability::Applicable
14575                && check.engine_prediction_v3().is_none()
14576            {
14577                return Err(OutputContractError::InvalidPrediction(
14578                    PredictionContractError::EngineClipBoundaryFacetMismatch,
14579                ));
14580            }
14581            let current_v4_provenance = match self.prediction_provenance.as_ref() {
14582                Some(CurrentPredictionProvenance::V4(provenance)) => Some(provenance),
14583                Some(CurrentPredictionProvenance::V3(_)) | None => None,
14584            };
14585            validate_current_engine_unit_scale_prediction_v4(
14586                check.check_id(),
14587                check.selection(),
14588                check.configuration(),
14589                check.applicability(),
14590                check.engine_prediction_v4(),
14591                current_v4_provenance,
14592                &self.evidence.measurements,
14593            )?;
14594            if let Some(prediction) = check.engine_prediction_v3() {
14595                let Some(CurrentPredictionProvenance::V3(provenance)) =
14596                    self.prediction_provenance.as_ref()
14597                else {
14598                    return Err(OutputContractError::PredictionRevisionMismatch);
14599                };
14600                prediction.validate_against_provenance(provenance)?;
14601                prediction.validate_for_check(
14602                    check.check_id(),
14603                    check.evaluated_scopes(),
14604                    check.gaps(),
14605                    check.findings(),
14606                )?;
14607                validate_current_engine_addressability_prediction_v3(
14608                    check.check_id(),
14609                    prediction,
14610                    provenance,
14611                )?;
14612                let finding_scopes = check
14613                    .findings()
14614                    .iter()
14615                    .filter_map(|finding| finding.prediction_scope.as_ref())
14616                    .collect::<Vec<_>>();
14617                validate_current_engine_clip_boundary_prediction_v3(
14618                    check.check_id(),
14619                    prediction,
14620                    provenance,
14621                    check.evaluated_scopes(),
14622                    &finding_scopes,
14623                )?;
14624                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14625                facets = facets
14626                    .checked_add(prediction.facets().len())
14627                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14628                references = references
14629                    .checked_add(prediction.basis_reference_count())
14630                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14631                text = text
14632                    .checked_add(prediction.retained_text_bytes()?)
14633                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14634            }
14635            if let Some(prediction) = check.engine_prediction_v4() {
14636                let Some(CurrentPredictionProvenance::V4(provenance)) =
14637                    self.prediction_provenance.as_ref()
14638                else {
14639                    return Err(OutputContractError::PredictionRevisionMismatch);
14640                };
14641                prediction.validate_against_provenance(provenance)?;
14642                prediction.validate_for_check(
14643                    check.check_id(),
14644                    check.evaluated_scopes(),
14645                    check.gaps(),
14646                    check.findings(),
14647                )?;
14648                has_facet_budget_summary |= prediction.has_facet_budget_summary();
14649                facets = facets
14650                    .checked_add(prediction.facets().len())
14651                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14652                references = references
14653                    .checked_add(prediction.basis_reference_count())
14654                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14655                text = text
14656                    .checked_add(prediction.retained_text_bytes()?)
14657                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14658            }
14659        }
14660        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14661            return Err(OutputContractError::TooManyPredictionFacets {
14662                found: facets,
14663                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14664            });
14665        }
14666        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14667            return Err(
14668                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14669                    found: facets,
14670                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14671                },
14672            );
14673        }
14674        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14675            return Err(OutputContractError::TooManyPredictionBasisReferences {
14676                found: references,
14677                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
14678            });
14679        }
14680        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
14681            return Err(OutputContractError::TooMuchPredictionText {
14682                found: text,
14683                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
14684            });
14685        }
14686        validate_measurement_references_batch_v3(
14687            &self.evidence.measurements,
14688            self.checks
14689                .iter()
14690                .enumerate()
14691                .filter_map(|(check_index, check)| {
14692                    check
14693                        .engine_prediction_v3()
14694                        .map(|prediction| (check_index, prediction))
14695                }),
14696        )
14697        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14698        validate_measurement_references_batch_v4(
14699            &self.evidence.measurements,
14700            self.checks
14701                .iter()
14702                .enumerate()
14703                .filter_map(|(check_index, check)| {
14704                    check
14705                        .engine_prediction_v4()
14706                        .map(|prediction| (check_index, prediction))
14707                }),
14708        )
14709        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
14710        Ok(())
14711    }
14712}
14713
14714#[derive(Debug, Clone, Serialize)]
14715struct EnvelopeHeaderV2 {
14716    schema_version: u32,
14717    schema: &'static str,
14718    tool: ToolInfo,
14719    command: &'static str,
14720}
14721
14722/// Immutable historical output-v15 lint envelope.
14723#[derive(Debug, Clone, Serialize)]
14724pub struct LintEnvelope {
14725    #[serde(flatten)]
14726    header: EnvelopeHeaderV2,
14727    summary: LintSummary,
14728    files: Vec<LintFileReport>,
14729}
14730
14731impl LintEnvelope {
14732    /// Construct a V2 lint envelope and derive its summaries.
14733    pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Result<Self, OutputContractError> {
14734        if files.len() > OUTPUT_V11_MAX_FILES {
14735            return Err(OutputContractError::TooManyFiles {
14736                found: files.len(),
14737                limit: OUTPUT_V11_MAX_FILES,
14738            });
14739        }
14740        let mut findings = FindingSummary::default();
14741        let mut checks = CheckSummary::default();
14742        let mut prediction_facets = PredictionFacetSummary::default();
14743        for file in &files {
14744            file.validate()?;
14745            for check in file.checks() {
14746                checks.total += 1;
14747                for finding in check.findings() {
14748                    findings.add(finding.severity);
14749                }
14750                match check.selection() {
14751                    SelectionState::Selected => checks.selection.selected += 1,
14752                    SelectionState::Unselected => checks.selection.unselected += 1,
14753                }
14754                match check.configuration() {
14755                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
14756                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
14757                }
14758                match check.applicability() {
14759                    Applicability::Applicable => checks.applicability.applicable += 1,
14760                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
14761                }
14762                match check.evaluation() {
14763                    EvaluationState::Complete => checks.evaluation.complete += 1,
14764                    EvaluationState::Partial => checks.evaluation.partial += 1,
14765                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
14766                }
14767                checks.gaps += check.gaps().len();
14768                if let Some(prediction) = check.engine_prediction_v3() {
14769                    for facet in prediction.facets() {
14770                        match facet.state() {
14771                            EnginePredictionFacetStateV1::Available => {
14772                                prediction_facets.available += 1;
14773                            }
14774                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14775                                prediction_facets.required_prediction_unavailable += 1;
14776                            }
14777                        }
14778                    }
14779                }
14780                if let Some(prediction) = check.engine_prediction_v4() {
14781                    for facet in prediction.facets() {
14782                        match facet.state() {
14783                            EnginePredictionFacetStateV1::Available => {
14784                                prediction_facets.available += 1;
14785                            }
14786                            EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
14787                                prediction_facets.required_prediction_unavailable += 1;
14788                            }
14789                        }
14790                    }
14791                }
14792            }
14793        }
14794        Ok(Self {
14795            header: EnvelopeHeaderV2 {
14796                schema_version: OUTPUT_V15_SCHEMA_VERSION,
14797                schema: OUTPUT_V15_SCHEMA_ID,
14798                tool,
14799                command: "lint",
14800            },
14801            summary: LintSummary {
14802                files: files.len(),
14803                findings,
14804                checks,
14805                prediction_facets,
14806            },
14807            files,
14808        })
14809    }
14810}
14811
14812#[derive(Debug, Clone, Serialize)]
14813#[serde(untagged)]
14814#[allow(clippy::large_enum_variant)]
14815enum CurrentPredictionProvenanceV16 {
14816    V3(PredictionProvenanceV3),
14817    V5(PredictionProvenanceV5),
14818}
14819
14820/// Current output-v16 lint file evidence with V3 or V5 prediction provenance.
14821#[derive(Debug, Clone, Serialize)]
14822pub struct LintFileReportV16 {
14823    #[serde(flatten)]
14824    evidence: FileEvidence,
14825    prediction_provenance: Option<CurrentPredictionProvenanceV16>,
14826    checks: Vec<CheckEvaluation>,
14827}
14828
14829impl LintFileReportV16 {
14830    /// Construct a revision-1-profile record whose V3 graph is unchanged.
14831    pub fn new(
14832        path: impl Into<String>,
14833        input: InputIdentity,
14834        rig: RigInfo,
14835        prediction_provenance: Option<PredictionProvenanceV3>,
14836        checks: Vec<CheckEvaluation>,
14837        measurements: MeasurementContract,
14838    ) -> Result<Self, OutputContractError> {
14839        let report = Self {
14840            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14841            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V3),
14842            checks,
14843        };
14844        report.validate()?;
14845        Ok(report)
14846    }
14847
14848    /// Construct a revision-2-contract record using V5 provenance and checks.
14849    pub fn new_v5(
14850        path: impl Into<String>,
14851        input: InputIdentity,
14852        rig: RigInfo,
14853        prediction_provenance: Option<PredictionProvenanceV5>,
14854        checks: Vec<CheckEvaluation>,
14855        measurements: MeasurementContract,
14856    ) -> Result<Self, OutputContractError> {
14857        let report = Self {
14858            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
14859            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV16::V5),
14860            checks,
14861        };
14862        report.validate()?;
14863        Ok(report)
14864    }
14865
14866    /// Display path supplied by the producer.
14867    pub fn path(&self) -> &str {
14868        &self.evidence.path
14869    }
14870    /// Immutable source-byte identity.
14871    pub fn input(&self) -> &InputIdentity {
14872        &self.evidence.input
14873    }
14874    /// Nested measurement evidence.
14875    pub fn measurements(&self) -> &MeasurementContract {
14876        &self.evidence.measurements
14877    }
14878    /// Catalog-ordered check records.
14879    pub fn checks(&self) -> &[CheckEvaluation] {
14880        &self.checks
14881    }
14882    /// V3 provenance for a revision-1 profile.
14883    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
14884        match self.prediction_provenance.as_ref() {
14885            Some(CurrentPredictionProvenanceV16::V3(provenance)) => Some(provenance),
14886            Some(CurrentPredictionProvenanceV16::V5(_)) | None => None,
14887        }
14888    }
14889    /// V5 provenance for a revision-2-contract profile.
14890    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
14891        match self.prediction_provenance.as_ref() {
14892            Some(CurrentPredictionProvenanceV16::V5(provenance)) => Some(provenance),
14893            Some(CurrentPredictionProvenanceV16::V3(_)) | None => None,
14894        }
14895    }
14896
14897    fn validate(&self) -> Result<(), OutputContractError> {
14898        require_measurements_v16(OUTPUT_V16_SCHEMA_VERSION, &self.evidence.measurements)?;
14899        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
14900            return Err(OutputContractError::TooManyChecks {
14901                found: self.checks.len(),
14902                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
14903            });
14904        }
14905        if let Some(CurrentPredictionProvenanceV16::V3(provenance)) = &self.prediction_provenance {
14906            return LintFileReport {
14907                evidence: self.evidence.clone(),
14908                prediction_provenance: Some(CurrentPredictionProvenance::V3(provenance.clone())),
14909                checks: self.checks.clone(),
14910            }
14911            .validate();
14912        }
14913        let provenance = match &self.prediction_provenance {
14914            Some(CurrentPredictionProvenanceV16::V5(provenance)) => {
14915                provenance.validate()?;
14916                if provenance.base().raw_source().primary_input() != &self.evidence.input {
14917                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
14918                }
14919                Some(provenance)
14920            }
14921            Some(CurrentPredictionProvenanceV16::V3(_)) => unreachable!(),
14922            None => None,
14923        };
14924        let mut facets = 0usize;
14925        let mut references = 0usize;
14926        let mut has_facet_budget_summary = false;
14927        let mut text = provenance
14928            .map(PredictionProvenanceV5::retained_text_bytes)
14929            .transpose()?
14930            .unwrap_or(0);
14931        for check in &self.checks {
14932            if check.engine_prediction().is_some()
14933                || check.engine_prediction_v2().is_some()
14934                || check.engine_prediction_v3().is_some()
14935                || check.engine_prediction_v4().is_some()
14936            {
14937                return Err(OutputContractError::HistoricalPredictionInV2Output);
14938            }
14939            let prediction = check.engine_prediction_v5();
14940            if provenance.is_none() && prediction.is_some() {
14941                return Err(OutputContractError::PredictionWithoutProvenance);
14942            }
14943            if let Some(prediction) = prediction {
14944                let provenance =
14945                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
14946                prediction.validate_against_provenance(provenance)?;
14947                prediction.validate_for_check(
14948                    check.check_id(),
14949                    check.evaluated_scopes(),
14950                    check.gaps(),
14951                    check.findings(),
14952                )?;
14953                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
14954                facets = facets
14955                    .checked_add(prediction.facets().len())
14956                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14957                references = references
14958                    .checked_add(prediction.basis_reference_count())
14959                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14960                text = text
14961                    .checked_add(prediction.retained_text_bytes()?)
14962                    .ok_or(OutputContractError::ArithmeticOverflow)?;
14963            }
14964            validate_current_engine_track_support_prediction_v5(
14965                check.check_id(),
14966                check.selection(),
14967                check.configuration(),
14968                check.applicability(),
14969                prediction,
14970                provenance,
14971                check.findings().is_empty(),
14972            )?;
14973            validate_current_engine_unit_scale_prediction_v5(
14974                check.check_id(),
14975                check.selection(),
14976                check.configuration(),
14977                check.applicability(),
14978                prediction,
14979                provenance,
14980                &self.evidence.measurements,
14981            )?;
14982        }
14983        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
14984            return Err(OutputContractError::TooManyPredictionFacets {
14985                found: facets,
14986                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14987            });
14988        }
14989        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
14990            return Err(
14991                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
14992                    found: facets,
14993                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
14994                },
14995            );
14996        }
14997        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
14998            return Err(OutputContractError::TooManyPredictionBasisReferences {
14999                found: references,
15000                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
15001            });
15002        }
15003        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
15004            return Err(OutputContractError::TooMuchPredictionText {
15005                found: text,
15006                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
15007            });
15008        }
15009        validate_measurement_references_batch_v4(
15010            &self.evidence.measurements,
15011            self.checks.iter().enumerate().filter_map(|(index, check)| {
15012                check
15013                    .engine_prediction_v5()
15014                    .map(|prediction| (index, prediction.base_prediction()))
15015            }),
15016        )
15017        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
15018        Ok(())
15019    }
15020}
15021
15022/// Current output-v16 lint envelope.
15023#[derive(Debug, Clone, Serialize)]
15024pub struct LintEnvelopeV16 {
15025    #[serde(flatten)]
15026    header: EnvelopeHeaderV2,
15027    summary: LintSummary,
15028    files: Vec<LintFileReportV16>,
15029}
15030
15031impl LintEnvelopeV16 {
15032    /// Construct a schema-valid V16 lint envelope and derive summaries.
15033    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV16>) -> Result<Self, OutputContractError> {
15034        if files.len() > OUTPUT_V11_MAX_FILES {
15035            return Err(OutputContractError::TooManyFiles {
15036                found: files.len(),
15037                limit: OUTPUT_V11_MAX_FILES,
15038            });
15039        }
15040        let mut findings = FindingSummary::default();
15041        let mut checks = CheckSummary::default();
15042        let mut prediction_facets = PredictionFacetSummary::default();
15043        for file in &files {
15044            file.validate()?;
15045            for check in file.checks() {
15046                checks.total += 1;
15047                for finding in check.findings() {
15048                    findings.add(finding.severity);
15049                }
15050                match check.selection() {
15051                    SelectionState::Selected => checks.selection.selected += 1,
15052                    SelectionState::Unselected => checks.selection.unselected += 1,
15053                }
15054                match check.configuration() {
15055                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
15056                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
15057                }
15058                match check.applicability() {
15059                    Applicability::Applicable => checks.applicability.applicable += 1,
15060                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
15061                }
15062                match check.evaluation() {
15063                    EvaluationState::Complete => checks.evaluation.complete += 1,
15064                    EvaluationState::Partial => checks.evaluation.partial += 1,
15065                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
15066                }
15067                checks.gaps += check.gaps().len();
15068                for facet in check
15069                    .engine_prediction_v3()
15070                    .into_iter()
15071                    .flat_map(EnginePredictionV3::facets)
15072                {
15073                    match facet.state() {
15074                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15075                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15076                            prediction_facets.required_prediction_unavailable += 1
15077                        }
15078                    }
15079                }
15080                for facet in check
15081                    .engine_prediction_v5()
15082                    .into_iter()
15083                    .flat_map(EnginePredictionV5::facets)
15084                {
15085                    match facet.state() {
15086                        EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15087                        EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15088                            prediction_facets.required_prediction_unavailable += 1
15089                        }
15090                    }
15091                }
15092            }
15093        }
15094        Ok(Self {
15095            header: EnvelopeHeaderV2 {
15096                schema_version: OUTPUT_V16_SCHEMA_VERSION,
15097                schema: OUTPUT_V16_SCHEMA_ID,
15098                tool,
15099                command: "lint",
15100            },
15101            summary: LintSummary {
15102                files: files.len(),
15103                findings,
15104                checks,
15105                prediction_facets,
15106            },
15107            files,
15108        })
15109    }
15110}
15111
15112#[derive(Debug, Clone, Serialize)]
15113#[serde(untagged)]
15114#[allow(clippy::large_enum_variant)]
15115enum CurrentPredictionProvenanceV17 {
15116    V3(PredictionProvenanceV3),
15117    V5(PredictionProvenanceV5),
15118    V6(PredictionProvenanceV6),
15119}
15120
15121/// Historical output-v17 lint file evidence with immutable V3, V5, or V6 provenance.
15122#[derive(Debug, Clone, Serialize)]
15123pub struct LintFileReportV17 {
15124    #[serde(flatten)]
15125    evidence: FileEvidence,
15126    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15127    checks: Vec<CheckEvaluation>,
15128}
15129
15130impl LintFileReportV17 {
15131    /// Construct a revision-1-profile record whose V3 graph is unchanged.
15132    pub fn new(
15133        path: impl Into<String>,
15134        input: InputIdentity,
15135        rig: RigInfo,
15136        prediction_provenance: Option<PredictionProvenanceV3>,
15137        checks: Vec<CheckEvaluation>,
15138        measurements: MeasurementContract,
15139    ) -> Result<Self, OutputContractError> {
15140        let report = Self {
15141            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15142            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
15143            checks,
15144        };
15145        report.validate()?;
15146        Ok(report)
15147    }
15148
15149    /// Construct a V5 record whose output-v16 graph remains unchanged.
15150    pub fn new_v5(
15151        path: impl Into<String>,
15152        input: InputIdentity,
15153        rig: RigInfo,
15154        prediction_provenance: Option<PredictionProvenanceV5>,
15155        checks: Vec<CheckEvaluation>,
15156        measurements: MeasurementContract,
15157    ) -> Result<Self, OutputContractError> {
15158        let report = Self {
15159            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15160            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
15161            checks,
15162        };
15163        report.validate()?;
15164        Ok(report)
15165    }
15166
15167    /// Construct a transform-path-and-intent-bound V6 record.
15168    pub fn new_v6(
15169        path: impl Into<String>,
15170        input: InputIdentity,
15171        rig: RigInfo,
15172        prediction_provenance: Option<PredictionProvenanceV6>,
15173        checks: Vec<CheckEvaluation>,
15174        measurements: MeasurementContract,
15175    ) -> Result<Self, OutputContractError> {
15176        let report = Self {
15177            evidence: FileEvidence::historical_v16(path, input, rig, measurements)?,
15178            prediction_provenance: prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
15179            checks,
15180        };
15181        report.validate()?;
15182        Ok(report)
15183    }
15184
15185    /// Display path supplied by the producer.
15186    pub fn path(&self) -> &str {
15187        &self.evidence.path
15188    }
15189    /// Immutable source-byte identity.
15190    pub fn input(&self) -> &InputIdentity {
15191        &self.evidence.input
15192    }
15193    /// Nested measurement evidence.
15194    pub fn measurements(&self) -> &MeasurementContract {
15195        &self.evidence.measurements
15196    }
15197    /// Catalog-ordered check records.
15198    pub fn checks(&self) -> &[CheckEvaluation] {
15199        &self.checks
15200    }
15201    /// V3 provenance for a revision-1 profile.
15202    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
15203        match self.prediction_provenance.as_ref() {
15204            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
15205            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
15206            | None => None,
15207        }
15208    }
15209    /// Immutable V5 provenance.
15210    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
15211        match self.prediction_provenance.as_ref() {
15212            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
15213            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
15214            | None => None,
15215        }
15216    }
15217    /// Current V6 provenance.
15218    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
15219        match self.prediction_provenance.as_ref() {
15220            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
15221            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
15222            | None => None,
15223        }
15224    }
15225
15226    fn validate(&self) -> Result<(), OutputContractError> {
15227        require_measurements_v16(OUTPUT_V17_SCHEMA_VERSION, &self.evidence.measurements)?;
15228        match &self.prediction_provenance {
15229            Some(CurrentPredictionProvenanceV17::V3(provenance)) => {
15230                return LintFileReportV16::new(
15231                    self.evidence.path.clone(),
15232                    self.evidence.input.clone(),
15233                    self.evidence.rig.clone(),
15234                    Some(provenance.clone()),
15235                    self.checks.clone(),
15236                    self.evidence.measurements.clone(),
15237                )
15238                .map(|_| ());
15239            }
15240            Some(CurrentPredictionProvenanceV17::V5(provenance)) => {
15241                return LintFileReportV16::new_v5(
15242                    self.evidence.path.clone(),
15243                    self.evidence.input.clone(),
15244                    self.evidence.rig.clone(),
15245                    Some(provenance.clone()),
15246                    self.checks.clone(),
15247                    self.evidence.measurements.clone(),
15248                )
15249                .map(|_| ());
15250            }
15251            Some(CurrentPredictionProvenanceV17::V6(_)) => {}
15252            None => {
15253                return LintFileReportV16::new(
15254                    self.evidence.path.clone(),
15255                    self.evidence.input.clone(),
15256                    self.evidence.rig.clone(),
15257                    None,
15258                    self.checks.clone(),
15259                    self.evidence.measurements.clone(),
15260                )
15261                .map(|_| ());
15262            }
15263        }
15264        if self.checks.len() > OUTPUT_V11_MAX_CHECKS_PER_FILE {
15265            return Err(OutputContractError::TooManyChecks {
15266                found: self.checks.len(),
15267                limit: OUTPUT_V11_MAX_CHECKS_PER_FILE,
15268            });
15269        }
15270        let provenance = match &self.prediction_provenance {
15271            Some(CurrentPredictionProvenanceV17::V6(provenance)) => {
15272                provenance.validate()?;
15273                if provenance.base().base().raw_source().primary_input() != &self.evidence.input {
15274                    return Err(OutputContractError::PredictionPrimaryInputMismatch);
15275                }
15276                Some(provenance)
15277            }
15278            _ => unreachable!(),
15279        };
15280        let mut facets = 0usize;
15281        let mut references = 0usize;
15282        let mut has_facet_budget_summary = false;
15283        let mut text = provenance
15284            .map(PredictionProvenanceV6::retained_text_bytes)
15285            .transpose()?
15286            .unwrap_or(0);
15287        for check in &self.checks {
15288            if check.engine_prediction().is_some()
15289                || check.engine_prediction_v2().is_some()
15290                || check.engine_prediction_v3().is_some()
15291                || check.engine_prediction_v4().is_some()
15292                || check.engine_prediction_v5().is_some()
15293            {
15294                return Err(OutputContractError::HistoricalPredictionInV2Output);
15295            }
15296            let prediction = check.engine_prediction_v6();
15297            if let Some(prediction) = prediction {
15298                let provenance =
15299                    provenance.ok_or(OutputContractError::PredictionWithoutProvenance)?;
15300                prediction.validate_against_provenance(provenance)?;
15301                prediction.validate_for_check(
15302                    check.check_id(),
15303                    check.evaluated_scopes(),
15304                    check.gaps(),
15305                    check.findings(),
15306                )?;
15307                has_facet_budget_summary |= prediction.base_prediction().has_facet_budget_summary();
15308                facets = facets
15309                    .checked_add(prediction.facets().len())
15310                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15311                references = references
15312                    .checked_add(prediction.basis_reference_count())
15313                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15314                text = text
15315                    .checked_add(prediction.retained_text_bytes()?)
15316                    .ok_or(OutputContractError::ArithmeticOverflow)?;
15317            }
15318            validate_current_engine_root_motion_prediction_v6(
15319                check.check_id(),
15320                check.selection(),
15321                check.configuration(),
15322                check.applicability(),
15323                prediction,
15324                provenance,
15325                check.findings(),
15326                &self.evidence.rig,
15327                &self.evidence.measurements,
15328            )?;
15329        }
15330        if facets > PREDICTION_V1_MAX_FACETS_PER_FILE {
15331            return Err(OutputContractError::TooManyPredictionFacets {
15332                found: facets,
15333                limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15334            });
15335        }
15336        if has_facet_budget_summary && facets != PREDICTION_V1_MAX_FACETS_PER_FILE {
15337            return Err(
15338                OutputContractError::FacetBudgetSummaryWithoutExhaustedFileBudget {
15339                    found: facets,
15340                    limit: PREDICTION_V1_MAX_FACETS_PER_FILE,
15341                },
15342            );
15343        }
15344        if references > PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE {
15345            return Err(OutputContractError::TooManyPredictionBasisReferences {
15346                found: references,
15347                limit: PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
15348            });
15349        }
15350        if text > PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE {
15351            return Err(OutputContractError::TooMuchPredictionText {
15352                found: text,
15353                limit: PREDICTION_V1_MAX_TOTAL_TEXT_BYTES_PER_FILE,
15354            });
15355        }
15356        validate_measurement_references_batch_v4(
15357            &self.evidence.measurements,
15358            self.checks.iter().enumerate().filter_map(|(index, check)| {
15359                check
15360                    .engine_prediction_v6()
15361                    .map(|prediction| (index, prediction.base_prediction()))
15362            }),
15363        )
15364        .map_err(|error| OutputContractError::InvalidPrediction(error.source))?;
15365        Ok(())
15366    }
15367}
15368
15369/// Current output-v19 lint-file payload. Immutable prediction contracts are
15370/// checked against the deterministic measurements-v16 projection while the
15371/// report retains the complete measurements-v18 evidence.
15372#[derive(Debug, Clone, Serialize)]
15373pub struct LintFileReportV19 {
15374    #[serde(flatten)]
15375    evidence: FileEvidence,
15376    prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15377    checks: Vec<CheckEvaluation>,
15378}
15379
15380impl LintFileReportV19 {
15381    /// Construct a revision-1-profile record whose V3 graph is unchanged.
15382    pub fn new(
15383        path: impl Into<String>,
15384        input: InputIdentity,
15385        rig: RigInfo,
15386        prediction_provenance: Option<PredictionProvenanceV3>,
15387        checks: Vec<CheckEvaluation>,
15388        measurements: MeasurementContract,
15389    ) -> Result<Self, OutputContractError> {
15390        Self::build(
15391            path,
15392            input,
15393            rig,
15394            prediction_provenance.map(CurrentPredictionProvenanceV17::V3),
15395            checks,
15396            measurements,
15397        )
15398    }
15399
15400    /// Construct a V5 record whose immutable prediction graph consumes V16.
15401    pub fn new_v5(
15402        path: impl Into<String>,
15403        input: InputIdentity,
15404        rig: RigInfo,
15405        prediction_provenance: Option<PredictionProvenanceV5>,
15406        checks: Vec<CheckEvaluation>,
15407        measurements: MeasurementContract,
15408    ) -> Result<Self, OutputContractError> {
15409        Self::build(
15410            path,
15411            input,
15412            rig,
15413            prediction_provenance.map(CurrentPredictionProvenanceV17::V5),
15414            checks,
15415            measurements,
15416        )
15417    }
15418
15419    /// Construct a V6 record whose immutable prediction graph consumes V16.
15420    pub fn new_v6(
15421        path: impl Into<String>,
15422        input: InputIdentity,
15423        rig: RigInfo,
15424        prediction_provenance: Option<PredictionProvenanceV6>,
15425        checks: Vec<CheckEvaluation>,
15426        measurements: MeasurementContract,
15427    ) -> Result<Self, OutputContractError> {
15428        Self::build(
15429            path,
15430            input,
15431            rig,
15432            prediction_provenance.map(CurrentPredictionProvenanceV17::V6),
15433            checks,
15434            measurements,
15435        )
15436    }
15437
15438    fn build(
15439        path: impl Into<String>,
15440        input: InputIdentity,
15441        rig: RigInfo,
15442        prediction_provenance: Option<CurrentPredictionProvenanceV17>,
15443        checks: Vec<CheckEvaluation>,
15444        measurements: MeasurementContract,
15445    ) -> Result<Self, OutputContractError> {
15446        require_measurements_v18(OUTPUT_SCHEMA_VERSION, &measurements)?;
15447        let report = Self {
15448            evidence: FileEvidence::new(path, input, rig, measurements),
15449            prediction_provenance,
15450            checks,
15451        };
15452        report.historical_prediction_view()?;
15453        Ok(report)
15454    }
15455
15456    /// Display path supplied by the producer.
15457    pub fn path(&self) -> &str {
15458        &self.evidence.path
15459    }
15460
15461    /// Immutable source-byte identity.
15462    pub fn input(&self) -> &InputIdentity {
15463        &self.evidence.input
15464    }
15465
15466    /// Nested current measurement evidence.
15467    pub fn measurements(&self) -> &MeasurementContract {
15468        &self.evidence.measurements
15469    }
15470
15471    /// Catalog-ordered check records.
15472    pub fn checks(&self) -> &[CheckEvaluation] {
15473        &self.checks
15474    }
15475
15476    /// V3 provenance for a revision-1 profile.
15477    pub const fn prediction_provenance_v3(&self) -> Option<&PredictionProvenanceV3> {
15478        match self.prediction_provenance.as_ref() {
15479            Some(CurrentPredictionProvenanceV17::V3(provenance)) => Some(provenance),
15480            Some(CurrentPredictionProvenanceV17::V5(_) | CurrentPredictionProvenanceV17::V6(_))
15481            | None => None,
15482        }
15483    }
15484
15485    /// Immutable V5 provenance.
15486    pub const fn prediction_provenance_v5(&self) -> Option<&PredictionProvenanceV5> {
15487        match self.prediction_provenance.as_ref() {
15488            Some(CurrentPredictionProvenanceV17::V5(provenance)) => Some(provenance),
15489            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V6(_))
15490            | None => None,
15491        }
15492    }
15493
15494    /// Immutable V6 provenance.
15495    pub const fn prediction_provenance_v6(&self) -> Option<&PredictionProvenanceV6> {
15496        match self.prediction_provenance.as_ref() {
15497            Some(CurrentPredictionProvenanceV17::V6(provenance)) => Some(provenance),
15498            Some(CurrentPredictionProvenanceV17::V3(_) | CurrentPredictionProvenanceV17::V5(_))
15499            | None => None,
15500        }
15501    }
15502
15503    fn historical_prediction_view(&self) -> Result<LintFileReportV17, OutputContractError> {
15504        let measurements = self.evidence.measurements.prediction_v16_projection()?;
15505        let report = LintFileReportV17 {
15506            evidence: FileEvidence::new(
15507                self.evidence.path.clone(),
15508                self.evidence.input.clone(),
15509                self.evidence.rig.clone(),
15510                measurements,
15511            ),
15512            prediction_provenance: self.prediction_provenance.clone(),
15513            checks: self.checks.clone(),
15514        };
15515        report.validate()?;
15516        Ok(report)
15517    }
15518}
15519
15520/// Historical output-v17 lint envelope retained for immutable writer tests and
15521/// the output-v19 prediction projection.
15522#[derive(Debug, Clone, Serialize)]
15523pub struct LintEnvelopeV17 {
15524    #[serde(flatten)]
15525    header: EnvelopeHeaderV2,
15526    summary: LintSummary,
15527    files: Vec<LintFileReportV17>,
15528}
15529
15530impl LintEnvelopeV17 {
15531    /// Construct a schema-valid V17 lint envelope and derive summaries.
15532    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV17>) -> Result<Self, OutputContractError> {
15533        if files.len() > OUTPUT_V11_MAX_FILES {
15534            return Err(OutputContractError::TooManyFiles {
15535                found: files.len(),
15536                limit: OUTPUT_V11_MAX_FILES,
15537            });
15538        }
15539        let mut findings = FindingSummary::default();
15540        let mut checks = CheckSummary::default();
15541        let mut prediction_facets = PredictionFacetSummary::default();
15542        for file in &files {
15543            file.validate()?;
15544            for check in file.checks() {
15545                checks.total += 1;
15546                for finding in check.findings() {
15547                    findings.add(finding.severity);
15548                }
15549                match check.selection() {
15550                    SelectionState::Selected => checks.selection.selected += 1,
15551                    SelectionState::Unselected => checks.selection.unselected += 1,
15552                }
15553                match check.configuration() {
15554                    ConfigurationState::Enabled => checks.configuration.enabled += 1,
15555                    ConfigurationState::Disabled => checks.configuration.disabled += 1,
15556                }
15557                match check.applicability() {
15558                    Applicability::Applicable => checks.applicability.applicable += 1,
15559                    Applicability::NotApplicable => checks.applicability.not_applicable += 1,
15560                }
15561                match check.evaluation() {
15562                    EvaluationState::Complete => checks.evaluation.complete += 1,
15563                    EvaluationState::Partial => checks.evaluation.partial += 1,
15564                    EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
15565                }
15566                checks.gaps += check.gaps().len();
15567                let mut add_facet = |state| match state {
15568                    EnginePredictionFacetStateV1::Available => prediction_facets.available += 1,
15569                    EnginePredictionFacetStateV1::RequiredPredictionUnavailable => {
15570                        prediction_facets.required_prediction_unavailable += 1
15571                    }
15572                };
15573                for facet in check
15574                    .engine_prediction_v3()
15575                    .into_iter()
15576                    .flat_map(EnginePredictionV3::facets)
15577                {
15578                    add_facet(facet.state());
15579                }
15580                for facet in check
15581                    .engine_prediction_v5()
15582                    .into_iter()
15583                    .flat_map(EnginePredictionV5::facets)
15584                {
15585                    add_facet(facet.state());
15586                }
15587                for facet in check
15588                    .engine_prediction_v6()
15589                    .into_iter()
15590                    .flat_map(EnginePredictionV6::facets)
15591                {
15592                    add_facet(facet.state());
15593                }
15594            }
15595        }
15596        Ok(Self {
15597            header: EnvelopeHeaderV2 {
15598                schema_version: OUTPUT_V17_SCHEMA_VERSION,
15599                schema: OUTPUT_V17_SCHEMA_ID,
15600                tool,
15601                command: "lint",
15602            },
15603            summary: LintSummary {
15604                files: files.len(),
15605                findings,
15606                checks,
15607                prediction_facets,
15608            },
15609            files,
15610        })
15611    }
15612}
15613
15614/// Current output-v19 lint envelope.
15615#[derive(Debug, Clone, Serialize)]
15616pub struct LintEnvelopeV19 {
15617    #[serde(flatten)]
15618    header: EnvelopeHeaderV2,
15619    summary: LintSummary,
15620    files: Vec<LintFileReportV19>,
15621}
15622
15623impl LintEnvelopeV19 {
15624    /// Construct a schema-valid V19 lint envelope and derive summaries.
15625    pub fn new(tool: ToolInfo, files: Vec<LintFileReportV19>) -> Result<Self, OutputContractError> {
15626        if files.len() > OUTPUT_V11_MAX_FILES {
15627            return Err(OutputContractError::TooManyFiles {
15628                found: files.len(),
15629                limit: OUTPUT_V11_MAX_FILES,
15630            });
15631        }
15632        for file in &files {
15633            require_measurements_v18(OUTPUT_SCHEMA_VERSION, &file.evidence.measurements)?;
15634        }
15635        let historical_files = files
15636            .iter()
15637            .map(LintFileReportV19::historical_prediction_view)
15638            .collect::<Result<Vec<_>, _>>()?;
15639        let historical = LintEnvelopeV17::new(tool.clone(), historical_files)?;
15640        Ok(Self {
15641            header: EnvelopeHeaderV2 {
15642                schema_version: OUTPUT_SCHEMA_VERSION,
15643                schema: OUTPUT_SCHEMA_ID,
15644                tool,
15645                command: "lint",
15646            },
15647            summary: historical.summary,
15648            files,
15649        })
15650    }
15651}
15652
15653#[derive(Debug, Clone, Serialize)]
15654struct DiffInputs {
15655    before: String,
15656    after: String,
15657}
15658
15659#[derive(Debug, Clone, Serialize)]
15660struct DiffSummary {
15661    deltas: usize,
15662}
15663
15664/// Current diff-command result envelope.
15665#[derive(Debug, Serialize)]
15666pub struct DiffEnvelope {
15667    #[serde(flatten)]
15668    header: EnvelopeHeader,
15669    inputs: DiffInputs,
15670    summary: DiffSummary,
15671    deltas: Vec<MetricDelta>,
15672}
15673
15674impl DiffEnvelope {
15675    /// Construct a schema-valid diff envelope.
15676    pub fn new(
15677        tool: ToolInfo,
15678        before: impl Into<String>,
15679        after: impl Into<String>,
15680        deltas: Vec<MetricDelta>,
15681    ) -> Self {
15682        Self {
15683            header: EnvelopeHeader::new(tool, "diff"),
15684            inputs: DiffInputs {
15685                before: before.into(),
15686                after: after.into(),
15687            },
15688            summary: DiffSummary {
15689                deltas: deltas.len(),
15690            },
15691            deltas,
15692        }
15693    }
15694}