Skip to main content

animsmith_engine/
track_support.rs

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