Skip to main content

animsmith_engine/
clip_boundary.rs

1//! Exact Unreal FBX animation-stack boundary prediction.
2
3use crate::error::PredictionRuleError;
4use crate::registry::profiles_v1;
5use animsmith_core::{
6    Applicability, Check, CheckCtx, CheckOutput, EngineFactIdV1, EngineFactStateV1,
7    EngineFactValueV1, EnginePredictionBasisV2, EnginePredictionFacetV3, EnginePredictionV3,
8    EvaluationScope, EvaluationScopeCode, ExactSourceTimingBasisReferenceV1,
9    ExactSourceTimingDomainV1, ExactSourceTimingKeyV1, ExactSourceTimingObservationStateV1,
10    Finding, LoadedSource, PredictionBasisReferenceV1, PredictionBasisReferenceV2,
11    PredictionFacetDemandV2, PredictionProvenanceV3, PredictionRuleAllocationV2,
12    PredictionUnavailableReasonV2, RawSourceBindingV2, RawSourceFieldIdV1, Severity,
13    SourceFormatV1, SourceSetCoverageStateV1,
14};
15
16/// Stable id for the exact Unreal FBX animation-stack boundary check.
17pub const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
18
19const UNREAL_FAMILY: &str = "unreal";
20const UNREAL_PROFILE_REVISION: u32 = 1;
21const UNREAL_ENGINE_VERSION: &str = "5.8";
22const UNREAL_IMPORTER: &str = "fbx-importer";
23const UNREAL_BOUNDARY_SOURCE: &str = "unreal-animation-sequences-5.8";
24const FACET_LIMIT: usize = animsmith_core::PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE;
25
26/// Engine-owned Unreal 5.8 check over exact same-load FBX stack timing.
27pub struct EngineClipBoundaryCheck<'a> {
28    source: &'a LoadedSource,
29    provenance: Option<&'a PredictionProvenanceV3>,
30}
31
32impl<'a> EngineClipBoundaryCheck<'a> {
33    /// Bind an optional exact-profile provenance record to the same loaded source.
34    pub fn new(
35        source: &'a LoadedSource,
36        provenance: Option<&'a PredictionProvenanceV3>,
37    ) -> Result<Self, PredictionRuleError> {
38        if let Some(provenance) = provenance {
39            provenance
40                .validate()
41                .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
42            let raw_source = RawSourceBindingV2::from_source(
43                source.source_facts(),
44                source.exact_source_timing(),
45            )
46            .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
47            if &raw_source != provenance.raw_source()
48                || source.dependency_closure() != provenance.dependency_closure()
49            {
50                return Err(PredictionRuleError::SourceProvenanceMismatch);
51            }
52            if is_unreal_tuple(provenance) && !has_frozen_unreal_profile(provenance) {
53                return Err(PredictionRuleError::FrozenProfileMismatch);
54            }
55        }
56        Ok(Self { source, provenance })
57    }
58}
59
60impl Check for EngineClipBoundaryCheck<'_> {
61    fn id(&self) -> &'static str {
62        ENGINE_CLIP_BOUNDARY_CHECK_ID
63    }
64
65    fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
66        match self.provenance {
67            Some(provenance)
68                if is_exact_unreal_fbx(self.source, provenance)
69                    && !complete_empty_stack_inventory(self.source) =>
70            {
71                Applicability::Applicable
72            }
73            _ => Applicability::NotApplicable,
74        }
75    }
76
77    fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
78        let Some(provenance) = self.provenance else {
79            return empty_output();
80        };
81        if !is_exact_unreal_fbx(self.source, provenance)
82            || complete_empty_stack_inventory(self.source)
83        {
84            return empty_output();
85        }
86        let demand = facet_demand(self.source);
87        let (capacity, summary_required) = match demand {
88            PredictionFacetDemandV2::Exact(count) => (count, false),
89            PredictionFacetDemandV2::NPlusOne => (FACET_LIMIT.saturating_sub(1), true),
90        };
91        evaluate_allocated(self.source, provenance, capacity, summary_required)
92    }
93
94    fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
95        match self.provenance {
96            Some(provenance)
97                if is_exact_unreal_fbx(self.source, provenance)
98                    && !complete_empty_stack_inventory(self.source) =>
99            {
100                facet_demand(self.source)
101            }
102            _ => PredictionFacetDemandV2::Exact(0),
103        }
104    }
105
106    fn evaluate_with_prediction_allocation_v2(
107        &self,
108        _ctx: &CheckCtx<'_>,
109        allocation: PredictionRuleAllocationV2<'_>,
110    ) -> CheckOutput {
111        let Some(provenance) = self.provenance else {
112            return empty_output();
113        };
114        if !is_exact_unreal_fbx(self.source, provenance)
115            || complete_empty_stack_inventory(self.source)
116        {
117            return empty_output();
118        }
119        evaluate_allocated(
120            self.source,
121            provenance,
122            allocation.candidate_capacity(),
123            allocation.summary_required(),
124        )
125    }
126}
127
128fn facet_demand(source: &LoadedSource) -> PredictionFacetDemandV2 {
129    let rows = source.source_facts().clips().rows().len();
130    let inventory = usize::from(
131        source.source_facts().clips().coverage().state() != SourceSetCoverageStateV1::Complete,
132    );
133    match rows.checked_add(inventory) {
134        Some(count) if count <= FACET_LIMIT => PredictionFacetDemandV2::Exact(count),
135        _ => PredictionFacetDemandV2::NPlusOne,
136    }
137}
138
139fn evaluate_allocated(
140    source: &LoadedSource,
141    provenance: &PredictionProvenanceV3,
142    candidate_capacity: usize,
143    summary_required: bool,
144) -> CheckOutput {
145    let inventory_incomplete =
146        source.source_facts().clips().coverage().state() != SourceSetCoverageStateV1::Complete;
147    // An incomplete inventory must retain one unsuppressible summary whenever
148    // a candidate slot exists. Row candidates consume only the remainder.
149    let inventory_slots = usize::from(inventory_incomplete && candidate_capacity != 0);
150    let row_capacity = candidate_capacity.saturating_sub(inventory_slots);
151    let mut findings = Vec::new();
152    let mut evaluated_scopes = Vec::with_capacity(row_capacity);
153    let mut facets =
154        Vec::with_capacity(candidate_capacity.saturating_add(usize::from(summary_required)));
155
156    let timing = source.exact_source_timing();
157    for source_clip_index in 0..source.source_facts().clips().rows().len().min(row_capacity) {
158        let scope = stack_scope(source_clip_index);
159        let row = timing.and_then(|timing| timing.clips().get(source_clip_index));
160        let declared_time_mode = timing.map(|timing| timing.declared_time_mode().state());
161        let frame_period = timing.map(|timing| timing.frame_period().state());
162        let tick_range = row.map(|row| row.source_time_range().state());
163        let basis = stack_basis(
164            provenance,
165            source_clip_index,
166            declared_time_mode,
167            frame_period,
168            tick_range,
169        );
170
171        match (declared_time_mode, frame_period, tick_range) {
172            (
173                Some(ExactSourceTimingObservationStateV1::Observed(_)),
174                Some(ExactSourceTimingObservationStateV1::Observed(period)),
175                Some(ExactSourceTimingObservationStateV1::Observed(range)),
176            ) => {
177                facets.push(
178                    EnginePredictionFacetV3::available(scope.clone(), basis)
179                        .expect("exact stack evidence forms an available facet"),
180                );
181                evaluated_scopes.push(scope.clone());
182                if range.end_units().rem_euclid(period.units_per_frame()) != 0 {
183                    findings.push(
184                        Finding::new(
185                            ENGINE_CLIP_BOUNDARY_CHECK_ID,
186                            Severity::Warning,
187                            format!(
188                                "FBX animation stack {source_clip_index} ends at KTime tick {}, which is not on the exact {}-tick frame lattice required by Unreal Engine 5.8",
189                                range.end_units(),
190                                period.units_per_frame()
191                            ),
192                        )
193                        .prediction_scope(scope),
194                    );
195                }
196            }
197            _ => facets.push(
198                EnginePredictionFacetV3::required_unavailable(
199                    scope,
200                    basis,
201                    unavailable_reasons(declared_time_mode, frame_period, tick_range),
202                )
203                .expect("missing exact stack evidence forms required-unavailable"),
204            ),
205        }
206    }
207
208    if inventory_slots != 0 {
209        facets.push(
210            EnginePredictionFacetV3::required_unavailable(
211                inventory_scope(),
212                inventory_basis(provenance),
213                vec![PredictionUnavailableReasonV2::RawSourceIncomplete],
214            )
215            .expect("partial stack inventory forms required-unavailable"),
216        );
217    }
218
219    if summary_required {
220        facets.push(
221            EnginePredictionFacetV3::required_unavailable(
222                EvaluationScope::new(EvaluationScopeCode::custom(
223                    "engine-clip-boundary:facet-budget",
224                )),
225                inventory_basis(provenance),
226                vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
227            )
228            .expect("facet-budget summary is valid"),
229        );
230    }
231
232    if facets.is_empty() {
233        return empty_output();
234    }
235    let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets)
236        .expect("allocated clip-boundary facets satisfy the shared bound");
237    CheckOutput::from_coverage(findings, evaluated_scopes, Vec::new())
238        .with_engine_prediction_v3(prediction)
239}
240
241fn stack_basis<T, U, V>(
242    provenance: &PredictionProvenanceV3,
243    source_clip_index: usize,
244    declared_time_mode: Option<&ExactSourceTimingObservationStateV1<T>>,
245    frame_period: Option<&ExactSourceTimingObservationStateV1<U>>,
246    tick_range: Option<&ExactSourceTimingObservationStateV1<V>>,
247) -> EnginePredictionBasisV2 {
248    let mut references = common_basis();
249    let Some(binding) = provenance.raw_source().exact_source_timing() else {
250        return EnginePredictionBasisV2::new(references).expect("static basis is valid");
251    };
252    references.push(exact_reference(
253        ExactSourceTimingDomainV1::Document,
254        ExactSourceTimingKeyV1::Document,
255        "declared_time_mode.state",
256        binding,
257    ));
258    references.push(exact_reference(
259        ExactSourceTimingDomainV1::Document,
260        ExactSourceTimingKeyV1::Document,
261        "frame_period.state",
262        binding,
263    ));
264    references.push(exact_reference(
265        ExactSourceTimingDomainV1::Clip,
266        ExactSourceTimingKeyV1::Clip {
267            source_clip_index: source_clip_index as u64,
268        },
269        "source_time_range.state",
270        binding,
271    ));
272    if matches!(
273        declared_time_mode,
274        Some(ExactSourceTimingObservationStateV1::Observed(_))
275    ) {
276        references.push(exact_reference(
277            ExactSourceTimingDomainV1::Document,
278            ExactSourceTimingKeyV1::Document,
279            "declared_time_mode.value.time_mode",
280            binding,
281        ));
282    }
283    if matches!(
284        frame_period,
285        Some(ExactSourceTimingObservationStateV1::Observed(_))
286    ) {
287        references.push(exact_reference(
288            ExactSourceTimingDomainV1::Document,
289            ExactSourceTimingKeyV1::Document,
290            "frame_period.value.units_per_frame",
291            binding,
292        ));
293    }
294    if matches!(
295        tick_range,
296        Some(ExactSourceTimingObservationStateV1::Observed(_))
297    ) {
298        references.push(exact_reference(
299            ExactSourceTimingDomainV1::Clip,
300            ExactSourceTimingKeyV1::Clip {
301                source_clip_index: source_clip_index as u64,
302            },
303            "source_time_range.value.end_units",
304            binding,
305        ));
306    }
307    EnginePredictionBasisV2::new(references).expect("exact timing basis is valid")
308}
309
310fn inventory_basis(provenance: &PredictionProvenanceV3) -> EnginePredictionBasisV2 {
311    let mut references = common_basis();
312    if let Some(binding) = provenance.raw_source().exact_source_timing() {
313        references.push(exact_reference(
314            ExactSourceTimingDomainV1::Document,
315            ExactSourceTimingKeyV1::Document,
316            "clip_coverage.state",
317            binding,
318        ));
319        references.push(exact_reference(
320            ExactSourceTimingDomainV1::Document,
321            ExactSourceTimingKeyV1::Document,
322            "clip_coverage.reason",
323            binding,
324        ));
325    }
326    EnginePredictionBasisV2::new(references).expect("inventory basis is valid")
327}
328
329fn common_basis() -> Vec<PredictionBasisReferenceV2> {
330    vec![
331        PredictionBasisReferenceV2::v1(
332            PredictionBasisReferenceV1::profile_fact("whole_end_frame_required")
333                .expect("static fact id is valid"),
334        ),
335        PredictionBasisReferenceV2::v1(
336            PredictionBasisReferenceV1::primary_source(UNREAL_BOUNDARY_SOURCE)
337                .expect("static source id is valid"),
338        ),
339    ]
340}
341
342fn exact_reference(
343    domain: ExactSourceTimingDomainV1,
344    key: ExactSourceTimingKeyV1,
345    field: &'static str,
346    binding: &animsmith_core::ExactSourceTimingBindingV1,
347) -> PredictionBasisReferenceV2 {
348    PredictionBasisReferenceV2::exact_source_timing(
349        ExactSourceTimingBasisReferenceV1::from_binding(
350            domain,
351            key,
352            RawSourceFieldIdV1::new(field).expect("static exact field id is valid"),
353            binding,
354        )
355        .expect("same-load exact field resolves"),
356    )
357}
358
359fn unavailable_reasons<T, U, V>(
360    declared_time_mode: Option<&ExactSourceTimingObservationStateV1<T>>,
361    frame_period: Option<&ExactSourceTimingObservationStateV1<U>>,
362    tick_range: Option<&ExactSourceTimingObservationStateV1<V>>,
363) -> Vec<PredictionUnavailableReasonV2> {
364    if declared_time_mode.is_none() && frame_period.is_none() && tick_range.is_none() {
365        return vec![custom_reason("animsmith:exact_source_timing_unavailable")];
366    }
367    let mut reasons = Vec::new();
368    if !matches!(
369        declared_time_mode,
370        Some(ExactSourceTimingObservationStateV1::Observed(_))
371    ) {
372        reasons.push(custom_reason(
373            "animsmith:source_declared_time_mode_unavailable",
374        ));
375    }
376    if !matches!(
377        frame_period,
378        Some(ExactSourceTimingObservationStateV1::Observed(_))
379    ) {
380        reasons.push(custom_reason("animsmith:source_frame_period_unavailable"));
381    }
382    if !matches!(
383        tick_range,
384        Some(ExactSourceTimingObservationStateV1::Observed(_))
385    ) {
386        reasons.push(custom_reason(
387            "animsmith:source_clip_time_range_unavailable",
388        ));
389    }
390    reasons
391}
392
393fn custom_reason(code: &'static str) -> PredictionUnavailableReasonV2 {
394    PredictionUnavailableReasonV2::custom(code).expect("static reason code is valid")
395}
396
397fn stack_scope(source_clip_index: usize) -> EvaluationScope {
398    EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
399        .subject(format!("source_stack:{source_clip_index}"))
400}
401
402fn inventory_scope() -> EvaluationScope {
403    EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY_INVENTORY)
404}
405
406fn empty_output() -> CheckOutput {
407    CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
408}
409
410fn complete_empty_stack_inventory(source: &LoadedSource) -> bool {
411    let clips = source.source_facts().clips();
412    clips.coverage().state() == SourceSetCoverageStateV1::Complete && clips.rows().is_empty()
413}
414
415fn is_unreal_tuple(provenance: &PredictionProvenanceV3) -> bool {
416    let selection = provenance.profile().selection();
417    selection.family() == UNREAL_FAMILY
418        && selection.profile_revision() == UNREAL_PROFILE_REVISION
419        && selection.engine_version() == UNREAL_ENGINE_VERSION
420        && selection.importer() == UNREAL_IMPORTER
421}
422
423fn has_frozen_unreal_profile(provenance: &PredictionProvenanceV3) -> bool {
424    profiles_v1().iter().any(|profile| {
425        let selection = profile.selection();
426        selection.family() == UNREAL_FAMILY
427            && selection.profile_revision() == UNREAL_PROFILE_REVISION
428            && selection.engine_version() == UNREAL_ENGINE_VERSION
429            && selection.importer() == UNREAL_IMPORTER
430            && profile.facts_identity() == provenance.profile().facts_identity()
431    })
432}
433
434fn is_exact_unreal_fbx(source: &LoadedSource, provenance: &PredictionProvenanceV3) -> bool {
435    source.source_facts().format() == SourceFormatV1::Fbx
436        && provenance.source_format() == SourceFormatV1::Fbx
437        && is_unreal_tuple(provenance)
438        && has_frozen_unreal_profile(provenance)
439        && matches!(
440            provenance
441                .profile()
442                .fact(EngineFactIdV1::WholeEndFrameRequired)
443                .map(|fact| fact.state()),
444            Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
445        )
446        && provenance
447            .profile()
448            .source(UNREAL_BOUNDARY_SOURCE)
449            .is_some()
450}