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}