Skip to main content

animsmith_core/
config.rs

1//! Typed configuration: rig selection, per-check settings, per-clip
2//! expectations, 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
43impl SeveritySetting {
44    /// Convert this setting into a finding severity.
45    ///
46    /// Returns `None` for [`SeveritySetting::Off`] because disabling a
47    /// check is handled before execution.
48    pub fn as_severity(self) -> Option<Severity> {
49        match self {
50            SeveritySetting::Off => None,
51            SeveritySetting::Note => Some(Severity::Note),
52            SeveritySetting::Warn => Some(Severity::Warning),
53            SeveritySetting::Error => Some(Severity::Error),
54        }
55    }
56}
57
58/// Per-check settings: a severity override plus the union of the
59/// built-in checks' tunables (only the owning check reads each field).
60#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct CheckSettings {
63    /// Per-check severity override. `None` leaves the check's default
64    /// severity intact.
65    pub severity: Option<SeveritySetting>,
66    /// `loop-seam`: ratio above which the seam is a pop (default 1.5).
67    pub max_ratio: Option<f64>,
68    /// `loop-seam`: stride floor in metres (default 0.02).
69    pub min_stride_step_m: Option<f64>,
70    /// `loop-closure`: maximum model-space position delta in metres
71    /// (default 0.01).
72    pub max_position_delta_m: Option<f64>,
73    /// `loop-closure`: maximum model-space rotation delta in degrees
74    /// (default 1.0).
75    pub max_rotation_delta_deg: Option<f64>,
76    /// `loop-seam-vel`: maximum incoming/outgoing model-space linear-velocity
77    /// difference in metres per second (default 0.1).
78    pub max_velocity_delta_mps: Option<f64>,
79    /// `loop-seam-rot`: maximum incoming/outgoing model-space angular-velocity
80    /// difference in degrees per second (default 5.0).
81    pub max_angular_velocity_delta_degps: Option<f64>,
82    /// `frozen-bone`: rotation floor in degrees (default 1.0).
83    pub min_rotation_deg: Option<f64>,
84    /// `bind-pose`: mean first-frame deviation cap in degrees
85    /// (default 45).
86    pub max_mean_rest_delta_deg: Option<f64>,
87    /// `foot-slide`: contact height above the per-clip foot minimum
88    /// (default 0.03 m).
89    pub contact_height_m: Option<f64>,
90    /// `foot-slide`: allowed stance-speed deviation (default 0.3 m/s).
91    pub max_slide_mps: Option<f64>,
92    /// `rest-world-scale`: exact names or `*` globs that must each resolve to
93    /// one source node before its effective rest-world scale is judged.
94    pub node_selectors: Option<Vec<String>>,
95    /// `rest-world-scale`: expected positive uniform scale factor (default
96    /// 1.0).
97    #[serde(default, deserialize_with = "deserialize_positive_finite_option")]
98    pub expected_uniform_scale: Option<f64>,
99    /// `rest-world-scale`: inclusive absolute tolerance around the expected
100    /// uniform factor (default 0.0001).
101    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
102    pub uniform_scale_tolerance: Option<f64>,
103}
104
105/// What the author declares about one clip (or a glob of clips).
106#[derive(Debug, Clone, Default, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ClipExpectations {
109    /// The clip is a cyclic loop; loop checks apply.
110    #[serde(rename = "loop")]
111    pub looping: Option<bool>,
112    /// `loop-closure`: per-clip maximum model-space position delta in
113    /// metres. When unset, the global `loop-closure` setting (or its
114    /// built-in default) applies.
115    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
116    pub max_loop_position_delta_m: Option<f64>,
117    /// `loop-closure`: per-clip maximum model-space rotation delta in
118    /// degrees. When unset, the global `loop-closure` setting (or its
119    /// built-in default) applies.
120    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
121    pub max_loop_rotation_delta_deg: Option<f64>,
122    /// `loop-seam-vel`: per-clip maximum incoming/outgoing model-space
123    /// linear-velocity difference in metres per second. When unset, the
124    /// global `loop-seam-vel` setting (or its built-in default) applies.
125    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
126    pub max_loop_velocity_delta_mps: Option<f64>,
127    /// `loop-seam-rot`: per-clip maximum incoming/outgoing model-space
128    /// angular-velocity difference in degrees per second. When unset, the
129    /// global `loop-seam-rot` setting (or its built-in default) applies.
130    #[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
131    pub max_loop_angular_velocity_delta_degps: Option<f64>,
132    /// Expected clip duration in seconds; consumed by the
133    /// `duration-sanity` check. Its value must be finite and positive,
134    /// and its tolerance must be finite and non-negative.
135    pub duration_s: Option<Pinned>,
136    /// Declared locomotion speed (m/s) carried by the clip's root
137    /// motion.
138    pub speed_mps: Option<Pinned>,
139    /// The clip is authored in place (no net root travel); consumed by
140    /// the `in-place` check (and exempts an in-place clip from
141    /// `root-motion-speed`).
142    pub in_place: Option<bool>,
143    /// Authored frame rate; consumed by the `fps` check (keys must land
144    /// on the `1/fps` grid).
145    pub fps: Option<f64>,
146    /// Bones that must carry keyframes and actually move
147    /// (`missing-bones` presence + `frozen-bone` rotation floor).
148    pub animates_bones: Option<Vec<String>>,
149}
150
151impl ClipExpectations {
152    /// Overlay `other` on `self` (other's set fields win).
153    fn merged_with(&self, other: &ClipExpectations) -> ClipExpectations {
154        ClipExpectations {
155            looping: other.looping.or(self.looping),
156            max_loop_position_delta_m: other
157                .max_loop_position_delta_m
158                .or(self.max_loop_position_delta_m),
159            max_loop_rotation_delta_deg: other
160                .max_loop_rotation_delta_deg
161                .or(self.max_loop_rotation_delta_deg),
162            max_loop_velocity_delta_mps: other
163                .max_loop_velocity_delta_mps
164                .or(self.max_loop_velocity_delta_mps),
165            max_loop_angular_velocity_delta_degps: other
166                .max_loop_angular_velocity_delta_degps
167                .or(self.max_loop_angular_velocity_delta_degps),
168            duration_s: other.duration_s.or(self.duration_s),
169            speed_mps: other.speed_mps.or(self.speed_mps),
170            in_place: other.in_place.or(self.in_place),
171            fps: other.fps.or(self.fps),
172            animates_bones: other
173                .animates_bones
174                .clone()
175                .or_else(|| self.animates_bones.clone()),
176        }
177    }
178}
179
180/// Deserialize an optional non-negative finite cap.
181///
182/// Loop-continuity evidence is always non-negative and finite, so accepting a
183/// negative or non-finite cap would make its pass/fail result surprising.
184fn deserialize_nonnegative_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
185where
186    D: Deserializer<'de>,
187{
188    let value = Option::<f64>::deserialize(deserializer)?;
189    if let Some(value) = value
190        && !is_valid_loop_cap(value)
191    {
192        return Err(serde::de::Error::custom(
193            "must be a finite non-negative number",
194        ));
195    }
196    Ok(value)
197}
198
199fn deserialize_positive_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
200where
201    D: Deserializer<'de>,
202{
203    let value = Option::<f64>::deserialize(deserializer)?;
204    if value.is_some_and(|value| !is_positive_finite(value)) {
205        return Err(serde::de::Error::custom(
206            "must be a finite number greater than zero",
207        ));
208    }
209    Ok(value)
210}
211
212fn is_valid_loop_cap(value: f64) -> bool {
213    value.is_finite() && value >= 0.0
214}
215
216fn is_positive_finite(value: f64) -> bool {
217    value.is_finite() && value > 0.0
218}
219
220/// A set of clips whose gait phases must agree (a directional blend
221/// ring).
222#[derive(Debug, Clone, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct GaitGroup {
225    /// Clip names that should share a gait phase.
226    pub clips: Vec<String>,
227    /// Maximum circular spread of the members' gait phases, in cycle
228    /// fraction `[0, 0.5]`.
229    pub max_gait_phase_spread: f64,
230    /// Members with L−R amplitude under this (metres) are excluded from
231    /// the spread (their phase is noise, not signal).
232    #[serde(default)]
233    pub min_lr_amplitude_m: f64,
234}
235
236/// Thresholds for detecting a pair that is more phase-similar under reflected
237/// time than under a declared same-time / absolute-sync rule.
238#[derive(Debug, Clone, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct TimeComplementSettings {
241    /// Minimum reflected-time minus same-time phase-similarity score required
242    /// to report the diagnostic. This threshold is in `[0, 1]`; emitted
243    /// advantages are positive and no greater than one.
244    #[serde(deserialize_with = "deserialize_unit_interval")]
245    pub min_reflected_time_advantage: f64,
246    /// Minimum L−R foot-height amplitude (metres) required before a phase is
247    /// considered evidence rather than noise.
248    #[serde(deserialize_with = "deserialize_nonnegative_finite")]
249    pub min_lr_amplitude_m: f64,
250}
251
252fn deserialize_unit_interval<'de, D>(deserializer: D) -> Result<f64, D::Error>
253where
254    D: Deserializer<'de>,
255{
256    let value = f64::deserialize(deserializer)?;
257    if !is_unit_interval(value) {
258        return Err(serde::de::Error::custom(
259            "must be a finite number in the range [0, 1]",
260        ));
261    }
262    Ok(value)
263}
264
265fn deserialize_nonnegative_finite<'de, D>(deserializer: D) -> Result<f64, D::Error>
266where
267    D: Deserializer<'de>,
268{
269    let value = f64::deserialize(deserializer)?;
270    if !is_valid_loop_cap(value) {
271        return Err(serde::de::Error::custom(
272            "must be a finite non-negative number",
273        ));
274    }
275    Ok(value)
276}
277
278fn is_unit_interval(value: f64) -> bool {
279    value.is_finite() && (0.0..=1.0).contains(&value)
280}
281
282/// A set of clips sampled together by a same-time / absolute-sync runtime.
283///
284/// The group compares timing representations; it does not prescribe a runtime
285/// retiming or repair strategy.
286#[derive(Debug, Clone, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct SyncGroup {
289    /// Clip names that must be compatible when sampled at the same time.
290    pub clips: Vec<String>,
291    /// Largest permitted duration range across members, in seconds.
292    pub max_duration_delta_s: f64,
293    /// Largest permitted longest-channel key-count range across members.
294    pub max_frame_count_delta: u32,
295    /// Largest permitted declared frame-rate range across members.
296    pub max_fps_delta: f64,
297    /// Optional phase-similarity diagnostic for time-complementary member
298    /// pairs. A two-member group declares one pair; larger groups compare each
299    /// configured-order unordered pair.
300    #[serde(default)]
301    pub time_complement: Option<TimeComplementSettings>,
302}
303
304/// Rig selection: a named profile ("auto" to detect) and/or an inline
305/// role map (which wins over the profile for the roles it names).
306#[derive(Debug, Clone, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct RigConfig {
309    /// Built-in profile name, or `"auto"` to select the best built-in
310    /// match.
311    #[serde(default = "default_profile")]
312    pub profile: String,
313    /// Inline role-to-bone-name bindings. These are interpreted as
314    /// explicit overrides by callers that merge them with a profile.
315    #[serde(default)]
316    pub roles: BTreeMap<Role, String>,
317    /// Bone names that must be present in the file's skeleton, regardless of
318    /// whether any clip keys them. This is for static runtime sockets, IK
319    /// targets, and mask bones; use [`ClipExpectations::animates_bones`] when
320    /// a bone must carry animation data in a particular clip.
321    pub required_bones: Option<Vec<String>>,
322}
323
324fn default_profile() -> String {
325    "auto".into()
326}
327
328impl Default for RigConfig {
329    fn default() -> Self {
330        Self {
331            profile: default_profile(),
332            roles: BTreeMap::new(),
333            required_bones: None,
334        }
335    }
336}
337
338/// Invalid values in a directly constructed [`Config`].
339///
340/// Numeric values are intentionally not retained in this error so it remains
341/// equality-comparable even when the rejected input was `NaN`.
342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
343#[non_exhaustive]
344pub enum ConfigValidationError {
345    /// A numeric per-check policy setting was outside its documented domain.
346    #[error("check {check_id:?} field {field:?} must be finite and within its documented range")]
347    InvalidCheckSetting {
348        /// Configured stable check id.
349        check_id: String,
350        /// Stable public [`CheckSettings`] field name.
351        field: &'static str,
352    },
353    /// A per-clip loop-continuity cap was negative or non-finite.
354    #[error("clip selector {selector:?} field {field:?} must be a finite non-negative number")]
355    InvalidClipLoopCap {
356        /// Exact clip name or glob containing the invalid cap.
357        selector: String,
358        /// Stable public [`ClipExpectations`] field name.
359        field: &'static str,
360    },
361    /// A sync-group tolerance was negative or non-finite.
362    #[error("sync group {group:?} field {field:?} must be a finite non-negative number")]
363    InvalidSyncGroupTolerance {
364        /// Configured group name.
365        group: String,
366        /// Stable public field name.
367        field: &'static str,
368    },
369    /// A time-complement setting was outside its finite declared domain.
370    #[error(
371        "sync group {group:?} time-complement field {field:?} must be finite and within its documented range"
372    )]
373    InvalidTimeComplementSetting {
374        /// Configured group name.
375        group: String,
376        /// Stable public field name.
377        field: &'static str,
378    },
379}
380
381/// The whole configuration. Field names match the `animsmith.toml`
382/// sections.
383#[derive(Debug, Clone, Default, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct Config {
386    /// Declarative rig profile and inline role bindings. Frontends resolve
387    /// these into [`crate::ResolvedRoles`] before creating a check context;
388    /// the core runner does not apply them automatically.
389    #[serde(default)]
390    pub rig: RigConfig,
391    /// Per-check settings keyed by stable check id.
392    #[serde(default)]
393    pub checks: BTreeMap<String, CheckSettings>,
394    /// Keyed by clip name or glob (`*` wildcards). An exact-name entry
395    /// overrides glob entries; among globs, later (lexicographically
396    /// greater) keys win on conflict.
397    #[serde(default)]
398    pub clips: BTreeMap<String, ClipExpectations>,
399    /// Named gait groups consumed by the `gait-group` check.
400    #[serde(default)]
401    pub gait_groups: BTreeMap<String, GaitGroup>,
402    /// Named same-time / absolute-sync groups consumed by `sync-group`.
403    #[serde(default)]
404    pub sync_groups: BTreeMap<String, SyncGroup>,
405}
406
407impl Config {
408    /// Validate values that can also be supplied through the public Rust
409    /// configuration structs.
410    ///
411    /// Deserialization rejects the same invalid values at the file/config
412    /// boundary. Embedded callers that construct [`Config`] directly may call
413    /// this method for an earlier error; [`crate::evaluate_checks`] always
414    /// calls it before inspecting or executing the supplied check catalog.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`ConfigValidationError::InvalidCheckSetting`] when a direct
419    /// per-check numeric policy is outside its documented finite domain,
420    /// [`ConfigValidationError::InvalidClipLoopCap`] when a clip selector
421    /// contains a negative or non-finite per-clip loop cap,
422    /// [`ConfigValidationError::InvalidSyncGroupTolerance`] when a same-time
423    /// group has an invalid timing tolerance, or
424    /// [`ConfigValidationError::InvalidTimeComplementSetting`] when an
425    /// enabled time-complement policy has an invalid threshold.
426    pub fn validate(&self) -> Result<(), ConfigValidationError> {
427        for (check_id, settings) in &self.checks {
428            for (field, valid) in [
429                (
430                    "expected_uniform_scale",
431                    settings
432                        .expected_uniform_scale
433                        .is_none_or(is_positive_finite),
434                ),
435                (
436                    "uniform_scale_tolerance",
437                    settings
438                        .uniform_scale_tolerance
439                        .is_none_or(is_valid_loop_cap),
440                ),
441            ] {
442                if !valid {
443                    return Err(ConfigValidationError::InvalidCheckSetting {
444                        check_id: check_id.clone(),
445                        field,
446                    });
447                }
448            }
449        }
450        for (selector, expectations) in &self.clips {
451            for (field, value) in [
452                (
453                    "max_loop_position_delta_m",
454                    expectations.max_loop_position_delta_m,
455                ),
456                (
457                    "max_loop_rotation_delta_deg",
458                    expectations.max_loop_rotation_delta_deg,
459                ),
460                (
461                    "max_loop_velocity_delta_mps",
462                    expectations.max_loop_velocity_delta_mps,
463                ),
464                (
465                    "max_loop_angular_velocity_delta_degps",
466                    expectations.max_loop_angular_velocity_delta_degps,
467                ),
468            ] {
469                if value.is_some_and(|value| !is_valid_loop_cap(value)) {
470                    return Err(ConfigValidationError::InvalidClipLoopCap {
471                        selector: selector.clone(),
472                        field,
473                    });
474                }
475            }
476        }
477        for (group, sync) in &self.sync_groups {
478            for (field, value) in [
479                ("max_duration_delta_s", sync.max_duration_delta_s),
480                ("max_fps_delta", sync.max_fps_delta),
481            ] {
482                if !is_valid_loop_cap(value) {
483                    return Err(ConfigValidationError::InvalidSyncGroupTolerance {
484                        group: group.clone(),
485                        field,
486                    });
487                }
488            }
489            if let Some(settings) = &sync.time_complement {
490                for (field, valid) in [
491                    (
492                        "min_reflected_time_advantage",
493                        is_unit_interval(settings.min_reflected_time_advantage),
494                    ),
495                    (
496                        "min_lr_amplitude_m",
497                        is_valid_loop_cap(settings.min_lr_amplitude_m),
498                    ),
499                ] {
500                    if !valid {
501                        return Err(ConfigValidationError::InvalidTimeComplementSetting {
502                            group: group.clone(),
503                            field,
504                        });
505                    }
506                }
507            }
508        }
509        Ok(())
510    }
511
512    /// Effective expectations for a clip: glob matches (in key order)
513    /// overlaid, exact match last.
514    pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
515        let mut out = ClipExpectations::default();
516        for (pattern, exp) in &self.clips {
517            if pattern != clip && glob_match(pattern, clip) {
518                out = out.merged_with(exp);
519            }
520        }
521        if let Some(exact) = self.clips.get(clip) {
522            out = out.merged_with(exact);
523        }
524        out
525    }
526
527    /// Settings for a check id, or defaults when the id is not present.
528    pub fn check_settings(&self, id: &str) -> CheckSettings {
529        self.checks.get(id).cloned().unwrap_or_default()
530    }
531
532    /// Effective stride floor for loop-seam metrics, in metres.
533    pub fn loop_seam_min_stride_step_m(&self) -> f64 {
534        self.check_settings("loop-seam")
535            .min_stride_step_m
536            .unwrap_or(MIN_STRIDE_STEP_M)
537    }
538}
539
540/// Minimal `*`-wildcard matcher (no character classes; `*` matches any
541/// run including empty).
542pub fn glob_match(pattern: &str, name: &str) -> bool {
543    fn inner(p: &[u8], n: &[u8]) -> bool {
544        match p.split_first() {
545            None => n.is_empty(),
546            Some((b'*', rest)) => (0..=n.len()).any(|skip| inner(rest, &n[skip..])),
547            Some((c, rest)) => n
548                .split_first()
549                .is_some_and(|(nc, nrest)| nc == c && inner(rest, nrest)),
550        }
551    }
552    inner(pattern.as_bytes(), name.as_bytes())
553}