Skip to main content

animsmith_core/
config.rs

1//! Typed configuration: rig selection, per-check settings, per-clip
2//! expectations and movement ownership, and typed clip groups. The TOML file (`animsmith.toml`) is
3//! *one* constructor of this — embedding pipelines build it
4//! programmatically through this module and keep their own contract
5//! formats on their side.
6//!
7//! The structs derive `Deserialize` so a frontend can parse any
8//! serde-compatible format (the CLI uses TOML); the core itself never
9//! touches a file format. [`crate::CheckCtx::new`] does not resolve
10//! [`Config::rig`]; the embedding frontend resolves roles first through
11//! [`crate::profile`] and passes the resulting [`crate::ResolvedRoles`].
12
13use crate::finding::Severity;
14use crate::metrics::MIN_STRIDE_STEP_M;
15use crate::profile::Role;
16use serde::{Deserialize, Deserializer};
17use std::collections::BTreeMap;
18
19/// A pinned expectation: declared value ± tolerance.
20#[derive(Debug, Clone, Copy, Deserialize)]
21pub struct Pinned {
22    /// Expected value.
23    pub value: f64,
24    /// Allowed absolute deviation from [`Pinned::value`].
25    pub tolerance: f64,
26}
27
28/// Severity override for a check; `Off` disables it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum SeveritySetting {
32    /// Remove the check from the run set.
33    Off,
34    /// Force content findings to notes.
35    Note,
36    /// Force content findings to warnings.
37    #[serde(alias = "warning")]
38    Warn,
39    /// Force content findings to errors.
40    Error,
41}
42
43/// The system that owns one component of a clip's world movement.
44///
45/// This is project intent, not a fact inferred from the animation or an
46/// engine profile. [`MovementOwner::Gameplay`] means the entity/controller
47/// supplies the component and an importer should bake it into pose;
48/// [`MovementOwner::Animation`] means extracted root motion supplies it.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum MovementOwner {
52    /// The entity or gameplay controller owns this movement component.
53    Gameplay,
54    /// Extracted animation root motion owns this movement component.
55    Animation,
56}
57
58impl MovementOwner {
59    /// Convert the legacy horizontal `in_place` declaration into its canonical
60    /// movement owner.
61    pub const fn from_in_place(in_place: bool) -> Self {
62        if in_place {
63            Self::Gameplay
64        } else {
65            Self::Animation
66        }
67    }
68}
69
70impl SeveritySetting {
71    /// Convert this setting into a finding severity.
72    ///
73    /// Returns `None` for [`SeveritySetting::Off`] because disabling a
74    /// check is handled before execution.
75    pub fn as_severity(self) -> Option<Severity> {
76        match self {
77            SeveritySetting::Off => None,
78            SeveritySetting::Note => Some(Severity::Note),
79            SeveritySetting::Warn => Some(Severity::Warning),
80            SeveritySetting::Error => Some(Severity::Error),
81        }
82    }
83}
84
85/// Per-check settings: a severity override plus the union of the
86/// built-in checks' tunables (only the owning check reads each field).
87#[derive(Debug, Clone, Default, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct CheckSettings {
90    /// Per-check severity override. `None` leaves the check's default
91    /// severity intact.
92    pub severity: Option<SeveritySetting>,
93    /// `loop-seam`: finite non-negative ratio above which the seam is a pop
94    /// (default 1.5).
95    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
96    pub max_ratio: Option<f64>,
97    /// `loop-seam`: finite non-negative stride floor in metres (default 0.02).
98    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
99    pub min_stride_step_m: Option<f64>,
100    /// `loop-closure`: finite non-negative maximum model-space position delta
101    /// in metres (default 0.01).
102    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
103    pub max_position_delta_m: Option<f64>,
104    /// `loop-closure`: finite non-negative maximum model-space rotation delta
105    /// in degrees (default 1.0).
106    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
107    pub max_rotation_delta_deg: Option<f64>,
108    /// `loop-seam-vel`: finite non-negative maximum incoming/outgoing
109    /// model-space linear-velocity difference in metres per second (default
110    /// 0.1).
111    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
112    pub max_velocity_delta_mps: Option<f64>,
113    /// `loop-seam-rot`: finite non-negative maximum incoming/outgoing
114    /// model-space angular-velocity difference in degrees per second (default
115    /// 5.0).
116    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
117    pub max_angular_velocity_delta_degps: Option<f64>,
118    /// `frozen-bone`: finite non-negative rotation floor in degrees (default
119    /// 1.0).
120    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
121    pub min_rotation_deg: Option<f64>,
122    /// `bind-pose`: finite non-negative mean first-frame deviation cap in
123    /// degrees (default 45).
124    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
125    pub max_mean_rest_delta_deg: Option<f64>,
126    /// `foot-slide`: finite non-negative contact height above the per-clip foot
127    /// minimum (default 0.03 m).
128    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
129    pub contact_height_m: Option<f64>,
130    /// `foot-slide`: finite non-negative allowed stance-speed deviation
131    /// (default 0.3 m/s).
132    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
133    pub max_slide_mps: Option<f64>,
134    /// Compatibility alias for [`RuntimeNodesConfig::selectors`].
135    ///
136    /// `rest-world-scale` consumes the shared runtime-node authority. This
137    /// legacy field remains accepted for existing configuration only when the
138    /// shared selector field is absent; declaring both fields is rejected by
139    /// [`Config::validate`].
140    pub node_selectors: Option<Vec<String>>,
141    /// `rest-world-scale`: expected positive uniform scale factor (default
142    /// 1.0).
143    #[serde(default, deserialize_with = "deserialize_positive_finite_option")]
144    pub expected_uniform_scale: Option<f64>,
145    /// `rest-world-scale`: inclusive absolute tolerance around the expected
146    /// uniform factor (default 0.0001).
147    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
148    pub uniform_scale_tolerance: Option<f64>,
149}
150
151/// What the author declares about one clip (or a glob of clips).
152#[derive(Debug, Clone, Default, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct ClipExpectations {
155    /// The clip is a cyclic loop; loop checks apply.
156    #[serde(rename = "loop")]
157    pub looping: Option<bool>,
158    /// `loop-closure`: per-clip maximum model-space position delta in
159    /// metres. When unset, the global `loop-closure` setting (or its
160    /// built-in default) applies.
161    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
162    pub max_loop_position_delta_m: Option<f64>,
163    /// `loop-closure`: per-clip maximum model-space rotation delta in
164    /// degrees. When unset, the global `loop-closure` setting (or its
165    /// built-in default) applies.
166    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
167    pub max_loop_rotation_delta_deg: Option<f64>,
168    /// `loop-seam-vel`: per-clip maximum incoming/outgoing model-space
169    /// linear-velocity difference in metres per second. When unset, the
170    /// global `loop-seam-vel` setting (or its built-in default) applies.
171    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
172    pub max_loop_velocity_delta_mps: Option<f64>,
173    /// `loop-seam-rot`: per-clip maximum incoming/outgoing model-space
174    /// angular-velocity difference in degrees per second. When unset, the
175    /// global `loop-seam-rot` setting (or its built-in default) applies.
176    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
177    pub max_loop_angular_velocity_delta_degps: Option<f64>,
178    /// Expected clip duration in seconds; consumed by the
179    /// `duration-sanity` check. Its value must be finite and positive,
180    /// and its tolerance must be finite and non-negative.
181    pub duration_s: Option<Pinned>,
182    /// Declared locomotion speed (m/s) carried by the clip's root
183    /// motion.
184    pub speed_mps: Option<Pinned>,
185    /// Owner of horizontal X/Z world movement.
186    pub movement_owner_xz: Option<MovementOwner>,
187    /// Owner of vertical Y world movement.
188    pub movement_owner_y: Option<MovementOwner>,
189    /// Owner of world yaw movement.
190    pub movement_owner_yaw: Option<MovementOwner>,
191    /// Compatibility input alias for [`ClipExpectations::movement_owner_xz`]:
192    /// `true` means [`MovementOwner::Gameplay`] and `false` means
193    /// [`MovementOwner::Animation`]. A selector entry must not declare both
194    /// spellings. Effective expectations returned by
195    /// [`Config::expectations_for`] normalize this alias into
196    /// `movement_owner_xz` and clear this field.
197    pub in_place: Option<bool>,
198    /// Authored frame rate; consumed by the `fps` check (keys must land
199    /// on the `1/fps` grid).
200    pub fps: Option<f64>,
201    /// Bones that must carry keyframes and actually move
202    /// (`missing-bones` presence + `frozen-bone` rotation floor).
203    pub animates_bones: Option<Vec<String>>,
204}
205
206/// Effective per-clip tolerances for loop pose and seam velocity.
207///
208/// Obtain this value through [`Config::loop_continuity_tolerances`]. It
209/// resolves the same exact-name/glob precedence and per-check defaults used by
210/// the built-in loop checks, so artifact proofs do not need a second tolerance
211/// authority.
212#[derive(Debug, Clone, Copy, PartialEq)]
213pub struct LoopContinuityTolerances {
214    max_position_delta_m: f64,
215    max_rotation_delta_deg: f64,
216    max_velocity_delta_mps: f64,
217    max_angular_velocity_delta_degps: f64,
218}
219
220impl LoopContinuityTolerances {
221    /// Inclusive model-space endpoint-position tolerance in metres.
222    pub const fn max_position_delta_m(self) -> f64 {
223        self.max_position_delta_m
224    }
225
226    /// Inclusive shortest-path endpoint-rotation tolerance in degrees.
227    pub const fn max_rotation_delta_deg(self) -> f64 {
228        self.max_rotation_delta_deg
229    }
230
231    /// Inclusive incoming/outgoing linear-velocity tolerance in metres per second.
232    pub const fn max_velocity_delta_mps(self) -> f64 {
233        self.max_velocity_delta_mps
234    }
235
236    /// Inclusive incoming/outgoing angular-velocity tolerance in degrees per second.
237    pub const fn max_angular_velocity_delta_degps(self) -> f64 {
238        self.max_angular_velocity_delta_degps
239    }
240}
241
242impl ClipExpectations {
243    /// Canonical horizontal owner declared by this selector entry.
244    ///
245    /// Call [`Config::validate`] before resolving expectations so a selector
246    /// that declares both the canonical field and its compatibility alias is
247    /// rejected as a typed configuration error.
248    pub fn normalized_movement_owner_xz(&self) -> Option<MovementOwner> {
249        self.movement_owner_xz
250            .or_else(|| self.in_place.map(MovementOwner::from_in_place))
251    }
252
253    /// Overlay `other` on `self` (other's set fields win).
254    fn merged_with(&self, other: &ClipExpectations) -> ClipExpectations {
255        ClipExpectations {
256            looping: other.looping.or(self.looping),
257            max_loop_position_delta_m: other
258                .max_loop_position_delta_m
259                .or(self.max_loop_position_delta_m),
260            max_loop_rotation_delta_deg: other
261                .max_loop_rotation_delta_deg
262                .or(self.max_loop_rotation_delta_deg),
263            max_loop_velocity_delta_mps: other
264                .max_loop_velocity_delta_mps
265                .or(self.max_loop_velocity_delta_mps),
266            max_loop_angular_velocity_delta_degps: other
267                .max_loop_angular_velocity_delta_degps
268                .or(self.max_loop_angular_velocity_delta_degps),
269            duration_s: other.duration_s.or(self.duration_s),
270            speed_mps: other.speed_mps.or(self.speed_mps),
271            movement_owner_xz: other
272                .normalized_movement_owner_xz()
273                .or_else(|| self.normalized_movement_owner_xz()),
274            movement_owner_y: other.movement_owner_y.or(self.movement_owner_y),
275            movement_owner_yaw: other.movement_owner_yaw.or(self.movement_owner_yaw),
276            in_place: None,
277            fps: other.fps.or(self.fps),
278            animates_bones: other
279                .animates_bones
280                .clone()
281                .or_else(|| self.animates_bones.clone()),
282        }
283    }
284}
285
286/// Deserialize an optional non-negative finite cap.
287///
288/// Loop-continuity evidence is always non-negative and finite, so accepting a
289/// negative or non-finite cap would make its pass/fail result surprising.
290fn deserialize_nonnegative_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
291where
292    D: Deserializer<'de>,
293{
294    let value = Option::<f64>::deserialize(deserializer)?;
295    if let Some(value) = value
296        && !is_nonnegative_finite(value)
297    {
298        return Err(serde::de::Error::custom(
299            "must be a finite non-negative number",
300        ));
301    }
302    Ok(value)
303}
304
305fn deserialize_positive_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
306where
307    D: Deserializer<'de>,
308{
309    let value = Option::<f64>::deserialize(deserializer)?;
310    if value.is_some_and(|value| !is_positive_finite(value)) {
311        return Err(serde::de::Error::custom(
312            "must be a finite number greater than zero",
313        ));
314    }
315    Ok(value)
316}
317
318fn is_nonnegative_finite(value: f64) -> bool {
319    value.is_finite() && value >= 0.0
320}
321
322fn is_positive_finite(value: f64) -> bool {
323    value.is_finite() && value > 0.0
324}
325
326/// A set of clips whose gait phases must agree (a directional blend
327/// ring).
328#[derive(Debug, Clone, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct GaitGroup {
331    /// Clip names that should share a gait phase.
332    pub clips: Vec<String>,
333    /// Maximum circular spread of the members' gait phases, in cycle
334    /// fraction `[0, 0.5]`.
335    ///
336    /// Half a cycle is the widest a circular spread can be, so a larger cap
337    /// admits every possible set and a negative or non-finite one is not a
338    /// tolerance at all. The TOML boundary refuses those; an embedder
339    /// building this value programmatically owes the same range.
340    #[serde(deserialize_with = "deserialize_half_cycle")]
341    pub max_gait_phase_spread: f64,
342    /// Members with L−R amplitude under this (metres) are excluded from
343    /// the spread (their phase is noise, not signal).
344    #[serde(default, deserialize_with = "deserialize_nonnegative_finite")]
345    pub min_lr_amplitude_m: f64,
346}
347
348fn deserialize_half_cycle<'de, D>(deserializer: D) -> Result<f64, D::Error>
349where
350    D: Deserializer<'de>,
351{
352    let value = f64::deserialize(deserializer)?;
353    if !is_half_cycle(value) {
354        return Err(serde::de::Error::custom(
355            "must be a finite number in the range [0, 0.5] cycle",
356        ));
357    }
358    Ok(value)
359}
360
361/// Whether a circular tolerance is a cycle fraction a spread can exceed.
362/// Half a cycle is the widest circular spread there is, so a larger cap
363/// admits every possible set.
364fn is_half_cycle(value: f64) -> bool {
365    value.is_finite() && (0.0..=0.5).contains(&value)
366}
367
368/// Thresholds for detecting a pair that is more phase-similar under reflected
369/// time than under a declared same-time / absolute-sync rule.
370#[derive(Debug, Clone, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct TimeComplementSettings {
373    /// Minimum reflected-time minus same-time phase-similarity score required
374    /// to report the diagnostic. This threshold is in `[0, 1]`; emitted
375    /// advantages are positive and no greater than one.
376    #[serde(deserialize_with = "deserialize_unit_interval")]
377    pub min_reflected_time_advantage: f64,
378    /// Minimum L−R foot-height amplitude (metres) required before a phase is
379    /// considered evidence rather than noise.
380    #[serde(deserialize_with = "deserialize_nonnegative_finite")]
381    pub min_lr_amplitude_m: f64,
382}
383
384fn deserialize_unit_interval<'de, D>(deserializer: D) -> Result<f64, D::Error>
385where
386    D: Deserializer<'de>,
387{
388    let value = f64::deserialize(deserializer)?;
389    if !is_unit_interval(value) {
390        return Err(serde::de::Error::custom(
391            "must be a finite number in the range [0, 1]",
392        ));
393    }
394    Ok(value)
395}
396
397fn deserialize_nonnegative_finite<'de, D>(deserializer: D) -> Result<f64, D::Error>
398where
399    D: Deserializer<'de>,
400{
401    let value = f64::deserialize(deserializer)?;
402    if !is_nonnegative_finite(value) {
403        return Err(serde::de::Error::custom(
404            "must be a finite non-negative number",
405        ));
406    }
407    Ok(value)
408}
409
410fn is_unit_interval(value: f64) -> bool {
411    value.is_finite() && (0.0..=1.0).contains(&value)
412}
413
414/// A set of clips sampled together by a same-time / absolute-sync runtime.
415///
416/// The group compares timing representations; it does not prescribe a runtime
417/// retiming or repair strategy.
418#[derive(Debug, Clone, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct SyncGroup {
421    /// Clip names that must be compatible when sampled at the same time.
422    pub clips: Vec<String>,
423    /// Largest permitted duration range across members, in seconds.
424    pub max_duration_delta_s: f64,
425    /// Largest permitted longest-channel key-count range across members.
426    pub max_frame_count_delta: u32,
427    /// Largest permitted declared frame-rate range across members.
428    pub max_fps_delta: f64,
429    /// Optional phase-similarity diagnostic for time-complementary member
430    /// pairs. A two-member group declares one pair; larger groups compare each
431    /// configured-order unordered pair.
432    #[serde(default)]
433    pub time_complement: Option<TimeComplementSettings>,
434}
435
436/// Rig selection: a named profile ("auto" to detect) and/or an inline
437/// role map (which wins over the profile for the roles it names).
438#[derive(Debug, Clone, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct RigConfig {
441    /// Built-in profile name, or `"auto"` to select the best built-in
442    /// match.
443    #[serde(default = "default_profile")]
444    pub profile: String,
445    /// Inline role-to-bone-name bindings. These are interpreted as
446    /// explicit overrides by callers that merge them with a profile.
447    #[serde(default)]
448    pub roles: BTreeMap<Role, String>,
449    /// Bone names that must be present in the file's skeleton, regardless of
450    /// whether any clip keys them. This is for static runtime sockets, IK
451    /// targets, and mask bones; use [`ClipExpectations::animates_bones`] when
452    /// a bone must carry animation data in a particular clip.
453    pub required_bones: Option<Vec<String>>,
454}
455
456fn default_profile() -> String {
457    "auto".into()
458}
459
460impl Default for RigConfig {
461    fn default() -> Self {
462        Self {
463            profile: default_profile(),
464            roles: BTreeMap::new(),
465            required_bones: None,
466        }
467    }
468}
469
470/// Engine-neutral runtime-node selection policy.
471///
472/// A runtime node is a source node whose identity matters to a consuming
473/// runtime, such as an attachment socket or IK target. The policy intentionally
474/// says nothing about a particular engine or the operation consuming it. An
475/// absent [`Self::selectors`] field and an explicit empty list both mean no
476/// runtime-node policy is declared.
477#[derive(Debug, Clone, Default, Deserialize)]
478#[serde(deny_unknown_fields)]
479pub struct RuntimeNodesConfig {
480    /// Exact source-node names or `*` globs, in declared priority order.
481    ///
482    /// Duplicate selectors are accepted and deterministically de-duplicated
483    /// by [`Config::runtime_node_selectors`], retaining their first occurrence.
484    #[serde(default)]
485    pub selectors: Option<Vec<String>>,
486}
487
488/// Normalized, deterministic runtime-node selection authority.
489///
490/// Obtain this value through [`Config::runtime_node_selectors`] after calling
491/// [`Config::validate`]. It retains configured selector order while removing
492/// later duplicate spellings.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct RuntimeNodeSelectors {
495    selectors: Vec<String>,
496}
497
498impl RuntimeNodeSelectors {
499    fn new(selectors: &[String]) -> Self {
500        let mut seen = std::collections::BTreeSet::new();
501        Self {
502            selectors: selectors
503                .iter()
504                .filter(|selector| seen.insert(selector.as_str()))
505                .cloned()
506                .collect(),
507        }
508    }
509
510    /// Normalized selectors in configured first-occurrence order.
511    pub fn selectors(&self) -> &[String] {
512        &self.selectors
513    }
514
515    /// Resolve every selector against named candidates in deterministic input
516    /// order.
517    ///
518    /// Exact names and `*` globs follow [`glob_match`]. Every configured
519    /// selector receives a result so consumers can preserve distinct no-match
520    /// and ambiguity handling without duplicating selector semantics. Unnamed
521    /// candidates are represented by omitting them from `candidates`.
522    pub fn resolve<'a, T: Clone>(
523        &self,
524        candidates: impl IntoIterator<Item = (&'a str, T)>,
525    ) -> Vec<RuntimeNodeSelectorResolution<T>> {
526        let candidates = candidates.into_iter().collect::<Vec<_>>();
527        self.selectors
528            .iter()
529            .map(|selector| {
530                let matches = candidates
531                    .iter()
532                    .filter(|(name, _)| glob_match(selector, name))
533                    .map(|(_, candidate)| candidate.clone())
534                    .collect::<Vec<_>>();
535                match matches.as_slice() {
536                    [] => RuntimeNodeSelectorResolution::NoMatch {
537                        selector: selector.clone(),
538                    },
539                    [node] => RuntimeNodeSelectorResolution::ExactlyOne {
540                        selector: selector.clone(),
541                        node: node.clone(),
542                    },
543                    _ => RuntimeNodeSelectorResolution::Ambiguous {
544                        selector: selector.clone(),
545                        nodes: matches,
546                    },
547                }
548            })
549            .collect()
550    }
551}
552
553/// One runtime-node selector's deterministic resolution result.
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum RuntimeNodeSelectorResolution<T> {
556    /// The selector matched no named candidate.
557    NoMatch {
558        /// Configured selector spelling.
559        selector: String,
560    },
561    /// The selector resolved to exactly one candidate.
562    ExactlyOne {
563        /// Configured selector spelling.
564        selector: String,
565        /// The sole matching candidate.
566        node: T,
567    },
568    /// The selector matched more than one candidate.
569    Ambiguous {
570        /// Configured selector spelling.
571        selector: String,
572        /// Matching candidates in the input's deterministic order.
573        nodes: Vec<T>,
574    },
575}
576
577/// Invalid values in a directly constructed [`Config`].
578///
579/// Numeric values are intentionally not retained in this error so it remains
580/// equality-comparable even when the rejected input was `NaN`.
581#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
582#[non_exhaustive]
583pub enum ConfigValidationError {
584    /// A numeric per-check policy setting was outside its documented domain.
585    #[error("check {check_id:?} field {field:?} must be finite and within its documented range")]
586    InvalidCheckSetting {
587        /// Configured stable check id.
588        check_id: String,
589        /// Stable public [`CheckSettings`] field name.
590        field: &'static str,
591    },
592    /// A per-clip loop-continuity cap was negative or non-finite.
593    #[error("clip selector {selector:?} field {field:?} must be a finite non-negative number")]
594    InvalidClipLoopCap {
595        /// Exact clip name or glob containing the invalid cap.
596        selector: String,
597        /// Stable public [`ClipExpectations`] field name.
598        field: &'static str,
599    },
600    /// One clip selector declared both the canonical horizontal owner and its
601    /// legacy `in_place` alias.
602    #[error(
603        "clip selector {selector:?} cannot declare both \"movement_owner_xz\" and \"in_place\""
604    )]
605    ConflictingClipMovementOwner {
606        /// Exact clip name or glob containing both spellings.
607        selector: String,
608    },
609    /// The shared runtime-node selector field and its rest-world-scale
610    /// compatibility alias were both declared.
611    #[error(
612        "cannot declare both \"runtime_nodes.selectors\" and \"checks.rest-world-scale.node_selectors\""
613    )]
614    ConflictingRuntimeNodeSelectors,
615    /// A gait-group tolerance was outside its finite declared domain.
616    #[error("gait group {group:?} field {field:?} must be finite and within its documented range")]
617    InvalidGaitGroupTolerance {
618        /// Declared gait group whose tolerance is invalid.
619        group: String,
620        /// Offending field name.
621        field: &'static str,
622    },
623    /// A sync-group tolerance was negative or non-finite.
624    #[error("sync group {group:?} field {field:?} must be a finite non-negative number")]
625    InvalidSyncGroupTolerance {
626        /// Configured group name.
627        group: String,
628        /// Stable public field name.
629        field: &'static str,
630    },
631    /// A time-complement setting was outside its finite declared domain.
632    #[error(
633        "sync group {group:?} time-complement field {field:?} must be finite and within its documented range"
634    )]
635    InvalidTimeComplementSetting {
636        /// Configured group name.
637        group: String,
638        /// Stable public field name.
639        field: &'static str,
640    },
641}
642
643/// The whole configuration. Field names match the `animsmith.toml`
644/// sections.
645///
646/// [`Self::runtime_nodes`] is an intentional pre-1.0 additive public field.
647/// Embedders that construct this struct with a literal must add
648/// `runtime_nodes: RuntimeNodesConfig::default()` or use
649/// `..Config::default()`.
650#[derive(Debug, Clone, Default, Deserialize)]
651#[serde(deny_unknown_fields)]
652pub struct Config {
653    /// Declarative rig profile and inline role bindings. Frontends resolve
654    /// these into [`crate::ResolvedRoles`] before creating a check context;
655    /// the core runner does not apply them automatically.
656    #[serde(default)]
657    pub rig: RigConfig,
658    /// Per-check settings keyed by stable check id.
659    #[serde(default)]
660    pub checks: BTreeMap<String, CheckSettings>,
661    /// Shared engine-neutral policy for source nodes addressed by the runtime.
662    #[serde(default)]
663    pub runtime_nodes: RuntimeNodesConfig,
664    /// Keyed by clip name or glob (`*` wildcards). An exact-name entry
665    /// overrides glob entries; among globs, later (lexicographically
666    /// greater) keys win on conflict.
667    #[serde(default)]
668    pub clips: BTreeMap<String, ClipExpectations>,
669    /// Named gait groups consumed by the `gait-group` check.
670    #[serde(default)]
671    pub gait_groups: BTreeMap<String, GaitGroup>,
672    /// Named same-time / absolute-sync groups consumed by `sync-group`.
673    #[serde(default)]
674    pub sync_groups: BTreeMap<String, SyncGroup>,
675}
676
677impl Config {
678    /// Validate values that can also be supplied through the public Rust
679    /// configuration structs.
680    ///
681    /// Deserialization rejects the same invalid values at the file/config
682    /// boundary. Embedded callers that construct [`Config`] directly must call
683    /// this method before passing it to measurement-only APIs;
684    /// [`crate::evaluate_checks`] always calls it before inspecting or
685    /// executing the supplied check catalog.
686    ///
687    /// # Errors
688    ///
689    /// Returns [`ConfigValidationError::InvalidCheckSetting`] when a direct
690    /// per-check numeric policy is outside its documented finite domain,
691    /// [`ConfigValidationError::InvalidClipLoopCap`] when a clip selector
692    /// contains a negative or non-finite per-clip loop cap,
693    /// [`ConfigValidationError::ConflictingClipMovementOwner`] when one clip
694    /// selector declares both `movement_owner_xz` and its `in_place` alias,
695    /// [`ConfigValidationError::ConflictingRuntimeNodeSelectors`] when shared
696    /// runtime-node selectors and the rest-world-scale compatibility alias are
697    /// both declared,
698    /// [`ConfigValidationError::InvalidGaitGroupTolerance`] when a declared
699    /// blend ring has an invalid phase cap or amplitude floor,
700    /// [`ConfigValidationError::InvalidSyncGroupTolerance`] when a same-time
701    /// group has an invalid timing tolerance, or
702    /// [`ConfigValidationError::InvalidTimeComplementSetting`] when an
703    /// enabled time-complement policy has an invalid threshold.
704    pub fn validate(&self) -> Result<(), ConfigValidationError> {
705        if self.runtime_nodes.selectors.is_some()
706            && self
707                .checks
708                .get("rest-world-scale")
709                .is_some_and(|settings| settings.node_selectors.is_some())
710        {
711            return Err(ConfigValidationError::ConflictingRuntimeNodeSelectors);
712        }
713        for (check_id, settings) in &self.checks {
714            for (field, valid) in [
715                (
716                    "max_ratio",
717                    settings.max_ratio.is_none_or(is_nonnegative_finite),
718                ),
719                (
720                    "min_stride_step_m",
721                    settings.min_stride_step_m.is_none_or(is_nonnegative_finite),
722                ),
723                (
724                    "max_position_delta_m",
725                    settings
726                        .max_position_delta_m
727                        .is_none_or(is_nonnegative_finite),
728                ),
729                (
730                    "max_rotation_delta_deg",
731                    settings
732                        .max_rotation_delta_deg
733                        .is_none_or(is_nonnegative_finite),
734                ),
735                (
736                    "max_velocity_delta_mps",
737                    settings
738                        .max_velocity_delta_mps
739                        .is_none_or(is_nonnegative_finite),
740                ),
741                (
742                    "max_angular_velocity_delta_degps",
743                    settings
744                        .max_angular_velocity_delta_degps
745                        .is_none_or(is_nonnegative_finite),
746                ),
747                (
748                    "min_rotation_deg",
749                    settings.min_rotation_deg.is_none_or(is_nonnegative_finite),
750                ),
751                (
752                    "max_mean_rest_delta_deg",
753                    settings
754                        .max_mean_rest_delta_deg
755                        .is_none_or(is_nonnegative_finite),
756                ),
757                (
758                    "contact_height_m",
759                    settings.contact_height_m.is_none_or(is_nonnegative_finite),
760                ),
761                (
762                    "max_slide_mps",
763                    settings.max_slide_mps.is_none_or(is_nonnegative_finite),
764                ),
765                (
766                    "expected_uniform_scale",
767                    settings
768                        .expected_uniform_scale
769                        .is_none_or(is_positive_finite),
770                ),
771                (
772                    "uniform_scale_tolerance",
773                    settings
774                        .uniform_scale_tolerance
775                        .is_none_or(is_nonnegative_finite),
776                ),
777            ] {
778                if !valid {
779                    return Err(ConfigValidationError::InvalidCheckSetting {
780                        check_id: check_id.clone(),
781                        field,
782                    });
783                }
784            }
785        }
786        for (selector, expectations) in &self.clips {
787            if expectations.movement_owner_xz.is_some() && expectations.in_place.is_some() {
788                return Err(ConfigValidationError::ConflictingClipMovementOwner {
789                    selector: selector.clone(),
790                });
791            }
792            for (field, value) in [
793                (
794                    "max_loop_position_delta_m",
795                    expectations.max_loop_position_delta_m,
796                ),
797                (
798                    "max_loop_rotation_delta_deg",
799                    expectations.max_loop_rotation_delta_deg,
800                ),
801                (
802                    "max_loop_velocity_delta_mps",
803                    expectations.max_loop_velocity_delta_mps,
804                ),
805                (
806                    "max_loop_angular_velocity_delta_degps",
807                    expectations.max_loop_angular_velocity_delta_degps,
808                ),
809            ] {
810                if value.is_some_and(|value| !is_nonnegative_finite(value)) {
811                    return Err(ConfigValidationError::InvalidClipLoopCap {
812                        selector: selector.clone(),
813                        field,
814                    });
815                }
816            }
817        }
818        // A gait group's tolerances have the same two homes as a
819        // time-complement setting's: the deserializer refuses them where a
820        // file declares them, and this refuses them where a pipeline builds
821        // the configuration itself. A cap that is not a number is not a
822        // tolerance — `spread > NaN` is false, so the check could never fail
823        // against it, and a presentation would draw a band around nothing.
824        for (group, gait) in &self.gait_groups {
825            for (field, valid) in [
826                (
827                    "max_gait_phase_spread",
828                    is_half_cycle(gait.max_gait_phase_spread),
829                ),
830                (
831                    "min_lr_amplitude_m",
832                    is_nonnegative_finite(gait.min_lr_amplitude_m),
833                ),
834            ] {
835                if !valid {
836                    return Err(ConfigValidationError::InvalidGaitGroupTolerance {
837                        group: group.clone(),
838                        field,
839                    });
840                }
841            }
842        }
843        for (group, sync) in &self.sync_groups {
844            for (field, value) in [
845                ("max_duration_delta_s", sync.max_duration_delta_s),
846                ("max_fps_delta", sync.max_fps_delta),
847            ] {
848                if !is_nonnegative_finite(value) {
849                    return Err(ConfigValidationError::InvalidSyncGroupTolerance {
850                        group: group.clone(),
851                        field,
852                    });
853                }
854            }
855            if let Some(settings) = &sync.time_complement {
856                for (field, valid) in [
857                    (
858                        "min_reflected_time_advantage",
859                        is_unit_interval(settings.min_reflected_time_advantage),
860                    ),
861                    (
862                        "min_lr_amplitude_m",
863                        is_nonnegative_finite(settings.min_lr_amplitude_m),
864                    ),
865                ] {
866                    if !valid {
867                        return Err(ConfigValidationError::InvalidTimeComplementSetting {
868                            group: group.clone(),
869                            field,
870                        });
871                    }
872                }
873            }
874        }
875        Ok(())
876    }
877
878    /// Effective expectations for a clip: glob matches (in key order)
879    /// overlaid, exact match last.
880    ///
881    /// Each selector entry's legacy `in_place` input is normalized into
882    /// [`ClipExpectations::movement_owner_xz`] before the field overlay. The
883    /// returned value therefore always has [`ClipExpectations::in_place`] set
884    /// to `None`. Call [`Config::validate`] before this method so same-entry
885    /// alias conflicts are rejected rather than resolved by precedence.
886    pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
887        let mut out = ClipExpectations::default();
888        for (pattern, exp) in &self.clips {
889            if pattern != clip && glob_match(pattern, clip) {
890                out = out.merged_with(exp);
891            }
892        }
893        if let Some(exact) = self.clips.get(clip) {
894            out = out.merged_with(exact);
895        }
896        out
897    }
898
899    /// Settings for a check id, or defaults when the id is not present.
900    pub fn check_settings(&self, id: &str) -> CheckSettings {
901        self.checks.get(id).cloned().unwrap_or_default()
902    }
903
904    /// The normalized runtime-node authority, if one is declared.
905    ///
906    /// The shared [`RuntimeNodesConfig::selectors`] field is used when it is
907    /// present. Callers must first call [`Self::validate`]: simultaneous use
908    /// of the legacy `checks.rest-world-scale.node_selectors` alias is rejected
909    /// and has no precedence rule. An absent field or explicit empty list
910    /// returns `None` and declares no policy.
911    pub fn runtime_node_selectors(&self) -> Option<RuntimeNodeSelectors> {
912        let selectors = self.runtime_nodes.selectors.as_ref().or_else(|| {
913            self.checks
914                .get("rest-world-scale")
915                .and_then(|settings| settings.node_selectors.as_ref())
916        })?;
917        (!selectors.is_empty()).then(|| RuntimeNodeSelectors::new(selectors))
918    }
919
920    /// Effective stride floor for loop-seam metrics, in metres.
921    pub fn loop_seam_min_stride_step_m(&self) -> f64 {
922        self.check_settings("loop-seam")
923            .min_stride_step_m
924            .unwrap_or(MIN_STRIDE_STEP_M)
925    }
926
927    /// Resolve the exact loop-pose and seam-velocity tolerances for `clip`.
928    ///
929    /// Per-clip values win over their owning check settings, which in turn
930    /// fall back to the immutable built-in check defaults. Call
931    /// [`Self::validate`] before using configuration supplied by an untrusted
932    /// decoder.
933    pub fn loop_continuity_tolerances(&self, clip: &str) -> LoopContinuityTolerances {
934        let expectations = self.expectations_for(clip);
935        self.loop_continuity_tolerances_for(&expectations)
936    }
937
938    pub(crate) fn loop_continuity_tolerances_for(
939        &self,
940        expectations: &ClipExpectations,
941    ) -> LoopContinuityTolerances {
942        let (max_position_delta_m, max_rotation_delta_deg) =
943            crate::checks::loop_closure::effective_caps(self, expectations);
944        LoopContinuityTolerances {
945            max_position_delta_m,
946            max_rotation_delta_deg,
947            max_velocity_delta_mps: crate::checks::loop_seam_vel::effective_cap(self, expectations),
948            max_angular_velocity_delta_degps: crate::checks::loop_seam_rot::effective_cap(
949                self,
950                expectations,
951            ),
952        }
953    }
954}
955
956/// Minimal linear-work `*`-wildcard matcher (no character classes; `*`
957/// matches any run including empty).
958pub fn glob_match(pattern: &str, name: &str) -> bool {
959    let pattern = pattern.as_bytes();
960    let name = name.as_bytes();
961    let Some(first_star) = pattern.iter().position(|&byte| byte == b'*') else {
962        return pattern == name;
963    };
964    let last_star = pattern
965        .iter()
966        .rposition(|&byte| byte == b'*')
967        .unwrap_or(first_star);
968
969    let prefix = &pattern[..first_star];
970    let suffix = &pattern[last_star + 1..];
971    if !name.starts_with(prefix) || !name.ends_with(suffix) {
972        return false;
973    }
974
975    let mut name_index = prefix.len();
976    let search_end = name.len() - suffix.len();
977    if name_index > search_end {
978        return false;
979    }
980
981    if first_star < last_star {
982        let mut failure = Vec::new();
983        for literal in pattern[first_star + 1..last_star]
984            .split(|&byte| byte == b'*')
985            .filter(|literal| !literal.is_empty())
986        {
987            let Some(offset) =
988                find_subslice_linear(&name[name_index..search_end], literal, &mut failure)
989            else {
990                return false;
991            };
992            name_index += offset + literal.len();
993        }
994    }
995
996    true
997}
998
999fn find_subslice_linear(haystack: &[u8], needle: &[u8], failure: &mut Vec<usize>) -> Option<usize> {
1000    debug_assert!(!needle.is_empty());
1001    if needle.len() > haystack.len() {
1002        return None;
1003    }
1004
1005    failure.clear();
1006    failure.resize(needle.len(), 0);
1007    let mut prefix_len = 0;
1008    for index in 1..needle.len() {
1009        while prefix_len > 0 && needle[index] != needle[prefix_len] {
1010            prefix_len = failure[prefix_len - 1];
1011        }
1012        if needle[index] == needle[prefix_len] {
1013            prefix_len += 1;
1014        }
1015        failure[index] = prefix_len;
1016    }
1017
1018    let mut matched = 0;
1019    for (index, &byte) in haystack.iter().enumerate() {
1020        while matched > 0 && byte != needle[matched] {
1021            matched = failure[matched - 1];
1022        }
1023        if byte == needle[matched] {
1024            matched += 1;
1025            if matched == needle.len() {
1026                return Some(index + 1 - needle.len());
1027            }
1028        }
1029    }
1030    None
1031}