Skip to main content

animsmith_engine/
track_support.rs

1//! Exact negative-only Bevy animation/channel import-gate prediction.
2
3use crate::SettingIdV2;
4use crate::canonical::matches_frozen_registry_projection_v2;
5use crate::error::PredictionRuleError;
6use animsmith_core::engine_contract::{EngineSettingIdV2, EngineSettingValueV2};
7use animsmith_core::prediction::{EnginePredictionBasisV4, PredictionBasisReferenceV4};
8use animsmith_core::{
9    Applicability, Check, CheckCtx, CheckOutput, EngineMachineResultV1, EnginePredictionFacetV4,
10    EnginePredictionV4, EnginePredictionV5, EvaluationScope, EvaluationScopeCode, LoadedSource,
11    PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE, PredictionBasisReferenceV1,
12    PredictionBasisReferenceV2, PredictionFacetDemandV2, PredictionProvenanceV5,
13    PredictionRuleAllocationV2, PredictionScalarV1, PredictionUnavailableReasonV2,
14    RawAnimationChannelInventoryV1, ResolvedSettingLocationV1, SourceFormatV1,
15    SourceImportDispositionResultV1, SourceImportDispositionV1, SourceImportSubjectKindV1,
16};
17
18/// Stable id for the exact Bevy raw animation/channel gate prediction.
19pub const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
20
21const BEVY_FAMILY: &str = "bevy";
22const BEVY_PROFILE_REVISION: u32 = 3;
23const BEVY_ENGINE_VERSION: &str = "0.19.0";
24const BEVY_IMPORTER: &str = "gltf-asset-loader";
25const BEVY_LOADER_SOURCE: &str = "bevy-gltf-loader-0.19.0-c6f634ca";
26const BEVY_MANIFEST_SOURCE: &str = "bevy-feature-manifest-0.19.0-c6f634ca";
27
28const ANIMATION_SCOPE: &str = "engine-track-support:animation";
29const CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
30const INVENTORY_SCOPE: &str = "engine-track-support:inventory";
31const BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
32
33/// Engine-owned check for Bevy revision 3's two materialized loading gates.
34pub struct EngineTrackSupportCheck<'a> {
35    source: &'a LoadedSource,
36    provenance: Option<&'a PredictionProvenanceV5>,
37}
38
39impl<'a> EngineTrackSupportCheck<'a> {
40    /// Bind a V5 sidecar to the exact same source load.
41    pub fn new(
42        source: &'a LoadedSource,
43        provenance: Option<&'a PredictionProvenanceV5>,
44    ) -> Result<Self, PredictionRuleError> {
45        if let Some(provenance) = provenance {
46            provenance
47                .validate()
48                .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
49            let raw_source = animsmith_core::RawSourceBindingV2::from_source(
50                source.source_facts(),
51                source.exact_source_timing(),
52            )
53            .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
54            if provenance.base().raw_source() != &raw_source
55                || provenance.raw_animation_channels()
56                    != &RawAnimationChannelInventoryV1::from_source(source.source_facts())
57                || provenance.base().dependency_closure() != source.dependency_closure()
58            {
59                return Err(PredictionRuleError::SourceProvenanceMismatch);
60            }
61            if is_bevy_tuple(provenance)
62                && !matches_frozen_registry_projection_v2(provenance.base().profile())
63            {
64                return Err(PredictionRuleError::FrozenProfileMismatch);
65            }
66        }
67        Ok(Self { source, provenance })
68    }
69}
70
71impl Check for EngineTrackSupportCheck<'_> {
72    fn id(&self) -> &'static str {
73        ENGINE_TRACK_SUPPORT_CHECK_ID
74    }
75
76    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
77        self.provenance
78            .filter(|provenance| {
79                is_exact_bevy_gltf(self.source, provenance)
80                    && !provenance.raw_animation_channels().is_complete_empty()
81            })
82            .map_or(Applicability::NotApplicable, |_| Applicability::Applicable)
83    }
84
85    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
86        let Some(provenance) = self.provenance else {
87            return empty_output();
88        };
89        if !is_exact_bevy_gltf(self.source, provenance)
90            || provenance.raw_animation_channels().is_complete_empty()
91        {
92            return empty_output();
93        }
94        let demand = demand(provenance.raw_animation_channels());
95        let (capacity, summary_required) = match demand {
96            PredictionFacetDemandV2::Exact(count) => (count, false),
97            PredictionFacetDemandV2::NPlusOne => (
98                PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(1),
99                true,
100            ),
101        };
102        evaluate_allocated(provenance, capacity, summary_required)
103    }
104
105    fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
106        self.provenance
107            .filter(|provenance| {
108                is_exact_bevy_gltf(self.source, provenance)
109                    && !provenance.raw_animation_channels().is_complete_empty()
110            })
111            .map_or(PredictionFacetDemandV2::Exact(0), |provenance| {
112                demand(provenance.raw_animation_channels())
113            })
114    }
115
116    fn evaluate_with_prediction_allocation_v2(
117        &self,
118        _ctx: &CheckCtx<'_>,
119        allocation: PredictionRuleAllocationV2<'_>,
120    ) -> CheckOutput {
121        let Some(provenance) = self.provenance else {
122            return empty_output();
123        };
124        if !is_exact_bevy_gltf(self.source, provenance)
125            || provenance.raw_animation_channels().is_complete_empty()
126        {
127            return empty_output();
128        }
129        evaluate_allocated(
130            provenance,
131            allocation.candidate_capacity(),
132            allocation.summary_required(),
133        )
134    }
135}
136
137fn demand(inventory: &RawAnimationChannelInventoryV1) -> PredictionFacetDemandV2 {
138    if inventory.is_complete_empty() {
139        return PredictionFacetDemandV2::Exact(0);
140    }
141    if !inventory.source_coverage_complete() {
142        return PredictionFacetDemandV2::Exact(1);
143    }
144    if inventory.candidate_overflow() {
145        return PredictionFacetDemandV2::NPlusOne;
146    }
147    usize::try_from(inventory.candidate_count())
148        .ok()
149        .filter(|count| *count <= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
150        .map_or(
151            PredictionFacetDemandV2::NPlusOne,
152            PredictionFacetDemandV2::Exact,
153        )
154}
155
156fn evaluate_allocated(
157    provenance: &PredictionProvenanceV5,
158    candidate_capacity: usize,
159    summary_required: bool,
160) -> CheckOutput {
161    let inventory = provenance.raw_animation_channels();
162    let mut facets =
163        Vec::with_capacity(candidate_capacity.saturating_add(usize::from(summary_required)));
164    if !inventory.is_complete_empty() && candidate_capacity != 0 {
165        if !inventory.source_coverage_complete() {
166            facets.push(unavailable(
167                EvaluationScope::new(EvaluationScopeCode::custom(INVENTORY_SCOPE)),
168                inventory_basis(inventory),
169                PredictionUnavailableReasonV2::RawSourceIncomplete,
170            ));
171        } else {
172            for row in inventory.rows() {
173                if facets.len() == candidate_capacity {
174                    break;
175                }
176                if let Some(channel) = row.source_channel_index() {
177                    facets.push(subject_facet(
178                        channel_scope(row.source_animation_index(), channel),
179                        row_basis(inventory, row.source_animation_index(), Some(channel)),
180                        SourceImportSubjectKindV1::AnimationChannel,
181                        gate(provenance),
182                    ));
183                } else {
184                    facets.push(subject_facet(
185                        animation_scope(row.source_animation_index()),
186                        row_basis(inventory, row.source_animation_index(), None),
187                        SourceImportSubjectKindV1::Animation,
188                        gate(provenance),
189                    ));
190                }
191            }
192        }
193    }
194    if summary_required {
195        facets.push(unavailable(
196            EvaluationScope::new(EvaluationScopeCode::custom(BUDGET_SCOPE)),
197            static_basis(),
198            PredictionUnavailableReasonV2::FacetBudgetExceeded,
199        ));
200    }
201    let evaluated_scopes = facets
202        .iter()
203        .filter(|facet| facet.result().is_some())
204        .map(|facet| facet.scope().clone())
205        .collect();
206    let inner = EnginePredictionV4::new(provenance.base().identity().clone(), facets)
207        .expect("bounded track-support facets satisfy V4");
208    let prediction = EnginePredictionV5::new(provenance, inner)
209        .expect("track-support V5 prediction binds its V5 provenance");
210    CheckOutput::from_coverage(Vec::new(), evaluated_scopes, Vec::new())
211        .with_engine_prediction_v5(prediction)
212}
213
214fn subject_facet(
215    scope: EvaluationScope,
216    basis: EnginePredictionBasisV4,
217    subject_kind: SourceImportSubjectKindV1,
218    controlling_gate: Option<EngineSettingIdV2>,
219) -> EnginePredictionFacetV4 {
220    match controlling_gate {
221        Some(gate) => EnginePredictionFacetV4::available(
222            scope,
223            basis,
224            EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
225                subject_kind,
226                disposition: SourceImportDispositionV1::Dropped,
227                controlling_gate: Some(gate),
228            }),
229        )
230        .expect("negative Bevy gate result is valid"),
231        None => unavailable(
232            scope,
233            basis,
234            PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
235        ),
236    }
237}
238
239fn unavailable(
240    scope: EvaluationScope,
241    basis: EnginePredictionBasisV4,
242    reason: PredictionUnavailableReasonV2,
243) -> EnginePredictionFacetV4 {
244    EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
245        .expect("track-support unavailable result is valid")
246}
247
248fn gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
249    let settings = provenance.base().settings();
250    if matches!(
251        settings
252            .document_setting(EngineSettingIdV2::BevyAnimationFeature)
253            .map(|row| row.value()),
254        Some(EngineSettingValueV2::Boolean(false))
255    ) {
256        Some(EngineSettingIdV2::BevyAnimationFeature)
257    } else if matches!(
258        settings
259            .document_setting(EngineSettingIdV2::LoadAnimations)
260            .map(|row| row.value()),
261        Some(EngineSettingValueV2::Boolean(false))
262    ) {
263        Some(EngineSettingIdV2::LoadAnimations)
264    } else {
265        None
266    }
267}
268
269fn static_basis() -> EnginePredictionBasisV4 {
270    let mut references = vec![
271        profile_fact_reference(),
272        primary_source_reference(BEVY_LOADER_SOURCE),
273        primary_source_reference(BEVY_MANIFEST_SOURCE),
274    ];
275    references.push(setting_reference(SettingIdV2::BevyAnimationFeature));
276    references.push(setting_reference(SettingIdV2::LoadAnimations));
277    EnginePredictionBasisV4::new(references).expect("static track-support basis is valid")
278}
279
280fn inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
281    let mut references = static_basis().references().to_vec();
282    references.push(project_reference(
283        "raw_animation_channel_inventory.animation_coverage",
284        PredictionScalarV1::text(inventory_coverage_name(inventory))
285            .expect("static coverage token"),
286    ));
287    references.push(project_reference(
288        "raw_animation_channel_inventory.source_coverage_complete",
289        PredictionScalarV1::Boolean {
290            value: inventory.source_coverage_complete(),
291        },
292    ));
293    if let Some(row) = inventory.rows().iter().find(|row| {
294        row.channel_coverage().is_some_and(|coverage| {
295            coverage.state() != animsmith_core::RawSourceSetCoverageStateV1::Complete
296        })
297    }) {
298        let coverage = row.channel_coverage().expect("matched animation row");
299        references.push(project_reference(
300            "raw_animation_channel_inventory.incomplete_channel_animation_row",
301            PredictionScalarV1::UnsignedInteger {
302                value: row.source_animation_index(),
303            },
304        ));
305        references.push(project_reference(
306            "raw_animation_channel_inventory.incomplete_channel_coverage",
307            PredictionScalarV1::text(coverage_state_name(coverage.state()))
308                .expect("static coverage token"),
309        ));
310        if let Some(reason) = coverage.reason() {
311            let reason = serde_json::to_value(reason).expect("coverage reason serializes");
312            references.push(project_reference(
313                "raw_animation_channel_inventory.incomplete_channel_reason",
314                PredictionScalarV1::text(reason.as_str().expect("coverage reason is a token"))
315                    .expect("static reason token"),
316            ));
317        }
318    }
319    EnginePredictionBasisV4::new(references).expect("inventory basis is valid")
320}
321
322fn row_basis(
323    inventory: &RawAnimationChannelInventoryV1,
324    animation: u64,
325    channel: Option<u64>,
326) -> EnginePredictionBasisV4 {
327    let mut references = inventory_basis(inventory).references().to_vec();
328    references.push(project_reference(
329        "raw_animation_channel_inventory.animation_row",
330        PredictionScalarV1::UnsignedInteger { value: animation },
331    ));
332    if let Some(channel) = channel {
333        references.push(project_reference(
334            "raw_animation_channel_inventory.channel_row",
335            PredictionScalarV1::UnsignedInteger { value: channel },
336        ));
337    }
338    EnginePredictionBasisV4::new(references).expect("row basis is valid")
339}
340
341fn project_reference(field: &'static str, value: PredictionScalarV1) -> PredictionBasisReferenceV4 {
342    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
343        PredictionBasisReferenceV1::project_field(field, value).expect("static inventory field id"),
344    ))
345}
346
347fn profile_fact_reference() -> PredictionBasisReferenceV4 {
348    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
349        PredictionBasisReferenceV1::profile_fact("source_import_disposition")
350            .expect("static fact id"),
351    ))
352}
353
354fn primary_source_reference(source: &'static str) -> PredictionBasisReferenceV4 {
355    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
356        PredictionBasisReferenceV1::primary_source(source).expect("static source id"),
357    ))
358}
359
360fn setting_reference(setting: SettingIdV2) -> PredictionBasisReferenceV4 {
361    PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
362        PredictionBasisReferenceV1::resolved_setting(
363            ResolvedSettingLocationV1::Document,
364            setting.as_str(),
365        )
366        .expect("static setting id"),
367    ))
368}
369
370fn animation_scope(animation: u64) -> EvaluationScope {
371    EvaluationScope::new(EvaluationScopeCode::custom(ANIMATION_SCOPE))
372        .subject(format!("source_animation:{animation}"))
373}
374
375fn channel_scope(animation: u64, channel: u64) -> EvaluationScope {
376    EvaluationScope::new(EvaluationScopeCode::custom(CHANNEL_SCOPE)).subject(format!(
377        "source_animation:{animation}:source_channel:{channel}"
378    ))
379}
380
381fn inventory_coverage_name(inventory: &RawAnimationChannelInventoryV1) -> &'static str {
382    coverage_state_name(inventory.animation_coverage().state())
383}
384
385fn coverage_state_name(state: animsmith_core::RawSourceSetCoverageStateV1) -> &'static str {
386    match state {
387        animsmith_core::RawSourceSetCoverageStateV1::Complete => "complete",
388        animsmith_core::RawSourceSetCoverageStateV1::Partial => "partial",
389        animsmith_core::RawSourceSetCoverageStateV1::Unavailable => "unavailable",
390    }
391}
392
393fn is_bevy_tuple(provenance: &PredictionProvenanceV5) -> bool {
394    let selection = provenance.base().profile().selection();
395    selection.family() == BEVY_FAMILY
396        && selection.profile_revision() == BEVY_PROFILE_REVISION
397        && selection.engine_version() == BEVY_ENGINE_VERSION
398        && selection.importer() == BEVY_IMPORTER
399}
400
401fn is_exact_bevy_gltf(source: &LoadedSource, provenance: &PredictionProvenanceV5) -> bool {
402    matches!(
403        source.source_facts().format(),
404        SourceFormatV1::GltfJson | SourceFormatV1::Glb
405    ) && source.source_facts().format() == provenance.base().source_format()
406        && is_bevy_tuple(provenance)
407        && matches_frozen_registry_projection_v2(provenance.base().profile())
408}
409
410fn empty_output() -> CheckOutput {
411    CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
412}