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}