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    pub max_gait_phase_spread: f64,
336    /// Members with L−R amplitude under this (metres) are excluded from
337    /// the spread (their phase is noise, not signal).
338    #[serde(default)]
339    pub min_lr_amplitude_m: f64,
340}
341
342/// Thresholds for detecting a pair that is more phase-similar under reflected
343/// time than under a declared same-time / absolute-sync rule.
344#[derive(Debug, Clone, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct TimeComplementSettings {
347    /// Minimum reflected-time minus same-time phase-similarity score required
348    /// to report the diagnostic. This threshold is in `[0, 1]`; emitted
349    /// advantages are positive and no greater than one.
350    #[serde(deserialize_with = "deserialize_unit_interval")]
351    pub min_reflected_time_advantage: f64,
352    /// Minimum L−R foot-height amplitude (metres) required before a phase is
353    /// considered evidence rather than noise.
354    #[serde(deserialize_with = "deserialize_nonnegative_finite")]
355    pub min_lr_amplitude_m: f64,
356}
357
358fn deserialize_unit_interval<'de, D>(deserializer: D) -> Result<f64, D::Error>
359where
360    D: Deserializer<'de>,
361{
362    let value = f64::deserialize(deserializer)?;
363    if !is_unit_interval(value) {
364        return Err(serde::de::Error::custom(
365            "must be a finite number in the range [0, 1]",
366        ));
367    }
368    Ok(value)
369}
370
371fn deserialize_nonnegative_finite<'de, D>(deserializer: D) -> Result<f64, D::Error>
372where
373    D: Deserializer<'de>,
374{
375    let value = f64::deserialize(deserializer)?;
376    if !is_nonnegative_finite(value) {
377        return Err(serde::de::Error::custom(
378            "must be a finite non-negative number",
379        ));
380    }
381    Ok(value)
382}
383
384fn is_unit_interval(value: f64) -> bool {
385    value.is_finite() && (0.0..=1.0).contains(&value)
386}
387
388/// A set of clips sampled together by a same-time / absolute-sync runtime.
389///
390/// The group compares timing representations; it does not prescribe a runtime
391/// retiming or repair strategy.
392#[derive(Debug, Clone, Deserialize)]
393#[serde(deny_unknown_fields)]
394pub struct SyncGroup {
395    /// Clip names that must be compatible when sampled at the same time.
396    pub clips: Vec<String>,
397    /// Largest permitted duration range across members, in seconds.
398    pub max_duration_delta_s: f64,
399    /// Largest permitted longest-channel key-count range across members.
400    pub max_frame_count_delta: u32,
401    /// Largest permitted declared frame-rate range across members.
402    pub max_fps_delta: f64,
403    /// Optional phase-similarity diagnostic for time-complementary member
404    /// pairs. A two-member group declares one pair; larger groups compare each
405    /// configured-order unordered pair.
406    #[serde(default)]
407    pub time_complement: Option<TimeComplementSettings>,
408}
409
410/// Rig selection: a named profile ("auto" to detect) and/or an inline
411/// role map (which wins over the profile for the roles it names).
412#[derive(Debug, Clone, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub struct RigConfig {
415    /// Built-in profile name, or `"auto"` to select the best built-in
416    /// match.
417    #[serde(default = "default_profile")]
418    pub profile: String,
419    /// Inline role-to-bone-name bindings. These are interpreted as
420    /// explicit overrides by callers that merge them with a profile.
421    #[serde(default)]
422    pub roles: BTreeMap<Role, String>,
423    /// Bone names that must be present in the file's skeleton, regardless of
424    /// whether any clip keys them. This is for static runtime sockets, IK
425    /// targets, and mask bones; use [`ClipExpectations::animates_bones`] when
426    /// a bone must carry animation data in a particular clip.
427    pub required_bones: Option<Vec<String>>,
428}
429
430fn default_profile() -> String {
431    "auto".into()
432}
433
434impl Default for RigConfig {
435    fn default() -> Self {
436        Self {
437            profile: default_profile(),
438            roles: BTreeMap::new(),
439            required_bones: None,
440        }
441    }
442}
443
444/// Engine-neutral runtime-node selection policy.
445///
446/// A runtime node is a source node whose identity matters to a consuming
447/// runtime, such as an attachment socket or IK target. The policy intentionally
448/// says nothing about a particular engine or the operation consuming it. An
449/// absent [`Self::selectors`] field and an explicit empty list both mean no
450/// runtime-node policy is declared.
451#[derive(Debug, Clone, Default, Deserialize)]
452#[serde(deny_unknown_fields)]
453pub struct RuntimeNodesConfig {
454    /// Exact source-node names or `*` globs, in declared priority order.
455    ///
456    /// Duplicate selectors are accepted and deterministically de-duplicated
457    /// by [`Config::runtime_node_selectors`], retaining their first occurrence.
458    #[serde(default)]
459    pub selectors: Option<Vec<String>>,
460}
461
462/// Normalized, deterministic runtime-node selection authority.
463///
464/// Obtain this value through [`Config::runtime_node_selectors`] after calling
465/// [`Config::validate`]. It retains configured selector order while removing
466/// later duplicate spellings.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct RuntimeNodeSelectors {
469    selectors: Vec<String>,
470}
471
472impl RuntimeNodeSelectors {
473    fn new(selectors: &[String]) -> Self {
474        let mut seen = std::collections::BTreeSet::new();
475        Self {
476            selectors: selectors
477                .iter()
478                .filter(|selector| seen.insert(selector.as_str()))
479                .cloned()
480                .collect(),
481        }
482    }
483
484    /// Normalized selectors in configured first-occurrence order.
485    pub fn selectors(&self) -> &[String] {
486        &self.selectors
487    }
488
489    /// Resolve every selector against named candidates in deterministic input
490    /// order.
491    ///
492    /// Exact names and `*` globs follow [`glob_match`]. Every configured
493    /// selector receives a result so consumers can preserve distinct no-match
494    /// and ambiguity handling without duplicating selector semantics. Unnamed
495    /// candidates are represented by omitting them from `candidates`.
496    pub fn resolve<'a, T: Clone>(
497        &self,
498        candidates: impl IntoIterator<Item = (&'a str, T)>,
499    ) -> Vec<RuntimeNodeSelectorResolution<T>> {
500        let candidates = candidates.into_iter().collect::<Vec<_>>();
501        self.selectors
502            .iter()
503            .map(|selector| {
504                let matches = candidates
505                    .iter()
506                    .filter(|(name, _)| glob_match(selector, name))
507                    .map(|(_, candidate)| candidate.clone())
508                    .collect::<Vec<_>>();
509                match matches.as_slice() {
510                    [] => RuntimeNodeSelectorResolution::NoMatch {
511                        selector: selector.clone(),
512                    },
513                    [node] => RuntimeNodeSelectorResolution::ExactlyOne {
514                        selector: selector.clone(),
515                        node: node.clone(),
516                    },
517                    _ => RuntimeNodeSelectorResolution::Ambiguous {
518                        selector: selector.clone(),
519                        nodes: matches,
520                    },
521                }
522            })
523            .collect()
524    }
525}
526
527/// One runtime-node selector's deterministic resolution result.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum RuntimeNodeSelectorResolution<T> {
530    /// The selector matched no named candidate.
531    NoMatch {
532        /// Configured selector spelling.
533        selector: String,
534    },
535    /// The selector resolved to exactly one candidate.
536    ExactlyOne {
537        /// Configured selector spelling.
538        selector: String,
539        /// The sole matching candidate.
540        node: T,
541    },
542    /// The selector matched more than one candidate.
543    Ambiguous {
544        /// Configured selector spelling.
545        selector: String,
546        /// Matching candidates in the input's deterministic order.
547        nodes: Vec<T>,
548    },
549}
550
551/// Invalid values in a directly constructed [`Config`].
552///
553/// Numeric values are intentionally not retained in this error so it remains
554/// equality-comparable even when the rejected input was `NaN`.
555#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
556#[non_exhaustive]
557pub enum ConfigValidationError {
558    /// A numeric per-check policy setting was outside its documented domain.
559    #[error("check {check_id:?} field {field:?} must be finite and within its documented range")]
560    InvalidCheckSetting {
561        /// Configured stable check id.
562        check_id: String,
563        /// Stable public [`CheckSettings`] field name.
564        field: &'static str,
565    },
566    /// A per-clip loop-continuity cap was negative or non-finite.
567    #[error("clip selector {selector:?} field {field:?} must be a finite non-negative number")]
568    InvalidClipLoopCap {
569        /// Exact clip name or glob containing the invalid cap.
570        selector: String,
571        /// Stable public [`ClipExpectations`] field name.
572        field: &'static str,
573    },
574    /// One clip selector declared both the canonical horizontal owner and its
575    /// legacy `in_place` alias.
576    #[error(
577        "clip selector {selector:?} cannot declare both \"movement_owner_xz\" and \"in_place\""
578    )]
579    ConflictingClipMovementOwner {
580        /// Exact clip name or glob containing both spellings.
581        selector: String,
582    },
583    /// The shared runtime-node selector field and its rest-world-scale
584    /// compatibility alias were both declared.
585    #[error(
586        "cannot declare both \"runtime_nodes.selectors\" and \"checks.rest-world-scale.node_selectors\""
587    )]
588    ConflictingRuntimeNodeSelectors,
589    /// A sync-group tolerance was negative or non-finite.
590    #[error("sync group {group:?} field {field:?} must be a finite non-negative number")]
591    InvalidSyncGroupTolerance {
592        /// Configured group name.
593        group: String,
594        /// Stable public field name.
595        field: &'static str,
596    },
597    /// A time-complement setting was outside its finite declared domain.
598    #[error(
599        "sync group {group:?} time-complement field {field:?} must be finite and within its documented range"
600    )]
601    InvalidTimeComplementSetting {
602        /// Configured group name.
603        group: String,
604        /// Stable public field name.
605        field: &'static str,
606    },
607}
608
609/// The whole configuration. Field names match the `animsmith.toml`
610/// sections.
611///
612/// [`Self::runtime_nodes`] is an intentional pre-1.0 additive public field.
613/// Embedders that construct this struct with a literal must add
614/// `runtime_nodes: RuntimeNodesConfig::default()` or use
615/// `..Config::default()`.
616#[derive(Debug, Clone, Default, Deserialize)]
617#[serde(deny_unknown_fields)]
618pub struct Config {
619    /// Declarative rig profile and inline role bindings. Frontends resolve
620    /// these into [`crate::ResolvedRoles`] before creating a check context;
621    /// the core runner does not apply them automatically.
622    #[serde(default)]
623    pub rig: RigConfig,
624    /// Per-check settings keyed by stable check id.
625    #[serde(default)]
626    pub checks: BTreeMap<String, CheckSettings>,
627    /// Shared engine-neutral policy for source nodes addressed by the runtime.
628    #[serde(default)]
629    pub runtime_nodes: RuntimeNodesConfig,
630    /// Keyed by clip name or glob (`*` wildcards). An exact-name entry
631    /// overrides glob entries; among globs, later (lexicographically
632    /// greater) keys win on conflict.
633    #[serde(default)]
634    pub clips: BTreeMap<String, ClipExpectations>,
635    /// Named gait groups consumed by the `gait-group` check.
636    #[serde(default)]
637    pub gait_groups: BTreeMap<String, GaitGroup>,
638    /// Named same-time / absolute-sync groups consumed by `sync-group`.
639    #[serde(default)]
640    pub sync_groups: BTreeMap<String, SyncGroup>,
641}
642
643impl Config {
644    /// Validate values that can also be supplied through the public Rust
645    /// configuration structs.
646    ///
647    /// Deserialization rejects the same invalid values at the file/config
648    /// boundary. Embedded callers that construct [`Config`] directly must call
649    /// this method before passing it to measurement-only APIs;
650    /// [`crate::evaluate_checks`] always calls it before inspecting or
651    /// executing the supplied check catalog.
652    ///
653    /// # Errors
654    ///
655    /// Returns [`ConfigValidationError::InvalidCheckSetting`] when a direct
656    /// per-check numeric policy is outside its documented finite domain,
657    /// [`ConfigValidationError::InvalidClipLoopCap`] when a clip selector
658    /// contains a negative or non-finite per-clip loop cap,
659    /// [`ConfigValidationError::ConflictingClipMovementOwner`] when one clip
660    /// selector declares both `movement_owner_xz` and its `in_place` alias,
661    /// [`ConfigValidationError::ConflictingRuntimeNodeSelectors`] when shared
662    /// runtime-node selectors and the rest-world-scale compatibility alias are
663    /// both declared,
664    /// [`ConfigValidationError::InvalidSyncGroupTolerance`] when a same-time
665    /// group has an invalid timing tolerance, or
666    /// [`ConfigValidationError::InvalidTimeComplementSetting`] when an
667    /// enabled time-complement policy has an invalid threshold.
668    pub fn validate(&self) -> Result<(), ConfigValidationError> {
669        if self.runtime_nodes.selectors.is_some()
670            && self
671                .checks
672                .get("rest-world-scale")
673                .is_some_and(|settings| settings.node_selectors.is_some())
674        {
675            return Err(ConfigValidationError::ConflictingRuntimeNodeSelectors);
676        }
677        for (check_id, settings) in &self.checks {
678            for (field, valid) in [
679                (
680                    "max_ratio",
681                    settings.max_ratio.is_none_or(is_nonnegative_finite),
682                ),
683                (
684                    "min_stride_step_m",
685                    settings.min_stride_step_m.is_none_or(is_nonnegative_finite),
686                ),
687                (
688                    "max_position_delta_m",
689                    settings
690                        .max_position_delta_m
691                        .is_none_or(is_nonnegative_finite),
692                ),
693                (
694                    "max_rotation_delta_deg",
695                    settings
696                        .max_rotation_delta_deg
697                        .is_none_or(is_nonnegative_finite),
698                ),
699                (
700                    "max_velocity_delta_mps",
701                    settings
702                        .max_velocity_delta_mps
703                        .is_none_or(is_nonnegative_finite),
704                ),
705                (
706                    "max_angular_velocity_delta_degps",
707                    settings
708                        .max_angular_velocity_delta_degps
709                        .is_none_or(is_nonnegative_finite),
710                ),
711                (
712                    "min_rotation_deg",
713                    settings.min_rotation_deg.is_none_or(is_nonnegative_finite),
714                ),
715                (
716                    "max_mean_rest_delta_deg",
717                    settings
718                        .max_mean_rest_delta_deg
719                        .is_none_or(is_nonnegative_finite),
720                ),
721                (
722                    "contact_height_m",
723                    settings.contact_height_m.is_none_or(is_nonnegative_finite),
724                ),
725                (
726                    "max_slide_mps",
727                    settings.max_slide_mps.is_none_or(is_nonnegative_finite),
728                ),
729                (
730                    "expected_uniform_scale",
731                    settings
732                        .expected_uniform_scale
733                        .is_none_or(is_positive_finite),
734                ),
735                (
736                    "uniform_scale_tolerance",
737                    settings
738                        .uniform_scale_tolerance
739                        .is_none_or(is_nonnegative_finite),
740                ),
741            ] {
742                if !valid {
743                    return Err(ConfigValidationError::InvalidCheckSetting {
744                        check_id: check_id.clone(),
745                        field,
746                    });
747                }
748            }
749        }
750        for (selector, expectations) in &self.clips {
751            if expectations.movement_owner_xz.is_some() && expectations.in_place.is_some() {
752                return Err(ConfigValidationError::ConflictingClipMovementOwner {
753                    selector: selector.clone(),
754                });
755            }
756            for (field, value) in [
757                (
758                    "max_loop_position_delta_m",
759                    expectations.max_loop_position_delta_m,
760                ),
761                (
762                    "max_loop_rotation_delta_deg",
763                    expectations.max_loop_rotation_delta_deg,
764                ),
765                (
766                    "max_loop_velocity_delta_mps",
767                    expectations.max_loop_velocity_delta_mps,
768                ),
769                (
770                    "max_loop_angular_velocity_delta_degps",
771                    expectations.max_loop_angular_velocity_delta_degps,
772                ),
773            ] {
774                if value.is_some_and(|value| !is_nonnegative_finite(value)) {
775                    return Err(ConfigValidationError::InvalidClipLoopCap {
776                        selector: selector.clone(),
777                        field,
778                    });
779                }
780            }
781        }
782        for (group, sync) in &self.sync_groups {
783            for (field, value) in [
784                ("max_duration_delta_s", sync.max_duration_delta_s),
785                ("max_fps_delta", sync.max_fps_delta),
786            ] {
787                if !is_nonnegative_finite(value) {
788                    return Err(ConfigValidationError::InvalidSyncGroupTolerance {
789                        group: group.clone(),
790                        field,
791                    });
792                }
793            }
794            if let Some(settings) = &sync.time_complement {
795                for (field, valid) in [
796                    (
797                        "min_reflected_time_advantage",
798                        is_unit_interval(settings.min_reflected_time_advantage),
799                    ),
800                    (
801                        "min_lr_amplitude_m",
802                        is_nonnegative_finite(settings.min_lr_amplitude_m),
803                    ),
804                ] {
805                    if !valid {
806                        return Err(ConfigValidationError::InvalidTimeComplementSetting {
807                            group: group.clone(),
808                            field,
809                        });
810                    }
811                }
812            }
813        }
814        Ok(())
815    }
816
817    /// Effective expectations for a clip: glob matches (in key order)
818    /// overlaid, exact match last.
819    ///
820    /// Each selector entry's legacy `in_place` input is normalized into
821    /// [`ClipExpectations::movement_owner_xz`] before the field overlay. The
822    /// returned value therefore always has [`ClipExpectations::in_place`] set
823    /// to `None`. Call [`Config::validate`] before this method so same-entry
824    /// alias conflicts are rejected rather than resolved by precedence.
825    pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
826        let mut out = ClipExpectations::default();
827        for (pattern, exp) in &self.clips {
828            if pattern != clip && glob_match(pattern, clip) {
829                out = out.merged_with(exp);
830            }
831        }
832        if let Some(exact) = self.clips.get(clip) {
833            out = out.merged_with(exact);
834        }
835        out
836    }
837
838    /// Settings for a check id, or defaults when the id is not present.
839    pub fn check_settings(&self, id: &str) -> CheckSettings {
840        self.checks.get(id).cloned().unwrap_or_default()
841    }
842
843    /// The normalized runtime-node authority, if one is declared.
844    ///
845    /// The shared [`RuntimeNodesConfig::selectors`] field is used when it is
846    /// present. Callers must first call [`Self::validate`]: simultaneous use
847    /// of the legacy `checks.rest-world-scale.node_selectors` alias is rejected
848    /// and has no precedence rule. An absent field or explicit empty list
849    /// returns `None` and declares no policy.
850    pub fn runtime_node_selectors(&self) -> Option<RuntimeNodeSelectors> {
851        let selectors = self.runtime_nodes.selectors.as_ref().or_else(|| {
852            self.checks
853                .get("rest-world-scale")
854                .and_then(|settings| settings.node_selectors.as_ref())
855        })?;
856        (!selectors.is_empty()).then(|| RuntimeNodeSelectors::new(selectors))
857    }
858
859    /// Effective stride floor for loop-seam metrics, in metres.
860    pub fn loop_seam_min_stride_step_m(&self) -> f64 {
861        self.check_settings("loop-seam")
862            .min_stride_step_m
863            .unwrap_or(MIN_STRIDE_STEP_M)
864    }
865
866    /// Resolve the exact loop-pose and seam-velocity tolerances for `clip`.
867    ///
868    /// Per-clip values win over their owning check settings, which in turn
869    /// fall back to the immutable built-in check defaults. Call
870    /// [`Self::validate`] before using configuration supplied by an untrusted
871    /// decoder.
872    pub fn loop_continuity_tolerances(&self, clip: &str) -> LoopContinuityTolerances {
873        let expectations = self.expectations_for(clip);
874        self.loop_continuity_tolerances_for(&expectations)
875    }
876
877    pub(crate) fn loop_continuity_tolerances_for(
878        &self,
879        expectations: &ClipExpectations,
880    ) -> LoopContinuityTolerances {
881        let (max_position_delta_m, max_rotation_delta_deg) =
882            crate::checks::loop_closure::effective_caps(self, expectations);
883        LoopContinuityTolerances {
884            max_position_delta_m,
885            max_rotation_delta_deg,
886            max_velocity_delta_mps: crate::checks::loop_seam_vel::effective_cap(self, expectations),
887            max_angular_velocity_delta_degps: crate::checks::loop_seam_rot::effective_cap(
888                self,
889                expectations,
890            ),
891        }
892    }
893}
894
895/// Minimal linear-work `*`-wildcard matcher (no character classes; `*`
896/// matches any run including empty).
897pub fn glob_match(pattern: &str, name: &str) -> bool {
898    let pattern = pattern.as_bytes();
899    let name = name.as_bytes();
900    let Some(first_star) = pattern.iter().position(|&byte| byte == b'*') else {
901        return pattern == name;
902    };
903    let last_star = pattern
904        .iter()
905        .rposition(|&byte| byte == b'*')
906        .unwrap_or(first_star);
907
908    let prefix = &pattern[..first_star];
909    let suffix = &pattern[last_star + 1..];
910    if !name.starts_with(prefix) || !name.ends_with(suffix) {
911        return false;
912    }
913
914    let mut name_index = prefix.len();
915    let search_end = name.len() - suffix.len();
916    if name_index > search_end {
917        return false;
918    }
919
920    if first_star < last_star {
921        let mut failure = Vec::new();
922        for literal in pattern[first_star + 1..last_star]
923            .split(|&byte| byte == b'*')
924            .filter(|literal| !literal.is_empty())
925        {
926            let Some(offset) =
927                find_subslice_linear(&name[name_index..search_end], literal, &mut failure)
928            else {
929                return false;
930            };
931            name_index += offset + literal.len();
932        }
933    }
934
935    true
936}
937
938fn find_subslice_linear(haystack: &[u8], needle: &[u8], failure: &mut Vec<usize>) -> Option<usize> {
939    debug_assert!(!needle.is_empty());
940    if needle.len() > haystack.len() {
941        return None;
942    }
943
944    failure.clear();
945    failure.resize(needle.len(), 0);
946    let mut prefix_len = 0;
947    for index in 1..needle.len() {
948        while prefix_len > 0 && needle[index] != needle[prefix_len] {
949            prefix_len = failure[prefix_len - 1];
950        }
951        if needle[index] == needle[prefix_len] {
952            prefix_len += 1;
953        }
954        failure[index] = prefix_len;
955    }
956
957    let mut matched = 0;
958    for (index, &byte) in haystack.iter().enumerate() {
959        while matched > 0 && byte != needle[matched] {
960            matched = failure[matched - 1];
961        }
962        if byte == needle[matched] {
963            matched += 1;
964            if matched == needle.len() {
965                return Some(index + 1 - needle.len());
966            }
967        }
968    }
969    None
970}