Skip to main content

animsmith_core/
directional_speed_policy.rs

1//! Format-neutral directional-speed policy V1 values.
2//!
3//! The CLI owns bounded TOML decoding. This module owns the immutable policy
4//! vocabulary and its finite, mode-specific, manifest-binding invariants.
5//! A future evaluator compares normalized raw collection-output V2 endpoint
6//! displacement for heading, uses the published `speed_mps` field for speed
7//! magnitude (not travel distance), and binds both policy and evidence by
8//! their raw [`InputIdentity`] values. A zero net displacement is typed
9//! complete/not-evaluated rather than a false speed finding. An unrepresentable
10//! ratio comparison is likewise a typed numeric-range/not-evaluated outcome.
11
12use serde::{Deserialize, Serialize};
13
14use crate::{CollectionIdV1, CollectionLogicalIdV1, CollectionRuntimeSetKindV1, InputIdentity};
15
16/// Schema identity for a directional-speed policy declaration.
17pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_ID: &str =
18    "urn:animsmith:schema:collection-directional-speed-policy:1";
19/// Schema version for a directional-speed policy declaration.
20pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_SCHEMA_VERSION: u32 = 1;
21/// Maximum raw directional-speed policy TOML byte identity.
22pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_BYTES: u64 = 8 * 1024 * 1024;
23/// Maximum raw collection-output V2 JSON byte identity consumed by evaluation.
24pub const COLLECTION_DIRECTIONAL_SPEED_EVIDENCE_V1_MAX_BYTES: u64 = 256 * 1024 * 1024;
25/// Maximum policy members retained by the V1 reader.
26pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_MEMBERS: usize = 4_096;
27/// Maximum absolute source-basis or semantic-coordinate component.
28pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_COMPONENT: f64 = 1_000_000.0;
29/// Maximum finite speed, ratio, or tolerance value.
30pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_SCALAR: f64 = 1_000_000.0;
31/// Maximum angular direction tolerance, in degrees, accepted by V1.
32pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_DIRECTION_TOLERANCE_DEG: f64 = 180.0;
33/// Maximum absolute cosine allowed between the declared source axes.
34pub const COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_AXIS_COSINE: f64 = 1e-9;
35
36/// Closed diagonal-input handling declaration.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum CollectionDirectionalSpeedDiagonalBehaviorV1 {
40    /// Preserve the authored diagonal magnitude. The declared speed fields
41    /// remain unit-input/base targets; a coordinate `c` contributes gain
42    /// `g(c) = hypot(c)` to the later speed expectation.
43    Preserve,
44    /// Normalize diagonal input magnitude before policy comparison. The
45    /// later speed gain for a coordinate is `g(c) = 1`.
46    Normalize,
47}
48
49/// Explicit source X/Z orientation witnesses in semantic 2-D policy
50/// coordinates.
51///
52/// `x` and `z` witness the raw collection-output V2 +X/+Z endpoint
53/// displacement directions. Their magnitudes are nonsemantic; a future
54/// evaluator uses unit axes for heading comparison.
55#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
56pub struct CollectionDirectionalSpeedSourceBasisV1 {
57    x: [f64; 2],
58    z: [f64; 2],
59}
60
61impl CollectionDirectionalSpeedSourceBasisV1 {
62    /// Construct a finite, bounded, nonzero, perpendicular X/Z orientation
63    /// witness for raw collection-output V2 endpoint displacement.
64    pub fn new(x: [f64; 2], z: [f64; 2]) -> Result<Self, CollectionDirectionalSpeedPolicyError> {
65        for component in x.into_iter().chain(z) {
66            if !bounded_component(component) {
67                return Err(CollectionDirectionalSpeedPolicyError::InvalidNumber {
68                    field: "source_basis",
69                });
70            }
71        }
72        let x_norm = x[0].hypot(x[1]);
73        let z_norm = z[0].hypot(z[1]);
74        if x_norm == 0.0 || z_norm == 0.0 {
75            return Err(CollectionDirectionalSpeedPolicyError::InvalidBasis);
76        }
77        let normalized_dot = (x[0] / x_norm) * (z[0] / z_norm) + (x[1] / x_norm) * (z[1] / z_norm);
78        if normalized_dot.abs() > COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_AXIS_COSINE {
79            return Err(CollectionDirectionalSpeedPolicyError::InvalidBasis);
80        }
81        Ok(Self { x, z })
82    }
83
84    /// Semantic coordinates of the source X axis.
85    pub const fn x(self) -> [f64; 2] {
86        self.x
87    }
88
89    /// Semantic coordinates of the source Z axis.
90    pub const fn z(self) -> [f64; 2] {
91        self.z
92    }
93}
94
95/// Exact manifest identity carried by a directional-speed policy.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct CollectionDirectionalSpeedManifestIdentityV1 {
98    collection_id: CollectionIdV1,
99    input: InputIdentity,
100}
101
102impl CollectionDirectionalSpeedManifestIdentityV1 {
103    /// Construct an identity from the exact collection id and manifest bytes.
104    pub fn new(
105        collection_id: CollectionIdV1,
106        input: InputIdentity,
107    ) -> Result<Self, CollectionDirectionalSpeedPolicyError> {
108        if input.bytes() > crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES {
109            return Err(CollectionDirectionalSpeedPolicyError::ManifestTooLarge);
110        }
111        Ok(Self {
112            collection_id,
113            input,
114        })
115    }
116
117    /// Collection namespace token.
118    pub fn collection_id(&self) -> &CollectionIdV1 {
119        &self.collection_id
120    }
121
122    /// Exact manifest-byte identity.
123    pub const fn input(&self) -> &InputIdentity {
124        &self.input
125    }
126}
127
128/// One ordered semantic coordinate and its mode-specific authored value.
129#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct CollectionDirectionalSpeedMemberV1 {
131    id: CollectionLogicalIdV1,
132    coordinate: [f64; 2],
133    speed_mps: Option<f64>,
134    expected_ratio: Option<f64>,
135}
136
137impl CollectionDirectionalSpeedMemberV1 {
138    /// Construct one member declaration. Mode-specific values are validated by
139    /// [`CollectionDirectionalSpeedPolicyV1::new`].
140    pub fn new(
141        id: CollectionLogicalIdV1,
142        coordinate: [f64; 2],
143        speed_mps: Option<f64>,
144        expected_ratio: Option<f64>,
145    ) -> Self {
146        Self {
147            id,
148            coordinate,
149            speed_mps,
150            expected_ratio,
151        }
152    }
153
154    /// Logical member id.
155    pub fn id(&self) -> &CollectionLogicalIdV1 {
156        &self.id
157    }
158
159    /// Semantic 2-D coordinate.
160    pub const fn coordinate(&self) -> [f64; 2] {
161        self.coordinate
162    }
163
164    /// Authored member speed, present only in authored mode.
165    pub const fn speed_mps(&self) -> Option<f64> {
166        self.speed_mps
167    }
168
169    /// Expected reference ratio, present only in ratios mode.
170    pub const fn expected_ratio(&self) -> Option<f64> {
171        self.expected_ratio
172    }
173}
174
175/// Closed speed-policy mode and its explicit mode-level fields.
176#[derive(Debug, Clone, PartialEq, Serialize)]
177pub enum CollectionDirectionalSpeedModeV1 {
178    /// Every member is compared with one declared unit-input/base speed.
179    Uniform {
180        /// Unit-input/base expected speed in metres per second.
181        speed_mps: f64,
182        /// Allowed speed deviation in metres per second.
183        speed_tolerance_mps: f64,
184    },
185    /// Every member carries one declared authored unit-input/base speed.
186    Authored {
187        /// Allowed speed deviation in metres per second.
188        speed_tolerance_mps: f64,
189    },
190    /// Every member carries one declared unit-input/base ratio to a reference
191    /// member. The later derived target is
192    /// `expected_measured_ratio_i_to_ref = declared_expected_ratio_i *
193    /// g(c_i) / g(c_ref)`; diagonal gains do not affect direction.
194    Ratios {
195        /// Member whose measured speed is the ratio denominator.
196        reference_member: CollectionLogicalIdV1,
197        /// Allowed dimensionless ratio deviation.
198        ratio_tolerance: f64,
199    },
200}
201
202/// Fully validated directional-speed policy V1.
203#[derive(Debug, Clone, PartialEq, Serialize)]
204pub struct CollectionDirectionalSpeedPolicyV1 {
205    schema: &'static str,
206    schema_version: u32,
207    manifest: CollectionDirectionalSpeedManifestIdentityV1,
208    runtime_set_id: CollectionLogicalIdV1,
209    source_basis: CollectionDirectionalSpeedSourceBasisV1,
210    diagonal_behavior: CollectionDirectionalSpeedDiagonalBehaviorV1,
211    direction_tolerance_deg: f64,
212    mode: CollectionDirectionalSpeedModeV1,
213    members: Vec<CollectionDirectionalSpeedMemberV1>,
214}
215
216impl CollectionDirectionalSpeedPolicyV1 {
217    /// Construct and validate one policy in declared member order.
218    pub fn new(
219        manifest: CollectionDirectionalSpeedManifestIdentityV1,
220        runtime_set_id: CollectionLogicalIdV1,
221        source_basis: CollectionDirectionalSpeedSourceBasisV1,
222        diagonal_behavior: CollectionDirectionalSpeedDiagonalBehaviorV1,
223        direction_tolerance_deg: f64,
224        mode: CollectionDirectionalSpeedModeV1,
225        members: Vec<CollectionDirectionalSpeedMemberV1>,
226    ) -> Result<Self, CollectionDirectionalSpeedPolicyError> {
227        if members.len() < 2 {
228            return Err(CollectionDirectionalSpeedPolicyError::TooFewMembers {
229                found: members.len(),
230            });
231        }
232        if members.len() > COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_MEMBERS {
233            return Err(CollectionDirectionalSpeedPolicyError::TooManyMembers {
234                found: members.len(),
235                max: COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_MEMBERS,
236            });
237        }
238        let mut seen = std::collections::BTreeSet::new();
239        let mut seen_coordinates = std::collections::HashSet::new();
240        for member in &members {
241            if !seen.insert(member.id.clone()) {
242                return Err(CollectionDirectionalSpeedPolicyError::DuplicateMember {
243                    value: member.id.as_str().to_owned(),
244                });
245            }
246            if member
247                .coordinate
248                .iter()
249                .any(|value| !bounded_component(*value))
250                || member.coordinate[0] == 0.0 && member.coordinate[1] == 0.0
251            {
252                return Err(CollectionDirectionalSpeedPolicyError::InvalidCoordinate {
253                    member: member.id.as_str().to_owned(),
254                });
255            }
256            let coordinate_key = member.coordinate.map(canonical_coordinate_bits);
257            if !seen_coordinates.insert(coordinate_key) {
258                return Err(CollectionDirectionalSpeedPolicyError::DuplicateCoordinate {
259                    member: member.id.as_str().to_owned(),
260                });
261            }
262        }
263        if !bounded_direction_tolerance(direction_tolerance_deg) {
264            return Err(CollectionDirectionalSpeedPolicyError::InvalidNumber {
265                field: "direction_tolerance_deg",
266            });
267        }
268        match &mode {
269            CollectionDirectionalSpeedModeV1::Uniform {
270                speed_mps,
271                speed_tolerance_mps,
272            } => {
273                if !bounded_scalar(*speed_mps)
274                    || !bounded_scalar(*speed_tolerance_mps)
275                    || members
276                        .iter()
277                        .any(|member| member.speed_mps.is_some() || member.expected_ratio.is_some())
278                {
279                    return Err(CollectionDirectionalSpeedPolicyError::InvalidModeFields);
280                }
281            }
282            CollectionDirectionalSpeedModeV1::Authored {
283                speed_tolerance_mps,
284            } => {
285                if !bounded_scalar(*speed_tolerance_mps)
286                    || members.iter().any(|member| {
287                        member.speed_mps.is_none()
288                            || member.expected_ratio.is_some()
289                            || !member.speed_mps.is_some_and(bounded_scalar)
290                    })
291                {
292                    return Err(CollectionDirectionalSpeedPolicyError::InvalidModeFields);
293                }
294            }
295            CollectionDirectionalSpeedModeV1::Ratios {
296                reference_member,
297                ratio_tolerance,
298            } => {
299                if !bounded_scalar(*ratio_tolerance)
300                    || !members.iter().any(|member| {
301                        member.id == *reference_member && member.expected_ratio == Some(1.0)
302                    })
303                    || members.iter().any(|member| {
304                        member.expected_ratio.is_none()
305                            || member.speed_mps.is_some()
306                            || !member.expected_ratio.is_some_and(bounded_scalar)
307                    })
308                {
309                    return Err(CollectionDirectionalSpeedPolicyError::InvalidModeFields);
310                }
311            }
312        }
313        Ok(Self {
314            schema: COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_ID,
315            schema_version: COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_SCHEMA_VERSION,
316            manifest,
317            runtime_set_id,
318            source_basis,
319            diagonal_behavior,
320            direction_tolerance_deg,
321            mode,
322            members,
323        })
324    }
325
326    /// Maximum angular deviation, in degrees, accepted by a later evaluator.
327    pub const fn direction_tolerance_deg(&self) -> f64 {
328        self.direction_tolerance_deg
329    }
330
331    /// Immutable policy schema identity.
332    pub const fn schema(&self) -> &'static str {
333        self.schema
334    }
335
336    /// Immutable policy schema version.
337    pub const fn schema_version(&self) -> u32 {
338        self.schema_version
339    }
340
341    /// Exact manifest identity bound by this policy.
342    pub fn manifest(&self) -> &CollectionDirectionalSpeedManifestIdentityV1 {
343        &self.manifest
344    }
345
346    /// Declared directional-blend runtime-set id.
347    pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
348        &self.runtime_set_id
349    }
350
351    /// Explicit source X/Z basis.
352    pub const fn source_basis(&self) -> CollectionDirectionalSpeedSourceBasisV1 {
353        self.source_basis
354    }
355
356    /// Explicit diagonal handling.
357    pub const fn diagonal_behavior(&self) -> CollectionDirectionalSpeedDiagonalBehaviorV1 {
358        self.diagonal_behavior
359    }
360
361    /// Closed speed mode.
362    pub fn mode(&self) -> &CollectionDirectionalSpeedModeV1 {
363        &self.mode
364    }
365
366    /// Members in declared policy order.
367    pub fn members(&self) -> &[CollectionDirectionalSpeedMemberV1] {
368        &self.members
369    }
370
371    /// Bind this policy to one exact manifest identity and directional set.
372    pub fn validate_binding(
373        &self,
374        manifest: &CollectionDirectionalSpeedManifestIdentityV1,
375        runtime_set_id: &CollectionLogicalIdV1,
376        kind: CollectionRuntimeSetKindV1,
377        members: &[CollectionLogicalIdV1],
378    ) -> Result<(), CollectionDirectionalSpeedPolicyError> {
379        if self.manifest != *manifest {
380            return Err(CollectionDirectionalSpeedPolicyError::ManifestMismatch);
381        }
382        if kind != CollectionRuntimeSetKindV1::DirectionalBlend {
383            return Err(CollectionDirectionalSpeedPolicyError::WrongRuntimeSetKind);
384        }
385        if self.runtime_set_id != *runtime_set_id {
386            return Err(CollectionDirectionalSpeedPolicyError::RuntimeSetMismatch);
387        }
388        if self
389            .members
390            .iter()
391            .map(|member| member.id.clone())
392            .collect::<Vec<_>>()
393            != members
394        {
395            return Err(CollectionDirectionalSpeedPolicyError::MemberOrderMismatch);
396        }
397        Ok(())
398    }
399}
400
401/// A directional-speed policy was malformed or violated a frozen V1 bound.
402#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
403#[non_exhaustive]
404pub enum CollectionDirectionalSpeedPolicyError {
405    /// A scalar or component was non-finite or outside its V1 bound.
406    #[error("invalid finite bounded number in {field}")]
407    InvalidNumber {
408        /// Stable field name.
409        field: &'static str,
410    },
411    /// The source X/Z basis was zero or not perpendicular.
412    #[error("source X/Z basis must be nonzero and perpendicular")]
413    InvalidBasis,
414    /// Fewer than two directional members were declared.
415    #[error("directional policy needs at least two members, found {found}")]
416    TooFewMembers {
417        /// Number supplied.
418        found: usize,
419    },
420    /// The policy exceeded its member bound.
421    #[error("directional policy has {found} members, exceeding V1 limit {max}")]
422    TooManyMembers {
423        /// Number supplied.
424        found: usize,
425        /// V1 maximum.
426        max: usize,
427    },
428    /// A member id was repeated.
429    #[error("duplicate policy member {value:?}")]
430    DuplicateMember {
431        /// Repeated id.
432        value: String,
433    },
434    /// A member coordinate was zero, non-finite, or out of range.
435    #[error("invalid coordinate for policy member {member:?}")]
436    InvalidCoordinate {
437        /// Affected member id.
438        member: String,
439    },
440    /// Two members used one exact semantic coordinate.
441    #[error("duplicate semantic coordinate for policy member {member:?}")]
442    DuplicateCoordinate {
443        /// Affected member id.
444        member: String,
445    },
446    /// Mode-specific fields were missing, extra, or invalid.
447    #[error("invalid mode-specific policy fields")]
448    InvalidModeFields,
449    /// The policy did not carry the exact manifest identity.
450    #[error("policy manifest identity does not match evidence manifest")]
451    ManifestMismatch,
452    /// The manifest byte identity exceeds the V1 bounded reader limit.
453    #[error("manifest identity exceeds the V1 byte limit")]
454    ManifestTooLarge,
455    /// The referenced runtime set was not directional-blend.
456    #[error("policy runtime set is not directional-blend")]
457    WrongRuntimeSetKind,
458    /// The policy runtime-set id differed from the evidence set id.
459    #[error("policy runtime-set id does not match evidence")]
460    RuntimeSetMismatch,
461    /// Policy member order or membership differed from the evidence set.
462    #[error("policy members do not exactly preserve evidence member order")]
463    MemberOrderMismatch,
464}
465
466fn bounded_component(value: f64) -> bool {
467    value.is_finite() && value.abs() <= COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_COMPONENT
468}
469
470fn bounded_scalar(value: f64) -> bool {
471    value.is_finite() && (0.0..=COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_SCALAR).contains(&value)
472}
473
474fn bounded_direction_tolerance(value: f64) -> bool {
475    value.is_finite()
476        && (0.0..=COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_DIRECTION_TOLERANCE_DEG)
477            .contains(&value)
478}
479
480fn canonical_coordinate_bits(value: f64) -> u64 {
481    if value == 0.0 { 0 } else { value.to_bits() }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn fixture(mode: CollectionDirectionalSpeedModeV1) -> CollectionDirectionalSpeedPolicyV1 {
489        let collection_id = CollectionIdV1::new("com.example").unwrap();
490        let members = vec![
491            CollectionDirectionalSpeedMemberV1::new(
492                CollectionLogicalIdV1::new("com.example/left").unwrap(),
493                [-1.0, 0.0],
494                match &mode {
495                    CollectionDirectionalSpeedModeV1::Authored { .. } => Some(1.0),
496                    _ => None,
497                },
498                match &mode {
499                    CollectionDirectionalSpeedModeV1::Ratios { .. } => Some(1.0),
500                    _ => None,
501                },
502            ),
503            CollectionDirectionalSpeedMemberV1::new(
504                CollectionLogicalIdV1::new("com.example/right").unwrap(),
505                [1.0, 0.0],
506                match &mode {
507                    CollectionDirectionalSpeedModeV1::Authored { .. } => Some(1.0),
508                    _ => None,
509                },
510                match &mode {
511                    CollectionDirectionalSpeedModeV1::Ratios { .. } => Some(1.0),
512                    _ => None,
513                },
514            ),
515        ];
516        CollectionDirectionalSpeedPolicyV1::new(
517            CollectionDirectionalSpeedManifestIdentityV1::new(
518                collection_id,
519                InputIdentity::from_bytes(b"manifest"),
520            )
521            .unwrap(),
522            CollectionLogicalIdV1::new("com.example/directional").unwrap(),
523            CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [0.0, 1.0]).unwrap(),
524            CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize,
525            1.0,
526            mode,
527            members,
528        )
529        .unwrap()
530    }
531
532    #[test]
533    fn all_closed_modes_validate_and_retain_typed_values() {
534        let uniform = fixture(CollectionDirectionalSpeedModeV1::Uniform {
535            speed_mps: 1.0,
536            speed_tolerance_mps: 0.1,
537        });
538        let authored = fixture(CollectionDirectionalSpeedModeV1::Authored {
539            speed_tolerance_mps: 0.1,
540        });
541        let ratios = fixture(CollectionDirectionalSpeedModeV1::Ratios {
542            reference_member: CollectionLogicalIdV1::new("com.example/left").unwrap(),
543            ratio_tolerance: 0.1,
544        });
545        assert_eq!(uniform.members().len(), 2);
546        assert_eq!(authored.members()[0].speed_mps(), Some(1.0));
547        assert_eq!(ratios.members()[1].expected_ratio(), Some(1.0));
548        assert_eq!(uniform.schema(), COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_ID);
549    }
550
551    #[test]
552    fn direction_tolerance_uses_the_distinct_inclusive_degree_bound() {
553        let valid = fixture(CollectionDirectionalSpeedModeV1::Uniform {
554            speed_mps: 1.0,
555            speed_tolerance_mps: 0.1,
556        });
557        let rebuild = |direction_tolerance_deg| {
558            CollectionDirectionalSpeedPolicyV1::new(
559                valid.manifest.clone(),
560                valid.runtime_set_id.clone(),
561                valid.source_basis,
562                valid.diagonal_behavior,
563                direction_tolerance_deg,
564                valid.mode.clone(),
565                valid.members.clone(),
566            )
567        };
568        assert_eq!(rebuild(0.0).unwrap().direction_tolerance_deg(), 0.0);
569        assert_eq!(
570            rebuild(COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_DIRECTION_TOLERANCE_DEG)
571                .unwrap()
572                .direction_tolerance_deg(),
573            180.0
574        );
575        assert!(matches!(
576            rebuild(180.00000000000003),
577            Err(CollectionDirectionalSpeedPolicyError::InvalidNumber {
578                field: "direction_tolerance_deg"
579            })
580        ));
581    }
582
583    #[test]
584    fn basis_perpendicularity_is_scale_independent_and_accepts_near_threshold() {
585        let tiny = CollectionDirectionalSpeedSourceBasisV1::new([1e-200, 0.0], [0.0, 1e-200]);
586        assert!(tiny.is_ok());
587        let diagonal = CollectionDirectionalSpeedSourceBasisV1::new([1e-6, 0.0], [1e-6, 1e-6]);
588        assert!(matches!(
589            diagonal,
590            Err(CollectionDirectionalSpeedPolicyError::InvalidBasis)
591        ));
592        let near_parallel =
593            CollectionDirectionalSpeedSourceBasisV1::new([1e-6, 0.0], [1e-6, 1e-12]);
594        assert!(matches!(
595            near_parallel,
596            Err(CollectionDirectionalSpeedPolicyError::InvalidBasis)
597        ));
598        let below = COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_AXIS_COSINE * 0.5;
599        let above = COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_AXIS_COSINE * 2.0;
600        assert!(
601            CollectionDirectionalSpeedSourceBasisV1::new(
602                [1.0, 0.0],
603                [below, (1.0 - below * below).sqrt()]
604            )
605            .is_ok()
606        );
607        assert!(matches!(
608            CollectionDirectionalSpeedSourceBasisV1::new(
609                [1.0, 0.0],
610                [above, (1.0 - above * above).sqrt()]
611            ),
612            Err(CollectionDirectionalSpeedPolicyError::InvalidBasis)
613        ));
614    }
615
616    #[test]
617    fn ratios_require_a_unit_reference_ratio() {
618        let manifest = CollectionDirectionalSpeedManifestIdentityV1::new(
619            CollectionIdV1::new("com.example").unwrap(),
620            InputIdentity::from_bytes(b"manifest"),
621        )
622        .unwrap();
623        let members = vec![
624            CollectionDirectionalSpeedMemberV1::new(
625                CollectionLogicalIdV1::new("com.example/left").unwrap(),
626                [-1.0, 0.0],
627                None,
628                Some(0.9),
629            ),
630            CollectionDirectionalSpeedMemberV1::new(
631                CollectionLogicalIdV1::new("com.example/right").unwrap(),
632                [1.0, 0.0],
633                None,
634                Some(1.1),
635            ),
636        ];
637        assert!(matches!(
638            CollectionDirectionalSpeedPolicyV1::new(
639                manifest,
640                CollectionLogicalIdV1::new("com.example/directional").unwrap(),
641                CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [0.0, 1.0]).unwrap(),
642                CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize,
643                1.0,
644                CollectionDirectionalSpeedModeV1::Ratios {
645                    reference_member: CollectionLogicalIdV1::new("com.example/left").unwrap(),
646                    ratio_tolerance: 0.1,
647                },
648                members,
649            ),
650            Err(CollectionDirectionalSpeedPolicyError::InvalidModeFields)
651        ));
652    }
653
654    #[test]
655    fn rejects_duplicate_coordinates_and_invalid_basis() {
656        assert!(matches!(
657            CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [1.0, 0.0]),
658            Err(CollectionDirectionalSpeedPolicyError::InvalidBasis)
659        ));
660        assert!(matches!(
661            CollectionDirectionalSpeedSourceBasisV1::new([f64::NAN, 0.0], [0.0, 1.0]),
662            Err(CollectionDirectionalSpeedPolicyError::InvalidNumber { .. })
663        ));
664        let mut policy = fixture(CollectionDirectionalSpeedModeV1::Uniform {
665            speed_mps: 1.0,
666            speed_tolerance_mps: 0.1,
667        });
668        policy.members[1].coordinate = policy.members[0].coordinate;
669        assert!(matches!(
670            CollectionDirectionalSpeedPolicyV1::new(
671                policy.manifest,
672                policy.runtime_set_id,
673                policy.source_basis,
674                policy.diagonal_behavior,
675                1.0,
676                policy.mode,
677                policy.members,
678            ),
679            Err(CollectionDirectionalSpeedPolicyError::DuplicateCoordinate { .. })
680        ));
681        let mut signed_zero = fixture(CollectionDirectionalSpeedModeV1::Uniform {
682            speed_mps: 1.0,
683            speed_tolerance_mps: 0.1,
684        });
685        signed_zero.members[0].coordinate = [1.0, -0.0];
686        signed_zero.members[1].coordinate = [1.0, 0.0];
687        assert!(matches!(
688            CollectionDirectionalSpeedPolicyV1::new(
689                signed_zero.manifest,
690                signed_zero.runtime_set_id,
691                signed_zero.source_basis,
692                signed_zero.diagonal_behavior,
693                1.0,
694                signed_zero.mode,
695                signed_zero.members,
696            ),
697            Err(CollectionDirectionalSpeedPolicyError::DuplicateCoordinate { .. })
698        ));
699    }
700
701    #[test]
702    fn binding_requires_exact_identity_kind_id_and_member_order() {
703        let policy = fixture(CollectionDirectionalSpeedModeV1::Uniform {
704            speed_mps: 1.0,
705            speed_tolerance_mps: 0.1,
706        });
707        let manifest = policy.manifest.clone();
708        let set = policy.runtime_set_id.clone();
709        let members = policy
710            .members
711            .iter()
712            .map(|member| member.id.clone())
713            .collect::<Vec<_>>();
714        assert!(
715            policy
716                .validate_binding(
717                    &manifest,
718                    &set,
719                    CollectionRuntimeSetKindV1::DirectionalBlend,
720                    &members,
721                )
722                .is_ok()
723        );
724        assert!(matches!(
725            policy.validate_binding(
726                &manifest,
727                &set,
728                CollectionRuntimeSetKindV1::GaitGroup,
729                &members
730            ),
731            Err(CollectionDirectionalSpeedPolicyError::WrongRuntimeSetKind)
732        ));
733        let stale_manifest = CollectionDirectionalSpeedManifestIdentityV1::new(
734            CollectionIdV1::new("com.other").unwrap(),
735            InputIdentity::from_bytes(b"manifest"),
736        )
737        .unwrap();
738        assert!(matches!(
739            policy.validate_binding(
740                &stale_manifest,
741                &set,
742                CollectionRuntimeSetKindV1::DirectionalBlend,
743                &members
744            ),
745            Err(CollectionDirectionalSpeedPolicyError::ManifestMismatch)
746        ));
747        let other_set = CollectionLogicalIdV1::new("com.example/other").unwrap();
748        assert!(matches!(
749            policy.validate_binding(
750                &manifest,
751                &other_set,
752                CollectionRuntimeSetKindV1::DirectionalBlend,
753                &members
754            ),
755            Err(CollectionDirectionalSpeedPolicyError::RuntimeSetMismatch)
756        ));
757        assert!(matches!(
758            policy.validate_binding(
759                &manifest,
760                &set,
761                CollectionRuntimeSetKindV1::DirectionalBlend,
762                &members[1..]
763            ),
764            Err(CollectionDirectionalSpeedPolicyError::MemberOrderMismatch)
765        ));
766    }
767
768    #[test]
769    fn manifest_identity_enforces_exact_v1_byte_limit() {
770        let collection = CollectionIdV1::new("com.example.collection").unwrap();
771        assert!(
772            CollectionDirectionalSpeedManifestIdentityV1::new(
773                collection.clone(),
774                InputIdentity::from_sha256_digest(
775                    [0; 32],
776                    crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES
777                )
778            )
779            .is_ok()
780        );
781        assert_eq!(
782            CollectionDirectionalSpeedManifestIdentityV1::new(
783                collection,
784                InputIdentity::from_sha256_digest(
785                    [0; 32],
786                    crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES + 1
787                )
788            ),
789            Err(CollectionDirectionalSpeedPolicyError::ManifestTooLarge)
790        );
791    }
792}