Skip to main content

animsmith_engine/
prediction.rs

1//! Engine-owned checks derived from immutable profile and same-load source evidence.
2
3use crate::addressability::{
4    GltfAnimationAddressabilityBevyAdapterV1, GltfAnimationAddressabilityInventoryV1,
5};
6use crate::error::PredictionRuleError;
7use crate::registry::profiles_v1;
8use animsmith_core::{
9    Applicability, Check, CheckCtx, CheckOutput, CheckSelection, EngineAnimationAddressabilityV1,
10    EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EnginePredictionBasisV1,
11    EnginePredictionFacetV1, EnginePredictionV1, EvaluationScope, EvaluationScopeCode,
12    LoadedSource, PredictionBasisReferenceV1, PredictionProvenanceV1,
13    PredictionUnavailableReasonV1, RAW_SOURCE_V1_MAX_CLIPS, RawSourceBasisReferenceV1,
14    RawSourceBindingV1, RawSourceDomainV1, RawSourceFieldIdV1, RawSourceKeyV1, SourceFormatV1,
15    SourceSetCoverageStateV1, evaluate_checks,
16};
17
18/// A Bevy animation asset-label index outside the bounded raw-source
19/// animation inventory.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21#[error("source animation index {source_clip_index} is outside the V1 limit of {limit} animations")]
22pub struct BevyAnimationAssetLabelError {
23    /// Supplied source animation index.
24    pub source_clip_index: usize,
25    /// Exclusive upper bound for V1 source animation indices.
26    pub limit: usize,
27}
28
29/// Typed failure while constructing the optional exact Bevy adapter for a
30/// glTF animation-addressability document.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[non_exhaustive]
33pub enum GltfAnimationAddressabilityAdapterError {
34    /// Same-load source binding or frozen-profile validation failed.
35    #[error(transparent)]
36    PredictionRule(#[from] PredictionRuleError),
37    /// The existing engine-addressability check could not form a valid
38    /// [`animsmith_core::CheckEvaluation`].
39    #[error(transparent)]
40    Evaluation(#[from] animsmith_core::EvaluationError),
41    /// The standalone contract rejected inconsistent inventory, provenance,
42    /// or check evidence.
43    #[error(transparent)]
44    Contract(#[from] crate::GltfAnimationAddressabilityError),
45}
46
47/// Stable check id for engine scene, animation, target, and runtime-label addressability.
48pub const ENGINE_ADDRESSABILITY_CHECK_ID: &str = "engine-addressability";
49
50/// Versioned engine-owned check ids callers may use for pre-I/O selection validation.
51pub const ENGINE_CHECK_IDS_V1: &[&str] = &[ENGINE_ADDRESSABILITY_CHECK_ID];
52
53const BEVY_FAMILY: &str = "bevy";
54const BEVY_PROFILE_REVISION: u32 = 1;
55const BEVY_ENGINE_VERSION: &str = "0.19.0";
56const BEVY_IMPORTER: &str = "gltf-asset-loader";
57const BEVY_ANIMATION_LABEL_SOURCE: &str = "bevy-gltf-asset-label-0.19.0";
58
59/// One exact Bevy 0.19.0 `GltfAssetLabel::Animation(index)` display selector.
60///
61/// The source animation index is authoritative. Source names are optional,
62/// non-unique metadata and never affect this value. Construction enforces the
63/// same 4,096-row ceiling as raw-source facts, so the retained selector text is
64/// bounded to `Animation0` through `Animation4095`.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct BevyAnimationAssetLabelV1 {
67    source_clip_index: usize,
68    display_selector: String,
69}
70
71impl BevyAnimationAssetLabelV1 {
72    /// Construct the indexed selector for one retained source animation.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`BevyAnimationAssetLabelError`] when the index cannot occur in
77    /// the bounded V1 raw-source animation inventory.
78    pub fn new(source_clip_index: usize) -> Result<Self, BevyAnimationAssetLabelError> {
79        if source_clip_index >= RAW_SOURCE_V1_MAX_CLIPS {
80            return Err(BevyAnimationAssetLabelError {
81                source_clip_index,
82                limit: RAW_SOURCE_V1_MAX_CLIPS,
83            });
84        }
85        Ok(Self {
86            source_clip_index,
87            display_selector: format!("Animation{source_clip_index}"),
88        })
89    }
90
91    /// Exact source animation index carried by this selector.
92    pub const fn source_clip_index(&self) -> usize {
93        self.source_clip_index
94    }
95
96    /// Exact Bevy display selector, such as `Animation0`.
97    pub fn as_str(&self) -> &str {
98        &self.display_selector
99    }
100}
101
102/// The engine addressability check over borrowed same-load source evidence.
103///
104/// When provenance is absent or is not exactly the frozen Bevy 0.19.0 glTF profile, the check
105/// records a stable not-applicable evaluation. Construction performs bounded in-memory
106/// validation against the immutable source sidecars but no filesystem or parser work.
107pub struct EngineAddressabilityCheck<'a> {
108    source: &'a LoadedSource,
109    provenance: Option<&'a PredictionProvenanceV1>,
110}
111
112impl<'a> EngineAddressabilityCheck<'a> {
113    /// Bind one source and optional immutable prediction provenance.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`PredictionRuleError::SourceProvenanceMismatch`] when the
118    /// provenance was not projected from the supplied same-load source facts
119    /// and dependency closure, or [`PredictionRuleError::FrozenProfileMismatch`]
120    /// when an exact Bevy tuple does not carry the frozen registry facts.
121    pub fn new(
122        source: &'a LoadedSource,
123        provenance: Option<&'a PredictionProvenanceV1>,
124    ) -> Result<Self, PredictionRuleError> {
125        if let Some(provenance) = provenance {
126            let raw_source = RawSourceBindingV1::from_source(source.source_facts());
127            if &raw_source != provenance.raw_source()
128                || source.dependency_closure() != provenance.dependency_closure()
129            {
130                return Err(PredictionRuleError::SourceProvenanceMismatch);
131            }
132            if is_bevy_tuple(provenance)
133                && !profiles_v1().iter().any(|profile| {
134                    let selection = profile.selection();
135                    selection.family() == BEVY_FAMILY
136                        && selection.profile_revision() == BEVY_PROFILE_REVISION
137                        && selection.engine_version() == BEVY_ENGINE_VERSION
138                        && selection.importer() == BEVY_IMPORTER
139                        && profile.facts_identity() == provenance.profile().facts_identity()
140                })
141            {
142                return Err(PredictionRuleError::FrozenProfileMismatch);
143            }
144        }
145        Ok(Self { source, provenance })
146    }
147}
148
149impl Check for EngineAddressabilityCheck<'_> {
150    fn id(&self) -> &'static str {
151        ENGINE_ADDRESSABILITY_CHECK_ID
152    }
153
154    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
155        borrowed_applicability(self.source, self.provenance)
156    }
157
158    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
159        let Some(provenance) = self.provenance else {
160            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
161        };
162        if !is_bevy_gltf(self.source, provenance) || facts_are_complete_and_empty(self.source) {
163            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
164        }
165        evaluate_animation_asset_labels(self.source, provenance)
166    }
167}
168
169/// Build the optional exact Bevy 0.19.0 adapter for one addressability document.
170///
171/// This is the single adapter assembly path: it binds the existing
172/// [`EngineAddressabilityCheck`] to the supplied same-load source and
173/// provenance, sends that one check through [`evaluate_checks`] exactly once,
174/// then lets the standalone contract validate the unchanged resulting evaluation
175/// against the neutral inventory. No-profile and valid non-Bevy provenance
176/// produce `None` without adding another check or prediction lifecycle.
177///
178/// # Errors
179///
180/// Returns [`GltfAnimationAddressabilityAdapterError`] when same-load or
181/// frozen-profile validation fails, the existing check cannot produce a valid
182/// evaluation, or the standalone contract rejects inconsistent evidence.
183pub fn build_bevy_animation_addressability_adapter_v1(
184    source: &LoadedSource,
185    inventory: &GltfAnimationAddressabilityInventoryV1,
186    prediction_provenance: Option<PredictionProvenanceV1>,
187    ctx: &CheckCtx<'_>,
188) -> Result<Option<GltfAnimationAddressabilityBevyAdapterV1>, GltfAnimationAddressabilityAdapterError>
189{
190    let Some(prediction_provenance) = prediction_provenance else {
191        return Ok(None);
192    };
193    let check = EngineAddressabilityCheck::new(source, Some(&prediction_provenance))?;
194    if !is_bevy_gltf(source, &prediction_provenance) {
195        return Ok(None);
196    }
197
198    let evaluation = {
199        let checks: [Box<dyn Check + '_>; 1] = [Box::new(check)];
200        let mut evaluations = evaluate_checks(ctx, &checks, CheckSelection::All)?;
201        let evaluation = evaluations
202            .pop()
203            .expect("one valid static check catalog produces one evaluation");
204        debug_assert!(evaluations.is_empty());
205        evaluation
206    };
207    Ok(Some(GltfAnimationAddressabilityBevyAdapterV1::new(
208        prediction_provenance,
209        evaluation,
210        inventory,
211    )?))
212}
213
214fn borrowed_applicability(
215    source: &LoadedSource,
216    provenance: Option<&PredictionProvenanceV1>,
217) -> Applicability {
218    let Some(provenance) = provenance else {
219        return Applicability::NotApplicable;
220    };
221    if !is_bevy_gltf(source, provenance) {
222        return Applicability::NotApplicable;
223    }
224    if facts_are_complete_and_empty(source) {
225        Applicability::NotApplicable
226    } else {
227        Applicability::Applicable
228    }
229}
230
231fn facts_are_complete_and_empty(source: &LoadedSource) -> bool {
232    let clips = source.source_facts().clips();
233    clips.coverage().state() == SourceSetCoverageStateV1::Complete && clips.rows().is_empty()
234}
235
236fn is_bevy_gltf(source: &LoadedSource, provenance: &PredictionProvenanceV1) -> bool {
237    let facts = source.source_facts();
238    facts.format() == provenance.source_format()
239        && is_bevy_tuple(provenance)
240        && matches!(
241            provenance.source_format(),
242            SourceFormatV1::GltfJson | SourceFormatV1::Glb
243        )
244        && matches!(
245            provenance
246                .profile()
247                .fact(EngineFactIdV1::AnimationAddressability)
248                .map(|fact| fact.state()),
249            Some(EngineFactStateV1::Known(
250                EngineFactValueV1::AnimationAddressability(
251                    EngineAnimationAddressabilityV1::GltfAssetLabel
252                )
253            ))
254        )
255        && provenance
256            .profile()
257            .source(BEVY_ANIMATION_LABEL_SOURCE)
258            .is_some()
259}
260
261fn is_bevy_tuple(provenance: &PredictionProvenanceV1) -> bool {
262    let selection = provenance.profile().selection();
263    selection.family() == BEVY_FAMILY
264        && selection.profile_revision() == BEVY_PROFILE_REVISION
265        && selection.engine_version() == BEVY_ENGINE_VERSION
266        && selection.importer() == BEVY_IMPORTER
267}
268
269fn evaluate_animation_asset_labels(
270    source: &LoadedSource,
271    provenance: &PredictionProvenanceV1,
272) -> CheckOutput {
273    let facts = source.source_facts();
274    let inventory_scope =
275        EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY);
276    if facts.format() != provenance.source_format()
277        || facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete
278    {
279        return unavailable_inventory(provenance, inventory_scope);
280    }
281
282    let mut scopes = Vec::with_capacity(facts.clips().rows().len());
283    let mut facets = Vec::with_capacity(facts.clips().rows().len());
284    for clip in facts.clips().rows() {
285        let source_index = clip.source_clip_index();
286        let label = BevyAnimationAssetLabelV1::new(source_index)
287            .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
288        let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
289            .subject(label.as_str().to_owned());
290        let source_name = RawSourceBasisReferenceV1::from_source(
291            RawSourceDomainV1::Clip,
292            RawSourceKeyV1::Clip {
293                source_clip_index: source_index as u64,
294            },
295            RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
296            facts,
297        )
298        .expect("loader-valid retained clip rows must resolve their own raw-source witness");
299        let basis = EnginePredictionBasisV1::new(vec![
300            PredictionBasisReferenceV1::profile_fact("animation_addressability")
301                .expect("static fact id is valid"),
302            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
303                .expect("static primary-source id is valid"),
304            PredictionBasisReferenceV1::raw_source(source_name),
305        ])
306        .expect("three distinct static basis references are valid");
307        let facet = EnginePredictionFacetV1::available(scope.clone(), basis)
308            .expect("complete source rows have unique bounded scopes");
309        scopes.push(scope);
310        facets.push(facet);
311    }
312
313    if facets.is_empty() {
314        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
315    }
316    let prediction = EnginePredictionV1::new(provenance.identity().clone(), facets)
317        .expect("complete bounded source rows form one valid canonical prediction");
318    CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction(prediction)
319}
320
321fn unavailable_inventory(
322    provenance: &PredictionProvenanceV1,
323    scope: EvaluationScope,
324) -> CheckOutput {
325    let basis = EnginePredictionBasisV1::new(vec![
326        PredictionBasisReferenceV1::profile_fact("animation_addressability")
327            .expect("static fact id is valid"),
328        PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
329            .expect("static primary-source id is valid"),
330    ])
331    .expect("static basis is nonempty and canonical");
332    let facet = EnginePredictionFacetV1::required_unavailable(
333        scope,
334        basis,
335        vec![PredictionUnavailableReasonV1::RawSourceIncomplete],
336    )
337    .expect("static unavailable facet is valid");
338    let prediction = EnginePredictionV1::new(provenance.identity().clone(), vec![facet])
339        .expect("one static facet is valid");
340    CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
341        .with_engine_prediction(prediction)
342}