Skip to main content

animsmith_engine/
resolver.rs

1use crate::canonical::{settings_identity, validate_clip_settings};
2use crate::{
3    DefaultStatus, EngineDeclaration, EngineDeclarationV2, EngineProfile, EngineProfileV2,
4    InvalidSettingReason, ProfileSelection, ResolutionError, ResolvedSettingOriginV2,
5    ResolvedSettingV2, SettingApplicability, SettingDefaultV2, SettingDomain, SettingDomainV2,
6    SettingId, SettingIdV2, SettingLocation, SettingMap, SettingMapV2, SettingScope, SettingValue,
7    SettingValueV2, profiles_v1, profiles_v2,
8};
9use animsmith_core::{
10    ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS, EngineContractError, InputIdentity, SourceFormatV1,
11};
12use std::collections::BTreeMap;
13
14/// Maximum UTF-8 byte count of a V1 source-transform path.
15pub const SOURCE_TRANSFORM_PATH_MAX_BYTES: usize = 4_096;
16
17/// Maximum actual clip settings rows retained by the bounded V2 projection.
18///
19/// The V2 path inspects at most this many rows plus one overflow sentinel. It
20/// never clones, validates, resolves, or sorts the sentinel row.
21pub const RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS: usize = ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS;
22
23/// Find one exact registry tuple without aliases, ranges, or fallback.
24///
25/// # Errors
26///
27/// Returns [`ResolutionError::UnknownProfile`] when no tuple is exactly equal.
28pub fn lookup_profile(
29    selection: &ProfileSelection,
30) -> Result<&'static EngineProfile, ResolutionError> {
31    profiles_v1()
32        .iter()
33        .find(|profile| profile.selection() == selection)
34        .ok_or_else(|| ResolutionError::UnknownProfile(selection.clone()))
35}
36
37/// Find one exact revision-2 registry tuple without fallback to revision 1.
38///
39/// # Errors
40///
41/// Returns [`ResolutionErrorV2::UnknownProfile`] when no tuple is exactly
42/// equal.
43pub fn lookup_profile_v2(
44    selection: &ProfileSelection,
45) -> Result<&'static EngineProfileV2, ResolutionErrorV2> {
46    profiles_v2()
47        .iter()
48        .find(|profile| profile.selection() == selection)
49        .ok_or_else(|| ResolutionErrorV2::UnknownProfile(selection.clone()))
50}
51
52/// Validate and fully materialize a revision-2 engine declaration.
53///
54/// Verified defaults are inserted with [`ResolvedSettingOriginV2::ProfileDefault`].
55/// Explicit values always retain [`ResolvedSettingOriginV2::ExplicitConfig`].
56/// Document settings are materialized during this phase. Clip declarations
57/// are retained and materialized once the authoritative clip names arrive.
58///
59/// # Errors
60///
61/// Returns a typed configuration error for an unknown exact tuple/key, wrong
62/// scope or domain, or a missing required-explicit setting.
63pub fn resolve_static_v2(
64    declaration: EngineDeclarationV2,
65) -> Result<Option<StaticResolutionV2>, ResolutionErrorV2> {
66    let EngineDeclarationV2 {
67        selection,
68        document_settings,
69        clip_settings,
70    } = declaration;
71    let Some(selection) = selection else {
72        if document_settings.is_some() || !clip_settings.is_empty() {
73            return Err(ResolutionErrorV2::SettingsWithoutSelection);
74        }
75        return Ok(None);
76    };
77    let profile = lookup_profile_v2(&selection)?;
78
79    let explicit_document = validate_map_v2(
80        profile,
81        document_settings.unwrap_or_default(),
82        SettingScope::Document,
83        SettingLocation::Document,
84    )?;
85    let mut document_settings = BTreeMap::new();
86    for descriptor in profile.setting_descriptors() {
87        if descriptor.scope() != SettingScope::Document {
88            continue;
89        }
90        if let Some(value) = explicit_document.get(&descriptor.id()) {
91            document_settings.insert(
92                descriptor.id(),
93                ResolvedSettingV2 {
94                    value: value.clone(),
95                    origin: ResolvedSettingOriginV2::ExplicitConfig,
96                },
97            );
98        } else {
99            match descriptor.default() {
100                SettingDefaultV2::RequiredExplicit => {
101                    return Err(ResolutionErrorV2::MissingRequiredSetting {
102                        setting: descriptor.id(),
103                        location: SettingLocation::Document,
104                    });
105                }
106                SettingDefaultV2::Verified(value) => {
107                    document_settings.insert(
108                        descriptor.id(),
109                        ResolvedSettingV2 {
110                            value: value.clone(),
111                            origin: ResolvedSettingOriginV2::ProfileDefault,
112                        },
113                    );
114                }
115            }
116        }
117    }
118
119    let mut clip_overlays = BTreeMap::new();
120    for (selector, settings) in clip_settings {
121        let values = validate_map_v2(
122            profile,
123            settings,
124            SettingScope::Clip,
125            SettingLocation::ClipSelector(selector.clone()),
126        )?;
127        clip_overlays.insert(selector, values);
128    }
129
130    Ok(Some(StaticResolutionV2 {
131        profile,
132        document_settings,
133        clip_overlays,
134    }))
135}
136
137/// Validated revision-2 profile and fully materialized document settings.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct StaticResolutionV2 {
140    profile: &'static EngineProfileV2,
141    document_settings: BTreeMap<SettingIdV2, ResolvedSettingV2>,
142    clip_overlays: BTreeMap<String, BTreeMap<SettingIdV2, SettingValueV2>>,
143}
144
145impl StaticResolutionV2 {
146    /// Exact immutable selected profile.
147    pub const fn profile(&self) -> &'static EngineProfileV2 {
148        self.profile
149    }
150
151    /// Fully materialized document settings with explicit/default origins.
152    pub const fn document_settings(&self) -> &BTreeMap<SettingIdV2, ResolvedSettingV2> {
153        &self.document_settings
154    }
155
156    /// Validated selector-keyed clip setting overlays.
157    pub const fn clip_overlays(&self) -> &BTreeMap<String, BTreeMap<SettingIdV2, SettingValueV2>> {
158        &self.clip_overlays
159    }
160
161    /// Validate the authoritative source format against the exact profile.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ResolutionErrorV2::UnacceptedInputFormat`] for any container
166    /// outside the selected profile's exact input boundary.
167    pub fn resolve_input(
168        &self,
169        source_format: SourceFormatV1,
170    ) -> Result<ResolvedProfileSettingsV2, ResolutionErrorV2> {
171        if !self.profile.accepted_inputs().contains(&source_format) {
172            return Err(ResolutionErrorV2::UnacceptedInputFormat {
173                selection: self.profile.selection().clone(),
174                format: source_format,
175            });
176        }
177        Ok(ResolvedProfileSettingsV2 {
178            profile: self.profile,
179            source_format,
180            document_settings: self.document_settings.clone(),
181            clips: Vec::new(),
182            clip_coverage: ResolvedClipCoverageV2::Complete,
183            work: ResolvedEngineSettingsWorkV2 {
184                actual_clip_rows_inspected: 0,
185                materialized_clip_rows: 0,
186                retained_clip_rows: 0,
187            },
188        })
189    }
190
191    /// Resolve the authoritative input format into bounded clip settings.
192    ///
193    /// Selector overlays use the same lexical-glob then exact-name precedence
194    /// as the historical V1 resolver. Duplicate clip names remain distinct
195    /// rows by source ordinal. At most 4,096 source-order rows are materialized;
196    /// a larger exact input produces partial coverage with N+1 work evidence,
197    /// and no tail name or setting is cloned, resolved, or canonicalized.
198    /// Retained rows are returned in canonical `(clip_name, source_ordinal)`
199    /// order.
200    pub fn resolve_input_with_clips(
201        &self,
202        source_format: SourceFormatV1,
203        clip_names: &[String],
204    ) -> Result<ResolvedProfileSettingsV2, ResolutionErrorV2> {
205        self.resolve_input_with_clips_iter(source_format, clip_names.iter().map(String::as_str))
206    }
207
208    /// Resolve an exact-size clip-name iterator without cloning its tail.
209    ///
210    /// This is the allocation-safe entry point for loaded documents. The
211    /// iterator length supplies complete versus N+1 coverage, and only the
212    /// bounded retained prefix is visited.
213    pub fn resolve_input_with_clips_iter<'a>(
214        &self,
215        source_format: SourceFormatV1,
216        clip_names: impl ExactSizeIterator<Item = &'a str>,
217    ) -> Result<ResolvedProfileSettingsV2, ResolutionErrorV2> {
218        if !self.profile.accepted_inputs().contains(&source_format) {
219            return Err(ResolutionErrorV2::UnacceptedInputFormat {
220                selection: self.profile.selection().clone(),
221                format: source_format,
222            });
223        }
224        // Profiles whose immutable vocabulary has no clip-scoped settings
225        // retain their historical empty clip-settings graph. Merely routing
226        // them through this source-aware entry point must not change Bevy's
227        // resolved-settings identity.
228        if !self
229            .profile
230            .setting_descriptors()
231            .iter()
232            .any(|descriptor| descriptor.scope() == SettingScope::Clip)
233        {
234            return self.resolve_input(source_format);
235        }
236        let actual_clip_rows = clip_names.len();
237        let retained_clip_rows = actual_clip_rows.min(RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS);
238        let overflowed = actual_clip_rows > RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS;
239        let mut clips = Vec::with_capacity(retained_clip_rows);
240        for (clip_ordinal, clip_name) in (0_u64..).zip(clip_names.take(retained_clip_rows)) {
241            let mut values = BTreeMap::new();
242            for (selector, settings) in &self.clip_overlays {
243                if selector != clip_name && animsmith_core::config::glob_match(selector, clip_name)
244                {
245                    values.extend(settings.iter().map(|(id, value)| (*id, value.clone())));
246                }
247            }
248            if let Some(exact) = self.clip_overlays.get(clip_name) {
249                values.extend(exact.iter().map(|(id, value)| (*id, value.clone())));
250            }
251            let mut resolved = BTreeMap::new();
252            for descriptor in self.profile.setting_descriptors() {
253                if descriptor.scope() != SettingScope::Clip {
254                    continue;
255                }
256                if let Some(value) = values.get(&descriptor.id()) {
257                    resolved.insert(
258                        descriptor.id(),
259                        ResolvedSettingV2 {
260                            value: value.clone(),
261                            origin: ResolvedSettingOriginV2::ExplicitConfig,
262                        },
263                    );
264                } else {
265                    match descriptor.default() {
266                        SettingDefaultV2::RequiredExplicit => {
267                            return Err(ResolutionErrorV2::MissingRequiredSetting {
268                                setting: descriptor.id(),
269                                location: SettingLocation::ClipSelector(clip_name.to_owned()),
270                            });
271                        }
272                        SettingDefaultV2::Verified(value) => {
273                            resolved.insert(
274                                descriptor.id(),
275                                ResolvedSettingV2 {
276                                    value: value.clone(),
277                                    origin: ResolvedSettingOriginV2::ProfileDefault,
278                                },
279                            );
280                        }
281                    }
282                }
283            }
284            clips.push(ResolvedClipSettingsV2 {
285                clip_ordinal,
286                clip_name: clip_name.to_owned(),
287                settings: resolved,
288            });
289        }
290        clips.sort_by(|left, right| {
291            (left.clip_name.as_str(), left.clip_ordinal)
292                .cmp(&(right.clip_name.as_str(), right.clip_ordinal))
293        });
294        Ok(ResolvedProfileSettingsV2 {
295            profile: self.profile,
296            source_format,
297            document_settings: self.document_settings.clone(),
298            clips,
299            clip_coverage: if overflowed {
300                ResolvedClipCoverageV2::Partial {
301                    reason: ResolvedClipCoverageReasonV2::ActualClipRowsExceeded,
302                }
303            } else {
304                ResolvedClipCoverageV2::Complete
305            },
306            work: ResolvedEngineSettingsWorkV2 {
307                actual_clip_rows_inspected: actual_clip_rows
308                    .min(RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS.saturating_add(1)),
309                materialized_clip_rows: retained_clip_rows,
310                retained_clip_rows,
311            },
312        })
313    }
314}
315
316/// Input-validated revision-2 profile settings.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct ResolvedProfileSettingsV2 {
319    profile: &'static EngineProfileV2,
320    source_format: SourceFormatV1,
321    document_settings: BTreeMap<SettingIdV2, ResolvedSettingV2>,
322    clips: Vec<ResolvedClipSettingsV2>,
323    clip_coverage: ResolvedClipCoverageV2,
324    work: ResolvedEngineSettingsWorkV2,
325}
326
327impl ResolvedProfileSettingsV2 {
328    /// Exact immutable selected profile.
329    pub const fn profile(&self) -> &'static EngineProfileV2 {
330        self.profile
331    }
332
333    /// Authoritative accepted source format.
334    pub const fn source_format(&self) -> SourceFormatV1 {
335        self.source_format
336    }
337
338    /// Fully materialized document settings with explicit/default origins.
339    pub const fn document_settings(&self) -> &BTreeMap<SettingIdV2, ResolvedSettingV2> {
340        &self.document_settings
341    }
342
343    /// Materialized clip-scoped prefix in canonical name/ordinal order.
344    ///
345    /// Consult [`Self::clip_coverage`] before interpreting this slice as a
346    /// complete inventory.
347    pub fn clip_settings(&self) -> &[ResolvedClipSettingsV2] {
348        &self.clips
349    }
350
351    /// Look up one materialized clip by its source ordinal and exact name.
352    pub fn clip_setting(&self, ordinal: u64, name: &str) -> Option<&ResolvedClipSettingsV2> {
353        self.clips
354            .iter()
355            .find(|clip| clip.clip_ordinal == ordinal && clip.clip_name == name)
356    }
357
358    /// Complete or bounded-partial coverage of actual clip settings.
359    pub const fn clip_coverage(&self) -> ResolvedClipCoverageV2 {
360        self.clip_coverage
361    }
362
363    /// Bounded clip-resolution work counters.
364    pub const fn work(&self) -> ResolvedEngineSettingsWorkV2 {
365        self.work
366    }
367}
368
369/// One materialized revision-2 clip settings row.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct ResolvedClipSettingsV2 {
372    clip_ordinal: u64,
373    clip_name: String,
374    settings: BTreeMap<SettingIdV2, ResolvedSettingV2>,
375}
376
377impl ResolvedClipSettingsV2 {
378    /// Original zero-based source clip ordinal.
379    pub const fn clip_ordinal(&self) -> u64 {
380        self.clip_ordinal
381    }
382
383    /// Exact source clip name.
384    pub fn clip_name(&self) -> &str {
385        &self.clip_name
386    }
387
388    /// Fully materialized settings for this clip.
389    pub const fn settings(&self) -> &BTreeMap<SettingIdV2, ResolvedSettingV2> {
390        &self.settings
391    }
392}
393
394/// Typed failure from revision-2 settings resolution.
395#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
396#[non_exhaustive]
397pub enum ResolutionErrorV2 {
398    /// Settings were declared without selecting an engine profile.
399    #[error("V2 engine settings were declared without an engine profile selection")]
400    SettingsWithoutSelection,
401    /// No exact revision-2 registry tuple matches the selection.
402    #[error("unknown V2 engine profile selection {0:?}")]
403    UnknownProfile(ProfileSelection),
404    /// A setting key is absent from the selected profile.
405    #[error("unknown V2 engine setting {key:?} in {location}")]
406    UnknownSetting {
407        /// Supplied setting key.
408        key: String,
409        /// Declaration location.
410        location: SettingLocation,
411    },
412    /// A known setting was declared in the wrong scope.
413    #[error(
414        "V2 engine setting {setting} has {expected:?} scope but was declared in {found:?} scope"
415    )]
416    WrongScope {
417        /// Stable setting id.
418        setting: SettingIdV2,
419        /// Descriptor scope.
420        expected: SettingScope,
421        /// Supplied scope.
422        found: SettingScope,
423        /// Declaration location.
424        location: SettingLocation,
425    },
426    /// A value variant does not match its descriptor's closed domain.
427    #[error("invalid value domain for V2 engine setting {setting} in {location}")]
428    InvalidSettingValue {
429        /// Stable setting id.
430        setting: SettingIdV2,
431        /// Required descriptor domain.
432        expected: SettingDomainV2,
433        /// Declaration location.
434        location: SettingLocation,
435    },
436    /// One required-explicit setting was not materialized.
437    #[error("missing required V2 engine setting {setting} in {location}")]
438    MissingRequiredSetting {
439        /// Stable setting id.
440        setting: SettingIdV2,
441        /// Document or exact clip location.
442        location: SettingLocation,
443    },
444    /// The authoritative input container is outside the exact profile.
445    #[error("input format {format:?} is not accepted by V2 engine profile {selection:?}")]
446    UnacceptedInputFormat {
447        /// Selected exact profile tuple.
448        selection: ProfileSelection,
449        /// Authoritative source format.
450        format: SourceFormatV1,
451    },
452}
453
454fn validate_map_v2(
455    profile: &EngineProfileV2,
456    settings: SettingMapV2,
457    supplied_scope: SettingScope,
458    location: SettingLocation,
459) -> Result<BTreeMap<SettingIdV2, SettingValueV2>, ResolutionErrorV2> {
460    let mut validated = BTreeMap::new();
461    for (key, value) in settings {
462        let Some(id) = SettingIdV2::from_str_v2(&key) else {
463            return Err(ResolutionErrorV2::UnknownSetting {
464                key,
465                location: location.clone(),
466            });
467        };
468        let Some(descriptor) = profile.setting_descriptor(id) else {
469            return Err(ResolutionErrorV2::UnknownSetting {
470                key,
471                location: location.clone(),
472            });
473        };
474        if descriptor.scope() != supplied_scope {
475            return Err(ResolutionErrorV2::WrongScope {
476                setting: id,
477                expected: descriptor.scope(),
478                found: supplied_scope,
479                location: location.clone(),
480            });
481        }
482        if !valid_setting_value_v2(profile, id, &value, descriptor.domain()) {
483            return Err(ResolutionErrorV2::InvalidSettingValue {
484                setting: id,
485                expected: descriptor.domain(),
486                location: location.clone(),
487            });
488        }
489        validated.insert(id, value);
490    }
491    Ok(validated)
492}
493
494fn valid_setting_value_v2(
495    profile: &EngineProfileV2,
496    id: SettingIdV2,
497    value: &SettingValueV2,
498    domain: SettingDomainV2,
499) -> bool {
500    if !value.matches_domain(domain) {
501        return false;
502    }
503    if id == SettingIdV2::RootMotionSource && !valid_source_transform_path_v2(value) {
504        return false;
505    }
506    if profile.selection().family() == "unity-generic"
507        && profile.selection().profile_revision() == 2
508    {
509        match (id, value) {
510            (
511                SettingIdV2::AnimationType,
512                SettingValueV2::AnimationType(crate::UnityAnimationTypeV2::Generic),
513            )
514            | (
515                SettingIdV2::AvatarSetup,
516                SettingValueV2::AvatarSetup(crate::UnityAvatarSetupV2::CreateFromThisModel),
517            )
518            | (SettingIdV2::ImportAnimation, SettingValueV2::Boolean(true)) => {}
519            (
520                SettingIdV2::AnimationType
521                | SettingIdV2::AvatarSetup
522                | SettingIdV2::ImportAnimation,
523                _,
524            ) => return false,
525            _ => {}
526        }
527    }
528    true
529}
530
531fn valid_source_transform_path_v2(value: &SettingValueV2) -> bool {
532    let SettingValueV2::SourceTransformPath(path) = value else {
533        return false;
534    };
535    animsmith_core::RawTransformPathV1::parse(path).is_ok()
536}
537
538/// Validate an engine declaration without inspecting an asset.
539///
540/// With neither selection nor settings this returns `Ok(None)`, preserving
541/// engine-neutral behavior. Every declaration is otherwise validated,
542/// including settings beneath selectors that may match no eventual clip.
543///
544/// # Errors
545///
546/// Returns a typed configuration error for settings without a selection, an
547/// unknown tuple/key, wrong scope, not-applicable setting, invalid value, or
548/// missing required document setting.
549pub fn resolve_static(
550    declaration: EngineDeclaration,
551) -> Result<Option<StaticResolution>, ResolutionError> {
552    let EngineDeclaration {
553        selection,
554        document_settings,
555        clip_settings,
556    } = declaration;
557    let Some(selection) = selection else {
558        if document_settings.is_some() || !clip_settings.is_empty() {
559            return Err(ResolutionError::SettingsWithoutSelection);
560        }
561        return Ok(None);
562    };
563    let profile = lookup_profile(&selection)?;
564
565    let document_settings = validate_map(
566        profile,
567        document_settings.unwrap_or_default(),
568        SettingScope::Document,
569        SettingLocation::Document,
570    )?;
571    for descriptor in profile.setting_descriptors() {
572        if descriptor.applicability() == SettingApplicability::Applicable
573            && descriptor.scope() == SettingScope::Document
574            && descriptor.default_status() == DefaultStatus::RequiredWithoutDefault
575            && !document_settings.contains_key(&descriptor.id())
576        {
577            return Err(ResolutionError::MissingRequiredSetting {
578                setting: descriptor.id(),
579                location: SettingLocation::Document,
580            });
581        }
582    }
583
584    let mut validated_clips = BTreeMap::new();
585    for (selector, settings) in clip_settings {
586        let values = validate_map(
587            profile,
588            settings,
589            SettingScope::Clip,
590            SettingLocation::ClipSelector(selector.clone()),
591        )?;
592        validated_clips.insert(selector, values);
593    }
594
595    Ok(Some(StaticResolution {
596        profile,
597        document_settings,
598        clip_overlays: validated_clips,
599    }))
600}
601
602/// Validated profile and declarations that require no asset I/O.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct StaticResolution {
605    profile: &'static EngineProfile,
606    document_settings: BTreeMap<SettingId, SettingValue>,
607    clip_overlays: BTreeMap<String, BTreeMap<SettingId, SettingValue>>,
608}
609
610impl StaticResolution {
611    /// Exact immutable selected profile.
612    pub const fn profile(&self) -> &'static EngineProfile {
613        self.profile
614    }
615
616    /// Fully materialized required document settings.
617    pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
618        &self.document_settings
619    }
620
621    /// Resolve the authoritative input format and actual clip names.
622    ///
623    /// Non-exact globs apply in lexical selector order, then an exact clip-name
624    /// table applies last. Overlay is field-by-field. The result contains one
625    /// sorted row per actual clip, including repeated clip names.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`ResolutionError::UnacceptedInputFormat`] outside the profile's
630    /// bounded V1 input set, [`ResolutionError::MissingRequiredSetting`] when
631    /// any real clip lacks an applicable required-without-default setting, or
632    /// [`ResolutionError::ResolvedSettingsContract`] when the fully
633    /// materialized settings exceed the V1 row/text bounds.
634    pub fn resolve_input(
635        &self,
636        source_format: SourceFormatV1,
637        clip_names: &[String],
638    ) -> Result<ResolvedProfile, ResolutionError> {
639        self.resolve_input_iter(source_format, clip_names.iter().map(String::as_str))
640    }
641
642    /// Resolve input from a borrowed, exactly sized clip-name iterator.
643    ///
644    /// This entry point lets loaders reject an oversized actual-clip inventory
645    /// before cloning or traversing any names.
646    ///
647    /// # Errors
648    ///
649    /// Returns the same typed errors as [`Self::resolve_input`]. An iterator
650    /// longer than the V1 collection bound is rejected before its first item is
651    /// consumed.
652    pub fn resolve_input_iter<'a>(
653        &self,
654        source_format: SourceFormatV1,
655        clip_names: impl ExactSizeIterator<Item = &'a str>,
656    ) -> Result<ResolvedProfile, ResolutionError> {
657        if !self.profile.accepted_inputs().contains(&source_format) {
658            return Err(ResolutionError::UnacceptedInputFormat {
659                selection: self.profile.selection().clone(),
660                format: source_format,
661            });
662        }
663
664        let clip_count = clip_names.len();
665        if clip_count > ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS {
666            return Err(ResolutionError::ResolvedSettingsContract(
667                EngineContractError::TooManyRows {
668                    field: "settings.clips",
669                    found: clip_count,
670                    max: ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS,
671                },
672            ));
673        }
674
675        let mut clips = Vec::with_capacity(clip_count);
676        for clip_name in clip_names {
677            let mut materialized = BTreeMap::new();
678            for (selector, settings) in &self.clip_overlays {
679                if selector != clip_name && animsmith_core::config::glob_match(selector, clip_name)
680                {
681                    materialized.extend(settings.iter().map(|(id, value)| (*id, value.clone())));
682                }
683            }
684            if let Some(exact) = self.clip_overlays.get(clip_name) {
685                materialized.extend(exact.iter().map(|(id, value)| (*id, value.clone())));
686            }
687            for descriptor in self.profile.setting_descriptors() {
688                if descriptor.applicability() == SettingApplicability::Applicable
689                    && descriptor.scope() == SettingScope::Clip
690                    && descriptor.default_status() == DefaultStatus::RequiredWithoutDefault
691                    && !materialized.contains_key(&descriptor.id())
692                {
693                    return Err(ResolutionError::MissingRequiredSetting {
694                        setting: descriptor.id(),
695                        location: SettingLocation::ClipSelector(clip_name.to_owned()),
696                    });
697                }
698            }
699            clips.push(ResolvedClipSettings {
700                clip_name: clip_name.to_owned(),
701                settings: materialized,
702            });
703        }
704        clips.sort_by(|left, right| left.clip_name.cmp(&right.clip_name));
705        let identity = settings_identity(
706            self.profile,
707            &self.document_settings,
708            clips
709                .iter()
710                .map(|clip| (clip.clip_name.as_str(), &clip.settings)),
711        )?;
712        Ok(ResolvedProfile {
713            profile: self.profile,
714            source_format,
715            document_settings: self.document_settings.clone(),
716            clips,
717            settings_identity: identity,
718        })
719    }
720
721    /// Resolve an input into the bounded V2 settings projection.
722    ///
723    /// In contrast with [`Self::resolve_input_iter`], an actual inventory
724    /// larger than 4,096 is a successful, explicitly partial result. The
725    /// source iterator is only consumed for the retained prefix. The exact
726    /// iterator length supplies the N+1 overflow sentinel, so no tail name or
727    /// settings value is cloned, validated, materialized, or canonicalized.
728    ///
729    /// Prefix validation deliberately happens before the overflow result: a
730    /// malformed retained row is still an ordinary typed configuration error,
731    /// while a defect solely in the tail cannot outrank bounded overflow.
732    ///
733    /// # Errors
734    ///
735    /// Returns the same configuration and profile errors as V1 for the
736    /// document and retained source-order prefix.
737    pub fn resolve_input_v2_iter<'a>(
738        &self,
739        source_format: SourceFormatV1,
740        clip_names: impl ExactSizeIterator<Item = &'a str>,
741    ) -> Result<ResolvedProfileV2, ResolutionError> {
742        if !self.profile.accepted_inputs().contains(&source_format) {
743            return Err(ResolutionError::UnacceptedInputFormat {
744                selection: self.profile.selection().clone(),
745                format: source_format,
746            });
747        }
748
749        let actual_clip_rows = clip_names.len();
750        let retained_clip_rows = actual_clip_rows.min(RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS);
751        let overflowed = actual_clip_rows > RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS;
752        let mut clips = Vec::with_capacity(retained_clip_rows);
753        for clip_name in clip_names.take(retained_clip_rows) {
754            clips.push(self.materialize_clip(clip_name)?);
755        }
756        clips.sort_by(|left, right| left.clip_name.cmp(&right.clip_name));
757
758        // This is intentionally the unchanged V1 preimage for the retained
759        // prefix only. The V2 wire identity additionally commits to coverage
760        // and work counters in its core-owned projection.
761        let settings_identity = settings_identity(
762            self.profile,
763            &self.document_settings,
764            clips
765                .iter()
766                .map(|clip| (clip.clip_name.as_str(), &clip.settings)),
767        )?;
768        Ok(ResolvedProfileV2 {
769            profile: self.profile,
770            source_format,
771            document_settings: self.document_settings.clone(),
772            clips,
773            settings_identity,
774            clip_coverage: if overflowed {
775                ResolvedClipCoverageV2::Partial {
776                    reason: ResolvedClipCoverageReasonV2::ActualClipRowsExceeded,
777                }
778            } else {
779                ResolvedClipCoverageV2::Complete
780            },
781            work: ResolvedEngineSettingsWorkV2 {
782                actual_clip_rows_inspected: actual_clip_rows
783                    .min(RESOLVED_ENGINE_SETTINGS_V2_MAX_CLIPS.saturating_add(1)),
784                materialized_clip_rows: retained_clip_rows,
785                retained_clip_rows,
786            },
787        })
788    }
789
790    fn materialize_clip(&self, clip_name: &str) -> Result<ResolvedClipSettings, ResolutionError> {
791        let mut materialized = BTreeMap::new();
792        for (selector, settings) in &self.clip_overlays {
793            if selector != clip_name && animsmith_core::config::glob_match(selector, clip_name) {
794                materialized.extend(settings.iter().map(|(id, value)| (*id, value.clone())));
795            }
796        }
797        if let Some(exact) = self.clip_overlays.get(clip_name) {
798            materialized.extend(exact.iter().map(|(id, value)| (*id, value.clone())));
799        }
800        for descriptor in self.profile.setting_descriptors() {
801            if descriptor.applicability() == SettingApplicability::Applicable
802                && descriptor.scope() == SettingScope::Clip
803                && descriptor.default_status() == DefaultStatus::RequiredWithoutDefault
804                && !materialized.contains_key(&descriptor.id())
805            {
806                return Err(ResolutionError::MissingRequiredSetting {
807                    setting: descriptor.id(),
808                    location: SettingLocation::ClipSelector(clip_name.to_owned()),
809                });
810            }
811        }
812        validate_clip_settings(clip_name, &materialized)?;
813        Ok(ResolvedClipSettings {
814            clip_name: clip_name.to_owned(),
815            settings: materialized,
816        })
817    }
818}
819
820/// Fully materialized settings for one real clip.
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct ResolvedClipSettings {
823    clip_name: String,
824    settings: BTreeMap<SettingId, SettingValue>,
825}
826
827impl ResolvedClipSettings {
828    /// Actual clip name supplied by the caller.
829    pub fn clip_name(&self) -> &str {
830        &self.clip_name
831    }
832
833    /// Fully materialized applicable clip settings.
834    pub const fn settings(&self) -> &BTreeMap<SettingId, SettingValue> {
835        &self.settings
836    }
837}
838
839/// Input-validated profile and fully materialized deterministic settings.
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct ResolvedProfile {
842    profile: &'static EngineProfile,
843    source_format: SourceFormatV1,
844    document_settings: BTreeMap<SettingId, SettingValue>,
845    clips: Vec<ResolvedClipSettings>,
846    settings_identity: InputIdentity,
847}
848
849/// Complete or explicitly partial actual-clip coverage in a V2 resolution.
850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
851pub enum ResolvedClipCoverageV2 {
852    /// Every actual clip has a retained materialized settings row.
853    Complete,
854    /// The retained prefix is valid but the actual inventory exceeded its cap.
855    Partial {
856        /// Stable reason for partial settings coverage.
857        reason: ResolvedClipCoverageReasonV2,
858    },
859}
860
861/// Stable reason why V2 settings do not cover every actual clip.
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum ResolvedClipCoverageReasonV2 {
864    /// The source reported more actual clips than the retained V2 prefix.
865    ActualClipRowsExceeded,
866}
867
868/// Bounded work accounting for one V2 settings resolution.
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub struct ResolvedEngineSettingsWorkV2 {
871    actual_clip_rows_inspected: usize,
872    materialized_clip_rows: usize,
873    retained_clip_rows: usize,
874}
875
876impl ResolvedEngineSettingsWorkV2 {
877    /// Actual source rows inspected, capped at the retained limit plus one.
878    pub const fn actual_clip_rows_inspected(&self) -> usize {
879        self.actual_clip_rows_inspected
880    }
881
882    /// Rows whose settings were materialized.
883    pub const fn materialized_clip_rows(&self) -> usize {
884        self.materialized_clip_rows
885    }
886
887    /// Rows retained in canonical lexical-name order.
888    pub const fn retained_clip_rows(&self) -> usize {
889        self.retained_clip_rows
890    }
891}
892
893/// V2 bounded engine resolution retaining a valid canonical prefix.
894#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct ResolvedProfileV2 {
896    profile: &'static EngineProfile,
897    source_format: SourceFormatV1,
898    document_settings: BTreeMap<SettingId, SettingValue>,
899    clips: Vec<ResolvedClipSettings>,
900    settings_identity: InputIdentity,
901    clip_coverage: ResolvedClipCoverageV2,
902    work: ResolvedEngineSettingsWorkV2,
903}
904
905impl ResolvedProfileV2 {
906    /// Exact immutable selected profile.
907    pub const fn profile(&self) -> &'static EngineProfile {
908        self.profile
909    }
910
911    /// Authoritative accepted source format.
912    pub const fn source_format(&self) -> SourceFormatV1 {
913        self.source_format
914    }
915
916    /// Fully materialized document settings.
917    pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
918        &self.document_settings
919    }
920
921    /// Retained clip settings rows in canonical lexical-name order.
922    pub fn clip_settings(&self) -> &[ResolvedClipSettings] {
923        &self.clips
924    }
925
926    /// V1-prefix identity, retained only as an input to the V2 projection.
927    pub const fn settings_identity(&self) -> &InputIdentity {
928        &self.settings_identity
929    }
930
931    /// Coverage state for the actual source clip inventory.
932    pub const fn clip_coverage(&self) -> ResolvedClipCoverageV2 {
933        self.clip_coverage
934    }
935
936    /// Bounded work counters.
937    pub const fn work(&self) -> ResolvedEngineSettingsWorkV2 {
938        self.work
939    }
940}
941
942impl ResolvedProfile {
943    /// Exact immutable selected profile.
944    pub const fn profile(&self) -> &'static EngineProfile {
945        self.profile
946    }
947
948    /// Authoritative accepted source format.
949    pub const fn source_format(&self) -> SourceFormatV1 {
950        self.source_format
951    }
952
953    /// Fully materialized document settings.
954    pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
955        &self.document_settings
956    }
957
958    /// Fully materialized clip rows in lexical actual-name order.
959    pub fn clip_settings(&self) -> &[ResolvedClipSettings] {
960        &self.clips
961    }
962
963    /// Canonical settings SHA-256 plus byte count.
964    pub const fn settings_identity(&self) -> &InputIdentity {
965        &self.settings_identity
966    }
967}
968
969fn validate_map(
970    profile: &EngineProfile,
971    settings: SettingMap,
972    supplied_scope: SettingScope,
973    location: SettingLocation,
974) -> Result<BTreeMap<SettingId, SettingValue>, ResolutionError> {
975    let mut validated = BTreeMap::new();
976    for (key, value) in settings {
977        let Some(id) = SettingId::from_str(&key) else {
978            return Err(ResolutionError::UnknownSetting {
979                key,
980                location: location.clone(),
981            });
982        };
983        let Some(descriptor) = profile.setting_descriptor(id) else {
984            return Err(ResolutionError::UnknownSetting {
985                key,
986                location: location.clone(),
987            });
988        };
989        if descriptor.applicability() == SettingApplicability::NotApplicable {
990            return Err(ResolutionError::NotApplicable {
991                setting: id,
992                location: location.clone(),
993            });
994        }
995        if descriptor.scope() != supplied_scope {
996            return Err(ResolutionError::WrongScope {
997                setting: id,
998                expected: descriptor.scope(),
999                found: supplied_scope,
1000                location: location.clone(),
1001            });
1002        }
1003        validate_value(id, descriptor.domain(), &value, location.clone())?;
1004        validated.insert(id, value);
1005    }
1006    Ok(validated)
1007}
1008
1009fn validate_value(
1010    setting: SettingId,
1011    domain: SettingDomain,
1012    value: &SettingValue,
1013    location: SettingLocation,
1014) -> Result<(), ResolutionError> {
1015    let domain_matches = matches!(
1016        (domain, value),
1017        (SettingDomain::Boolean, SettingValue::Boolean(_))
1018            | (SettingDomain::BakeOrExtract, SettingValue::BakeOrExtract(_))
1019            | (
1020                SettingDomain::SourceTransformPath,
1021                SettingValue::SourceTransformPath(_)
1022            )
1023    );
1024    if !domain_matches {
1025        return Err(ResolutionError::InvalidSettingValue {
1026            setting,
1027            location,
1028            reason: InvalidSettingReason::WrongDomain {
1029                expected: domain,
1030                found: value.kind(),
1031            },
1032        });
1033    }
1034    if let SettingValue::SourceTransformPath(path) = value {
1035        let reason = if path.is_empty() {
1036            Some(InvalidSettingReason::EmptyPath)
1037        } else if path.len() > SOURCE_TRANSFORM_PATH_MAX_BYTES {
1038            Some(InvalidSettingReason::PathTooLong {
1039                bytes: path.len(),
1040                limit: SOURCE_TRANSFORM_PATH_MAX_BYTES,
1041            })
1042        } else if path.starts_with('/') {
1043            Some(InvalidSettingReason::AbsolutePath)
1044        } else if path.chars().any(char::is_control) {
1045            Some(InvalidSettingReason::ControlCharacter)
1046        } else if path.split('/').any(str::is_empty) {
1047            Some(InvalidSettingReason::EmptyPathSegment)
1048        } else if path.split('/').any(|segment| matches!(segment, "." | "..")) {
1049            Some(InvalidSettingReason::DotPathSegment)
1050        } else {
1051            None
1052        };
1053        if let Some(reason) = reason {
1054            return Err(ResolutionError::InvalidSettingValue {
1055                setting,
1056                location,
1057                reason,
1058            });
1059        }
1060    }
1061    Ok(())
1062}