Skip to main content

animsmith_engine/
addressability.rs

1//! Bounded glTF animation inventory and exact Bevy addressability envelope.
2//!
3//! This contract projects only animation declarations already retained by the
4//! immutable raw-source facts sidecar. It deliberately has no scene, skin,
5//! extension-policy, named-map, or animation-target-path vocabulary.
6
7use animsmith_core::evaluation::{
8    Applicability, CheckEvaluation, ConfigurationState, EvaluationScope, EvaluationState,
9    SelectionState,
10};
11use animsmith_core::{
12    DependencyClosureV1, EngineAnimationAddressabilityV1, EngineFactIdV1, EngineFactStateV1,
13    EngineFactValueV1, EnginePredictionFacetStateV1, EnginePredictionV1, InputIdentity,
14    LoadedSource, PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE, PREDICTION_V1_MAX_FACETS_PER_FILE,
15    PredictionBasisReferenceV1, PredictionContractError, PredictionProvenanceV1,
16    PredictionScalarV1, PredictionUnavailableReasonV1, RawSourceDomainV1, RawSourceKeyV1,
17    RawSourceSetCoverageStateV1, RawSourceUnavailableReasonV1, SourceChannelPropertyV1,
18    SourceFormatV1, SourceObservationStateV1, SourceSetCoverageStateV1, SourceTargetKindV1,
19    SourceUnavailableReasonV1, ToolInfo,
20};
21use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
22use serde::{Deserialize, Deserializer, Serialize};
23use serde_json::value::RawValue;
24use std::convert::Infallible;
25use std::fmt;
26use std::io::Read;
27use std::marker::PhantomData;
28
29/// Version of the standalone glTF animation-addressability envelope.
30pub const GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION: u32 = 1;
31/// Immutable identity of the standalone glTF animation-addressability contract.
32pub const GLTF_ANIMATION_ADDRESSABILITY_V1_ID: &str =
33    "urn:animsmith:schema:gltf-animation-addressability:1";
34/// Immutable command discriminator carried by the standalone envelope.
35pub const GLTF_ANIMATION_ADDRESSABILITY_COMMAND: &str = "generate-addressability";
36/// Maximum serialized bytes accepted by the standalone report reader.
37pub const GLTF_ANIMATION_ADDRESSABILITY_V1_MAX_REPORT_BYTES: u64 = 256 * 1024 * 1024;
38
39const INVENTORY_IDENTITY_DOMAIN: &str = "animsmith-gltf-animation-addressability-inventory-v1";
40const ENGINE_ADDRESSABILITY_CHECK_ID: &str = "engine-addressability";
41const BEVY_FAMILY: &str = "bevy";
42const BEVY_PROFILE_REVISION: u32 = 1;
43const BEVY_ENGINE_VERSION: &str = "0.19.0";
44const BEVY_IMPORTER: &str = "gltf-asset-loader";
45const BEVY_ANIMATION_LABEL_SOURCE: &str = "bevy-gltf-asset-label-0.19.0";
46const BEVY_PROFILE_FACTS_SHA256: &str =
47    "873b98e896f05de73d5ea30560a4555c1f93650beeec9ed929e30dbcf7ce8c1e";
48const BEVY_PROFILE_FACTS_CANONICAL_BYTES: u64 = 1_642;
49
50/// Domain-separated identity of one engine-neutral animation inventory.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(transparent)]
53pub struct GltfAnimationAddressabilityIdentityV1(InputIdentity);
54
55impl GltfAnimationAddressabilityIdentityV1 {
56    /// SHA-256 and byte count of the canonical inventory preimage.
57    pub const fn input_identity(&self) -> &InputIdentity {
58        &self.0
59    }
60}
61
62/// Stable reason why a raw animation observation is incomplete.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum GltfAnimationUnavailableReasonV1 {
66    /// The source declaration is malformed.
67    Malformed,
68    /// The loader discarded the source value.
69    Discarded,
70    /// Normalization removed the original form.
71    NormalizedAway,
72    /// Animation baking removed the original form.
73    BakedAway,
74    /// The loader does not model the source domain.
75    LoaderUnsupported,
76    /// The deterministic raw-source budget was exhausted.
77    ProjectionBudgetExceeded,
78    /// The parser did not make the evidence available.
79    ParserUnavailable,
80}
81
82impl From<SourceUnavailableReasonV1> for GltfAnimationUnavailableReasonV1 {
83    fn from(value: SourceUnavailableReasonV1) -> Self {
84        match value {
85            SourceUnavailableReasonV1::Malformed => Self::Malformed,
86            SourceUnavailableReasonV1::Discarded => Self::Discarded,
87            SourceUnavailableReasonV1::NormalizedAway => Self::NormalizedAway,
88            SourceUnavailableReasonV1::BakedAway => Self::BakedAway,
89            SourceUnavailableReasonV1::LoaderUnsupported => Self::LoaderUnsupported,
90            SourceUnavailableReasonV1::ProjectionBudgetExceeded => Self::ProjectionBudgetExceeded,
91            SourceUnavailableReasonV1::ParserUnavailable => Self::ParserUnavailable,
92        }
93    }
94}
95
96/// Exhaustiveness state of one raw animation row domain.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum GltfAnimationCoverageStateV1 {
100    /// Every source row is retained.
101    Complete,
102    /// Retained rows are an authoritative source-order prefix.
103    Partial,
104    /// No source rows are available.
105    Unavailable,
106}
107
108/// Coverage and typed reason for one raw animation row domain.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(deny_unknown_fields)]
111pub struct GltfAnimationCoverageV1 {
112    state: GltfAnimationCoverageStateV1,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    reason: Option<GltfAnimationUnavailableReasonV1>,
115}
116
117#[derive(Deserialize)]
118#[serde(deny_unknown_fields)]
119struct GltfAnimationCoverageWireV1 {
120    state: GltfAnimationCoverageStateV1,
121    #[serde(default, deserialize_with = "deserialize_required_nullable")]
122    reason: RequiredNullable<GltfAnimationUnavailableReasonV1>,
123}
124
125impl<'de> Deserialize<'de> for GltfAnimationCoverageV1 {
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: Deserializer<'de>,
129    {
130        let wire = GltfAnimationCoverageWireV1::deserialize(deserializer)?;
131        let reason = match (wire.state, wire.reason) {
132            (GltfAnimationCoverageStateV1::Complete, RequiredNullable::Missing) => None,
133            (
134                GltfAnimationCoverageStateV1::Partial | GltfAnimationCoverageStateV1::Unavailable,
135                RequiredNullable::Present(Some(reason)),
136            ) => Some(reason),
137            _ => {
138                return Err(D::Error::custom(
139                    GltfAnimationAddressabilityError::InvalidCoverage,
140                ));
141            }
142        };
143        let value = Self {
144            state: wire.state,
145            reason,
146        };
147        value.validate().map_err(D::Error::custom)?;
148        Ok(value)
149    }
150}
151
152impl GltfAnimationCoverageV1 {
153    fn from_source(value: animsmith_core::SourceSetCoverageV1) -> Self {
154        Self {
155            state: match value.state() {
156                SourceSetCoverageStateV1::Complete => GltfAnimationCoverageStateV1::Complete,
157                SourceSetCoverageStateV1::Partial => GltfAnimationCoverageStateV1::Partial,
158                SourceSetCoverageStateV1::Unavailable => GltfAnimationCoverageStateV1::Unavailable,
159            },
160            reason: value.reason().map(Into::into),
161        }
162    }
163
164    /// Exhaustiveness state.
165    pub const fn state(self) -> GltfAnimationCoverageStateV1 {
166        self.state
167    }
168
169    /// Typed incompleteness reason, absent exactly for complete coverage.
170    pub const fn reason(self) -> Option<GltfAnimationUnavailableReasonV1> {
171        self.reason
172    }
173
174    fn validate(self) -> Result<(), GltfAnimationAddressabilityError> {
175        if matches!(
176            (self.state, self.reason),
177            (GltfAnimationCoverageStateV1::Complete, None)
178                | (
179                    GltfAnimationCoverageStateV1::Partial
180                        | GltfAnimationCoverageStateV1::Unavailable,
181                    Some(_)
182                )
183        ) {
184            Ok(())
185        } else {
186            Err(GltfAnimationAddressabilityError::InvalidCoverage)
187        }
188    }
189}
190
191/// Exact state of one scalar raw animation observation.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
194pub enum GltfAnimationObservationV1<T> {
195    /// The raw value was observed.
196    Observed {
197        /// Exact retained value.
198        value: T,
199    },
200    /// Complete evidence proves that no source value was authored.
201    ProvenAbsent,
202    /// The raw value could not be established.
203    Unavailable {
204        /// Stable reason.
205        reason: GltfAnimationUnavailableReasonV1,
206    },
207}
208
209#[derive(Deserialize)]
210#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
211enum GltfAnimationObservationWireV1<T> {
212    Observed {
213        value: T,
214    },
215    ProvenAbsent,
216    Unavailable {
217        reason: GltfAnimationUnavailableReasonV1,
218    },
219}
220
221impl<T> From<GltfAnimationObservationWireV1<T>> for GltfAnimationObservationV1<T> {
222    fn from(value: GltfAnimationObservationWireV1<T>) -> Self {
223        match value {
224            GltfAnimationObservationWireV1::Observed { value } => Self::Observed { value },
225            GltfAnimationObservationWireV1::ProvenAbsent => Self::ProvenAbsent,
226            GltfAnimationObservationWireV1::Unavailable { reason } => Self::Unavailable { reason },
227        }
228    }
229}
230
231impl<T> GltfAnimationObservationV1<T> {
232    fn from_source<U>(
233        value: &animsmith_core::SourceObservationV1<U>,
234        map: impl FnOnce(&U) -> T,
235    ) -> Self {
236        match value.state() {
237            SourceObservationStateV1::Observed(value) => Self::Observed { value: map(value) },
238            SourceObservationStateV1::ProvenAbsent => Self::ProvenAbsent,
239            SourceObservationStateV1::Unavailable(reason) => Self::Unavailable {
240                reason: (*reason).into(),
241            },
242        }
243    }
244}
245
246/// Source target identity kind for one raw animation channel.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum GltfAnimationTargetKindV1 {
250    /// glTF source node-array identity.
251    Node,
252    /// Parser-stable non-node identity.
253    Element,
254    /// Another bounded source target domain.
255    Other,
256}
257
258impl From<SourceTargetKindV1> for GltfAnimationTargetKindV1 {
259    fn from(value: SourceTargetKindV1) -> Self {
260        match value {
261            SourceTargetKindV1::Node => Self::Node,
262            SourceTargetKindV1::Element => Self::Element,
263            SourceTargetKindV1::Other => Self::Other,
264        }
265    }
266}
267
268/// Source target identity for one raw animation channel.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
270#[serde(deny_unknown_fields)]
271pub struct GltfAnimationTargetV1 {
272    kind: GltfAnimationTargetKindV1,
273    index: u64,
274}
275
276#[derive(Deserialize)]
277#[serde(deny_unknown_fields)]
278struct GltfAnimationTargetWireV1 {
279    kind: GltfAnimationTargetKindV1,
280    index: u64,
281}
282
283impl<'de> Deserialize<'de> for GltfAnimationTargetV1 {
284    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
285    where
286        D: Deserializer<'de>,
287    {
288        let wire = GltfAnimationTargetWireV1::deserialize(deserializer)?;
289        if wire.kind != GltfAnimationTargetKindV1::Node {
290            return Err(D::Error::custom(
291                GltfAnimationAddressabilityError::InvalidGltfTargetKind,
292            ));
293        }
294        Ok(Self {
295            kind: wire.kind,
296            index: wire.index,
297        })
298    }
299}
300
301impl GltfAnimationTargetV1 {
302    /// Source target identity kind.
303    pub const fn kind(self) -> GltfAnimationTargetKindV1 {
304        self.kind
305    }
306
307    /// Stable index in the source target domain.
308    pub const fn index(self) -> u64 {
309        self.index
310    }
311}
312
313/// Format-neutral property targeted by one raw animation channel.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum GltfAnimationChannelPropertyV1 {
317    /// Local translation.
318    Translation,
319    /// Local rotation.
320    Rotation,
321    /// Local scale.
322    Scale,
323    /// Morph-target weights.
324    Weights,
325    /// Another bounded source property.
326    Other,
327}
328
329impl From<SourceChannelPropertyV1> for GltfAnimationChannelPropertyV1 {
330    fn from(value: SourceChannelPropertyV1) -> Self {
331        match value {
332            SourceChannelPropertyV1::Translation => Self::Translation,
333            SourceChannelPropertyV1::Rotation => Self::Rotation,
334            SourceChannelPropertyV1::Scale => Self::Scale,
335            SourceChannelPropertyV1::Weights => Self::Weights,
336            SourceChannelPropertyV1::Other => Self::Other,
337        }
338    }
339}
340
341/// One exact source-order raw animation channel row.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
343#[serde(deny_unknown_fields)]
344pub struct GltfAnimationAddressabilityChannelV1 {
345    source_channel_index: u64,
346    target: GltfAnimationTargetV1,
347    property: GltfAnimationChannelPropertyV1,
348    input_accessor_index: u64,
349    output_accessor_index: u64,
350}
351
352#[derive(Deserialize)]
353#[serde(deny_unknown_fields)]
354struct GltfAnimationAddressabilityChannelWireV1 {
355    source_channel_index: u64,
356    target: GltfAnimationTargetV1,
357    property: GltfAnimationChannelPropertyV1,
358    input_accessor_index: u64,
359    output_accessor_index: u64,
360}
361
362impl<'de> Deserialize<'de> for GltfAnimationAddressabilityChannelV1 {
363    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364    where
365        D: Deserializer<'de>,
366    {
367        let wire = GltfAnimationAddressabilityChannelWireV1::deserialize(deserializer)?;
368        let limit = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1;
369        if wire.source_channel_index >= limit as u64 {
370            return Err(D::Error::custom(
371                GltfAnimationAddressabilityError::SourceIndexOutOfRange {
372                    field: "source channel index",
373                    actual: wire.source_channel_index,
374                    limit,
375                },
376            ));
377        }
378        Ok(Self {
379            source_channel_index: wire.source_channel_index,
380            target: wire.target,
381            property: wire.property,
382            input_accessor_index: wire.input_accessor_index,
383            output_accessor_index: wire.output_accessor_index,
384        })
385    }
386}
387
388impl GltfAnimationAddressabilityChannelV1 {
389    /// Exact source-order channel index within its animation.
390    pub const fn source_channel_index(&self) -> u64 {
391        self.source_channel_index
392    }
393
394    /// Exact source target identity.
395    pub const fn target(&self) -> GltfAnimationTargetV1 {
396        self.target
397    }
398
399    /// Raw channel property.
400    pub const fn property(&self) -> GltfAnimationChannelPropertyV1 {
401        self.property
402    }
403
404    /// Exact input accessor index retained for this glTF channel.
405    pub const fn input_accessor_index(&self) -> u64 {
406        self.input_accessor_index
407    }
408
409    /// Exact output accessor index retained for this glTF channel.
410    pub const fn output_accessor_index(&self) -> u64 {
411        self.output_accessor_index
412    }
413}
414
415/// Coverage-qualified raw channel rows for one source animation.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
417#[serde(deny_unknown_fields)]
418pub struct GltfAnimationAddressabilityChannelSetV1 {
419    coverage: GltfAnimationCoverageV1,
420    rows: Vec<GltfAnimationAddressabilityChannelV1>,
421}
422
423#[derive(Deserialize)]
424#[serde(deny_unknown_fields)]
425struct GltfAnimationAddressabilityChannelSetWireV1 {
426    coverage: GltfAnimationCoverageV1,
427    #[serde(deserialize_with = "deserialize_channel_rows")]
428    rows: Vec<GltfAnimationAddressabilityChannelV1>,
429}
430
431impl<'de> Deserialize<'de> for GltfAnimationAddressabilityChannelSetV1 {
432    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
433    where
434        D: Deserializer<'de>,
435    {
436        let wire = GltfAnimationAddressabilityChannelSetWireV1::deserialize(deserializer)?;
437        Self::from_wire(wire).map_err(D::Error::custom)
438    }
439}
440
441impl GltfAnimationAddressabilityChannelSetV1 {
442    fn from_wire(
443        wire: GltfAnimationAddressabilityChannelSetWireV1,
444    ) -> Result<Self, GltfAnimationAddressabilityError> {
445        if wire.coverage.state == GltfAnimationCoverageStateV1::Unavailable && !wire.rows.is_empty()
446        {
447            return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
448        }
449        if wire.rows.len() >= animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
450            return Err(GltfAnimationAddressabilityError::TooManyRows {
451                found: wire.rows.len(),
452                limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1,
453            });
454        }
455        for (offset, row) in wire.rows.iter().enumerate() {
456            let expected = u64::try_from(offset).map_err(|_| {
457                GltfAnimationAddressabilityError::IndexOverflow {
458                    field: "channel index",
459                }
460            })?;
461            if row.source_channel_index != expected {
462                return Err(GltfAnimationAddressabilityError::NonCanonicalChannelIndex {
463                    source_clip_index: 0,
464                    expected,
465                    actual: row.source_channel_index,
466                });
467            }
468        }
469        Ok(Self {
470            coverage: wire.coverage,
471            rows: wire.rows,
472        })
473    }
474
475    /// Exhaustiveness and typed reason for this channel domain.
476    pub const fn coverage(&self) -> GltfAnimationCoverageV1 {
477        self.coverage
478    }
479
480    /// Retained source-order channel rows.
481    pub fn rows(&self) -> &[GltfAnimationAddressabilityChannelV1] {
482        &self.rows
483    }
484}
485
486/// One exact source-order raw animation row.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(deny_unknown_fields)]
489pub struct GltfAnimationAddressabilityAnimationV1 {
490    source_clip_index: u64,
491    source_name: GltfAnimationObservationV1<String>,
492    normalized_clip_index: GltfAnimationObservationV1<u64>,
493    channels: GltfAnimationAddressabilityChannelSetV1,
494}
495
496#[derive(Deserialize)]
497#[serde(deny_unknown_fields)]
498struct GltfAnimationAddressabilityAnimationWireV1 {
499    source_clip_index: u64,
500    source_name: GltfAnimationObservationWireV1<String>,
501    normalized_clip_index: GltfAnimationObservationWireV1<u64>,
502    channels: GltfAnimationAddressabilityChannelSetV1,
503}
504
505impl<'de> Deserialize<'de> for GltfAnimationAddressabilityAnimationV1 {
506    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507    where
508        D: Deserializer<'de>,
509    {
510        let wire = GltfAnimationAddressabilityAnimationWireV1::deserialize(deserializer)?;
511        Self::from_wire(wire).map_err(D::Error::custom)
512    }
513}
514
515impl GltfAnimationAddressabilityAnimationV1 {
516    fn from_wire(
517        wire: GltfAnimationAddressabilityAnimationWireV1,
518    ) -> Result<Self, GltfAnimationAddressabilityError> {
519        if wire.source_clip_index >= animsmith_core::RAW_SOURCE_V1_MAX_CLIPS as u64 {
520            return Err(GltfAnimationAddressabilityError::SourceIndexOutOfRange {
521                field: "source clip index",
522                actual: wire.source_clip_index,
523                limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
524            });
525        }
526        if let GltfAnimationObservationWireV1::Observed { value } = &wire.source_name
527            && value.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
528        {
529            return Err(GltfAnimationAddressabilityError::TextTooLong {
530                found: value.len(),
531                limit: animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES,
532            });
533        }
534        Ok(Self {
535            source_clip_index: wire.source_clip_index,
536            source_name: wire.source_name.into(),
537            normalized_clip_index: wire.normalized_clip_index.into(),
538            channels: wire.channels,
539        })
540    }
541
542    /// Exact source animation-array index.
543    pub const fn source_clip_index(&self) -> u64 {
544        self.source_clip_index
545    }
546
547    /// Raw authored-name observation; names remain optional and non-unique.
548    pub const fn source_name(&self) -> &GltfAnimationObservationV1<String> {
549        &self.source_name
550    }
551
552    /// Mapping to the normalized clip table, separate from source identity.
553    pub const fn normalized_clip_index(&self) -> &GltfAnimationObservationV1<u64> {
554        &self.normalized_clip_index
555    }
556
557    /// Coverage-qualified raw channel rows.
558    pub const fn channels(&self) -> &GltfAnimationAddressabilityChannelSetV1 {
559        &self.channels
560    }
561}
562
563/// Coverage-qualified source animation rows.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
565#[serde(deny_unknown_fields)]
566pub struct GltfAnimationAddressabilityAnimationSetV1 {
567    coverage: GltfAnimationCoverageV1,
568    rows: Vec<GltfAnimationAddressabilityAnimationV1>,
569}
570
571#[derive(Deserialize)]
572#[serde(deny_unknown_fields)]
573struct GltfAnimationAddressabilityAnimationSetWireV1 {
574    coverage: GltfAnimationCoverageV1,
575    #[serde(deserialize_with = "deserialize_animation_rows")]
576    rows: Vec<GltfAnimationAddressabilityAnimationV1>,
577}
578
579impl<'de> Deserialize<'de> for GltfAnimationAddressabilityAnimationSetV1 {
580    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
581    where
582        D: Deserializer<'de>,
583    {
584        let wire = GltfAnimationAddressabilityAnimationSetWireV1::deserialize(deserializer)?;
585        let value = Self {
586            coverage: wire.coverage,
587            rows: wire.rows,
588        };
589        validate_animation_set(&value).map_err(D::Error::custom)?;
590        Ok(value)
591    }
592}
593
594impl GltfAnimationAddressabilityAnimationSetV1 {
595    /// Exhaustiveness and typed reason for the source animation domain.
596    pub const fn coverage(&self) -> GltfAnimationCoverageV1 {
597        self.coverage
598    }
599
600    /// Retained source-order animation rows.
601    pub fn rows(&self) -> &[GltfAnimationAddressabilityAnimationV1] {
602        &self.rows
603    }
604}
605
606/// Engine-neutral glTF animation inventory projected from one immutable load.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
608pub struct GltfAnimationAddressabilityInventoryV1 {
609    schema: &'static str,
610    identity: GltfAnimationAddressabilityIdentityV1,
611    source_format: SourceFormatV1,
612    primary_input: InputIdentity,
613    dependency_closure: DependencyClosureV1,
614    animations: GltfAnimationAddressabilityAnimationSetV1,
615}
616
617#[derive(Deserialize)]
618#[serde(deny_unknown_fields)]
619struct GltfAnimationAddressabilityInventoryWireV1 {
620    schema: String,
621    identity: GltfAnimationAddressabilityIdentityV1,
622    source_format: SourceFormatV1,
623    primary_input: InputIdentity,
624    dependency_closure: DependencyClosureV1,
625    animations: GltfAnimationAddressabilityAnimationSetV1,
626}
627
628impl<'de> Deserialize<'de> for GltfAnimationAddressabilityInventoryV1 {
629    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
630    where
631        D: Deserializer<'de>,
632    {
633        let wire = GltfAnimationAddressabilityInventoryWireV1::deserialize(deserializer)?;
634        let value = Self {
635            schema: if wire.schema == GLTF_ANIMATION_ADDRESSABILITY_V1_ID {
636                GLTF_ANIMATION_ADDRESSABILITY_V1_ID
637            } else {
638                return Err(D::Error::custom(format!(
639                    "inventory schema must be {GLTF_ANIMATION_ADDRESSABILITY_V1_ID:?}"
640                )));
641            },
642            identity: wire.identity,
643            source_format: wire.source_format,
644            primary_input: wire.primary_input,
645            dependency_closure: wire.dependency_closure,
646            animations: wire.animations,
647        };
648        value.validate().map_err(D::Error::custom)?;
649        Ok(value)
650    }
651}
652
653impl GltfAnimationAddressabilityInventoryV1 {
654    /// Project one bounded engine-neutral animation inventory from a same-load source.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`GltfAnimationAddressabilityError::UnsupportedSourceFormat`] for
659    /// a non-glTF source and [`GltfAnimationAddressabilityError::TooManyAnimations`]
660    /// when the normalized document exceeds the V1 limit of 4,096 clips.
661    /// Other loader-valid glTF/GLB sidecars already satisfy the retained
662    /// collection, text, ordering, and closure invariants.
663    pub fn from_source(source: &LoadedSource) -> Result<Self, GltfAnimationAddressabilityError> {
664        let facts = source.source_facts();
665        require_gltf(facts.format())?;
666        let document_clip_count = source.document().clips.len();
667        if document_clip_count > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
668            return Err(GltfAnimationAddressabilityError::TooManyAnimations {
669                found: document_clip_count,
670                limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
671            });
672        }
673        preflight_source(facts)?;
674
675        let mut animations = Vec::with_capacity(facts.clips().rows().len());
676        for clip in facts.clips().rows() {
677            let mut channels = Vec::with_capacity(clip.channels().rows().len());
678            for channel in clip.channels().rows() {
679                let (input_accessor_index, output_accessor_index) = match (
680                    channel.input_accessor_index(),
681                    channel.output_accessor_index(),
682                ) {
683                    (Some(input), Some(output)) => (
684                        to_u64("input accessor index", input)?,
685                        to_u64("output accessor index", output)?,
686                    ),
687                    (None, None) => {
688                        return Err(GltfAnimationAddressabilityError::MissingAccessorPair);
689                    }
690                    _ => {
691                        return Err(GltfAnimationAddressabilityError::IncompleteAccessorPair);
692                    }
693                };
694                channels.push(GltfAnimationAddressabilityChannelV1 {
695                    source_channel_index: to_u64(
696                        "source channel index",
697                        channel.source_channel_index(),
698                    )?,
699                    target: GltfAnimationTargetV1 {
700                        kind: channel.target().kind().into(),
701                        index: channel.target().index(),
702                    },
703                    property: channel.property().into(),
704                    input_accessor_index,
705                    output_accessor_index,
706                });
707            }
708            animations.push(GltfAnimationAddressabilityAnimationV1 {
709                source_clip_index: to_u64("source clip index", clip.source_clip_index())?,
710                source_name: GltfAnimationObservationV1::from_source(clip.source_name(), |value| {
711                    value.as_str().to_owned()
712                }),
713                normalized_clip_index: map_index_observation(clip.normalized_clip_index())?,
714                channels: GltfAnimationAddressabilityChannelSetV1 {
715                    coverage: GltfAnimationCoverageV1::from_source(clip.channels().coverage()),
716                    rows: channels,
717                },
718            });
719        }
720
721        let mut value = Self {
722            schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
723            identity: GltfAnimationAddressabilityIdentityV1(InputIdentity::from_bytes(&[])),
724            source_format: facts.format(),
725            primary_input: facts.primary_identity().clone(),
726            dependency_closure: source.dependency_closure().clone(),
727            animations: GltfAnimationAddressabilityAnimationSetV1 {
728                coverage: GltfAnimationCoverageV1::from_source(facts.clips().coverage()),
729                rows: animations,
730            },
731        };
732        value.validate_without_identity()?;
733        value.identity = GltfAnimationAddressabilityIdentityV1(value.computed_identity());
734        Ok(value)
735    }
736
737    /// Immutable inventory contract identity.
738    pub const fn contract_id(&self) -> &'static str {
739        self.schema
740    }
741
742    /// Canonical identity over engine-neutral fields only.
743    pub const fn identity(&self) -> &GltfAnimationAddressabilityIdentityV1 {
744        &self.identity
745    }
746
747    /// Exact glTF or GLB source format.
748    pub const fn source_format(&self) -> SourceFormatV1 {
749        self.source_format
750    }
751
752    /// Exact primary input parsed by the loader.
753    pub const fn primary_input(&self) -> &InputIdentity {
754        &self.primary_input
755    }
756
757    /// Full same-load dependency-closure evidence.
758    pub const fn dependency_closure(&self) -> &DependencyClosureV1 {
759        &self.dependency_closure
760    }
761
762    /// Coverage-qualified raw animation inventory.
763    pub const fn animations(&self) -> &GltfAnimationAddressabilityAnimationSetV1 {
764        &self.animations
765    }
766
767    fn validate(&self) -> Result<(), GltfAnimationAddressabilityError> {
768        self.validate_without_identity()?;
769        if self.identity.0 != self.computed_identity() {
770            return Err(GltfAnimationAddressabilityError::InventoryIdentityMismatch);
771        }
772        Ok(())
773    }
774
775    fn validate_without_identity(&self) -> Result<(), GltfAnimationAddressabilityError> {
776        require_gltf(self.source_format)?;
777        if self.dependency_closure.primary_input() != &self.primary_input {
778            return Err(GltfAnimationAddressabilityError::DependencyClosurePrimaryMismatch);
779        }
780        validate_animation_set(&self.animations)
781    }
782
783    fn computed_identity(&self) -> InputIdentity {
784        let closure_fingerprint = self.dependency_closure.record_identity();
785        let mut encoder = CanonicalEncoder::new(INVENTORY_IDENTITY_DOMAIN);
786        encoder.field("schema");
787        encoder.token(self.schema);
788        encoder.field("source_format");
789        encoder.token(source_format_name(self.source_format));
790        encoder.field("primary_input");
791        encode_input_identity(&mut encoder, &self.primary_input);
792        encoder.field("dependency_closure_fingerprint");
793        encode_input_identity(&mut encoder, &closure_fingerprint);
794        encode_animation_set(&mut encoder, &self.animations);
795        encoder.identity()
796    }
797}
798
799/// Exact optional Bevy adapter embedded in the standalone envelope.
800#[derive(Debug, Clone, Serialize)]
801pub struct GltfAnimationAddressabilityBevyAdapterV1 {
802    prediction_provenance: PredictionProvenanceV1,
803    check: CheckEvaluation,
804}
805
806impl GltfAnimationAddressabilityBevyAdapterV1 {
807    /// Bind the unchanged `engine-addressability` evaluation to its exact provenance.
808    ///
809    /// # Errors
810    ///
811    /// Returns a typed contract error unless the provenance is the exact Bevy
812    /// 0.19.0 glTF tuple and the check is the sole #154 lifecycle for this
813    /// inventory.
814    pub fn new(
815        prediction_provenance: PredictionProvenanceV1,
816        check: CheckEvaluation,
817        inventory: &GltfAnimationAddressabilityInventoryV1,
818    ) -> Result<Self, GltfAnimationAddressabilityError> {
819        validate_bevy_adapter(
820            &prediction_provenance,
821            BevyAddressabilityCheckView::from(&check),
822            inventory,
823        )?;
824        Ok(Self {
825            prediction_provenance,
826            check,
827        })
828    }
829
830    /// Same-load prediction provenance used by the embedded check.
831    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
832        &self.prediction_provenance
833    }
834
835    /// Unchanged existing `engine-addressability` check evaluation.
836    pub const fn check(&self) -> &CheckEvaluation {
837        &self.check
838    }
839}
840
841/// Standalone producer envelope for `generate addressability`.
842#[derive(Debug, Clone, Serialize)]
843pub struct GltfAnimationAddressabilityV1 {
844    schema_version: u32,
845    schema: &'static str,
846    tool: ToolInfo,
847    command: &'static str,
848    input: InputIdentity,
849    inventory: GltfAnimationAddressabilityInventoryV1,
850    bevy: Option<GltfAnimationAddressabilityBevyAdapterV1>,
851}
852
853impl GltfAnimationAddressabilityV1 {
854    /// Construct one validated standalone result document.
855    ///
856    /// # Errors
857    ///
858    /// Returns a typed contract error when an optional adapter is not bound to
859    /// this exact inventory.
860    pub fn new(
861        tool: ToolInfo,
862        inventory: GltfAnimationAddressabilityInventoryV1,
863        bevy: Option<GltfAnimationAddressabilityBevyAdapterV1>,
864    ) -> Result<Self, GltfAnimationAddressabilityError> {
865        inventory.validate()?;
866        if let Some(adapter) = &bevy {
867            validate_bevy_adapter(
868                adapter.prediction_provenance(),
869                BevyAddressabilityCheckView::from(adapter.check()),
870                &inventory,
871            )?;
872        }
873        Ok(Self {
874            schema_version: GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION,
875            schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
876            tool,
877            command: GLTF_ANIMATION_ADDRESSABILITY_COMMAND,
878            input: inventory.primary_input.clone(),
879            inventory,
880            bevy,
881        })
882    }
883
884    /// Exact primary input identity.
885    pub const fn input(&self) -> &InputIdentity {
886        &self.input
887    }
888
889    /// Engine-neutral animation inventory.
890    pub const fn inventory(&self) -> &GltfAnimationAddressabilityInventoryV1 {
891        &self.inventory
892    }
893
894    /// Exact Bevy adapter, absent without the selected compatible profile.
895    pub const fn bevy(&self) -> Option<&GltfAnimationAddressabilityBevyAdapterV1> {
896        self.bevy.as_ref()
897    }
898}
899
900#[derive(Debug, Deserialize)]
901#[serde(deny_unknown_fields)]
902struct ToolSourceInputV1 {
903    #[serde(default, deserialize_with = "deserialize_required_nullable")]
904    revision: RequiredNullable<String>,
905    #[serde(default, deserialize_with = "deserialize_required_nullable")]
906    dirty: RequiredNullable<bool>,
907}
908
909#[derive(Debug, Deserialize)]
910#[serde(deny_unknown_fields)]
911struct ToolInputV1 {
912    name: String,
913    version: String,
914    source: ToolSourceInputV1,
915}
916
917#[derive(Debug, Deserialize)]
918#[serde(deny_unknown_fields)]
919struct BevyAdapterInputV1 {
920    prediction_provenance: Box<RawValue>,
921    check: Box<RawValue>,
922}
923
924#[derive(Debug, Default)]
925enum RequiredNullable<T> {
926    #[default]
927    Missing,
928    Present(Option<T>),
929}
930
931fn deserialize_required_nullable<'de, D, T>(
932    deserializer: D,
933) -> Result<RequiredNullable<T>, D::Error>
934where
935    D: Deserializer<'de>,
936    T: Deserialize<'de>,
937{
938    Option::<T>::deserialize(deserializer).map(RequiredNullable::Present)
939}
940
941fn deserialize_present_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
942where
943    D: Deserializer<'de>,
944    T: Deserialize<'de>,
945{
946    T::deserialize(deserializer).map(Some)
947}
948
949#[derive(Clone, Copy)]
950enum AddressabilitySequenceBound {
951    AnimationRows,
952    ChannelRows,
953    AggregateRows,
954    EvaluatedScopes,
955    EmptyCheckEntries,
956}
957
958impl AddressabilitySequenceBound {
959    const fn limit(self) -> usize {
960        match self {
961            Self::AnimationRows | Self::EvaluatedScopes => animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
962            Self::ChannelRows => animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1,
963            Self::AggregateRows => animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
964            Self::EmptyCheckEntries => 0,
965        }
966    }
967
968    fn error(self) -> GltfAnimationAddressabilityError {
969        let limit = self.limit();
970        match self {
971            Self::AnimationRows => GltfAnimationAddressabilityError::TooManyAnimations {
972                found: limit + 1,
973                limit,
974            },
975            Self::ChannelRows => GltfAnimationAddressabilityError::TooManyRows {
976                found: limit + 1,
977                limit,
978            },
979            Self::AggregateRows => GltfAnimationAddressabilityError::TooManyRows {
980                found: limit + 1,
981                limit,
982            },
983            Self::EvaluatedScopes | Self::EmptyCheckEntries => {
984                GltfAnimationAddressabilityError::InvalidBevyCheckSubset
985            }
986        }
987    }
988}
989
990struct BoundedSequenceVisitor<T> {
991    bound: AddressabilitySequenceBound,
992    element: PhantomData<fn() -> T>,
993}
994
995impl<'de, T> Visitor<'de> for BoundedSequenceVisitor<T>
996where
997    T: Deserialize<'de>,
998{
999    type Value = Vec<T>;
1000
1001    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1002        write!(
1003            formatter,
1004            "a sequence with at most {} elements",
1005            self.bound.limit()
1006        )
1007    }
1008
1009    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1010    where
1011        A: SeqAccess<'de>,
1012    {
1013        let limit = self.bound.limit();
1014        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(limit));
1015        while values.len() < limit {
1016            let Some(value) = sequence.next_element()? else {
1017                return Ok(values);
1018            };
1019            values.push(value);
1020        }
1021        reject_next_element(&mut sequence, self.bound)?;
1022        Ok(values)
1023    }
1024}
1025
1026struct RejectExtraElement(AddressabilitySequenceBound);
1027
1028impl<'de> DeserializeSeed<'de> for RejectExtraElement {
1029    type Value = Infallible;
1030
1031    fn deserialize<D>(self, _deserializer: D) -> Result<Self::Value, D::Error>
1032    where
1033        D: Deserializer<'de>,
1034    {
1035        Err(D::Error::custom(self.0.error()))
1036    }
1037}
1038
1039fn reject_next_element<'de, A>(
1040    sequence: &mut A,
1041    bound: AddressabilitySequenceBound,
1042) -> Result<(), A::Error>
1043where
1044    A: SeqAccess<'de>,
1045{
1046    match sequence.next_element_seed(RejectExtraElement(bound)) {
1047        Ok(None) => Ok(()),
1048        Ok(Some(never)) => match never {},
1049        Err(error) => Err(error),
1050    }
1051}
1052
1053fn deserialize_bounded_sequence<'de, D, T>(
1054    deserializer: D,
1055    bound: AddressabilitySequenceBound,
1056) -> Result<Vec<T>, D::Error>
1057where
1058    D: Deserializer<'de>,
1059    T: Deserialize<'de>,
1060{
1061    deserializer.deserialize_seq(BoundedSequenceVisitor {
1062        bound,
1063        element: PhantomData,
1064    })
1065}
1066
1067fn deserialize_animation_rows<'de, D>(
1068    deserializer: D,
1069) -> Result<Vec<GltfAnimationAddressabilityAnimationV1>, D::Error>
1070where
1071    D: Deserializer<'de>,
1072{
1073    struct AnimationRowsVisitor;
1074
1075    impl<'de> Visitor<'de> for AnimationRowsVisitor {
1076        type Value = Vec<GltfAnimationAddressabilityAnimationV1>;
1077
1078        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1079            formatter.write_str("a bounded sequence of animation rows")
1080        }
1081
1082        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1083        where
1084            A: SeqAccess<'de>,
1085        {
1086            let animation_limit = animsmith_core::RAW_SOURCE_V1_MAX_CLIPS;
1087            let mut remaining_rows = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS;
1088            let mut animations =
1089                Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(animation_limit));
1090            loop {
1091                let bound = if animations.len() == animation_limit {
1092                    Some(AddressabilitySequenceBound::AnimationRows)
1093                } else if remaining_rows == 0 {
1094                    Some(AddressabilitySequenceBound::AggregateRows)
1095                } else {
1096                    None
1097                };
1098                if let Some(bound) = bound {
1099                    reject_next_element(&mut sequence, bound)?;
1100                    return Ok(animations);
1101                }
1102                let Some(animation) = sequence.next_element_seed(AnimationRowSeed {
1103                    remaining_rows: &mut remaining_rows,
1104                })?
1105                else {
1106                    return Ok(animations);
1107                };
1108                animations.push(animation);
1109            }
1110        }
1111    }
1112
1113    deserializer.deserialize_seq(AnimationRowsVisitor)
1114}
1115
1116struct AnimationRowSeed<'a> {
1117    remaining_rows: &'a mut usize,
1118}
1119
1120impl<'de> DeserializeSeed<'de> for AnimationRowSeed<'_> {
1121    type Value = GltfAnimationAddressabilityAnimationV1;
1122
1123    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1124    where
1125        D: Deserializer<'de>,
1126    {
1127        *self.remaining_rows -= 1;
1128        deserializer.deserialize_struct(
1129            "GltfAnimationAddressabilityAnimationV1",
1130            ANIMATION_ROW_FIELDS,
1131            AnimationRowVisitor {
1132                remaining_rows: self.remaining_rows,
1133            },
1134        )
1135    }
1136}
1137
1138const ANIMATION_ROW_FIELDS: &[&str] = &[
1139    "source_clip_index",
1140    "source_name",
1141    "normalized_clip_index",
1142    "channels",
1143];
1144
1145#[derive(Deserialize)]
1146#[serde(field_identifier, rename_all = "snake_case")]
1147enum AnimationRowField {
1148    SourceClipIndex,
1149    SourceName,
1150    NormalizedClipIndex,
1151    Channels,
1152}
1153
1154struct AnimationRowVisitor<'a> {
1155    remaining_rows: &'a mut usize,
1156}
1157
1158impl<'de> Visitor<'de> for AnimationRowVisitor<'_> {
1159    type Value = GltfAnimationAddressabilityAnimationV1;
1160
1161    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1162        formatter.write_str("a strict animation row object")
1163    }
1164
1165    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1166    where
1167        A: MapAccess<'de>,
1168    {
1169        let mut source_clip_index = None;
1170        let mut source_name = None;
1171        let mut normalized_clip_index = None;
1172        let mut channels = None;
1173        while let Some(field) = map.next_key()? {
1174            match field {
1175                AnimationRowField::SourceClipIndex => {
1176                    if source_clip_index.is_some() {
1177                        return Err(A::Error::duplicate_field("source_clip_index"));
1178                    }
1179                    source_clip_index = Some(map.next_value()?);
1180                }
1181                AnimationRowField::SourceName => {
1182                    if source_name.is_some() {
1183                        return Err(A::Error::duplicate_field("source_name"));
1184                    }
1185                    source_name = Some(map.next_value()?);
1186                }
1187                AnimationRowField::NormalizedClipIndex => {
1188                    if normalized_clip_index.is_some() {
1189                        return Err(A::Error::duplicate_field("normalized_clip_index"));
1190                    }
1191                    normalized_clip_index = Some(map.next_value()?);
1192                }
1193                AnimationRowField::Channels => {
1194                    if channels.is_some() {
1195                        return Err(A::Error::duplicate_field("channels"));
1196                    }
1197                    channels = Some(map.next_value_seed(ChannelSetSeed {
1198                        remaining_rows: self.remaining_rows,
1199                    })?);
1200                }
1201            }
1202        }
1203        let wire = GltfAnimationAddressabilityAnimationWireV1 {
1204            source_clip_index: source_clip_index
1205                .ok_or_else(|| A::Error::missing_field("source_clip_index"))?,
1206            source_name: source_name.ok_or_else(|| A::Error::missing_field("source_name"))?,
1207            normalized_clip_index: normalized_clip_index
1208                .ok_or_else(|| A::Error::missing_field("normalized_clip_index"))?,
1209            channels: channels.ok_or_else(|| A::Error::missing_field("channels"))?,
1210        };
1211        GltfAnimationAddressabilityAnimationV1::from_wire(wire).map_err(A::Error::custom)
1212    }
1213}
1214
1215struct ChannelSetSeed<'a> {
1216    remaining_rows: &'a mut usize,
1217}
1218
1219impl<'de> DeserializeSeed<'de> for ChannelSetSeed<'_> {
1220    type Value = GltfAnimationAddressabilityChannelSetV1;
1221
1222    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1223    where
1224        D: Deserializer<'de>,
1225    {
1226        deserializer.deserialize_struct(
1227            "GltfAnimationAddressabilityChannelSetV1",
1228            CHANNEL_SET_FIELDS,
1229            ChannelSetVisitor {
1230                remaining_rows: self.remaining_rows,
1231            },
1232        )
1233    }
1234}
1235
1236const CHANNEL_SET_FIELDS: &[&str] = &["coverage", "rows"];
1237
1238#[derive(Deserialize)]
1239#[serde(field_identifier, rename_all = "snake_case")]
1240enum ChannelSetField {
1241    Coverage,
1242    Rows,
1243}
1244
1245struct ChannelSetVisitor<'a> {
1246    remaining_rows: &'a mut usize,
1247}
1248
1249impl<'de> Visitor<'de> for ChannelSetVisitor<'_> {
1250    type Value = GltfAnimationAddressabilityChannelSetV1;
1251
1252    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1253        formatter.write_str("a strict animation channel-set object")
1254    }
1255
1256    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1257    where
1258        A: MapAccess<'de>,
1259    {
1260        let mut coverage = None;
1261        let mut rows = None;
1262        while let Some(field) = map.next_key()? {
1263            match field {
1264                ChannelSetField::Coverage => {
1265                    if coverage.is_some() {
1266                        return Err(A::Error::duplicate_field("coverage"));
1267                    }
1268                    coverage = Some(map.next_value()?);
1269                }
1270                ChannelSetField::Rows => {
1271                    if rows.is_some() {
1272                        return Err(A::Error::duplicate_field("rows"));
1273                    }
1274                    rows = Some(map.next_value_seed(AggregateChannelRowsSeed {
1275                        remaining_rows: self.remaining_rows,
1276                    })?);
1277                }
1278            }
1279        }
1280        let wire = GltfAnimationAddressabilityChannelSetWireV1 {
1281            coverage: coverage.ok_or_else(|| A::Error::missing_field("coverage"))?,
1282            rows: rows.ok_or_else(|| A::Error::missing_field("rows"))?,
1283        };
1284        GltfAnimationAddressabilityChannelSetV1::from_wire(wire).map_err(A::Error::custom)
1285    }
1286}
1287
1288struct AggregateChannelRowsSeed<'a> {
1289    remaining_rows: &'a mut usize,
1290}
1291
1292impl<'de> DeserializeSeed<'de> for AggregateChannelRowsSeed<'_> {
1293    type Value = Vec<GltfAnimationAddressabilityChannelV1>;
1294
1295    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1296    where
1297        D: Deserializer<'de>,
1298    {
1299        deserializer.deserialize_seq(AggregateChannelRowsVisitor {
1300            remaining_rows: self.remaining_rows,
1301        })
1302    }
1303}
1304
1305struct AggregateChannelRowsVisitor<'a> {
1306    remaining_rows: &'a mut usize,
1307}
1308
1309impl<'de> Visitor<'de> for AggregateChannelRowsVisitor<'_> {
1310    type Value = Vec<GltfAnimationAddressabilityChannelV1>;
1311
1312    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1313        formatter.write_str("a channel-row sequence within the remaining aggregate row budget")
1314    }
1315
1316    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1317    where
1318        A: SeqAccess<'de>,
1319    {
1320        let channel_limit = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1;
1321        let limit = (*self.remaining_rows).min(channel_limit);
1322        let mut rows = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(limit));
1323        while rows.len() < limit {
1324            let Some(row) = sequence.next_element()? else {
1325                return Ok(rows);
1326            };
1327            rows.push(row);
1328            *self.remaining_rows -= 1;
1329        }
1330        let bound = if limit == channel_limit {
1331            AddressabilitySequenceBound::ChannelRows
1332        } else {
1333            AddressabilitySequenceBound::AggregateRows
1334        };
1335        reject_next_element(&mut sequence, bound)?;
1336        Ok(rows)
1337    }
1338}
1339
1340fn deserialize_channel_rows<'de, D>(
1341    deserializer: D,
1342) -> Result<Vec<GltfAnimationAddressabilityChannelV1>, D::Error>
1343where
1344    D: Deserializer<'de>,
1345{
1346    deserialize_bounded_sequence(deserializer, AddressabilitySequenceBound::ChannelRows)
1347}
1348
1349fn deserialize_evaluated_scopes<'de, D>(deserializer: D) -> Result<Vec<EvaluationScope>, D::Error>
1350where
1351    D: Deserializer<'de>,
1352{
1353    deserialize_bounded_sequence(deserializer, AddressabilitySequenceBound::EvaluatedScopes)
1354}
1355
1356#[derive(Debug, Default)]
1357struct EmptyCheckSequence;
1358
1359impl<'de> Deserialize<'de> for EmptyCheckSequence {
1360    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1361    where
1362        D: Deserializer<'de>,
1363    {
1364        struct EmptyCheckSequenceVisitor;
1365
1366        impl<'de> Visitor<'de> for EmptyCheckSequenceVisitor {
1367            type Value = EmptyCheckSequence;
1368
1369            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1370                formatter.write_str("an empty sequence")
1371            }
1372
1373            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1374            where
1375                A: SeqAccess<'de>,
1376            {
1377                reject_next_element(
1378                    &mut sequence,
1379                    AddressabilitySequenceBound::EmptyCheckEntries,
1380                )?;
1381                Ok(EmptyCheckSequence)
1382            }
1383        }
1384
1385        deserializer.deserialize_seq(EmptyCheckSequenceVisitor)
1386    }
1387}
1388
1389fn required_nullable_value<T>(value: RequiredNullable<T>) -> Option<T> {
1390    match value {
1391        RequiredNullable::Present(value) => value,
1392        RequiredNullable::Missing => {
1393            unreachable!("validated required-nullable tool field cannot be missing")
1394        }
1395    }
1396}
1397
1398/// Staged bounded reader input for a standalone addressability document.
1399#[derive(Debug, Deserialize)]
1400#[serde(deny_unknown_fields)]
1401pub struct GltfAnimationAddressabilityInput {
1402    schema_version: u32,
1403    schema: String,
1404    tool: ToolInputV1,
1405    command: String,
1406    input: InputIdentity,
1407    inventory: Box<RawValue>,
1408    #[serde(default, deserialize_with = "deserialize_required_nullable")]
1409    bevy: RequiredNullable<BevyAdapterInputV1>,
1410}
1411
1412impl GltfAnimationAddressabilityInput {
1413    /// Read one document through the immutable byte bound before JSON parsing.
1414    ///
1415    /// # Errors
1416    ///
1417    /// Returns a typed I/O, N+1 size, or JSON-shape error. Semantic validation
1418    /// remains in [`Self::into_report`].
1419    pub fn read_from(reader: impl Read) -> Result<Self, GltfAnimationAddressabilityReadError> {
1420        Self::read_from_with_limit(reader, GLTF_ANIMATION_ADDRESSABILITY_V1_MAX_REPORT_BYTES)
1421    }
1422
1423    fn read_from_with_limit(
1424        reader: impl Read,
1425        limit: u64,
1426    ) -> Result<Self, GltfAnimationAddressabilityReadError> {
1427        let mut bounded = reader.take(limit.saturating_add(1));
1428        let mut bytes = Vec::new();
1429        bounded
1430            .read_to_end(&mut bytes)
1431            .map_err(|source| GltfAnimationAddressabilityReadError::Io { source })?;
1432        if bytes.len() as u64 > limit {
1433            return Err(GltfAnimationAddressabilityReadError::ReportTooLarge { limit });
1434        }
1435        serde_json::from_slice(&bytes)
1436            .map_err(|source| GltfAnimationAddressabilityReadError::InvalidJson { source })
1437    }
1438
1439    /// Validate every contract identity and recover the strict read-only report.
1440    ///
1441    /// # Errors
1442    ///
1443    /// Returns a typed error for a wrong header, malformed tool identity,
1444    /// invalid inventory, input mismatch, or invalid Bevy adapter lifecycle.
1445    pub fn into_report(
1446        self,
1447    ) -> Result<GltfAnimationAddressabilityReadbackV1, GltfAnimationAddressabilityError> {
1448        if self.schema_version != GLTF_ANIMATION_ADDRESSABILITY_SCHEMA_VERSION {
1449            return Err(GltfAnimationAddressabilityError::UnsupportedSchemaVersion {
1450                found: self.schema_version,
1451            });
1452        }
1453        if self.schema != GLTF_ANIMATION_ADDRESSABILITY_V1_ID {
1454            return Err(GltfAnimationAddressabilityError::WrongSchema);
1455        }
1456        if self.command != GLTF_ANIMATION_ADDRESSABILITY_COMMAND {
1457            return Err(GltfAnimationAddressabilityError::WrongCommand);
1458        }
1459        validate_tool(&self.tool)?;
1460        let inventory: GltfAnimationAddressabilityInventoryV1 =
1461            serde_json::from_str(self.inventory.get()).map_err(|source| {
1462                GltfAnimationAddressabilityError::InvalidInventoryShape {
1463                    reason: source.to_string(),
1464                }
1465            })?;
1466        if self.input != *inventory.primary_input() {
1467            return Err(GltfAnimationAddressabilityError::RootInputMismatch);
1468        }
1469        let bevy = match self.bevy {
1470            RequiredNullable::Missing => {
1471                return Err(GltfAnimationAddressabilityError::MissingBevyField);
1472            }
1473            RequiredNullable::Present(None) => None,
1474            RequiredNullable::Present(Some(adapter)) => {
1475                let prediction_provenance: PredictionProvenanceV1 =
1476                    serde_json::from_str(adapter.prediction_provenance.get()).map_err(
1477                        |source| GltfAnimationAddressabilityError::InvalidBevyProvenance {
1478                            reason: source.to_string(),
1479                        },
1480                    )?;
1481                let check_wire: GltfAnimationAddressabilityCheckWireV1 =
1482                    serde_json::from_str(adapter.check.get()).map_err(|source| {
1483                        GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1484                            reason: source.to_string(),
1485                        }
1486                    })?;
1487                let check = GltfAnimationAddressabilityCheckReadbackV1::from_wire(check_wire)?;
1488                check.validate(&prediction_provenance, &inventory)?;
1489                Some(GltfAnimationAddressabilityBevyReadbackV1 {
1490                    prediction_provenance,
1491                    check,
1492                })
1493            }
1494        };
1495        Ok(GltfAnimationAddressabilityReadbackV1 {
1496            tool: GltfAnimationAddressabilityToolReadbackV1 {
1497                name: self.tool.name,
1498                version: self.tool.version,
1499                revision: required_nullable_value(self.tool.source.revision),
1500                dirty: required_nullable_value(self.tool.source.dirty),
1501            },
1502            input: self.input,
1503            inventory,
1504            bevy,
1505        })
1506    }
1507}
1508
1509/// Validated read-side producer identity.
1510#[derive(Debug, Clone, PartialEq, Eq)]
1511pub struct GltfAnimationAddressabilityToolReadbackV1 {
1512    name: String,
1513    version: String,
1514    revision: Option<String>,
1515    dirty: Option<bool>,
1516}
1517
1518impl GltfAnimationAddressabilityToolReadbackV1 {
1519    /// Producer name.
1520    pub fn name(&self) -> &str {
1521        &self.name
1522    }
1523
1524    /// Producer version.
1525    pub fn version(&self) -> &str {
1526        &self.version
1527    }
1528
1529    /// Full source revision, when established.
1530    pub fn revision(&self) -> Option<&str> {
1531        self.revision.as_deref()
1532    }
1533
1534    /// Dirty-worktree observation, when established.
1535    pub const fn dirty(&self) -> Option<bool> {
1536        self.dirty
1537    }
1538}
1539
1540/// Validated read-side representation of the exact embedded check subset.
1541#[derive(Debug, Clone)]
1542pub struct GltfAnimationAddressabilityCheckReadbackV1 {
1543    check_id: String,
1544    selection: SelectionState,
1545    configuration: ConfigurationState,
1546    applicability: Applicability,
1547    evaluation: EvaluationState,
1548    evaluated_scopes: Vec<EvaluationScope>,
1549    prediction: Option<EnginePredictionV1>,
1550}
1551
1552#[derive(Debug, Deserialize)]
1553#[serde(deny_unknown_fields)]
1554struct GltfAnimationAddressabilityCheckWireV1 {
1555    check_id: String,
1556    selection: SelectionState,
1557    configuration: ConfigurationState,
1558    applicability: Applicability,
1559    evaluation: EvaluationState,
1560    #[serde(rename = "findings")]
1561    _findings: EmptyCheckSequence,
1562    #[serde(default, deserialize_with = "deserialize_evaluated_scopes")]
1563    evaluated_scopes: Vec<EvaluationScope>,
1564    #[serde(default)]
1565    #[serde(rename = "gaps")]
1566    _gaps: EmptyCheckSequence,
1567    #[serde(default, deserialize_with = "deserialize_present_non_null")]
1568    prediction: Option<Box<RawValue>>,
1569}
1570
1571impl GltfAnimationAddressabilityCheckReadbackV1 {
1572    fn from_wire(
1573        wire: GltfAnimationAddressabilityCheckWireV1,
1574    ) -> Result<Self, GltfAnimationAddressabilityError> {
1575        let prediction = wire
1576            .prediction
1577            .map(|raw| {
1578                let mut deserializer = serde_json::Deserializer::from_str(raw.get());
1579                let prediction = EnginePredictionV1::deserialize_with_file_limits(
1580                    &mut deserializer,
1581                    PREDICTION_V1_MAX_FACETS_PER_FILE,
1582                    PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE,
1583                )
1584                .map_err(|source| {
1585                    GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1586                        reason: source.to_string(),
1587                    }
1588                })?;
1589                deserializer.end().map_err(|source| {
1590                    GltfAnimationAddressabilityError::InvalidBevyCheckShape {
1591                        reason: source.to_string(),
1592                    }
1593                })?;
1594                Ok(prediction)
1595            })
1596            .transpose()?;
1597        Ok(Self {
1598            check_id: wire.check_id,
1599            selection: wire.selection,
1600            configuration: wire.configuration,
1601            applicability: wire.applicability,
1602            evaluation: wire.evaluation,
1603            evaluated_scopes: wire.evaluated_scopes,
1604            prediction,
1605        })
1606    }
1607
1608    /// Stable check id, always `engine-addressability` after validation.
1609    pub fn check_id(&self) -> &str {
1610        &self.check_id
1611    }
1612
1613    /// Selection state carried by the unchanged embedded check.
1614    pub const fn selection(&self) -> SelectionState {
1615        self.selection
1616    }
1617
1618    /// Configuration state carried by the unchanged embedded check.
1619    pub const fn configuration(&self) -> ConfigurationState {
1620        self.configuration
1621    }
1622
1623    /// Applicability state carried by the unchanged embedded check.
1624    pub const fn applicability(&self) -> Applicability {
1625        self.applicability
1626    }
1627
1628    /// Derived evaluation state carried by the serialized check.
1629    pub const fn evaluation(&self) -> EvaluationState {
1630        self.evaluation
1631    }
1632
1633    /// Exact evaluated scopes carried by the unchanged embedded check.
1634    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
1635        &self.evaluated_scopes
1636    }
1637
1638    /// Embedded prediction attachment, when applicable.
1639    pub const fn prediction(&self) -> Option<&EnginePredictionV1> {
1640        self.prediction.as_ref()
1641    }
1642
1643    fn validate(
1644        &self,
1645        provenance: &PredictionProvenanceV1,
1646        inventory: &GltfAnimationAddressabilityInventoryV1,
1647    ) -> Result<(), GltfAnimationAddressabilityError> {
1648        validate_bevy_adapter(
1649            provenance,
1650            BevyAddressabilityCheckView::from(self),
1651            inventory,
1652        )
1653    }
1654}
1655
1656/// Validated read-side Bevy adapter.
1657#[derive(Debug, Clone)]
1658pub struct GltfAnimationAddressabilityBevyReadbackV1 {
1659    prediction_provenance: PredictionProvenanceV1,
1660    check: GltfAnimationAddressabilityCheckReadbackV1,
1661}
1662
1663impl GltfAnimationAddressabilityBevyReadbackV1 {
1664    /// Exact prediction provenance.
1665    pub const fn prediction_provenance(&self) -> &PredictionProvenanceV1 {
1666        &self.prediction_provenance
1667    }
1668
1669    /// Strict validated check subset.
1670    pub const fn check(&self) -> &GltfAnimationAddressabilityCheckReadbackV1 {
1671        &self.check
1672    }
1673}
1674
1675/// Fully validated read-side standalone document.
1676#[derive(Debug, Clone)]
1677pub struct GltfAnimationAddressabilityReadbackV1 {
1678    tool: GltfAnimationAddressabilityToolReadbackV1,
1679    input: InputIdentity,
1680    inventory: GltfAnimationAddressabilityInventoryV1,
1681    bevy: Option<GltfAnimationAddressabilityBevyReadbackV1>,
1682}
1683
1684impl GltfAnimationAddressabilityReadbackV1 {
1685    /// Validated producer identity.
1686    pub const fn tool(&self) -> &GltfAnimationAddressabilityToolReadbackV1 {
1687        &self.tool
1688    }
1689
1690    /// Exact primary input identity.
1691    pub const fn input(&self) -> &InputIdentity {
1692        &self.input
1693    }
1694
1695    /// Validated engine-neutral inventory.
1696    pub const fn inventory(&self) -> &GltfAnimationAddressabilityInventoryV1 {
1697        &self.inventory
1698    }
1699
1700    /// Validated exact Bevy adapter, when present.
1701    pub const fn bevy(&self) -> Option<&GltfAnimationAddressabilityBevyReadbackV1> {
1702        self.bevy.as_ref()
1703    }
1704}
1705
1706/// A serialized standalone addressability document could not be read safely.
1707#[derive(Debug, thiserror::Error)]
1708#[non_exhaustive]
1709pub enum GltfAnimationAddressabilityReadError {
1710    /// Reading the bounded input failed.
1711    #[error("cannot read addressability report: {source}")]
1712    Io {
1713        /// Underlying bounded-reader failure.
1714        #[source]
1715        source: std::io::Error,
1716    },
1717    /// The serialized report exceeded the immutable byte limit.
1718    #[error("addressability report exceeds the V1 limit of {limit} bytes")]
1719    ReportTooLarge {
1720        /// Immutable maximum accepted byte count.
1721        limit: u64,
1722    },
1723    /// The bounded bytes were not valid JSON for the strict root shape.
1724    #[error("invalid addressability report JSON: {source}")]
1725    InvalidJson {
1726        /// JSON syntax or typed-shape failure.
1727        #[source]
1728        source: serde_json::Error,
1729    },
1730}
1731
1732/// A producer or reader encountered an invalid addressability V1 contract.
1733#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1734#[non_exhaustive]
1735pub enum GltfAnimationAddressabilityError {
1736    /// The raw source is not glTF JSON or GLB.
1737    #[error("source format {format:?} is not supported by glTF animation addressability V1")]
1738    UnsupportedSourceFormat {
1739        /// Actual source format.
1740        format: SourceFormatV1,
1741    },
1742    /// A platform-sized source index could not be represented on the wire.
1743    #[error("{field} cannot be represented as an unsigned 64-bit integer")]
1744    IndexOverflow {
1745        /// Field that overflowed.
1746        field: &'static str,
1747    },
1748    /// A source-order row index exceeded its standalone V1 wire bound.
1749    #[error("{field} {actual} is outside the V1 limit of {limit} rows")]
1750    SourceIndexOutOfRange {
1751        /// Bounded source index field.
1752        field: &'static str,
1753        /// Supplied source index.
1754        actual: u64,
1755        /// Exclusive V1 row-count limit.
1756        limit: usize,
1757    },
1758    /// Checked aggregate accounting overflowed.
1759    #[error("checked arithmetic overflow while validating {field}")]
1760    ArithmeticOverflow {
1761        /// Aggregate that overflowed.
1762        field: &'static str,
1763    },
1764    /// An animation collection exceeded the inherited raw-source bound.
1765    #[error("inventory contains {found} animations, exceeding the V1 limit of {limit}")]
1766    TooManyAnimations {
1767        /// Supplied row count.
1768        found: usize,
1769        /// Immutable limit.
1770        limit: usize,
1771    },
1772    /// Aggregate animation and channel rows exceeded the inherited bound.
1773    #[error("inventory contains {found} animation/channel rows, exceeding the V1 limit of {limit}")]
1774    TooManyRows {
1775        /// Supplied aggregate row count.
1776        found: usize,
1777        /// Immutable limit.
1778        limit: usize,
1779    },
1780    /// One retained name exceeded the inherited text bound.
1781    #[error("source name is {found} bytes, exceeding the V1 limit of {limit}")]
1782    TextTooLong {
1783        /// Supplied UTF-8 byte count.
1784        found: usize,
1785        /// Immutable limit.
1786        limit: usize,
1787    },
1788    /// Aggregate retained source-name text exceeded the inherited bound.
1789    #[error("inventory retains {found} source-name bytes, exceeding the V1 limit of {limit}")]
1790    TooMuchText {
1791        /// Supplied aggregate UTF-8 byte count.
1792        found: usize,
1793        /// Immutable limit.
1794        limit: usize,
1795    },
1796    /// Coverage state and reason do not form a valid pair.
1797    #[error("coverage reason must be absent exactly for complete coverage")]
1798    InvalidCoverage,
1799    /// Unavailable coverage incorrectly retained positive rows.
1800    #[error("unavailable coverage must retain no rows")]
1801    RowsWithUnavailableCoverage,
1802    /// A source animation row did not preserve prefix order.
1803    #[error("animation row index {actual} does not match expected source index {expected}")]
1804    NonCanonicalAnimationIndex {
1805        /// Expected source-order index.
1806        expected: u64,
1807        /// Actual source index.
1808        actual: u64,
1809    },
1810    /// A source channel row did not preserve prefix order.
1811    #[error(
1812        "animation {source_clip_index} channel index {actual} does not match expected {expected}"
1813    )]
1814    NonCanonicalChannelIndex {
1815        /// Parent source animation index.
1816        source_clip_index: u64,
1817        /// Expected source-order channel index.
1818        expected: u64,
1819        /// Actual source channel index.
1820        actual: u64,
1821    },
1822    /// Only one accessor of a required input/output pair was retained.
1823    #[error("channel accessor identities must be both present or both absent")]
1824    IncompleteAccessorPair,
1825    /// A retained glTF channel omitted its exact input/output accessor identities.
1826    #[error("retained glTF channels must carry exact input and output accessor identities")]
1827    MissingAccessorPair,
1828    /// A glTF channel targeted a non-node source domain.
1829    #[error("retained glTF channels must target source nodes")]
1830    InvalidGltfTargetKind,
1831    /// The embedded closure names a different primary input.
1832    #[error("dependency closure primary input does not match the inventory")]
1833    DependencyClosurePrimaryMismatch,
1834    /// The canonical inventory identity was mutated or forged.
1835    #[error("inventory identity does not match its canonical engine-neutral preimage")]
1836    InventoryIdentityMismatch,
1837    /// Envelope schema version is unsupported.
1838    #[error("addressability report has schema_version {found}; this build reads version 1")]
1839    UnsupportedSchemaVersion {
1840        /// Supplied version.
1841        found: u32,
1842    },
1843    /// Envelope schema identity is wrong.
1844    #[error("addressability report has the wrong schema identity")]
1845    WrongSchema,
1846    /// Envelope command discriminator is wrong.
1847    #[error("addressability report has the wrong command discriminator")]
1848    WrongCommand,
1849    /// Producer tool fields are malformed or exceed V1 text bounds.
1850    #[error("addressability report has an invalid tool identity")]
1851    InvalidTool,
1852    /// Nested inventory shape or semantic decoding failed.
1853    #[error("addressability report has an invalid inventory: {reason}")]
1854    InvalidInventoryShape {
1855        /// Stable nested decoding diagnostic.
1856        reason: String,
1857    },
1858    /// Root and inventory primary input identities differ.
1859    #[error("root input does not match the inventory primary input")]
1860    RootInputMismatch,
1861    /// Required nullable Bevy field is missing.
1862    #[error("addressability report has no required `bevy` field")]
1863    MissingBevyField,
1864    /// Bevy provenance failed strict decoding.
1865    #[error("addressability report has invalid Bevy provenance: {reason}")]
1866    InvalidBevyProvenance {
1867        /// Stable nested decoding diagnostic.
1868        reason: String,
1869    },
1870    /// Bevy check shape failed strict decoding.
1871    #[error("addressability report has an invalid Bevy check: {reason}")]
1872    InvalidBevyCheckShape {
1873        /// Stable nested decoding diagnostic.
1874        reason: String,
1875    },
1876    /// The adapter is not the exact frozen Bevy tuple and known label fact.
1877    #[error("addressability adapter is not the exact Bevy 0.19.0 glTF profile subset")]
1878    InvalidBevyProfile,
1879    /// Adapter provenance and inventory identify different source evidence.
1880    #[error("Bevy provenance does not bind to the engine-neutral inventory")]
1881    BevyInventoryMismatch,
1882    /// The embedded check is not the exact selected/enabled #154 subset.
1883    #[error("Bevy adapter check is not the exact engine-addressability subset")]
1884    InvalidBevyCheckSubset,
1885    /// Nested prediction evidence is invalid.
1886    #[error("invalid Bevy prediction: {0}")]
1887    InvalidPrediction(PredictionContractError),
1888}
1889
1890fn require_gltf(format: SourceFormatV1) -> Result<(), GltfAnimationAddressabilityError> {
1891    if matches!(format, SourceFormatV1::GltfJson | SourceFormatV1::Glb) {
1892        Ok(())
1893    } else {
1894        Err(GltfAnimationAddressabilityError::UnsupportedSourceFormat { format })
1895    }
1896}
1897
1898fn to_u64(field: &'static str, value: usize) -> Result<u64, GltfAnimationAddressabilityError> {
1899    u64::try_from(value).map_err(|_| GltfAnimationAddressabilityError::IndexOverflow { field })
1900}
1901
1902fn map_index_observation(
1903    value: &animsmith_core::SourceObservationV1<usize>,
1904) -> Result<GltfAnimationObservationV1<u64>, GltfAnimationAddressabilityError> {
1905    Ok(match value.state() {
1906        SourceObservationStateV1::Observed(value) => GltfAnimationObservationV1::Observed {
1907            value: to_u64("normalized clip index", *value)?,
1908        },
1909        SourceObservationStateV1::ProvenAbsent => GltfAnimationObservationV1::ProvenAbsent,
1910        SourceObservationStateV1::Unavailable(reason) => GltfAnimationObservationV1::Unavailable {
1911            reason: (*reason).into(),
1912        },
1913    })
1914}
1915
1916fn preflight_source(
1917    facts: animsmith_core::SourceFactsViewV1<'_>,
1918) -> Result<(), GltfAnimationAddressabilityError> {
1919    let mut rows = facts.clips().rows().len();
1920    let mut text = 0usize;
1921    for clip in facts.clips().rows() {
1922        rows = rows.checked_add(clip.channels().rows().len()).ok_or(
1923            GltfAnimationAddressabilityError::ArithmeticOverflow {
1924                field: "animation/channel rows",
1925            },
1926        )?;
1927        if let SourceObservationStateV1::Observed(name) = clip.source_name().state() {
1928            text = text.checked_add(name.as_str().len()).ok_or(
1929                GltfAnimationAddressabilityError::ArithmeticOverflow {
1930                    field: "source-name text",
1931                },
1932            )?;
1933        }
1934    }
1935    if facts.clips().rows().len() > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
1936        return Err(GltfAnimationAddressabilityError::TooManyAnimations {
1937            found: facts.clips().rows().len(),
1938            limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
1939        });
1940    }
1941    if rows > animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
1942        return Err(GltfAnimationAddressabilityError::TooManyRows {
1943            found: rows,
1944            limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
1945        });
1946    }
1947    if text > animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES {
1948        return Err(GltfAnimationAddressabilityError::TooMuchText {
1949            found: text,
1950            limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
1951        });
1952    }
1953    Ok(())
1954}
1955
1956fn validate_animation_set(
1957    animations: &GltfAnimationAddressabilityAnimationSetV1,
1958) -> Result<(), GltfAnimationAddressabilityError> {
1959    animations.coverage.validate()?;
1960    if animations.coverage.state == GltfAnimationCoverageStateV1::Unavailable
1961        && !animations.rows.is_empty()
1962    {
1963        return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
1964    }
1965    if animations.rows.len() > animsmith_core::RAW_SOURCE_V1_MAX_CLIPS {
1966        return Err(GltfAnimationAddressabilityError::TooManyAnimations {
1967            found: animations.rows.len(),
1968            limit: animsmith_core::RAW_SOURCE_V1_MAX_CLIPS,
1969        });
1970    }
1971    let mut total_rows = animations.rows.len();
1972    let mut total_text = 0usize;
1973    for (animation_offset, animation) in animations.rows.iter().enumerate() {
1974        let expected = to_u64("expected animation index", animation_offset)?;
1975        if animation.source_clip_index != expected {
1976            return Err(
1977                GltfAnimationAddressabilityError::NonCanonicalAnimationIndex {
1978                    expected,
1979                    actual: animation.source_clip_index,
1980                },
1981            );
1982        }
1983        animation.channels.coverage.validate()?;
1984        if animation.channels.coverage.state == GltfAnimationCoverageStateV1::Unavailable
1985            && !animation.channels.rows.is_empty()
1986        {
1987            return Err(GltfAnimationAddressabilityError::RowsWithUnavailableCoverage);
1988        }
1989        total_rows = total_rows
1990            .checked_add(animation.channels.rows.len())
1991            .ok_or(GltfAnimationAddressabilityError::ArithmeticOverflow {
1992                field: "animation/channel rows",
1993            })?;
1994        for (channel_offset, channel) in animation.channels.rows.iter().enumerate() {
1995            let expected = to_u64("expected channel index", channel_offset)?;
1996            if channel.source_channel_index != expected {
1997                return Err(GltfAnimationAddressabilityError::NonCanonicalChannelIndex {
1998                    source_clip_index: animation.source_clip_index,
1999                    expected,
2000                    actual: channel.source_channel_index,
2001                });
2002            }
2003            if channel.target.kind != GltfAnimationTargetKindV1::Node {
2004                return Err(GltfAnimationAddressabilityError::InvalidGltfTargetKind);
2005            }
2006        }
2007        if let GltfAnimationObservationV1::Observed { value } = &animation.source_name {
2008            if value.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES {
2009                return Err(GltfAnimationAddressabilityError::TextTooLong {
2010                    found: value.len(),
2011                    limit: animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES,
2012                });
2013            }
2014            total_text = total_text.checked_add(value.len()).ok_or(
2015                GltfAnimationAddressabilityError::ArithmeticOverflow {
2016                    field: "source-name text",
2017                },
2018            )?;
2019        }
2020    }
2021    if total_rows > animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS {
2022        return Err(GltfAnimationAddressabilityError::TooManyRows {
2023            found: total_rows,
2024            limit: animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS,
2025        });
2026    }
2027    if total_text > animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES {
2028        return Err(GltfAnimationAddressabilityError::TooMuchText {
2029            found: total_text,
2030            limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
2031        });
2032    }
2033    Ok(())
2034}
2035
2036#[derive(Clone, Copy)]
2037struct BevyAddressabilityCheckView<'a> {
2038    check_id: &'a str,
2039    selection: SelectionState,
2040    configuration: ConfigurationState,
2041    applicability: Applicability,
2042    evaluation: EvaluationState,
2043    finding_count: usize,
2044    evaluated_scopes: &'a [EvaluationScope],
2045    gap_count: usize,
2046    prediction: Option<&'a EnginePredictionV1>,
2047}
2048
2049impl<'a> From<&'a CheckEvaluation> for BevyAddressabilityCheckView<'a> {
2050    fn from(check: &'a CheckEvaluation) -> Self {
2051        Self {
2052            check_id: check.check_id(),
2053            selection: check.selection(),
2054            configuration: check.configuration(),
2055            applicability: check.applicability(),
2056            evaluation: check.evaluation(),
2057            finding_count: check.findings().len(),
2058            evaluated_scopes: check.evaluated_scopes(),
2059            gap_count: check.gaps().len(),
2060            prediction: check.engine_prediction(),
2061        }
2062    }
2063}
2064
2065impl<'a> From<&'a GltfAnimationAddressabilityCheckReadbackV1> for BevyAddressabilityCheckView<'a> {
2066    fn from(check: &'a GltfAnimationAddressabilityCheckReadbackV1) -> Self {
2067        Self {
2068            check_id: &check.check_id,
2069            selection: check.selection,
2070            configuration: check.configuration,
2071            applicability: check.applicability,
2072            evaluation: check.evaluation,
2073            finding_count: 0,
2074            evaluated_scopes: &check.evaluated_scopes,
2075            gap_count: 0,
2076            prediction: check.prediction.as_ref(),
2077        }
2078    }
2079}
2080
2081fn validate_bevy_adapter(
2082    provenance: &PredictionProvenanceV1,
2083    check: BevyAddressabilityCheckView<'_>,
2084    inventory: &GltfAnimationAddressabilityInventoryV1,
2085) -> Result<(), GltfAnimationAddressabilityError> {
2086    provenance
2087        .validate()
2088        .map_err(GltfAnimationAddressabilityError::InvalidPrediction)?;
2089    let profile = provenance.profile();
2090    let profile_selection = profile.selection();
2091    if profile_selection.family() != BEVY_FAMILY
2092        || profile_selection.profile_revision() != BEVY_PROFILE_REVISION
2093        || profile_selection.engine_version() != BEVY_ENGINE_VERSION
2094        || profile_selection.importer() != BEVY_IMPORTER
2095        || profile.facts_identity().sha256() != BEVY_PROFILE_FACTS_SHA256
2096        || profile.facts_identity().bytes() != BEVY_PROFILE_FACTS_CANONICAL_BYTES
2097        || !matches!(
2098            profile
2099                .fact(EngineFactIdV1::AnimationAddressability)
2100                .map(|fact| fact.state()),
2101            Some(EngineFactStateV1::Known(
2102                EngineFactValueV1::AnimationAddressability(
2103                    EngineAnimationAddressabilityV1::GltfAssetLabel
2104                )
2105            ))
2106        )
2107        || profile.source(BEVY_ANIMATION_LABEL_SOURCE).is_none()
2108    {
2109        return Err(GltfAnimationAddressabilityError::InvalidBevyProfile);
2110    }
2111    validate_inventory_provenance_binding(provenance, inventory)?;
2112    if check.check_id != ENGINE_ADDRESSABILITY_CHECK_ID
2113        || check.selection != SelectionState::Selected
2114        || check.finding_count != 0
2115        || check.gap_count != 0
2116    {
2117        return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2118    }
2119    let expected_applicability = if inventory.animations.coverage.state
2120        == GltfAnimationCoverageStateV1::Complete
2121        && inventory.animations.rows.is_empty()
2122    {
2123        Applicability::NotApplicable
2124    } else {
2125        Applicability::Applicable
2126    };
2127    if check.applicability != expected_applicability {
2128        return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2129    }
2130    if check.configuration == ConfigurationState::Disabled {
2131        if check.evaluation != EvaluationState::NotEvaluated
2132            || !check.evaluated_scopes.is_empty()
2133            || check.prediction.is_some()
2134        {
2135            return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2136        }
2137        return Ok(());
2138    }
2139    if let Some(prediction) = check.prediction {
2140        prediction
2141            .validate_against_provenance(provenance)
2142            .map_err(GltfAnimationAddressabilityError::InvalidPrediction)?;
2143    }
2144
2145    let rows = inventory.animations.rows();
2146    match inventory.animations.coverage.state {
2147        GltfAnimationCoverageStateV1::Complete if rows.is_empty() => {
2148            if check.applicability != Applicability::NotApplicable
2149                || check.evaluation != EvaluationState::NotEvaluated
2150                || !check.evaluated_scopes.is_empty()
2151                || check.prediction.is_some()
2152            {
2153                return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2154            }
2155        }
2156        GltfAnimationCoverageStateV1::Complete => {
2157            let prediction = check
2158                .prediction
2159                .ok_or(GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2160            if check.applicability != Applicability::Applicable
2161                || check.evaluation != EvaluationState::Complete
2162                || prediction.facets().len() != rows.len()
2163                || check.evaluated_scopes.len() != rows.len()
2164            {
2165                return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2166            }
2167            for (row, evaluated) in rows.iter().zip(check.evaluated_scopes) {
2168                let source_clip_index = usize::try_from(row.source_clip_index).map_err(|_| {
2169                    GltfAnimationAddressabilityError::IndexOverflow {
2170                        field: "source clip index",
2171                    }
2172                })?;
2173                let label = crate::BevyAnimationAssetLabelV1::new(source_clip_index)
2174                    .map_err(|_| GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2175                let expected = EvaluationScope::new(
2176                    animsmith_core::EvaluationScopeCode::ANIMATION_ASSET_LABEL,
2177                )
2178                .subject(label.as_str().to_owned());
2179                let Some(facet) = prediction.facets().iter().find(|facet| {
2180                    facet.state() == EnginePredictionFacetStateV1::Available
2181                        && facet.scope() == &expected
2182                }) else {
2183                    return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2184                };
2185                if evaluated != &expected
2186                    || prediction
2187                        .facets()
2188                        .iter()
2189                        .filter(|facet| {
2190                            facet.state() == EnginePredictionFacetStateV1::Available
2191                                && facet.scope() == &expected
2192                        })
2193                        .count()
2194                        != 1
2195                    || !is_exact_available_basis(facet.basis().references(), row)
2196                {
2197                    return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2198                }
2199            }
2200        }
2201        GltfAnimationCoverageStateV1::Partial | GltfAnimationCoverageStateV1::Unavailable => {
2202            let prediction = check
2203                .prediction
2204                .ok_or(GltfAnimationAddressabilityError::InvalidBevyCheckSubset)?;
2205            let facets = prediction.facets();
2206            let expected = EvaluationScope::new(
2207                animsmith_core::EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY,
2208            );
2209            if check.applicability != Applicability::Applicable
2210                || check.evaluation != EvaluationState::NotEvaluated
2211                || !check.evaluated_scopes.is_empty()
2212                || facets.len() != 1
2213                || facets[0].state() != EnginePredictionFacetStateV1::RequiredPredictionUnavailable
2214                || facets[0].scope() != &expected
2215                || facets[0].reasons() != [PredictionUnavailableReasonV1::RawSourceIncomplete]
2216                || !is_exact_unavailable_basis(facets[0].basis().references())
2217            {
2218                return Err(GltfAnimationAddressabilityError::InvalidBevyCheckSubset);
2219            }
2220        }
2221    }
2222    Ok(())
2223}
2224
2225fn is_exact_available_basis(
2226    references: &[PredictionBasisReferenceV1],
2227    row: &GltfAnimationAddressabilityAnimationV1,
2228) -> bool {
2229    if references.len() != 3 {
2230        return false;
2231    }
2232    let expected_state = match row.source_name() {
2233        GltfAnimationObservationV1::Observed { .. } => "observed",
2234        GltfAnimationObservationV1::ProvenAbsent => "proven_absent",
2235        GltfAnimationObservationV1::Unavailable { .. } => "unavailable",
2236    };
2237    let mut profile_fact = false;
2238    let mut primary_source = false;
2239    let mut raw_source = false;
2240    for reference in references {
2241        match reference {
2242            PredictionBasisReferenceV1::ProfileFact { fact_id }
2243                if fact_id == "animation_addressability" =>
2244            {
2245                profile_fact = true;
2246            }
2247            PredictionBasisReferenceV1::PrimarySource { source_id }
2248                if source_id == BEVY_ANIMATION_LABEL_SOURCE =>
2249            {
2250                primary_source = true;
2251            }
2252            PredictionBasisReferenceV1::RawSource { reference }
2253                if reference.domain() == RawSourceDomainV1::Clip
2254                    && reference.key()
2255                        == &RawSourceKeyV1::Clip {
2256                            source_clip_index: row.source_clip_index(),
2257                        }
2258                    && reference.field().as_str() == "source_name.state"
2259                    && matches!(
2260                        reference.value(),
2261                        PredictionScalarV1::Token { value } if value == expected_state
2262                    ) =>
2263            {
2264                raw_source = true;
2265            }
2266            _ => return false,
2267        }
2268    }
2269    profile_fact && primary_source && raw_source
2270}
2271
2272fn is_exact_unavailable_basis(references: &[PredictionBasisReferenceV1]) -> bool {
2273    if references.len() != 2 {
2274        return false;
2275    }
2276    let mut profile_fact = false;
2277    let mut primary_source = false;
2278    for reference in references {
2279        match reference {
2280            PredictionBasisReferenceV1::ProfileFact { fact_id }
2281                if fact_id == "animation_addressability" =>
2282            {
2283                profile_fact = true;
2284            }
2285            PredictionBasisReferenceV1::PrimarySource { source_id }
2286                if source_id == BEVY_ANIMATION_LABEL_SOURCE =>
2287            {
2288                primary_source = true;
2289            }
2290            _ => return false,
2291        }
2292    }
2293    profile_fact && primary_source
2294}
2295
2296fn validate_inventory_provenance_binding(
2297    provenance: &PredictionProvenanceV1,
2298    inventory: &GltfAnimationAddressabilityInventoryV1,
2299) -> Result<(), GltfAnimationAddressabilityError> {
2300    if provenance.source_format() != inventory.source_format
2301        || provenance.raw_source().primary_input() != inventory.primary_input()
2302        || provenance.dependency_closure() != inventory.dependency_closure()
2303        || raw_coverage_state(provenance.raw_source().clips_coverage().state())
2304            != inventory.animations.coverage.state
2305        || provenance
2306            .raw_source()
2307            .clips_coverage()
2308            .reason()
2309            .map(raw_unavailable_reason)
2310            != inventory.animations.coverage.reason
2311    {
2312        return Err(GltfAnimationAddressabilityError::BevyInventoryMismatch);
2313    }
2314    Ok(())
2315}
2316
2317fn validate_tool(tool: &ToolInputV1) -> Result<(), GltfAnimationAddressabilityError> {
2318    if tool.name != "animsmith"
2319        || !is_schema_semver(&tool.version)
2320        || tool.name.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
2321        || tool.version.len() > animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES
2322        || matches!(tool.source.revision, RequiredNullable::Missing)
2323        || matches!(tool.source.dirty, RequiredNullable::Missing)
2324        || matches!(
2325            &tool.source.revision,
2326            RequiredNullable::Present(Some(revision))
2327                if revision.len() != 40
2328                    || !revision.bytes().all(|value| value.is_ascii_hexdigit())
2329        )
2330    {
2331        return Err(GltfAnimationAddressabilityError::InvalidTool);
2332    }
2333    Ok(())
2334}
2335
2336fn is_schema_semver(version: &str) -> bool {
2337    let (without_build, build) = match version.split_once('+') {
2338        Some((value, build))
2339            if !build.is_empty()
2340                && !build.contains('+')
2341                && build
2342                    .bytes()
2343                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2344        {
2345            (value, Some(build))
2346        }
2347        Some(_) => return false,
2348        None => (version, None),
2349    };
2350    let _ = build;
2351    let (core, prerelease) = match without_build.split_once('-') {
2352        Some((core, prerelease))
2353            if !prerelease.is_empty()
2354                && prerelease
2355                    .bytes()
2356                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) =>
2357        {
2358            (core, Some(prerelease))
2359        }
2360        Some(_) => return false,
2361        None => (without_build, None),
2362    };
2363    let _ = prerelease;
2364    let mut components = core.split('.');
2365    (0..3).all(|_| {
2366        components.next().is_some_and(|value| {
2367            !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
2368        })
2369    }) && components.next().is_none()
2370}
2371
2372fn raw_coverage_state(value: RawSourceSetCoverageStateV1) -> GltfAnimationCoverageStateV1 {
2373    match value {
2374        RawSourceSetCoverageStateV1::Complete => GltfAnimationCoverageStateV1::Complete,
2375        RawSourceSetCoverageStateV1::Partial => GltfAnimationCoverageStateV1::Partial,
2376        RawSourceSetCoverageStateV1::Unavailable => GltfAnimationCoverageStateV1::Unavailable,
2377    }
2378}
2379
2380fn raw_unavailable_reason(value: RawSourceUnavailableReasonV1) -> GltfAnimationUnavailableReasonV1 {
2381    match value {
2382        RawSourceUnavailableReasonV1::Malformed => GltfAnimationUnavailableReasonV1::Malformed,
2383        RawSourceUnavailableReasonV1::Discarded => GltfAnimationUnavailableReasonV1::Discarded,
2384        RawSourceUnavailableReasonV1::NormalizedAway => {
2385            GltfAnimationUnavailableReasonV1::NormalizedAway
2386        }
2387        RawSourceUnavailableReasonV1::BakedAway => GltfAnimationUnavailableReasonV1::BakedAway,
2388        RawSourceUnavailableReasonV1::LoaderUnsupported => {
2389            GltfAnimationUnavailableReasonV1::LoaderUnsupported
2390        }
2391        RawSourceUnavailableReasonV1::ProjectionBudgetExceeded => {
2392            GltfAnimationUnavailableReasonV1::ProjectionBudgetExceeded
2393        }
2394        RawSourceUnavailableReasonV1::ParserUnavailable => {
2395            GltfAnimationUnavailableReasonV1::ParserUnavailable
2396        }
2397    }
2398}
2399
2400fn encode_animation_set(
2401    encoder: &mut CanonicalEncoder,
2402    animations: &GltfAnimationAddressabilityAnimationSetV1,
2403) {
2404    encoder.field("animations");
2405    encode_coverage(encoder, animations.coverage);
2406    encoder.count(animations.rows.len());
2407    for animation in &animations.rows {
2408        encoder.token(animation.source_clip_index.to_string());
2409        encode_observation(encoder, &animation.source_name, |encoder, value| {
2410            encoder.token(value)
2411        });
2412        encode_observation(
2413            encoder,
2414            &animation.normalized_clip_index,
2415            |encoder, value| encoder.token(value.to_string()),
2416        );
2417        encode_coverage(encoder, animation.channels.coverage);
2418        encoder.count(animation.channels.rows.len());
2419        for channel in &animation.channels.rows {
2420            encoder.token(channel.source_channel_index.to_string());
2421            encoder.token(target_kind_name(channel.target.kind));
2422            encoder.token(channel.target.index.to_string());
2423            encoder.token(property_name(channel.property));
2424            encoder.token(channel.input_accessor_index.to_string());
2425            encoder.token(channel.output_accessor_index.to_string());
2426        }
2427    }
2428}
2429
2430fn encode_coverage(encoder: &mut CanonicalEncoder, coverage: GltfAnimationCoverageV1) {
2431    encoder.token(match coverage.state {
2432        GltfAnimationCoverageStateV1::Complete => "complete",
2433        GltfAnimationCoverageStateV1::Partial => "partial",
2434        GltfAnimationCoverageStateV1::Unavailable => "unavailable",
2435    });
2436    encoder.token(
2437        coverage
2438            .reason
2439            .map(unavailable_reason_name)
2440            .unwrap_or("null"),
2441    );
2442}
2443
2444fn encode_observation<T>(
2445    encoder: &mut CanonicalEncoder,
2446    value: &GltfAnimationObservationV1<T>,
2447    encode_value: impl FnOnce(&mut CanonicalEncoder, &T),
2448) {
2449    match value {
2450        GltfAnimationObservationV1::Observed { value } => {
2451            encoder.token("observed");
2452            encode_value(encoder, value);
2453        }
2454        GltfAnimationObservationV1::ProvenAbsent => encoder.token("proven_absent"),
2455        GltfAnimationObservationV1::Unavailable { reason } => {
2456            encoder.token("unavailable");
2457            encoder.token(unavailable_reason_name(*reason));
2458        }
2459    }
2460}
2461
2462#[derive(Debug, Default)]
2463struct CanonicalEncoder(Vec<u8>);
2464
2465impl CanonicalEncoder {
2466    fn new(domain: &str) -> Self {
2467        let mut encoder = Self::default();
2468        encoder.token(domain);
2469        encoder
2470    }
2471
2472    fn token(&mut self, token: impl AsRef<str>) {
2473        let bytes = token.as_ref().as_bytes();
2474        self.0
2475            .extend_from_slice(&(bytes.len() as u64).to_be_bytes());
2476        self.0.extend_from_slice(bytes);
2477    }
2478
2479    fn field(&mut self, field: &'static str) {
2480        self.token(field);
2481    }
2482
2483    fn count(&mut self, count: usize) {
2484        self.token(count.to_string());
2485    }
2486
2487    fn identity(self) -> InputIdentity {
2488        InputIdentity::from_bytes(&self.0)
2489    }
2490}
2491
2492fn encode_input_identity(encoder: &mut CanonicalEncoder, identity: &InputIdentity) {
2493    encoder.token("sha256");
2494    encoder.token(identity.sha256());
2495    encoder.token("bytes");
2496    encoder.token(identity.bytes().to_string());
2497}
2498
2499fn source_format_name(value: SourceFormatV1) -> &'static str {
2500    match value {
2501        SourceFormatV1::GltfJson => "gltf_json",
2502        SourceFormatV1::Glb => "glb",
2503        SourceFormatV1::Fbx => "fbx",
2504    }
2505}
2506
2507fn unavailable_reason_name(value: GltfAnimationUnavailableReasonV1) -> &'static str {
2508    match value {
2509        GltfAnimationUnavailableReasonV1::Malformed => "malformed",
2510        GltfAnimationUnavailableReasonV1::Discarded => "discarded",
2511        GltfAnimationUnavailableReasonV1::NormalizedAway => "normalized_away",
2512        GltfAnimationUnavailableReasonV1::BakedAway => "baked_away",
2513        GltfAnimationUnavailableReasonV1::LoaderUnsupported => "loader_unsupported",
2514        GltfAnimationUnavailableReasonV1::ProjectionBudgetExceeded => "projection_budget_exceeded",
2515        GltfAnimationUnavailableReasonV1::ParserUnavailable => "parser_unavailable",
2516    }
2517}
2518
2519fn target_kind_name(value: GltfAnimationTargetKindV1) -> &'static str {
2520    match value {
2521        GltfAnimationTargetKindV1::Node => "node",
2522        GltfAnimationTargetKindV1::Element => "element",
2523        GltfAnimationTargetKindV1::Other => "other",
2524    }
2525}
2526
2527fn property_name(value: GltfAnimationChannelPropertyV1) -> &'static str {
2528    match value {
2529        GltfAnimationChannelPropertyV1::Translation => "translation",
2530        GltfAnimationChannelPropertyV1::Rotation => "rotation",
2531        GltfAnimationChannelPropertyV1::Scale => "scale",
2532        GltfAnimationChannelPropertyV1::Weights => "weights",
2533        GltfAnimationChannelPropertyV1::Other => "other",
2534    }
2535}
2536
2537#[cfg(test)]
2538mod tests {
2539    use super::*;
2540    use animsmith_core::{
2541        Check, CheckCtx, CheckOutput, CheckSelection, Clip, Config, Document, EngineFactValueV1,
2542        EnginePrimarySourceV1, EngineProfileFactV1, EngineProfileSelectionV1, MetricGrids,
2543        RawSourceBindingV1, RawSourceFactsBuilderV1, ResolvedEngineProfileV1,
2544        ResolvedEngineSettingsV1, SeveritySetting, SourceComponentMaskV1, SourceFactDomainV1,
2545        SourceFactSetV1, SourceLoaderDispositionV1, SourceObservationV1, SourceProvenanceV1,
2546        SourceTargetV1, SourceTextV1, ToolSource, evaluate_checks, resolve_configured_roles,
2547    };
2548    use std::collections::BTreeMap;
2549    use std::io::Cursor;
2550
2551    fn dummy_clip(index: usize) -> Clip {
2552        Clip {
2553            name: format!("clip-{index}"),
2554            duration_s: 0.0,
2555            tracks: Vec::new(),
2556        }
2557    }
2558
2559    fn raw_channel(
2560        index: usize,
2561        property: SourceChannelPropertyV1,
2562    ) -> animsmith_core::SourceChannelFactV1 {
2563        animsmith_core::SourceChannelFactV1::new(
2564            index,
2565            SourceTargetV1::new(SourceTargetKindV1::Node, index as u64),
2566            property,
2567            SourceComponentMaskV1::new(true, true, true),
2568            SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2569            SourceLoaderDispositionV1::Preserved,
2570            SourceProvenanceV1::format_defined(),
2571        )
2572        .with_accessors(index * 2, index * 2 + 1)
2573    }
2574
2575    fn raw_clip(
2576        index: usize,
2577        name: GltfAnimationObservationV1<String>,
2578        channels: Vec<animsmith_core::SourceChannelFactV1>,
2579    ) -> animsmith_core::SourceClipFactV1 {
2580        raw_clip_with_channel_set(index, name, SourceFactSetV1::complete(channels))
2581    }
2582
2583    fn raw_clip_with_channel_set(
2584        index: usize,
2585        name: GltfAnimationObservationV1<String>,
2586        channels: SourceFactSetV1<animsmith_core::SourceChannelFactV1>,
2587    ) -> animsmith_core::SourceClipFactV1 {
2588        let source_name = match name {
2589            GltfAnimationObservationV1::Observed { value } => SourceObservationV1::observed(
2590                SourceTextV1::new(value).expect("bounded test name"),
2591                SourceProvenanceV1::format_defined(),
2592                SourceLoaderDispositionV1::Preserved,
2593            ),
2594            GltfAnimationObservationV1::ProvenAbsent => {
2595                SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined())
2596            }
2597            GltfAnimationObservationV1::Unavailable { reason: _ } => {
2598                SourceObservationV1::unavailable(
2599                    SourceUnavailableReasonV1::ParserUnavailable,
2600                    None,
2601                    SourceLoaderDispositionV1::Unknown,
2602                )
2603            }
2604        };
2605        animsmith_core::SourceClipFactV1::new(
2606            index,
2607            source_name,
2608            SourceObservationV1::observed(
2609                index,
2610                SourceProvenanceV1::format_defined(),
2611                SourceLoaderDispositionV1::Normalized,
2612            ),
2613            SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2614            SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2615            channels,
2616        )
2617    }
2618
2619    fn loaded_source(format: SourceFormatV1) -> LoadedSource {
2620        let primary = InputIdentity::from_bytes(b"analytic-gltf");
2621        let mut builder = RawSourceFactsBuilderV1::new(format, primary);
2622        assert!(builder.push_clip(raw_clip(
2623            0,
2624            GltfAnimationObservationV1::Observed {
2625                value: "walk".to_owned(),
2626            },
2627            vec![
2628                raw_channel(0, SourceChannelPropertyV1::Translation),
2629                raw_channel(1, SourceChannelPropertyV1::Rotation),
2630                raw_channel(2, SourceChannelPropertyV1::Scale),
2631                raw_channel(3, SourceChannelPropertyV1::Weights),
2632            ],
2633        )));
2634        assert!(builder.push_clip(raw_clip(
2635            1,
2636            GltfAnimationObservationV1::ProvenAbsent,
2637            Vec::new(),
2638        )));
2639        assert!(builder.push_clip(raw_clip(
2640            2,
2641            GltfAnimationObservationV1::Observed {
2642                value: "walk".to_owned(),
2643            },
2644            Vec::new(),
2645        )));
2646        builder.mark_complete(SourceFactDomainV1::Clips);
2647        let document = Document {
2648            clips: (0..3).map(dummy_clip).collect(),
2649            ..Document::default()
2650        };
2651        builder.finish(document).expect("valid same-load source")
2652    }
2653
2654    fn loaded_source_with_channel_set(
2655        channels: SourceFactSetV1<animsmith_core::SourceChannelFactV1>,
2656    ) -> LoadedSource {
2657        let primary = InputIdentity::from_bytes(b"analytic-channel-coverage");
2658        let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::GltfJson, primary);
2659        assert!(builder.push_clip(raw_clip_with_channel_set(
2660            0,
2661            GltfAnimationObservationV1::Observed {
2662                value: "walk".to_owned(),
2663            },
2664            channels,
2665        )));
2666        builder.mark_complete(SourceFactDomainV1::Clips);
2667        let document = Document {
2668            clips: vec![dummy_clip(0)],
2669            ..Document::default()
2670        };
2671        builder.finish(document).expect("valid same-load source")
2672    }
2673
2674    fn frozen_bevy_provenance(source: &LoadedSource) -> PredictionProvenanceV1 {
2675        let profile = crate::resolve_static(crate::EngineDeclaration {
2676            selection: Some(crate::ProfileSelection::new(
2677                BEVY_FAMILY,
2678                BEVY_PROFILE_REVISION,
2679                BEVY_ENGINE_VERSION,
2680                BEVY_IMPORTER,
2681            )),
2682            ..crate::EngineDeclaration::default()
2683        })
2684        .unwrap()
2685        .unwrap()
2686        .resolve_input(SourceFormatV1::GltfJson, &["walk".to_owned()])
2687        .unwrap();
2688        crate::project_prediction_provenance_v1(&profile, source).unwrap()
2689    }
2690
2691    fn direct_animation(index: usize, name: String) -> GltfAnimationAddressabilityAnimationV1 {
2692        GltfAnimationAddressabilityAnimationV1 {
2693            source_clip_index: index as u64,
2694            source_name: GltfAnimationObservationV1::Observed { value: name },
2695            normalized_clip_index: GltfAnimationObservationV1::Observed {
2696                value: index as u64,
2697            },
2698            channels: GltfAnimationAddressabilityChannelSetV1 {
2699                coverage: GltfAnimationCoverageV1 {
2700                    state: GltfAnimationCoverageStateV1::Complete,
2701                    reason: None,
2702                },
2703                rows: Vec::new(),
2704            },
2705        }
2706    }
2707
2708    fn direct_channel(index: usize) -> GltfAnimationAddressabilityChannelV1 {
2709        GltfAnimationAddressabilityChannelV1 {
2710            source_channel_index: index as u64,
2711            target: GltfAnimationTargetV1 {
2712                kind: GltfAnimationTargetKindV1::Node,
2713                index: 0,
2714            },
2715            property: GltfAnimationChannelPropertyV1::Translation,
2716            input_accessor_index: (index * 2) as u64,
2717            output_accessor_index: (index * 2 + 1) as u64,
2718        }
2719    }
2720
2721    fn direct_inventory(
2722        animations: Vec<GltfAnimationAddressabilityAnimationV1>,
2723    ) -> GltfAnimationAddressabilityInventoryV1 {
2724        let primary_input = InputIdentity::from_bytes(b"direct");
2725        direct_inventory_with_closure(
2726            primary_input.clone(),
2727            DependencyClosureV1::unavailable(primary_input),
2728            animations,
2729        )
2730    }
2731
2732    fn direct_inventory_with_closure(
2733        primary_input: InputIdentity,
2734        dependency_closure: DependencyClosureV1,
2735        animations: Vec<GltfAnimationAddressabilityAnimationV1>,
2736    ) -> GltfAnimationAddressabilityInventoryV1 {
2737        let mut value = GltfAnimationAddressabilityInventoryV1 {
2738            schema: GLTF_ANIMATION_ADDRESSABILITY_V1_ID,
2739            identity: GltfAnimationAddressabilityIdentityV1(InputIdentity::from_bytes(&[])),
2740            source_format: SourceFormatV1::GltfJson,
2741            primary_input: primary_input.clone(),
2742            dependency_closure,
2743            animations: GltfAnimationAddressabilityAnimationSetV1 {
2744                coverage: GltfAnimationCoverageV1 {
2745                    state: GltfAnimationCoverageStateV1::Complete,
2746                    reason: None,
2747                },
2748                rows: animations,
2749            },
2750        };
2751        value
2752            .validate_without_identity()
2753            .expect("valid direct inventory");
2754        value.identity = GltfAnimationAddressabilityIdentityV1(value.computed_identity());
2755        value
2756    }
2757
2758    fn bevy_provenance(source: &LoadedSource) -> PredictionProvenanceV1 {
2759        let all_fact_ids = [
2760            EngineFactIdV1::AcceptedInputs,
2761            EngineFactIdV1::AnimationAddressability,
2762            EngineFactIdV1::AnimationChannelHandling,
2763            EngineFactIdV1::AnimationTargetAddressability,
2764            EngineFactIdV1::AxisConversionControl,
2765            EngineFactIdV1::ConstructHandling,
2766            EngineFactIdV1::ExactAxisConversion,
2767            EngineFactIdV1::ExtensionHandling,
2768            EngineFactIdV1::ResultingHierarchyScale,
2769            EngineFactIdV1::RootMotionAddressability,
2770            EngineFactIdV1::TargetCoordinateBasis,
2771            EngineFactIdV1::TargetLinearUnit,
2772            EngineFactIdV1::UnitConversionControl,
2773            EngineFactIdV1::WholeEndFrameRequired,
2774        ];
2775        let facts = all_fact_ids
2776            .into_iter()
2777            .map(|id| {
2778                let state = match id {
2779                    EngineFactIdV1::AcceptedInputs => {
2780                        EngineFactStateV1::Known(EngineFactValueV1::AcceptedFormats(vec![
2781                            SourceFormatV1::GltfJson,
2782                            SourceFormatV1::Glb,
2783                        ]))
2784                    }
2785                    EngineFactIdV1::AnimationAddressability => {
2786                        EngineFactStateV1::Known(EngineFactValueV1::AnimationAddressability(
2787                            EngineAnimationAddressabilityV1::GltfAssetLabel,
2788                        ))
2789                    }
2790                    _ => EngineFactStateV1::Unknown,
2791                };
2792                EngineProfileFactV1::new(id, state)
2793            })
2794            .collect();
2795        let profile = ResolvedEngineProfileV1::new(
2796            EngineProfileSelectionV1::new(
2797                BEVY_FAMILY,
2798                BEVY_PROFILE_REVISION,
2799                BEVY_ENGINE_VERSION,
2800                BEVY_IMPORTER,
2801            )
2802            .unwrap(),
2803            "urn:animsmith:engine-profile:bevy:test",
2804            facts,
2805            Vec::new(),
2806            vec![
2807                EnginePrimarySourceV1::new(
2808                    BEVY_ANIMATION_LABEL_SOURCE,
2809                    "test",
2810                    "https://example.invalid/bevy",
2811                    "2026-08-20",
2812                    vec![
2813                        EngineFactIdV1::AcceptedInputs,
2814                        EngineFactIdV1::AnimationAddressability,
2815                    ],
2816                    Vec::new(),
2817                )
2818                .unwrap(),
2819            ],
2820        )
2821        .unwrap();
2822        let settings = ResolvedEngineSettingsV1::new(&profile, Vec::new(), Vec::new()).unwrap();
2823        PredictionProvenanceV1::new(
2824            profile,
2825            source.source_facts().format(),
2826            settings,
2827            RawSourceBindingV1::from_source(source.source_facts()),
2828            source.dependency_closure().clone(),
2829        )
2830        .unwrap()
2831    }
2832
2833    fn disabled_addressability_check(source: &LoadedSource) -> CheckEvaluation {
2834        struct Stub;
2835        impl Check for Stub {
2836            fn id(&self) -> &'static str {
2837                ENGINE_ADDRESSABILITY_CHECK_ID
2838            }
2839
2840            fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
2841                Applicability::Applicable
2842            }
2843
2844            fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
2845                panic!("disabled check must not evaluate")
2846            }
2847        }
2848
2849        let mut config = Config::default();
2850        config.checks = BTreeMap::from([(
2851            ENGINE_ADDRESSABILITY_CHECK_ID.to_owned(),
2852            animsmith_core::config::CheckSettings {
2853                severity: Some(SeveritySetting::Off),
2854                ..animsmith_core::config::CheckSettings::default()
2855            },
2856        )]);
2857        let roles = resolve_configured_roles(&source.document().skeleton, &config.rig);
2858        let grids = MetricGrids::new(source.document());
2859        let ctx = CheckCtx::new(&grids, &roles, &config);
2860        evaluate_checks(&ctx, &[Box::new(Stub)], CheckSelection::All)
2861            .unwrap()
2862            .pop()
2863            .unwrap()
2864    }
2865
2866    #[test]
2867    fn projection_preserves_source_identity_names_channels_and_accessors() {
2868        let source = loaded_source(SourceFormatV1::GltfJson);
2869        assert!(
2870            !source.dependency_closure().coverage().is_complete(),
2871            "the analytic source deliberately lacks complete dependency capture"
2872        );
2873        let inventory =
2874            GltfAnimationAddressabilityInventoryV1::from_source(&source).expect("glTF projection");
2875        assert_eq!(inventory.contract_id(), GLTF_ANIMATION_ADDRESSABILITY_V1_ID);
2876        assert_eq!(
2877            inventory.identity().input_identity().sha256(),
2878            "e6fa6c7e7cc8dbe5e61a8365f5c9c8a5e1c6343d3a5c0416eb1c2ea60cfba1b5"
2879        );
2880        assert_eq!(inventory.identity().input_identity().bytes(), 1_050);
2881        assert_eq!(
2882            inventory.primary_input(),
2883            source.source_facts().primary_identity()
2884        );
2885        assert_eq!(inventory.dependency_closure(), source.dependency_closure());
2886        assert_eq!(
2887            inventory.animations().coverage().state(),
2888            GltfAnimationCoverageStateV1::Complete
2889        );
2890        let rows = inventory.animations().rows();
2891        assert_eq!(rows.len(), 3);
2892        assert_eq!(rows[0].source_clip_index(), 0);
2893        assert_eq!(
2894            rows[0].source_name(),
2895            &GltfAnimationObservationV1::Observed {
2896                value: "walk".to_owned()
2897            }
2898        );
2899        assert_eq!(
2900            rows[1].source_name(),
2901            &GltfAnimationObservationV1::ProvenAbsent
2902        );
2903        assert_eq!(
2904            rows[2].source_name(),
2905            &GltfAnimationObservationV1::Observed {
2906                value: "walk".to_owned()
2907            }
2908        );
2909        let channels = rows[0].channels().rows();
2910        assert_eq!(channels.len(), 4);
2911        assert_eq!(
2912            channels[3].property(),
2913            GltfAnimationChannelPropertyV1::Weights
2914        );
2915        assert_eq!(channels[3].target().kind(), GltfAnimationTargetKindV1::Node);
2916        assert_eq!(channels[3].target().index(), 3);
2917        assert_eq!(channels[3].input_accessor_index(), 6);
2918        assert_eq!(channels[3].output_accessor_index(), 7);
2919    }
2920
2921    #[test]
2922    fn public_nested_readback_rejects_impossible_values() {
2923        assert!(
2924            serde_json::from_value::<GltfAnimationCoverageV1>(serde_json::json!({
2925                "state": "complete",
2926                "reason": "malformed"
2927            }))
2928            .is_err()
2929        );
2930        assert!(
2931            serde_json::from_value::<GltfAnimationCoverageV1>(serde_json::json!({
2932                "state": "complete",
2933                "reason": null
2934            }))
2935            .is_err()
2936        );
2937        assert!(
2938            serde_json::from_value::<GltfAnimationTargetV1>(serde_json::json!({
2939                "kind": "element",
2940                "index": 0
2941            }))
2942            .is_err()
2943        );
2944        assert!(
2945            serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(serde_json::json!({
2946                "source_channel_index": 0,
2947                "target": {"kind": "node", "index": 0},
2948                "property": "translation",
2949                "input_accessor_index": 1,
2950                "output_accessor_index": null
2951            }))
2952            .is_err()
2953        );
2954        assert!(
2955            serde_json::from_value::<GltfAnimationAddressabilityChannelSetV1>(serde_json::json!({
2956                "coverage": {"state": "unavailable", "reason": "parser_unavailable"},
2957                "rows": [{
2958                    "source_channel_index": 0,
2959                    "target": {"kind": "node", "index": 0},
2960                    "property": "translation",
2961                    "input_accessor_index": 1,
2962                    "output_accessor_index": 2
2963                }]
2964            }))
2965            .is_err()
2966        );
2967
2968        let channel_at_limit = serde_json::json!({
2969            "source_channel_index": animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 2,
2970            "target": {"kind": "node", "index": 0},
2971            "property": "translation",
2972            "input_accessor_index": 1,
2973            "output_accessor_index": 2
2974        });
2975        assert!(
2976            serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(
2977                channel_at_limit.clone()
2978            )
2979            .is_ok()
2980        );
2981        let mut channel_over_limit = channel_at_limit;
2982        channel_over_limit["source_channel_index"] =
2983            serde_json::json!(animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1);
2984        assert!(
2985            serde_json::from_value::<GltfAnimationAddressabilityChannelV1>(channel_over_limit)
2986                .unwrap_err()
2987                .to_string()
2988                .contains("source channel index")
2989        );
2990
2991        let animation_at_limit = serde_json::json!({
2992            "source_clip_index": animsmith_core::RAW_SOURCE_V1_MAX_CLIPS - 1,
2993            "source_name": {"state": "proven_absent"},
2994            "normalized_clip_index": {"state": "proven_absent"},
2995            "channels": {
2996                "coverage": {"state": "complete"},
2997                "rows": []
2998            }
2999        });
3000        assert!(
3001            serde_json::from_value::<GltfAnimationAddressabilityAnimationV1>(
3002                animation_at_limit.clone()
3003            )
3004            .is_ok()
3005        );
3006        let mut animation_over_limit = animation_at_limit;
3007        animation_over_limit["source_clip_index"] =
3008            serde_json::json!(animsmith_core::RAW_SOURCE_V1_MAX_CLIPS);
3009        assert!(
3010            serde_json::from_value::<GltfAnimationAddressabilityAnimationV1>(animation_over_limit)
3011                .unwrap_err()
3012                .to_string()
3013                .contains("source clip index")
3014        );
3015    }
3016
3017    #[test]
3018    fn partial_and_unavailable_channel_coverage_round_trip_without_blocking_selectors() {
3019        let cases = [
3020            (
3021                SourceFactSetV1::partial(
3022                    vec![raw_channel(0, SourceChannelPropertyV1::Translation)],
3023                    SourceUnavailableReasonV1::ParserUnavailable,
3024                ),
3025                GltfAnimationCoverageStateV1::Partial,
3026                1,
3027            ),
3028            (
3029                SourceFactSetV1::unavailable(SourceUnavailableReasonV1::ParserUnavailable),
3030                GltfAnimationCoverageStateV1::Unavailable,
3031                0,
3032            ),
3033        ];
3034        let mut identities = Vec::new();
3035
3036        for (channels, expected_state, expected_rows) in cases {
3037            let source = loaded_source_with_channel_set(channels);
3038            let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3039            assert_eq!(
3040                inventory.animations().coverage().state(),
3041                GltfAnimationCoverageStateV1::Complete
3042            );
3043            let projected_channels = inventory.animations().rows()[0].channels();
3044            assert_eq!(projected_channels.coverage().state(), expected_state);
3045            assert!(projected_channels.coverage().reason().is_some());
3046            assert_eq!(projected_channels.rows().len(), expected_rows);
3047            identities.push(inventory.identity().clone());
3048
3049            let provenance = frozen_bevy_provenance(&source);
3050            let grids = MetricGrids::new(source.document());
3051            let roles = resolve_configured_roles(
3052                &source.document().skeleton,
3053                &animsmith_core::Config::default().rig,
3054            );
3055            let config = animsmith_core::Config::default();
3056            let adapter = crate::build_bevy_animation_addressability_adapter_v1(
3057                &source,
3058                &inventory,
3059                Some(provenance),
3060                &CheckCtx::new(&grids, &roles, &config),
3061            )
3062            .unwrap()
3063            .unwrap();
3064            let report = GltfAnimationAddressabilityV1::new(
3065                ToolInfo::animsmith(ToolSource::new(None, None)),
3066                inventory.clone(),
3067                Some(adapter),
3068            )
3069            .unwrap();
3070            let json = serde_json::to_vec(&report).unwrap();
3071            let readback = GltfAnimationAddressabilityInput::read_from(Cursor::new(json))
3072                .unwrap()
3073                .into_report()
3074                .unwrap();
3075            assert_eq!(readback.inventory(), &inventory);
3076            let check = readback.bevy().unwrap().check();
3077            assert_eq!(check.evaluation(), EvaluationState::Complete);
3078            let prediction = check.prediction().unwrap();
3079            assert_eq!(prediction.facets().len(), 1);
3080            assert_eq!(
3081                prediction.facets()[0].state(),
3082                EnginePredictionFacetStateV1::Available
3083            );
3084            assert_eq!(
3085                prediction.facets()[0].scope().subject.as_deref(),
3086                Some("Animation0")
3087            );
3088        }
3089
3090        assert_ne!(identities[0], identities[1]);
3091    }
3092
3093    #[test]
3094    fn strict_reader_rejects_noncanonical_collections_and_null_prediction() {
3095        let source = loaded_source(SourceFormatV1::GltfJson);
3096        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3097        let provenance = frozen_bevy_provenance(&source);
3098        let adapter = GltfAnimationAddressabilityBevyAdapterV1::new(
3099            provenance,
3100            disabled_addressability_check(&source),
3101            &inventory,
3102        )
3103        .unwrap();
3104        let report = GltfAnimationAddressabilityV1::new(
3105            ToolInfo::animsmith(ToolSource::new(None, None)),
3106            inventory,
3107            Some(adapter),
3108        )
3109        .unwrap();
3110        let json = serde_json::to_value(report).unwrap();
3111        for field in ["findings", "gaps"] {
3112            let mut nonempty = json.clone();
3113            nonempty["bevy"]["check"][field] = serde_json::json!([null]);
3114            let bytes = serde_json::to_vec(&nonempty).unwrap();
3115            let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(bytes)).unwrap();
3116            assert!(matches!(
3117                input.into_report(),
3118                Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { reason })
3119                    if reason.contains("exact engine-addressability subset")
3120            ));
3121        }
3122
3123        let mut null_prediction = json;
3124        assert!(null_prediction["bevy"]["check"].get("prediction").is_none());
3125        null_prediction["bevy"]["check"]["prediction"] = serde_json::Value::Null;
3126        let bytes = serde_json::to_vec(&null_prediction).unwrap();
3127        let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(bytes)).unwrap();
3128        assert!(matches!(
3129            input.into_report(),
3130            Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { .. })
3131        ));
3132    }
3133
3134    #[test]
3135    fn strict_reader_stops_at_the_prediction_file_basis_n_plus_one() {
3136        let source = loaded_source(SourceFormatV1::GltfJson);
3137        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3138        let provenance = frozen_bevy_provenance(&source);
3139        let grids = MetricGrids::new(source.document());
3140        let roles = resolve_configured_roles(
3141            &source.document().skeleton,
3142            &animsmith_core::Config::default().rig,
3143        );
3144        let config = animsmith_core::Config::default();
3145        let adapter = crate::build_bevy_animation_addressability_adapter_v1(
3146            &source,
3147            &inventory,
3148            Some(provenance),
3149            &CheckCtx::new(&grids, &roles, &config),
3150        )
3151        .unwrap()
3152        .unwrap();
3153        let report = GltfAnimationAddressabilityV1::new(
3154            ToolInfo::animsmith(ToolSource::new(None, None)),
3155            inventory,
3156            Some(adapter),
3157        )
3158        .unwrap();
3159        let mut wire = serde_json::to_value(report).unwrap();
3160        let mut full_facet = wire["bevy"]["check"]["prediction"]["facets"][0].clone();
3161        let reference = full_facet["basis"]["references"][0].clone();
3162        full_facet["basis"]["references"] =
3163            vec![reference; animsmith_core::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET].into();
3164        let exact_count = PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FILE
3165            / animsmith_core::PREDICTION_V1_MAX_BASIS_REFERENCES_PER_FACET;
3166        let mut facets = vec![full_facet.clone(); exact_count];
3167        full_facet["basis"]["references"] = serde_json::json!([null]);
3168        facets.push(full_facet);
3169        wire["bevy"]["check"]["prediction"]["facets"] = facets.into();
3170
3171        let input = GltfAnimationAddressabilityInput::read_from(Cursor::new(
3172            serde_json::to_vec(&wire).unwrap(),
3173        ))
3174        .unwrap();
3175        assert!(matches!(
3176            input.into_report(),
3177            Err(GltfAnimationAddressabilityError::InvalidBevyCheckShape { reason })
3178                if reason.contains("file basis-reference limit")
3179        ));
3180    }
3181
3182    #[test]
3183    fn projection_rejects_non_gltf_without_mutating_raw_facts() {
3184        let source = loaded_source(SourceFormatV1::Fbx);
3185        assert_eq!(
3186            GltfAnimationAddressabilityInventoryV1::from_source(&source),
3187            Err(GltfAnimationAddressabilityError::UnsupportedSourceFormat {
3188                format: SourceFormatV1::Fbx
3189            })
3190        );
3191        assert_eq!(source.source_facts().clips().rows().len(), 3);
3192    }
3193
3194    #[test]
3195    fn inventory_round_trip_is_strict_and_identity_bound() {
3196        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3197            SourceFormatV1::Glb,
3198        ))
3199        .unwrap();
3200        let json = serde_json::to_value(&inventory).unwrap();
3201        let round_trip: GltfAnimationAddressabilityInventoryV1 =
3202            serde_json::from_value(json.clone()).unwrap();
3203        assert_eq!(round_trip, inventory);
3204
3205        let mut unknown = json.clone();
3206        unknown["unknown"] = serde_json::json!(true);
3207        assert!(serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown).is_err());
3208        let mut unknown_animation = json.clone();
3209        unknown_animation["animations"]["rows"][0]["unknown"] = serde_json::json!(true);
3210        let error =
3211            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown_animation)
3212                .unwrap_err()
3213                .to_string();
3214        assert!(error.contains("unknown field `unknown`"), "{error}");
3215        let mut unknown_channel_set = json.clone();
3216        unknown_channel_set["animations"]["rows"][0]["channels"]["unknown"] =
3217            serde_json::json!(true);
3218        let error =
3219            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(unknown_channel_set)
3220                .unwrap_err()
3221                .to_string();
3222        assert!(error.contains("unknown field `unknown`"), "{error}");
3223        let mut changed = json;
3224        changed["animations"]["rows"][0]["source_name"]["value"] = serde_json::json!("run");
3225        assert!(
3226            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(changed)
3227                .unwrap_err()
3228                .to_string()
3229                .contains("canonical engine-neutral preimage")
3230        );
3231    }
3232
3233    #[test]
3234    fn gltf_rows_reject_non_node_targets_and_missing_accessors() {
3235        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3236            SourceFormatV1::GltfJson,
3237        ))
3238        .unwrap();
3239        let json = serde_json::to_value(&inventory).unwrap();
3240
3241        let mut non_node = json.clone();
3242        non_node["animations"]["rows"][0]["channels"]["rows"][0]["target"]["kind"] =
3243            serde_json::json!("element");
3244        let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(non_node)
3245            .unwrap_err()
3246            .to_string();
3247        assert!(error.contains("target source nodes"), "{error}");
3248
3249        let mut missing_accessors = json;
3250        missing_accessors["animations"]["rows"][0]["channels"]["rows"][0]["input_accessor_index"] =
3251            serde_json::Value::Null;
3252        missing_accessors["animations"]["rows"][0]["channels"]["rows"][0]["output_accessor_index"] =
3253            serde_json::Value::Null;
3254        let error =
3255            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(missing_accessors)
3256                .unwrap_err()
3257                .to_string();
3258        assert!(error.contains("expected u64"), "{error}");
3259    }
3260
3261    #[test]
3262    fn inventory_identity_changes_with_external_content_identity() {
3263        fn inventory(external: &[u8]) -> GltfAnimationAddressabilityInventoryV1 {
3264            let primary = InputIdentity::from_bytes(b"primary");
3265            let key = animsmith_core::DependencyResourceKeyV1::from_source_str(
3266                "buffer.bin",
3267                animsmith_core::ResourceKeySyntaxV1::GltfUri,
3268            )
3269            .unwrap();
3270            let mut builder = animsmith_core::DependencyClosureBuilderV1::new(
3271                primary.clone(),
3272                animsmith_core::SourceSetCoverageV1::complete(),
3273                1,
3274            );
3275            assert!(builder.begin_reference("buffer.bin".len(), 1));
3276            assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
3277            builder.record_external_open_attempt(&key).unwrap();
3278            assert!(
3279                builder
3280                    .push_captured_external(
3281                        0,
3282                        animsmith_core::SourceResourceKindV1::Buffer,
3283                        0,
3284                        key,
3285                        InputIdentity::from_bytes(external),
3286                    )
3287                    .unwrap()
3288            );
3289            direct_inventory_with_closure(primary, builder.finish().unwrap(), Vec::new())
3290        }
3291
3292        let before = inventory(b"before");
3293        let after = inventory(b"after");
3294        assert_ne!(before.dependency_closure(), after.dependency_closure());
3295        assert_ne!(before.identity(), after.identity());
3296    }
3297
3298    #[test]
3299    fn adapter_rejects_fabricated_profile_identity_and_coverage_binding_detects_mutation() {
3300        let source = loaded_source(SourceFormatV1::GltfJson);
3301        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&source).unwrap();
3302        let provenance = bevy_provenance(&source);
3303        let check = disabled_addressability_check(&source);
3304        assert_eq!(check.configuration(), ConfigurationState::Disabled);
3305        assert_ne!(
3306            provenance.profile().facts_identity().sha256(),
3307            BEVY_PROFILE_FACTS_SHA256
3308        );
3309        assert!(matches!(
3310            GltfAnimationAddressabilityBevyAdapterV1::new(provenance.clone(), check, &inventory,),
3311            Err(GltfAnimationAddressabilityError::InvalidBevyProfile)
3312        ));
3313
3314        let mut changed = inventory.clone();
3315        changed.animations.coverage = GltfAnimationCoverageV1 {
3316            state: GltfAnimationCoverageStateV1::Partial,
3317            reason: Some(GltfAnimationUnavailableReasonV1::ParserUnavailable),
3318        };
3319        changed.identity = GltfAnimationAddressabilityIdentityV1(changed.computed_identity());
3320        assert_eq!(
3321            validate_inventory_provenance_binding(&provenance, &changed),
3322            Err(GltfAnimationAddressabilityError::BevyInventoryMismatch)
3323        );
3324    }
3325
3326    #[test]
3327    fn animation_collection_accepts_n_and_rejects_n_plus_one() {
3328        let rows = (0..animsmith_core::RAW_SOURCE_V1_MAX_CLIPS)
3329            .map(|index| direct_animation(index, String::new()))
3330            .collect();
3331        let inventory = direct_inventory(rows);
3332        let mut json = serde_json::to_value(&inventory).unwrap();
3333        assert!(
3334            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3335        );
3336        let rows = json["animations"]["rows"].as_array_mut().unwrap();
3337        rows.push(serde_json::Value::Null);
3338        let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3339            .unwrap_err()
3340            .to_string();
3341        assert!(
3342            error.contains("4097 animations, exceeding the V1 limit of 4096"),
3343            "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3344        );
3345    }
3346
3347    #[test]
3348    fn channel_collection_accepts_n_and_rejects_n_plus_one() {
3349        let channels = (0..animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 1)
3350            .map(direct_channel)
3351            .collect();
3352        let mut animation = direct_animation(0, String::new());
3353        animation.channels.rows = channels;
3354        let inventory = direct_inventory(vec![animation]);
3355        let mut json = serde_json::to_value(&inventory).unwrap();
3356        assert!(
3357            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3358        );
3359        let rows = json["animations"]["rows"][0]["channels"]["rows"]
3360            .as_array_mut()
3361            .unwrap();
3362        rows.push(serde_json::Value::Null);
3363        let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3364            .unwrap_err()
3365            .to_string();
3366        assert!(
3367            error.contains("65536 animation/channel rows, exceeding the V1 limit of 65535"),
3368            "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3369        );
3370    }
3371
3372    #[test]
3373    fn aggregate_row_budget_rejects_cross_animation_n_plus_one_before_decoding() {
3374        let first_channel_count = animsmith_core::RAW_SOURCE_V1_MAX_OBSERVATIONS - 3;
3375        let mut first = direct_animation(0, String::new());
3376        first.channels.rows = (0..first_channel_count).map(direct_channel).collect();
3377        let mut second = direct_animation(1, String::new());
3378        second.channels.rows = vec![direct_channel(0)];
3379        let inventory = direct_inventory(vec![first, second]);
3380        let mut json = serde_json::to_value(&inventory).unwrap();
3381        assert!(
3382            serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json.clone()).is_ok()
3383        );
3384
3385        json["animations"]["rows"][1]["channels"]["rows"]
3386            .as_array_mut()
3387            .unwrap()
3388            .push(serde_json::Value::Null);
3389        let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(json)
3390            .unwrap_err()
3391            .to_string();
3392        assert!(
3393            error.contains("65537 animation/channel rows, exceeding the V1 limit of 65536"),
3394            "the global N+1 sentinel must be rejected before its null shape is decoded: {error}"
3395        );
3396    }
3397
3398    #[test]
3399    fn evaluated_scope_collection_accepts_n_and_rejects_n_plus_one() {
3400        let scopes: Vec<_> = (0..animsmith_core::RAW_SOURCE_V1_MAX_CLIPS)
3401            .map(|index| {
3402                serde_json::json!({
3403                    "code": "animation_asset_label",
3404                    "subject": format!("Animation{index}"),
3405                })
3406            })
3407            .collect();
3408        let mut check = serde_json::json!({
3409            "check_id": ENGINE_ADDRESSABILITY_CHECK_ID,
3410            "selection": "selected",
3411            "configuration": "enabled",
3412            "applicability": "applicable",
3413            "evaluation": "complete",
3414            "findings": [],
3415            "evaluated_scopes": scopes,
3416            "gaps": [],
3417        });
3418        let wire: GltfAnimationAddressabilityCheckWireV1 =
3419            serde_json::from_value(check.clone()).unwrap();
3420        assert_eq!(
3421            wire.evaluated_scopes.len(),
3422            animsmith_core::RAW_SOURCE_V1_MAX_CLIPS
3423        );
3424
3425        check["evaluated_scopes"]
3426            .as_array_mut()
3427            .unwrap()
3428            .push(serde_json::Value::Null);
3429        let error = serde_json::from_value::<GltfAnimationAddressabilityCheckWireV1>(check)
3430            .unwrap_err()
3431            .to_string();
3432        assert!(
3433            error.contains("exact engine-addressability subset"),
3434            "the N+1 sentinel must be rejected before its null shape is decoded: {error}"
3435        );
3436    }
3437
3438    #[test]
3439    fn embedded_check_rejects_first_finding_or_gap_without_materializing_it() {
3440        for field in ["findings", "gaps"] {
3441            let mut check = serde_json::json!({
3442                "check_id": ENGINE_ADDRESSABILITY_CHECK_ID,
3443                "selection": "selected",
3444                "configuration": "enabled",
3445                "applicability": "applicable",
3446                "evaluation": "complete",
3447                "findings": [],
3448                "evaluated_scopes": [],
3449                "gaps": [],
3450            });
3451            check[field] = serde_json::json!([null]);
3452            let error = serde_json::from_value::<GltfAnimationAddressabilityCheckWireV1>(check)
3453                .unwrap_err()
3454                .to_string();
3455            assert!(
3456                error.contains("exact engine-addressability subset"),
3457                "{field}'s first sentinel must be rejected before Box<RawValue> decoding: {error}"
3458            );
3459        }
3460    }
3461
3462    #[test]
3463    fn per_text_and_aggregate_text_bounds_accept_n_and_reject_n_plus_one() {
3464        let at_per_text_limit = direct_inventory(vec![direct_animation(
3465            0,
3466            "x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES),
3467        )]);
3468        let mut too_long = serde_json::to_value(&at_per_text_limit).unwrap();
3469        too_long["animations"]["rows"][0]["source_name"]["value"] =
3470            serde_json::json!("x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES + 1));
3471        let error = serde_json::from_value::<GltfAnimationAddressabilityInventoryV1>(too_long)
3472            .unwrap_err()
3473            .to_string();
3474        assert!(error.contains("source name"), "{error}");
3475
3476        let full_names = animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES
3477            / animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES;
3478        let at_aggregate_limit = direct_inventory(
3479            (0..full_names)
3480                .map(|index| {
3481                    direct_animation(
3482                        index,
3483                        "x".repeat(animsmith_core::RAW_SOURCE_V1_MAX_TEXT_BYTES),
3484                    )
3485                })
3486                .collect(),
3487        );
3488        let mut over = at_aggregate_limit.animations.rows.clone();
3489        over.push(direct_animation(over.len(), "x".to_owned()));
3490        let mut invalid = GltfAnimationAddressabilityInventoryV1 {
3491            animations: GltfAnimationAddressabilityAnimationSetV1 {
3492                coverage: at_aggregate_limit.animations.coverage,
3493                rows: over,
3494            },
3495            ..at_aggregate_limit
3496        };
3497        assert_eq!(
3498            invalid.validate_without_identity(),
3499            Err(GltfAnimationAddressabilityError::TooMuchText {
3500                found: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES + 1,
3501                limit: animsmith_core::RAW_SOURCE_V1_MAX_TOTAL_TEXT_BYTES,
3502            })
3503        );
3504        invalid.animations.rows.pop();
3505        assert!(invalid.validate_without_identity().is_ok());
3506    }
3507
3508    #[test]
3509    fn standalone_null_adapter_round_trips_and_cross_contracts_are_rejected() {
3510        let inventory = GltfAnimationAddressabilityInventoryV1::from_source(&loaded_source(
3511            SourceFormatV1::GltfJson,
3512        ))
3513        .unwrap();
3514        let report = GltfAnimationAddressabilityV1::new(
3515            ToolInfo::animsmith(ToolSource::new(None, None)),
3516            inventory.clone(),
3517            None,
3518        )
3519        .unwrap();
3520        let json = serde_json::to_vec(&report).unwrap();
3521        assert!(crate::validate_bevy_readback_v1(json.as_slice()).is_err());
3522        let read = GltfAnimationAddressabilityInput::read_from(Cursor::new(&json))
3523            .unwrap()
3524            .into_report()
3525            .unwrap();
3526        assert_eq!(read.inventory(), &inventory);
3527        assert!(read.bevy().is_none());
3528
3529        let output_v10 = serde_json::json!({
3530            "schema_version": 10,
3531            "schema": animsmith_core::OUTPUT_SCHEMA_ID,
3532            "tool": {"name":"animsmith","version":"0.3.1","source":{"revision":null,"dirty":null}},
3533            "command": "measure",
3534            "summary": {"files":0},
3535            "files": []
3536        });
3537        assert!(serde_json::from_value::<GltfAnimationAddressabilityInput>(output_v10).is_err());
3538        assert!(
3539            serde_json::from_slice::<animsmith_core::MeasurementReportInput>(&json)
3540                .unwrap()
3541                .into_files()
3542                .is_err()
3543        );
3544
3545        let mut invalid_tool = serde_json::to_value(&report).unwrap();
3546        invalid_tool["tool"]["name"] = serde_json::json!("other");
3547        let input: GltfAnimationAddressabilityInput = serde_json::from_value(invalid_tool).unwrap();
3548        assert!(matches!(
3549            input.into_report(),
3550            Err(GltfAnimationAddressabilityError::InvalidTool)
3551        ));
3552
3553        let mut other_version = serde_json::to_value(&report).unwrap();
3554        other_version["tool"]["version"] = serde_json::json!("9.9.9-beta.1+pipeline");
3555        let input: GltfAnimationAddressabilityInput =
3556            serde_json::from_value(other_version).unwrap();
3557        assert!(
3558            input.into_report().is_ok(),
3559            "immutable V1 readback must not be coupled to the reader package version"
3560        );
3561
3562        for invalid_version in ["", "1", "1.2", "1.2.3.4", "1.2.3+", "1.2.3 bad"] {
3563            let mut invalid = serde_json::to_value(&report).unwrap();
3564            invalid["tool"]["version"] = serde_json::json!(invalid_version);
3565            let input: GltfAnimationAddressabilityInput = serde_json::from_value(invalid).unwrap();
3566            assert!(matches!(
3567                input.into_report(),
3568                Err(GltfAnimationAddressabilityError::InvalidTool)
3569            ));
3570        }
3571
3572        let mut missing_tool_field = serde_json::to_value(&report).unwrap();
3573        missing_tool_field["tool"]["source"]
3574            .as_object_mut()
3575            .unwrap()
3576            .remove("revision");
3577        let input: GltfAnimationAddressabilityInput =
3578            serde_json::from_value(missing_tool_field).unwrap();
3579        assert!(matches!(
3580            input.into_report(),
3581            Err(GltfAnimationAddressabilityError::InvalidTool)
3582        ));
3583    }
3584
3585    #[test]
3586    fn staged_reader_accepts_byte_n_and_rejects_n_plus_one() {
3587        let exact = GltfAnimationAddressabilityInput::read_from_with_limit(Cursor::new(b"null"), 4);
3588        assert!(matches!(
3589            exact,
3590            Err(GltfAnimationAddressabilityReadError::InvalidJson { .. })
3591        ));
3592        let over = GltfAnimationAddressabilityInput::read_from_with_limit(Cursor::new(b"null "), 4);
3593        assert!(matches!(
3594            over,
3595            Err(GltfAnimationAddressabilityReadError::ReportTooLarge { limit: 4 })
3596        ));
3597    }
3598}