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