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    EnginePredictionBasisV2, EnginePredictionFacetV1, EnginePredictionFacetV2,
12    EnginePredictionFacetV3, EnginePredictionV1, EnginePredictionV2, EnginePredictionV3,
13    EvaluationScope, EvaluationScopeCode, LoadedSource, PredictionBasisReferenceV1,
14    PredictionBasisReferenceV2, PredictionFacetDemandV2, PredictionProvenanceV1,
15    PredictionProvenanceV2, PredictionProvenanceV3, PredictionRuleAllocationV2,
16    PredictionUnavailableReasonV1, RAW_SOURCE_V1_MAX_CLIPS, RawSourceBasisReferenceV1,
17    RawSourceBindingV1, RawSourceBindingV2, RawSourceDomainV1, RawSourceFieldIdV1, RawSourceKeyV1,
18    SourceFormatV1, SourceSetCoverageStateV1, evaluate_checks,
19};
20
21/// A Bevy animation asset-label index outside the bounded raw-source
22/// animation inventory.
23#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
24#[error("source animation index {source_clip_index} is outside the V1 limit of {limit} animations")]
25pub struct BevyAnimationAssetLabelError {
26    /// Supplied source animation index.
27    pub source_clip_index: usize,
28    /// Exclusive upper bound for V1 source animation indices.
29    pub limit: usize,
30}
31
32/// Typed failure while constructing the optional exact Bevy adapter for a
33/// glTF animation-addressability document.
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35#[non_exhaustive]
36pub enum GltfAnimationAddressabilityAdapterError {
37    /// Same-load source binding or frozen-profile validation failed.
38    #[error(transparent)]
39    PredictionRule(#[from] PredictionRuleError),
40    /// The existing engine-addressability check could not form a valid
41    /// [`animsmith_core::CheckEvaluation`].
42    #[error(transparent)]
43    Evaluation(#[from] animsmith_core::EvaluationError),
44    /// The standalone contract rejected inconsistent inventory, provenance,
45    /// or check evidence.
46    #[error(transparent)]
47    Contract(#[from] crate::GltfAnimationAddressabilityError),
48}
49
50/// Stable check id for engine scene, animation, target, and runtime-label addressability.
51pub const ENGINE_ADDRESSABILITY_CHECK_ID: &str = "engine-addressability";
52
53/// Versioned engine-owned check ids callers may use for pre-I/O selection validation.
54pub const ENGINE_CHECK_IDS_V1: &[&str] = &[ENGINE_ADDRESSABILITY_CHECK_ID];
55
56/// Current engine-owned check ids in immutable catalog order.
57///
58/// The V1 catalog remains unchanged for historical standalone addressability
59/// artifacts; current lint may additionally register the V3-only exact source
60/// boundary rule.
61pub const ENGINE_CHECK_IDS_V2: &[&str] = &[
62    ENGINE_ADDRESSABILITY_CHECK_ID,
63    crate::clip_boundary::ENGINE_CLIP_BOUNDARY_CHECK_ID,
64    crate::unit_scale::ENGINE_UNIT_SCALE_CHECK_ID,
65    crate::track_support::ENGINE_TRACK_SUPPORT_CHECK_ID,
66    crate::root_motion::ENGINE_ROOT_MOTION_CHECK_ID,
67];
68
69const BEVY_FAMILY: &str = "bevy";
70const BEVY_PROFILE_REVISION: u32 = 1;
71const BEVY_ENGINE_VERSION: &str = "0.19.0";
72const BEVY_IMPORTER: &str = "gltf-asset-loader";
73const BEVY_ANIMATION_LABEL_SOURCE: &str = "bevy-gltf-asset-label-0.19.0";
74
75/// One exact Bevy 0.19.0 `GltfAssetLabel::Animation(index)` display selector.
76///
77/// The source animation index is authoritative. Source names are optional,
78/// non-unique metadata and never affect this value. Construction enforces the
79/// same 4,096-row ceiling as raw-source facts, so the retained selector text is
80/// bounded to `Animation0` through `Animation4095`.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BevyAnimationAssetLabelV1 {
83    source_clip_index: usize,
84    display_selector: String,
85}
86
87impl BevyAnimationAssetLabelV1 {
88    /// Construct the indexed selector for one retained source animation.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`BevyAnimationAssetLabelError`] when the index cannot occur in
93    /// the bounded V1 raw-source animation inventory.
94    pub fn new(source_clip_index: usize) -> Result<Self, BevyAnimationAssetLabelError> {
95        if source_clip_index >= RAW_SOURCE_V1_MAX_CLIPS {
96            return Err(BevyAnimationAssetLabelError {
97                source_clip_index,
98                limit: RAW_SOURCE_V1_MAX_CLIPS,
99            });
100        }
101        Ok(Self {
102            source_clip_index,
103            display_selector: format!("Animation{source_clip_index}"),
104        })
105    }
106
107    /// Exact source animation index carried by this selector.
108    pub const fn source_clip_index(&self) -> usize {
109        self.source_clip_index
110    }
111
112    /// Exact Bevy display selector, such as `Animation0`.
113    pub fn as_str(&self) -> &str {
114        &self.display_selector
115    }
116}
117
118/// The engine addressability check over borrowed same-load source evidence.
119///
120/// When provenance is absent or is not exactly the frozen Bevy 0.19.0 glTF profile, the check
121/// records a stable not-applicable evaluation. Construction performs bounded in-memory
122/// validation against the immutable source sidecars but no filesystem or parser work.
123pub struct EngineAddressabilityCheck<'a> {
124    source: &'a LoadedSource,
125    provenance: Option<&'a PredictionProvenanceV1>,
126}
127
128/// Current-lint V2 engine addressability check. Standalone addressability
129/// artifacts keep using [`EngineAddressabilityCheck`] and immutable V1 wire
130/// evidence.
131pub struct EngineAddressabilityCheckV2<'a> {
132    source: &'a LoadedSource,
133    provenance: Option<&'a PredictionProvenanceV2>,
134}
135
136/// Current-output V3 engine addressability check.
137///
138/// This preserves the V2 rule and allocation behavior while lifting its basis
139/// references and prediction attachment into the exact-source-capable V3
140/// contracts required by current lint output.
141pub struct EngineAddressabilityCheckV3<'a> {
142    source: &'a LoadedSource,
143    provenance: Option<&'a PredictionProvenanceV3>,
144}
145
146impl<'a> EngineAddressabilityCheckV3<'a> {
147    /// Bind current V3 provenance to same-load source evidence.
148    pub fn new(
149        source: &'a LoadedSource,
150        provenance: Option<&'a PredictionProvenanceV3>,
151    ) -> Result<Self, PredictionRuleError> {
152        if let Some(provenance) = provenance {
153            provenance
154                .validate()
155                .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
156            let raw_source = RawSourceBindingV2::from_source(
157                source.source_facts(),
158                source.exact_source_timing(),
159            )
160            .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
161            if &raw_source != provenance.raw_source()
162                || source.dependency_closure() != provenance.dependency_closure()
163            {
164                return Err(PredictionRuleError::SourceProvenanceMismatch);
165            }
166            let selection = provenance.profile().selection();
167            if selection.family() == BEVY_FAMILY
168                && selection.profile_revision() == BEVY_PROFILE_REVISION
169                && selection.engine_version() == BEVY_ENGINE_VERSION
170                && selection.importer() == BEVY_IMPORTER
171                && !profiles_v1().iter().any(|profile| {
172                    let selection = profile.selection();
173                    selection.family() == BEVY_FAMILY
174                        && selection.profile_revision() == BEVY_PROFILE_REVISION
175                        && selection.engine_version() == BEVY_ENGINE_VERSION
176                        && selection.importer() == BEVY_IMPORTER
177                        && profile.facts_identity() == provenance.profile().facts_identity()
178                })
179            {
180                return Err(PredictionRuleError::FrozenProfileMismatch);
181            }
182        }
183        Ok(Self { source, provenance })
184    }
185}
186
187impl Check for EngineAddressabilityCheckV3<'_> {
188    fn id(&self) -> &'static str {
189        ENGINE_ADDRESSABILITY_CHECK_ID
190    }
191
192    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
193        match self.provenance {
194            Some(provenance)
195                if is_bevy_gltf_v3(self.source, provenance)
196                    && !facts_are_complete_and_empty(self.source) =>
197            {
198                Applicability::Applicable
199            }
200            _ => Applicability::NotApplicable,
201        }
202    }
203
204    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
205        let Some(provenance) = self.provenance else {
206            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
207        };
208        if !is_bevy_gltf_v3(self.source, provenance) || facts_are_complete_and_empty(self.source) {
209            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
210        }
211        evaluate_animation_asset_labels_v3(self.source, provenance)
212    }
213
214    fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
215        match self.provenance {
216            Some(provenance)
217                if is_bevy_gltf_v3(self.source, provenance)
218                    && !facts_are_complete_and_empty(self.source) =>
219            {
220                if self.source.source_facts().clips().coverage().state()
221                    != SourceSetCoverageStateV1::Complete
222                    || matches!(
223                        provenance.settings().clip_coverage().state(),
224                        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
225                    )
226                {
227                    PredictionFacetDemandV2::Exact(1)
228                } else {
229                    PredictionFacetDemandV2::Exact(self.source.source_facts().clips().rows().len())
230                }
231            }
232            _ => PredictionFacetDemandV2::Exact(0),
233        }
234    }
235
236    fn evaluate_with_prediction_allocation_v2(
237        &self,
238        _ctx: &CheckCtx<'_>,
239        allocation: PredictionRuleAllocationV2<'_>,
240    ) -> CheckOutput {
241        let Some(provenance) = self.provenance else {
242            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
243        };
244        if !is_bevy_gltf_v3(self.source, provenance) || facts_are_complete_and_empty(self.source) {
245            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
246        }
247        evaluate_animation_asset_labels_v3_allocated(self.source, provenance, allocation)
248    }
249}
250
251impl<'a> EngineAddressabilityCheckV2<'a> {
252    /// Bind current-lint V2 provenance to same-load source evidence.
253    pub fn new(
254        source: &'a LoadedSource,
255        provenance: Option<&'a PredictionProvenanceV2>,
256    ) -> Result<Self, PredictionRuleError> {
257        if let Some(provenance) = provenance {
258            provenance
259                .validate()
260                .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
261            let raw_source = RawSourceBindingV1::from_source(source.source_facts());
262            if &raw_source != provenance.raw_source()
263                || source.dependency_closure() != provenance.dependency_closure()
264            {
265                return Err(PredictionRuleError::SourceProvenanceMismatch);
266            }
267            let selection = provenance.profile().selection();
268            if selection.family() == BEVY_FAMILY
269                && selection.profile_revision() == BEVY_PROFILE_REVISION
270                && selection.engine_version() == BEVY_ENGINE_VERSION
271                && selection.importer() == BEVY_IMPORTER
272                && !profiles_v1().iter().any(|profile| {
273                    let selection = profile.selection();
274                    selection.family() == BEVY_FAMILY
275                        && selection.profile_revision() == BEVY_PROFILE_REVISION
276                        && selection.engine_version() == BEVY_ENGINE_VERSION
277                        && selection.importer() == BEVY_IMPORTER
278                        && profile.facts_identity() == provenance.profile().facts_identity()
279                })
280            {
281                return Err(PredictionRuleError::FrozenProfileMismatch);
282            }
283        }
284        Ok(Self { source, provenance })
285    }
286}
287
288impl Check for EngineAddressabilityCheckV2<'_> {
289    fn id(&self) -> &'static str {
290        ENGINE_ADDRESSABILITY_CHECK_ID
291    }
292
293    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
294        let Some(provenance) = self.provenance else {
295            return Applicability::NotApplicable;
296        };
297        if !is_bevy_gltf_v2(self.source, provenance) || facts_are_complete_and_empty(self.source) {
298            Applicability::NotApplicable
299        } else {
300            Applicability::Applicable
301        }
302    }
303
304    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
305        let Some(provenance) = self.provenance else {
306            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
307        };
308        if !is_bevy_gltf_v2(self.source, provenance) || facts_are_complete_and_empty(self.source) {
309            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
310        }
311        evaluate_animation_asset_labels_v2(self.source, provenance)
312    }
313
314    fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
315        match self.provenance {
316            Some(provenance)
317                if is_bevy_gltf_v2(self.source, provenance)
318                    && !facts_are_complete_and_empty(self.source) =>
319            {
320                // An incomplete raw/settings inventory produces one
321                // unavailable inventory facet, not one facet per retained
322                // row.  Its pre-evaluation reservation must match that
323                // actual candidate work.
324                if self.source.source_facts().clips().coverage().state()
325                    != SourceSetCoverageStateV1::Complete
326                    || matches!(
327                        provenance.settings().clip_coverage().state(),
328                        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
329                    )
330                {
331                    PredictionFacetDemandV2::Exact(1)
332                } else {
333                    PredictionFacetDemandV2::Exact(self.source.source_facts().clips().rows().len())
334                }
335            }
336            _ => PredictionFacetDemandV2::Exact(0),
337        }
338    }
339
340    fn evaluate_with_prediction_allocation_v2(
341        &self,
342        _ctx: &CheckCtx<'_>,
343        allocation: PredictionRuleAllocationV2<'_>,
344    ) -> CheckOutput {
345        let Some(provenance) = self.provenance else {
346            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
347        };
348        if !is_bevy_gltf_v2(self.source, provenance) || facts_are_complete_and_empty(self.source) {
349            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
350        }
351        evaluate_animation_asset_labels_v2_allocated(self.source, provenance, allocation)
352    }
353}
354
355impl<'a> EngineAddressabilityCheck<'a> {
356    /// Bind one source and optional immutable prediction provenance.
357    ///
358    /// # Errors
359    ///
360    /// Returns [`PredictionRuleError::SourceProvenanceMismatch`] when the
361    /// provenance was not projected from the supplied same-load source facts
362    /// and dependency closure, or [`PredictionRuleError::FrozenProfileMismatch`]
363    /// when an exact Bevy tuple does not carry the frozen registry facts.
364    pub fn new(
365        source: &'a LoadedSource,
366        provenance: Option<&'a PredictionProvenanceV1>,
367    ) -> Result<Self, PredictionRuleError> {
368        if let Some(provenance) = provenance {
369            let raw_source = RawSourceBindingV1::from_source(source.source_facts());
370            if &raw_source != provenance.raw_source()
371                || source.dependency_closure() != provenance.dependency_closure()
372            {
373                return Err(PredictionRuleError::SourceProvenanceMismatch);
374            }
375            if is_bevy_tuple(provenance)
376                && !profiles_v1().iter().any(|profile| {
377                    let selection = profile.selection();
378                    selection.family() == BEVY_FAMILY
379                        && selection.profile_revision() == BEVY_PROFILE_REVISION
380                        && selection.engine_version() == BEVY_ENGINE_VERSION
381                        && selection.importer() == BEVY_IMPORTER
382                        && profile.facts_identity() == provenance.profile().facts_identity()
383                })
384            {
385                return Err(PredictionRuleError::FrozenProfileMismatch);
386            }
387        }
388        Ok(Self { source, provenance })
389    }
390}
391
392impl Check for EngineAddressabilityCheck<'_> {
393    fn id(&self) -> &'static str {
394        ENGINE_ADDRESSABILITY_CHECK_ID
395    }
396
397    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
398        borrowed_applicability(self.source, self.provenance)
399    }
400
401    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
402        let Some(provenance) = self.provenance else {
403            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
404        };
405        if !is_bevy_gltf(self.source, provenance) || facts_are_complete_and_empty(self.source) {
406            return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
407        }
408        evaluate_animation_asset_labels(self.source, provenance)
409    }
410}
411
412/// Build the optional exact Bevy 0.19.0 adapter for one addressability document.
413///
414/// This is the single adapter assembly path: it binds the existing
415/// [`EngineAddressabilityCheck`] to the supplied same-load source and
416/// provenance, sends that one check through [`evaluate_checks`] exactly once,
417/// then lets the standalone contract validate the unchanged resulting evaluation
418/// against the neutral inventory. No-profile and valid non-Bevy provenance
419/// produce `None` without adding another check or prediction lifecycle.
420///
421/// # Errors
422///
423/// Returns [`GltfAnimationAddressabilityAdapterError`] when same-load or
424/// frozen-profile validation fails, the existing check cannot produce a valid
425/// evaluation, or the standalone contract rejects inconsistent evidence.
426pub fn build_bevy_animation_addressability_adapter_v1(
427    source: &LoadedSource,
428    inventory: &GltfAnimationAddressabilityInventoryV1,
429    prediction_provenance: Option<PredictionProvenanceV1>,
430    ctx: &CheckCtx<'_>,
431) -> Result<Option<GltfAnimationAddressabilityBevyAdapterV1>, GltfAnimationAddressabilityAdapterError>
432{
433    let Some(prediction_provenance) = prediction_provenance else {
434        return Ok(None);
435    };
436    let check = EngineAddressabilityCheck::new(source, Some(&prediction_provenance))?;
437    if !is_bevy_gltf(source, &prediction_provenance) {
438        return Ok(None);
439    }
440
441    let evaluation = {
442        let checks: [Box<dyn Check + '_>; 1] = [Box::new(check)];
443        let mut evaluations = evaluate_checks(ctx, &checks, CheckSelection::All)?;
444        let evaluation = evaluations
445            .pop()
446            .expect("one valid static check catalog produces one evaluation");
447        debug_assert!(evaluations.is_empty());
448        evaluation
449    };
450    Ok(Some(GltfAnimationAddressabilityBevyAdapterV1::new(
451        prediction_provenance,
452        evaluation,
453        inventory,
454    )?))
455}
456
457fn borrowed_applicability(
458    source: &LoadedSource,
459    provenance: Option<&PredictionProvenanceV1>,
460) -> Applicability {
461    let Some(provenance) = provenance else {
462        return Applicability::NotApplicable;
463    };
464    if !is_bevy_gltf(source, provenance) {
465        return Applicability::NotApplicable;
466    }
467    if facts_are_complete_and_empty(source) {
468        Applicability::NotApplicable
469    } else {
470        Applicability::Applicable
471    }
472}
473
474fn facts_are_complete_and_empty(source: &LoadedSource) -> bool {
475    let clips = source.source_facts().clips();
476    clips.coverage().state() == SourceSetCoverageStateV1::Complete && clips.rows().is_empty()
477}
478
479fn is_bevy_gltf(source: &LoadedSource, provenance: &PredictionProvenanceV1) -> bool {
480    let facts = source.source_facts();
481    facts.format() == provenance.source_format()
482        && is_bevy_tuple(provenance)
483        && matches!(
484            provenance.source_format(),
485            SourceFormatV1::GltfJson | SourceFormatV1::Glb
486        )
487        && matches!(
488            provenance
489                .profile()
490                .fact(EngineFactIdV1::AnimationAddressability)
491                .map(|fact| fact.state()),
492            Some(EngineFactStateV1::Known(
493                EngineFactValueV1::AnimationAddressability(
494                    EngineAnimationAddressabilityV1::GltfAssetLabel
495                )
496            ))
497        )
498        && provenance
499            .profile()
500            .source(BEVY_ANIMATION_LABEL_SOURCE)
501            .is_some()
502}
503
504fn is_bevy_gltf_v2(source: &LoadedSource, provenance: &PredictionProvenanceV2) -> bool {
505    let selection = provenance.profile().selection();
506    source.source_facts().format() == provenance.source_format()
507        && selection.family() == BEVY_FAMILY
508        && selection.profile_revision() == BEVY_PROFILE_REVISION
509        && selection.engine_version() == BEVY_ENGINE_VERSION
510        && selection.importer() == BEVY_IMPORTER
511        && matches!(
512            provenance
513                .profile()
514                .fact(EngineFactIdV1::AnimationAddressability)
515                .map(|fact| fact.state()),
516            Some(EngineFactStateV1::Known(
517                EngineFactValueV1::AnimationAddressability(
518                    EngineAnimationAddressabilityV1::GltfAssetLabel
519                )
520            ))
521        )
522        && provenance
523            .profile()
524            .source(BEVY_ANIMATION_LABEL_SOURCE)
525            .is_some()
526}
527
528fn is_bevy_gltf_v3(source: &LoadedSource, provenance: &PredictionProvenanceV3) -> bool {
529    let selection = provenance.profile().selection();
530    source.source_facts().format() == provenance.source_format()
531        && selection.family() == BEVY_FAMILY
532        && selection.profile_revision() == BEVY_PROFILE_REVISION
533        && selection.engine_version() == BEVY_ENGINE_VERSION
534        && selection.importer() == BEVY_IMPORTER
535        && matches!(
536            provenance
537                .profile()
538                .fact(EngineFactIdV1::AnimationAddressability)
539                .map(|fact| fact.state()),
540            Some(EngineFactStateV1::Known(
541                EngineFactValueV1::AnimationAddressability(
542                    EngineAnimationAddressabilityV1::GltfAssetLabel
543                )
544            ))
545        )
546        && provenance
547            .profile()
548            .source(BEVY_ANIMATION_LABEL_SOURCE)
549            .is_some()
550}
551
552fn is_bevy_tuple(provenance: &PredictionProvenanceV1) -> bool {
553    let selection = provenance.profile().selection();
554    selection.family() == BEVY_FAMILY
555        && selection.profile_revision() == BEVY_PROFILE_REVISION
556        && selection.engine_version() == BEVY_ENGINE_VERSION
557        && selection.importer() == BEVY_IMPORTER
558}
559
560fn evaluate_animation_asset_labels(
561    source: &LoadedSource,
562    provenance: &PredictionProvenanceV1,
563) -> CheckOutput {
564    let facts = source.source_facts();
565    let inventory_scope =
566        EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY);
567    if facts.format() != provenance.source_format()
568        || facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete
569    {
570        return unavailable_inventory(provenance, inventory_scope);
571    }
572
573    let mut scopes = Vec::with_capacity(facts.clips().rows().len());
574    let mut facets = Vec::with_capacity(facts.clips().rows().len());
575    for clip in facts.clips().rows() {
576        let source_index = clip.source_clip_index();
577        let label = BevyAnimationAssetLabelV1::new(source_index)
578            .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
579        let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
580            .subject(label.as_str().to_owned());
581        let source_name = RawSourceBasisReferenceV1::from_source(
582            RawSourceDomainV1::Clip,
583            RawSourceKeyV1::Clip {
584                source_clip_index: source_index as u64,
585            },
586            RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
587            facts,
588        )
589        .expect("loader-valid retained clip rows must resolve their own raw-source witness");
590        let basis = EnginePredictionBasisV1::new(vec![
591            PredictionBasisReferenceV1::profile_fact("animation_addressability")
592                .expect("static fact id is valid"),
593            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
594                .expect("static primary-source id is valid"),
595            PredictionBasisReferenceV1::raw_source(source_name),
596        ])
597        .expect("three distinct static basis references are valid");
598        let facet = EnginePredictionFacetV1::available(scope.clone(), basis)
599            .expect("complete source rows have unique bounded scopes");
600        scopes.push(scope);
601        facets.push(facet);
602    }
603
604    if facets.is_empty() {
605        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
606    }
607    let prediction = EnginePredictionV1::new(provenance.identity().clone(), facets)
608        .expect("complete bounded source rows form one valid canonical prediction");
609    CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction(prediction)
610}
611
612fn unavailable_inventory(
613    provenance: &PredictionProvenanceV1,
614    scope: EvaluationScope,
615) -> CheckOutput {
616    let basis = EnginePredictionBasisV1::new(vec![
617        PredictionBasisReferenceV1::profile_fact("animation_addressability")
618            .expect("static fact id is valid"),
619        PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
620            .expect("static primary-source id is valid"),
621    ])
622    .expect("static basis is nonempty and canonical");
623    let facet = EnginePredictionFacetV1::required_unavailable(
624        scope,
625        basis,
626        vec![PredictionUnavailableReasonV1::RawSourceIncomplete],
627    )
628    .expect("static unavailable facet is valid");
629    let prediction = EnginePredictionV1::new(provenance.identity().clone(), vec![facet])
630        .expect("one static facet is valid");
631    CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
632        .with_engine_prediction(prediction)
633}
634
635fn evaluate_animation_asset_labels_v2(
636    source: &LoadedSource,
637    provenance: &PredictionProvenanceV2,
638) -> CheckOutput {
639    let facts = source.source_facts();
640    let inventory_scope =
641        EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY);
642    let settings_overflow = matches!(
643        provenance.settings().clip_coverage().state(),
644        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
645    );
646    if facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete || settings_overflow {
647        let basis = EnginePredictionBasisV1::new(vec![
648            PredictionBasisReferenceV1::profile_fact("animation_addressability")
649                .expect("static fact id is valid"),
650            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
651                .expect("static primary-source id is valid"),
652        ])
653        .expect("static basis is canonical");
654        let mut reasons = Vec::new();
655        if facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete {
656            reasons.push(animsmith_core::PredictionUnavailableReasonV2::RawSourceIncomplete);
657        }
658        if settings_overflow {
659            reasons.push(animsmith_core::PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
660        }
661        let facet = EnginePredictionFacetV2::required_unavailable(inventory_scope, basis, reasons)
662            .expect("static unavailable V2 facet is valid");
663        let prediction = EnginePredictionV2::new(provenance.identity().clone(), vec![facet])
664            .expect("one static V2 facet is valid");
665        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
666            .with_engine_prediction_v2(prediction);
667    }
668
669    let mut scopes = Vec::with_capacity(facts.clips().rows().len());
670    let mut facets = Vec::with_capacity(facts.clips().rows().len());
671    for clip in facts.clips().rows() {
672        let source_index = clip.source_clip_index();
673        let label = BevyAnimationAssetLabelV1::new(source_index)
674            .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
675        let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
676            .subject(label.as_str().to_owned());
677        let source_name = RawSourceBasisReferenceV1::from_source(
678            RawSourceDomainV1::Clip,
679            RawSourceKeyV1::Clip {
680                source_clip_index: source_index as u64,
681            },
682            RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
683            facts,
684        )
685        .expect("retained clip rows resolve their raw-source witness");
686        let basis = EnginePredictionBasisV1::new(vec![
687            PredictionBasisReferenceV1::profile_fact("animation_addressability")
688                .expect("static fact id is valid"),
689            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
690                .expect("static primary-source id is valid"),
691            PredictionBasisReferenceV1::raw_source(source_name),
692        ])
693        .expect("three static basis references are valid");
694        facets.push(
695            EnginePredictionFacetV2::available(scope.clone(), basis)
696                .expect("complete rows form V2 facets"),
697        );
698        scopes.push(scope);
699    }
700    if facets.is_empty() {
701        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
702    }
703    let prediction = EnginePredictionV2::new(provenance.identity().clone(), facets)
704        .expect("complete bounded source rows form V2 prediction");
705    CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction_v2(prediction)
706}
707
708fn evaluate_animation_asset_labels_v2_allocated(
709    source: &LoadedSource,
710    provenance: &PredictionProvenanceV2,
711    allocation: PredictionRuleAllocationV2<'_>,
712) -> CheckOutput {
713    let facts = source.source_facts();
714    let settings_partial = matches!(
715        provenance.settings().clip_coverage().state(),
716        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
717    );
718    let raw_partial = facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete;
719    let mut scopes = Vec::with_capacity(allocation.candidate_capacity());
720    let mut facets = Vec::with_capacity(
721        allocation.candidate_capacity() + usize::from(allocation.summary_required()),
722    );
723
724    if raw_partial || settings_partial {
725        // The incomplete-inventory representation has exactly one candidate.
726        // When its capacity is zero, do not construct it only to discard it.
727        if allocation.candidate_capacity() != 0 {
728            let mut reasons = Vec::new();
729            if raw_partial {
730                reasons.push(animsmith_core::PredictionUnavailableReasonV2::RawSourceIncomplete);
731            }
732            if settings_partial {
733                reasons
734                    .push(animsmith_core::PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
735            }
736            let basis = EnginePredictionBasisV1::new(vec![
737                PredictionBasisReferenceV1::profile_fact("animation_addressability")
738                    .expect("static fact"),
739                PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
740                    .expect("static source"),
741            ])
742            .expect("static basis");
743            facets.push(
744                EnginePredictionFacetV2::required_unavailable(
745                    EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
746                    basis,
747                    reasons,
748                )
749                .expect("static unavailable V2 facet"),
750            );
751        }
752    } else {
753        // Construct only the catalog-allocated prefix.  In particular, do not
754        // form raw-source basis references/facets for omitted candidates.
755        for clip in facts
756            .clips()
757            .rows()
758            .iter()
759            .take(allocation.candidate_capacity())
760        {
761            let source_index = clip.source_clip_index();
762            let label = BevyAnimationAssetLabelV1::new(source_index)
763                .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
764            let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
765                .subject(label.as_str().to_owned());
766            let source_name = RawSourceBasisReferenceV1::from_source(
767                RawSourceDomainV1::Clip,
768                RawSourceKeyV1::Clip {
769                    source_clip_index: source_index as u64,
770                },
771                RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
772                facts,
773            )
774            .expect("retained clip rows resolve their raw-source witness");
775            let basis = EnginePredictionBasisV1::new(vec![
776                PredictionBasisReferenceV1::profile_fact("animation_addressability")
777                    .expect("static fact id is valid"),
778                PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
779                    .expect("static primary-source id is valid"),
780                PredictionBasisReferenceV1::raw_source(source_name),
781            ])
782            .expect("three static basis references are valid");
783            facets.push(
784                EnginePredictionFacetV2::available(scope.clone(), basis)
785                    .expect("complete rows form V2 facets"),
786            );
787            scopes.push(scope);
788        }
789    }
790
791    if allocation.summary_required() {
792        let basis = EnginePredictionBasisV1::new(vec![
793            PredictionBasisReferenceV1::profile_fact("animation_addressability")
794                .expect("static fact"),
795            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
796                .expect("static source"),
797        ])
798        .expect("static basis");
799        facets.push(
800            EnginePredictionFacetV2::required_unavailable(
801                EvaluationScope::new(EvaluationScopeCode::custom(
802                    "engine-addressability:facet-budget",
803                )),
804                basis,
805                vec![animsmith_core::PredictionUnavailableReasonV2::FacetBudgetExceeded],
806            )
807            .expect("budget summary facet"),
808        );
809    }
810    if facets.is_empty() {
811        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
812    }
813    CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction_v2(
814        EnginePredictionV2::new(provenance.identity().clone(), facets)
815            .expect("allocated V2 facets"),
816    )
817}
818
819fn evaluate_animation_asset_labels_v3(
820    source: &LoadedSource,
821    provenance: &PredictionProvenanceV3,
822) -> CheckOutput {
823    let raw_partial =
824        source.source_facts().clips().coverage().state() != SourceSetCoverageStateV1::Complete;
825    let settings_partial = matches!(
826        provenance.settings().clip_coverage().state(),
827        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
828    );
829    let candidate_capacity = if raw_partial || settings_partial {
830        1
831    } else {
832        source.source_facts().clips().rows().len()
833    };
834    evaluate_animation_asset_labels_v3_with_limits(source, provenance, candidate_capacity, false)
835}
836
837fn evaluate_animation_asset_labels_v3_allocated(
838    source: &LoadedSource,
839    provenance: &PredictionProvenanceV3,
840    allocation: PredictionRuleAllocationV2<'_>,
841) -> CheckOutput {
842    evaluate_animation_asset_labels_v3_with_limits(
843        source,
844        provenance,
845        allocation.candidate_capacity(),
846        allocation.summary_required(),
847    )
848}
849
850fn evaluate_animation_asset_labels_v3_with_limits(
851    source: &LoadedSource,
852    provenance: &PredictionProvenanceV3,
853    candidate_capacity: usize,
854    summary_required: bool,
855) -> CheckOutput {
856    let facts = source.source_facts();
857    let settings_partial = matches!(
858        provenance.settings().clip_coverage().state(),
859        animsmith_core::ResolvedEngineSettingsCoverageStateV2::Partial
860    );
861    let raw_partial = facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete;
862    let mut scopes = Vec::with_capacity(candidate_capacity);
863    let mut facets = Vec::with_capacity(candidate_capacity + usize::from(summary_required));
864
865    if raw_partial || settings_partial {
866        if candidate_capacity != 0 {
867            let mut reasons = Vec::new();
868            if raw_partial {
869                reasons.push(animsmith_core::PredictionUnavailableReasonV2::RawSourceIncomplete);
870            }
871            if settings_partial {
872                reasons
873                    .push(animsmith_core::PredictionUnavailableReasonV2::ResolvedSettingsOverflow);
874            }
875            facets.push(
876                EnginePredictionFacetV3::required_unavailable(
877                    EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY),
878                    addressability_basis_v3(facts, None),
879                    reasons,
880                )
881                .expect("static unavailable V3 facet"),
882            );
883        }
884    } else {
885        for clip in facts.clips().rows().iter().take(candidate_capacity) {
886            let source_index = clip.source_clip_index();
887            let label = BevyAnimationAssetLabelV1::new(source_index)
888                .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
889            let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
890                .subject(label.as_str().to_owned());
891            facets.push(
892                EnginePredictionFacetV3::available(
893                    scope.clone(),
894                    addressability_basis_v3(facts, Some(source_index)),
895                )
896                .expect("complete rows form V3 facets"),
897            );
898            scopes.push(scope);
899        }
900    }
901
902    if summary_required {
903        facets.push(
904            EnginePredictionFacetV3::required_unavailable(
905                EvaluationScope::new(EvaluationScopeCode::custom(
906                    "engine-addressability:facet-budget",
907                )),
908                addressability_basis_v3(facts, None),
909                vec![animsmith_core::PredictionUnavailableReasonV2::FacetBudgetExceeded],
910            )
911            .expect("budget summary V3 facet"),
912        );
913    }
914    if facets.is_empty() {
915        return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
916    }
917    CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction_v3(
918        EnginePredictionV3::new(provenance.identity().clone(), facets)
919            .expect("allocated V3 facets"),
920    )
921}
922
923fn addressability_basis_v3(
924    facts: animsmith_core::SourceFactsViewV1<'_>,
925    source_index: Option<usize>,
926) -> EnginePredictionBasisV2 {
927    let mut references = vec![
928        PredictionBasisReferenceV2::v1(
929            PredictionBasisReferenceV1::profile_fact("animation_addressability")
930                .expect("static fact id is valid"),
931        ),
932        PredictionBasisReferenceV2::v1(
933            PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
934                .expect("static primary-source id is valid"),
935        ),
936    ];
937    if let Some(source_index) = source_index {
938        let source_name = RawSourceBasisReferenceV1::from_source(
939            RawSourceDomainV1::Clip,
940            RawSourceKeyV1::Clip {
941                source_clip_index: source_index as u64,
942            },
943            RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
944            facts,
945        )
946        .expect("retained clip rows resolve their raw-source witness");
947        references.push(PredictionBasisReferenceV2::v1(
948            PredictionBasisReferenceV1::raw_source(source_name),
949        ));
950    }
951    EnginePredictionBasisV2::new(references).expect("static addressability basis is canonical")
952}