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}