Skip to main content

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}