Skip to main content

animsmith_core/
directional_speed_evaluation.rs

1//! Pure, manifest-bound directional-speed policy evaluation V1.
2//!
3//! The format/CLI layer decodes `collection-output:2` strictly, then adapts
4//! its retained root-travel evidence into [`CollectionDirectionalSpeedEvidenceV1`].
5//! This module deliberately has no filesystem or JSON-reader authority.
6
7use serde::Serialize;
8use std::collections::BTreeSet;
9
10use crate::{
11    CollectionDirectionalSpeedDiagonalBehaviorV1, CollectionDirectionalSpeedManifestIdentityV1,
12    CollectionDirectionalSpeedModeV1, CollectionDirectionalSpeedPolicyV1, CollectionLogicalIdV1,
13    CollectionRuntimeSetKindV1, InputIdentity,
14};
15
16/// Schema identity for a directional-speed evaluation result.
17pub const COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_ID: &str =
18    "urn:animsmith:schema:collection-directional-speed-evaluation:1";
19/// Schema version for a directional-speed evaluation result.
20pub const COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_SCHEMA_VERSION: u32 = 1;
21
22/// The retained root-travel lifecycle from collection-output V2.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[allow(missing_docs)]
25#[serde(rename_all = "snake_case")]
26pub enum CollectionDirectionalSpeedLifecycleV1 {
27    Complete,
28    Incomplete,
29}
30
31/// Raw root-travel values for one manifest member. `None` values retain a V2
32/// unavailable/not-applicable prerequisite without creating a subset result.
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct CollectionDirectionalSpeedEvidenceMemberV1 {
35    id: CollectionLogicalIdV1,
36    /// Raw measured duration.
37    pub duration_s: Option<f64>,
38    /// Raw positive-X endpoint displacement.
39    pub horizontal_displacement_x_m: Option<f64>,
40    /// Raw positive-Z endpoint displacement.
41    pub horizontal_displacement_z_m: Option<f64>,
42    /// Raw sampled horizontal travel, retained but never used as speed.
43    pub horizontal_travel_m: Option<f64>,
44    /// Published V2 speed measurement.
45    pub speed_mps: Option<f64>,
46}
47
48impl CollectionDirectionalSpeedEvidenceMemberV1 {
49    /// Construct a raw member row.
50    pub fn new(
51        id: CollectionLogicalIdV1,
52        duration_s: Option<f64>,
53        x: Option<f64>,
54        z: Option<f64>,
55        travel: Option<f64>,
56        speed: Option<f64>,
57    ) -> Self {
58        Self {
59            id,
60            duration_s,
61            horizontal_displacement_x_m: x,
62            horizontal_displacement_z_m: z,
63            horizontal_travel_m: travel,
64            speed_mps: speed,
65        }
66    }
67    /// Logical member identity.
68    pub fn id(&self) -> &CollectionLogicalIdV1 {
69        &self.id
70    }
71    fn measured(&self) -> bool {
72        self.duration_s.is_some_and(valid_nonnegative)
73            && self.horizontal_displacement_x_m.is_some_and(f64::is_finite)
74            && self.horizontal_displacement_z_m.is_some_and(f64::is_finite)
75            && self.horizontal_travel_m.is_some_and(valid_nonnegative)
76            && self.speed_mps.is_some_and(valid_nonnegative)
77    }
78    fn valid_partial(&self) -> bool {
79        self.duration_s.is_none_or(valid_nonnegative)
80            && self.horizontal_displacement_x_m.is_none_or(f64::is_finite)
81            && self.horizontal_displacement_z_m.is_none_or(f64::is_finite)
82            && self.horizontal_travel_m.is_none_or(valid_nonnegative)
83            && self.speed_mps.is_none_or(valid_nonnegative)
84    }
85}
86
87/// Strict V2 evidence as consumed by the evaluator.
88#[derive(Debug, Clone, PartialEq, Serialize)]
89pub struct CollectionDirectionalSpeedEvidenceV1 {
90    manifest: CollectionDirectionalSpeedManifestIdentityV1,
91    runtime_set_id: CollectionLogicalIdV1,
92    kind: CollectionRuntimeSetKindV1,
93    lifecycle: CollectionDirectionalSpeedLifecycleV1,
94    gaps: Vec<CollectionLogicalIdV1>,
95    members: Vec<CollectionDirectionalSpeedEvidenceMemberV1>,
96}
97
98impl CollectionDirectionalSpeedEvidenceV1 {
99    /// Construct evidence after strict V2 decoding. The constructor rejects
100    /// contradictory lifecycle/member representations; incomplete evidence is
101    /// nevertheless a normal evaluator input and result.
102    pub fn new(
103        manifest: CollectionDirectionalSpeedManifestIdentityV1,
104        runtime_set_id: CollectionLogicalIdV1,
105        kind: CollectionRuntimeSetKindV1,
106        lifecycle: CollectionDirectionalSpeedLifecycleV1,
107        gaps: Vec<CollectionLogicalIdV1>,
108        members: Vec<CollectionDirectionalSpeedEvidenceMemberV1>,
109    ) -> Result<Self, CollectionDirectionalSpeedEvaluationControlError> {
110        if manifest.input().bytes() > crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES {
111            return Err(CollectionDirectionalSpeedEvaluationControlError::ContradictoryEvidence);
112        }
113        let ids = members
114            .iter()
115            .map(|member| member.id.clone())
116            .collect::<BTreeSet<_>>();
117        if members.len() < 2 || ids.len() != members.len() {
118            return Err(CollectionDirectionalSpeedEvaluationControlError::ContradictoryEvidence);
119        }
120        let gap_ids = gaps.iter().cloned().collect::<BTreeSet<_>>();
121        if gap_ids.len() != gaps.len() || !gap_ids.is_subset(&ids) {
122            return Err(CollectionDirectionalSpeedEvaluationControlError::ContradictoryEvidence);
123        }
124        if members.iter().any(|member| !member.valid_partial()) {
125            return Err(CollectionDirectionalSpeedEvaluationControlError::ContradictoryEvidence);
126        }
127        let all_measured = members
128            .iter()
129            .all(CollectionDirectionalSpeedEvidenceMemberV1::measured);
130        if (lifecycle == CollectionDirectionalSpeedLifecycleV1::Complete
131            && (!gaps.is_empty() || !all_measured))
132            || (lifecycle == CollectionDirectionalSpeedLifecycleV1::Incomplete && all_measured)
133        {
134            return Err(CollectionDirectionalSpeedEvaluationControlError::ContradictoryEvidence);
135        }
136        Ok(Self {
137            manifest,
138            runtime_set_id,
139            kind,
140            lifecycle,
141            gaps,
142            members,
143        })
144    }
145    /// Exact manifest identity.
146    pub fn manifest(&self) -> &CollectionDirectionalSpeedManifestIdentityV1 {
147        &self.manifest
148    }
149    /// Set id.
150    pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
151        &self.runtime_set_id
152    }
153    /// Set kind.
154    pub const fn kind(&self) -> CollectionRuntimeSetKindV1 {
155        self.kind
156    }
157    /// Root-travel lifecycle.
158    pub const fn lifecycle(&self) -> CollectionDirectionalSpeedLifecycleV1 {
159        self.lifecycle
160    }
161    /// Retained gaps in V2 order.
162    pub fn gaps(&self) -> &[CollectionLogicalIdV1] {
163        &self.gaps
164    }
165    /// Retained member evidence in V2 manifest order.
166    pub fn members(&self) -> &[CollectionDirectionalSpeedEvidenceMemberV1] {
167        &self.members
168    }
169}
170
171/// Typed complete/not-evaluated reason.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[allow(missing_docs)]
174#[serde(rename_all = "snake_case")]
175pub enum CollectionDirectionalSpeedNotEvaluatedReasonV1 {
176    IncompleteRootTravel,
177    ZeroNetDisplacement,
178    ZeroReferenceSpeed,
179    NumericRange,
180}
181
182/// One policy finding. Order is deterministic: manifest member order, then
183/// direction, speed, ratio.
184#[derive(Debug, Clone, PartialEq, Serialize)]
185#[allow(missing_docs)]
186#[serde(tag = "kind", rename_all = "snake_case")]
187pub enum CollectionDirectionalSpeedFindingV1 {
188    /// Endpoint heading exceeded the inclusive angular tolerance.
189    Direction {
190        member_id: CollectionLogicalIdV1,
191        angle_deg: f64,
192        tolerance_deg: f64,
193    },
194    /// Published V2 speed exceeded the inclusive absolute tolerance.
195    Speed {
196        member_id: CollectionLogicalIdV1,
197        measured_speed_mps: f64,
198        expected_speed_mps: f64,
199        tolerance_mps: f64,
200    },
201    /// Published V2 speed ratio exceeded the inclusive ratio tolerance.
202    Ratio {
203        member_id: CollectionLogicalIdV1,
204        measured_ratio: f64,
205        expected_ratio: f64,
206        tolerance: f64,
207    },
208}
209
210/// One immutable per-member comparison row. It always retains its raw V2
211/// evidence and policy coordinate; comparison fields are absent only when the
212/// set's typed lifecycle/reason makes comparison unavailable.
213#[derive(Debug, Clone, PartialEq, Serialize)]
214pub struct CollectionDirectionalSpeedMemberEvaluationV1 {
215    /// Semantic policy coordinate.
216    pub coordinate: [f64; 2],
217    /// Retained raw V2 row.
218    pub evidence: CollectionDirectionalSpeedEvidenceMemberV1,
219    /// Normalized projected semantic heading.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub projected_heading: Option<[f64; 2]>,
222    /// Angular difference from the declared coordinate.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub angle_deg: Option<f64>,
225    /// Expected speed for uniform/authored mode.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub expected_speed_mps: Option<f64>,
228    /// Measured/expected ratio for ratios mode.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub measured_ratio: Option<f64>,
231    /// Expected ratio for ratios mode.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub expected_ratio: Option<f64>,
234    /// Applied inclusive speed or ratio tolerance.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub magnitude_tolerance: Option<f64>,
237    /// Absolute speed or ratio deviation from the expected value.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub magnitude_deviation: Option<f64>,
240    /// Direction comparison passed its inclusive tolerance.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub direction_passed: Option<bool>,
243    /// Speed/ratio comparison passed its inclusive tolerance.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub magnitude_passed: Option<bool>,
246}
247
248/// Immutable result. `policy_input` and `evidence_input` identify exactly the
249/// raw TOML and JSON bytes, never a normalized representation.
250#[derive(Debug, Clone, PartialEq, Serialize)]
251pub struct CollectionDirectionalSpeedEvaluationV1 {
252    schema: &'static str,
253    schema_version: u32,
254    manifest: CollectionDirectionalSpeedManifestIdentityV1,
255    policy_input: InputIdentity,
256    evidence_input: InputIdentity,
257    runtime_set_id: CollectionLogicalIdV1,
258    lifecycle: CollectionDirectionalSpeedLifecycleV1,
259    gaps: Vec<CollectionLogicalIdV1>,
260    members: Vec<CollectionDirectionalSpeedMemberEvaluationV1>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    not_evaluated_reason: Option<CollectionDirectionalSpeedNotEvaluatedReasonV1>,
263    findings: Vec<CollectionDirectionalSpeedFindingV1>,
264}
265
266impl CollectionDirectionalSpeedEvaluationV1 {
267    /// Result schema identity.
268    pub const fn schema(&self) -> &'static str {
269        self.schema
270    }
271    /// Result schema version.
272    pub const fn schema_version(&self) -> u32 {
273        self.schema_version
274    }
275    /// Exact collection-manifest identity bound by this result.
276    pub fn manifest(&self) -> &CollectionDirectionalSpeedManifestIdentityV1 {
277        &self.manifest
278    }
279    /// Exact raw policy-byte identity.
280    pub const fn policy_input(&self) -> &InputIdentity {
281        &self.policy_input
282    }
283    /// Exact raw collection-output-byte identity.
284    pub const fn evidence_input(&self) -> &InputIdentity {
285        &self.evidence_input
286    }
287    /// Evaluated directional runtime-set id.
288    pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
289        &self.runtime_set_id
290    }
291    /// Retained root-travel lifecycle.
292    pub const fn lifecycle(&self) -> CollectionDirectionalSpeedLifecycleV1 {
293        self.lifecycle
294    }
295    /// Retained V2 gaps in source order.
296    pub fn gaps(&self) -> &[CollectionLogicalIdV1] {
297        &self.gaps
298    }
299    /// Typed not-evaluated reason, if evaluation was not possible.
300    pub const fn not_evaluated_reason(
301        &self,
302    ) -> Option<CollectionDirectionalSpeedNotEvaluatedReasonV1> {
303        self.not_evaluated_reason
304    }
305    /// Deterministic policy findings.
306    pub fn findings(&self) -> &[CollectionDirectionalSpeedFindingV1] {
307        &self.findings
308    }
309    /// Audit rows in manifest member order.
310    pub fn members(&self) -> &[CollectionDirectionalSpeedMemberEvaluationV1] {
311        &self.members
312    }
313}
314
315/// Invalid/stale/wrong-kind/contradictory input. Ordinary incomplete and
316/// not-evaluable sets are represented by successful typed results instead.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
318#[non_exhaustive]
319pub enum CollectionDirectionalSpeedEvaluationControlError {
320    /// Raw policy or evidence identity exceeds its immutable input cap.
321    #[error("directional-speed raw input identity exceeds its byte limit")]
322    OverBudgetInput,
323    /// Policy did not bind to the evidence manifest/set/kind/member sequence.
324    #[error("policy does not bind to directional-speed evidence")]
325    InvalidBinding,
326    /// The strict evidence adapter was contradictory.
327    #[error("collection-output root-travel evidence is contradictory")]
328    ContradictoryEvidence,
329}
330
331/// Evaluate one fully declared set without I/O.
332///
333/// # Errors
334/// Returns a control error only for invalid/stale policy binding or
335/// contradictory typed evidence. Incomplete and not-evaluable coverage is a
336/// successful typed result.
337pub fn evaluate_collection_directional_speed_v1(
338    policy: &CollectionDirectionalSpeedPolicyV1,
339    policy_input: InputIdentity,
340    evidence_input: InputIdentity,
341    evidence: &CollectionDirectionalSpeedEvidenceV1,
342) -> Result<CollectionDirectionalSpeedEvaluationV1, CollectionDirectionalSpeedEvaluationControlError>
343{
344    if policy_input.bytes() > crate::COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_BYTES
345        || evidence_input.bytes() > crate::COLLECTION_DIRECTIONAL_SPEED_EVIDENCE_V1_MAX_BYTES
346    {
347        return Err(CollectionDirectionalSpeedEvaluationControlError::OverBudgetInput);
348    }
349    policy
350        .validate_binding(
351            evidence.manifest(),
352            evidence.runtime_set_id(),
353            evidence.kind(),
354            &evidence
355                .members()
356                .iter()
357                .map(|m| m.id().clone())
358                .collect::<Vec<_>>(),
359        )
360        .map_err(|_| CollectionDirectionalSpeedEvaluationControlError::InvalidBinding)?;
361    let mut result = CollectionDirectionalSpeedEvaluationV1 {
362        schema: COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_ID,
363        schema_version: COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_SCHEMA_VERSION,
364        manifest: evidence.manifest().clone(),
365        policy_input,
366        evidence_input,
367        runtime_set_id: evidence.runtime_set_id().clone(),
368        lifecycle: evidence.lifecycle(),
369        gaps: evidence.gaps().to_vec(),
370        members: policy
371            .members()
372            .iter()
373            .zip(evidence.members())
374            .map(
375                |(policy, evidence)| CollectionDirectionalSpeedMemberEvaluationV1 {
376                    coordinate: policy.coordinate(),
377                    evidence: evidence.clone(),
378                    projected_heading: None,
379                    angle_deg: None,
380                    expected_speed_mps: None,
381                    measured_ratio: None,
382                    expected_ratio: None,
383                    magnitude_tolerance: None,
384                    magnitude_deviation: None,
385                    direction_passed: None,
386                    magnitude_passed: None,
387                },
388            )
389            .collect(),
390        not_evaluated_reason: None,
391        findings: Vec::new(),
392    };
393    if evidence.lifecycle() == CollectionDirectionalSpeedLifecycleV1::Incomplete {
394        result.not_evaluated_reason =
395            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::IncompleteRootTravel);
396        return Ok(result);
397    }
398    let headings = evidence
399        .members()
400        .iter()
401        .map(|member| heading(member, policy))
402        .collect::<Option<Vec<_>>>();
403    let Some(headings) = headings else {
404        result.not_evaluated_reason =
405            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::ZeroNetDisplacement);
406        return Ok(result);
407    };
408    if let CollectionDirectionalSpeedModeV1::Ratios {
409        reference_member, ..
410    } = policy.mode()
411    {
412        let reference = evidence
413            .members()
414            .iter()
415            .find(|member| member.id() == reference_member)
416            .expect("binding checked")
417            .speed_mps
418            .expect("complete evidence");
419        if reference == 0.0 {
420            result.not_evaluated_reason =
421                Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::ZeroReferenceSpeed);
422            return Ok(result);
423        }
424    }
425    let Some(expected) = expectations(policy, evidence.members()) else {
426        result.not_evaluated_reason =
427            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::NumericRange);
428        return Ok(result);
429    };
430    for (index, (((member, policy_member), heading), expected)) in evidence
431        .members()
432        .iter()
433        .zip(policy.members())
434        .zip(headings)
435        .zip(expected)
436        .enumerate()
437    {
438        let coordinate = normalize(policy_member.coordinate()).expect("policy coordinate checked");
439        let angle = heading
440            .0
441            .mul_add(coordinate[1], -heading.1 * coordinate[0])
442            .abs()
443            .atan2(heading.0 * coordinate[0] + heading.1 * coordinate[1])
444            .to_degrees();
445        let row = &mut result.members[index];
446        row.projected_heading = Some([heading.0, heading.1]);
447        row.angle_deg = Some(angle);
448        row.direction_passed = Some(angle <= policy.direction_tolerance_deg());
449        if angle > policy.direction_tolerance_deg() {
450            result
451                .findings
452                .push(CollectionDirectionalSpeedFindingV1::Direction {
453                    member_id: member.id().clone(),
454                    angle_deg: angle,
455                    tolerance_deg: policy.direction_tolerance_deg(),
456                });
457        }
458        match expected {
459            Expected::Speed { value, tolerance } => {
460                let measured = member.speed_mps.expect("complete evidence");
461                row.expected_speed_mps = Some(value);
462                row.magnitude_tolerance = Some(tolerance);
463                row.magnitude_deviation = Some((measured - value).abs());
464                row.magnitude_passed = Some(row.magnitude_deviation.expect("set") <= tolerance);
465                if (measured - value).abs() > tolerance {
466                    result
467                        .findings
468                        .push(CollectionDirectionalSpeedFindingV1::Speed {
469                            member_id: member.id().clone(),
470                            measured_speed_mps: measured,
471                            expected_speed_mps: value,
472                            tolerance_mps: tolerance,
473                        });
474                }
475            }
476            Expected::Ratio {
477                value,
478                tolerance,
479                reference,
480            } => {
481                let measured = checked_div(member.speed_mps.expect("complete evidence"), reference)
482                    .expect("numeric range checked before findings");
483                row.measured_ratio = Some(measured);
484                row.expected_ratio = Some(value);
485                row.magnitude_tolerance = Some(tolerance);
486                row.magnitude_deviation = Some((measured - value).abs());
487                row.magnitude_passed = Some(row.magnitude_deviation.expect("set") <= tolerance);
488                if (measured - value).abs() > tolerance {
489                    result
490                        .findings
491                        .push(CollectionDirectionalSpeedFindingV1::Ratio {
492                            member_id: member.id().clone(),
493                            measured_ratio: measured,
494                            expected_ratio: value,
495                            tolerance,
496                        });
497                }
498            }
499        }
500    }
501    Ok(result)
502}
503
504enum Expected {
505    Speed {
506        value: f64,
507        tolerance: f64,
508    },
509    Ratio {
510        value: f64,
511        tolerance: f64,
512        reference: f64,
513    },
514}
515
516fn expectations(
517    policy: &CollectionDirectionalSpeedPolicyV1,
518    evidence: &[CollectionDirectionalSpeedEvidenceMemberV1],
519) -> Option<Vec<Expected>> {
520    let gains = policy
521        .members()
522        .iter()
523        .map(|m| gain(policy.diagonal_behavior(), m.coordinate()))
524        .collect::<Option<Vec<_>>>()?;
525    match policy.mode() {
526        CollectionDirectionalSpeedModeV1::Uniform {
527            speed_mps,
528            speed_tolerance_mps,
529        } => policy
530            .members()
531            .iter()
532            .zip(gains)
533            .map(|(_, gain)| {
534                Some(Expected::Speed {
535                    value: checked_mul(*speed_mps, gain)?,
536                    tolerance: *speed_tolerance_mps,
537                })
538            })
539            .collect(),
540        CollectionDirectionalSpeedModeV1::Authored {
541            speed_tolerance_mps,
542        } => policy
543            .members()
544            .iter()
545            .zip(gains)
546            .map(|(m, gain)| {
547                Some(Expected::Speed {
548                    value: checked_mul(m.speed_mps().expect("policy checked"), gain)?,
549                    tolerance: *speed_tolerance_mps,
550                })
551            })
552            .collect(),
553        CollectionDirectionalSpeedModeV1::Ratios {
554            reference_member,
555            ratio_tolerance,
556        } => {
557            let index = policy
558                .members()
559                .iter()
560                .position(|m| m.id() == reference_member)
561                .expect("policy checked");
562            let reference_gain = gains[index];
563            let reference = evidence[index].speed_mps.expect("complete evidence");
564            policy
565                .members()
566                .iter()
567                .zip(gains)
568                .zip(evidence)
569                .map(|((m, gain), evidence_member)| {
570                    let value = if m.id() == reference_member {
571                        1.0
572                    } else {
573                        checked_div(
574                            checked_mul(m.expected_ratio().expect("policy checked"), gain)?,
575                            reference_gain,
576                        )?
577                    };
578                    // Deliberately make every member's measured ratio representable
579                    // before any direction finding can be emitted.
580                    checked_div(
581                        evidence_member.speed_mps.expect("complete evidence"),
582                        reference,
583                    )?;
584                    Some(Expected::Ratio {
585                        value,
586                        tolerance: *ratio_tolerance,
587                        reference,
588                    })
589                })
590                .collect()
591        }
592    }
593}
594
595fn heading(
596    member: &CollectionDirectionalSpeedEvidenceMemberV1,
597    policy: &CollectionDirectionalSpeedPolicyV1,
598) -> Option<(f64, f64)> {
599    let raw = normalize([
600        member.horizontal_displacement_x_m?,
601        member.horizontal_displacement_z_m?,
602    ])?;
603    let x = normalize(policy.source_basis().x())?;
604    let z = normalize(policy.source_basis().z())?;
605    normalize([raw[0] * x[0] + raw[1] * z[0], raw[0] * x[1] + raw[1] * z[1]]).map(|v| (v[0], v[1]))
606}
607fn gain(mode: CollectionDirectionalSpeedDiagonalBehaviorV1, c: [f64; 2]) -> Option<f64> {
608    match mode {
609        CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize => Some(1.0),
610        CollectionDirectionalSpeedDiagonalBehaviorV1::Preserve => {
611            let n = c[0].hypot(c[1]);
612            (n.is_finite() && n != 0.0).then_some(n)
613        }
614    }
615}
616fn normalize(v: [f64; 2]) -> Option<[f64; 2]> {
617    if !v.into_iter().all(f64::is_finite) {
618        return None;
619    }
620    let scale = v[0].abs().max(v[1].abs());
621    if scale == 0.0 {
622        return None;
623    }
624    let x = v[0] / scale;
625    let z = v[1] / scale;
626    let norm = x.hypot(z);
627    (norm.is_finite() && norm != 0.0).then_some([x / norm, z / norm])
628}
629fn checked_mul(a: f64, b: f64) -> Option<f64> {
630    let value = a * b;
631    (value.is_finite() && !(a != 0.0 && b != 0.0 && value == 0.0)).then_some(value)
632}
633fn checked_div(a: f64, b: f64) -> Option<f64> {
634    if b == 0.0 {
635        return None;
636    }
637    let value = a / b;
638    (value.is_finite() && !(a != 0.0 && value == 0.0)).then_some(value)
639}
640fn valid_nonnegative(v: f64) -> bool {
641    v.is_finite() && v >= 0.0
642}
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::{
647        CollectionDirectionalSpeedMemberV1, CollectionDirectionalSpeedSourceBasisV1, CollectionIdV1,
648    };
649
650    fn id(value: &str) -> CollectionLogicalIdV1 {
651        CollectionLogicalIdV1::new(value).unwrap()
652    }
653
654    fn policy() -> CollectionDirectionalSpeedPolicyV1 {
655        CollectionDirectionalSpeedPolicyV1::new(
656            CollectionDirectionalSpeedManifestIdentityV1::new(
657                CollectionIdV1::new("com.example.collection").unwrap(),
658                InputIdentity::from_bytes(b"manifest"),
659            )
660            .unwrap(),
661            id("com.example/set"),
662            CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [0.0, 1.0]).unwrap(),
663            CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize,
664            1e-9,
665            CollectionDirectionalSpeedModeV1::Uniform {
666                speed_mps: 1.0,
667                speed_tolerance_mps: 0.1,
668            },
669            vec![
670                CollectionDirectionalSpeedMemberV1::new(
671                    id("com.example/x"),
672                    [1.0, 0.0],
673                    None,
674                    None,
675                ),
676                CollectionDirectionalSpeedMemberV1::new(
677                    id("com.example/z"),
678                    [0.0, 1.0],
679                    None,
680                    None,
681                ),
682            ],
683        )
684        .unwrap()
685    }
686
687    fn evidence(
688        x_speed: f64,
689        z_speed: f64,
690        x_displacement: f64,
691    ) -> CollectionDirectionalSpeedEvidenceV1 {
692        let policy = policy();
693        CollectionDirectionalSpeedEvidenceV1::new(
694            policy.manifest().clone(),
695            policy.runtime_set_id().clone(),
696            CollectionRuntimeSetKindV1::DirectionalBlend,
697            CollectionDirectionalSpeedLifecycleV1::Complete,
698            vec![],
699            vec![
700                CollectionDirectionalSpeedEvidenceMemberV1::new(
701                    id("com.example/x"),
702                    Some(1.0),
703                    Some(x_displacement),
704                    Some(0.0),
705                    Some(1.0),
706                    Some(x_speed),
707                ),
708                CollectionDirectionalSpeedEvidenceMemberV1::new(
709                    id("com.example/z"),
710                    Some(1.0),
711                    Some(0.0),
712                    Some(1.0),
713                    Some(1.0),
714                    Some(z_speed),
715                ),
716            ],
717        )
718        .unwrap()
719    }
720
721    #[test]
722    fn uniform_heading_speed_and_zero_endpoint_are_typed_and_ordered() {
723        let policy = policy();
724        let passing = evaluate_collection_directional_speed_v1(
725            &policy,
726            InputIdentity::from_bytes(b"p"),
727            InputIdentity::from_bytes(b"e"),
728            &evidence(1.0, 1.0, 1.0),
729        )
730        .unwrap();
731        assert!(passing.findings().is_empty());
732        let failing = evaluate_collection_directional_speed_v1(
733            &policy,
734            InputIdentity::from_bytes(b"p"),
735            InputIdentity::from_bytes(b"e"),
736            &evidence(1.2, 1.0, 1.0),
737        )
738        .unwrap();
739        assert!(
740            matches!(failing.findings(), [CollectionDirectionalSpeedFindingV1::Speed { member_id, .. }] if member_id.as_str() == "com.example/x")
741        );
742        let zero = evaluate_collection_directional_speed_v1(
743            &policy,
744            InputIdentity::from_bytes(b"p"),
745            InputIdentity::from_bytes(b"e"),
746            &evidence(1.0, 1.0, 0.0),
747        )
748        .unwrap();
749        assert_eq!(
750            zero.not_evaluated_reason(),
751            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::ZeroNetDisplacement)
752        );
753    }
754
755    #[test]
756    fn heading_uses_forward_basis_image_not_the_transpose() {
757        let base = policy();
758        let rotated = CollectionDirectionalSpeedPolicyV1::new(
759            base.manifest().clone(),
760            base.runtime_set_id().clone(),
761            CollectionDirectionalSpeedSourceBasisV1::new([0.0, 1.0], [-1.0, 0.0]).unwrap(),
762            CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize,
763            0.0,
764            CollectionDirectionalSpeedModeV1::Uniform {
765                speed_mps: 1.0,
766                speed_tolerance_mps: 0.1,
767            },
768            vec![
769                CollectionDirectionalSpeedMemberV1::new(
770                    id("com.example/x"),
771                    [0.0, 1.0],
772                    None,
773                    None,
774                ),
775                CollectionDirectionalSpeedMemberV1::new(
776                    id("com.example/z"),
777                    [-1.0, 0.0],
778                    None,
779                    None,
780                ),
781            ],
782        )
783        .unwrap();
784        let result = evaluate_collection_directional_speed_v1(
785            &rotated,
786            InputIdentity::from_bytes(b"p"),
787            InputIdentity::from_bytes(b"e"),
788            &evidence(1.0, 1.0, 1.0),
789        )
790        .unwrap();
791        assert!(result.findings().is_empty());
792        assert_eq!(result.members()[0].projected_heading, Some([0.0, 1.0]));
793    }
794
795    #[test]
796    fn authored_preserve_ratios_and_numeric_outcomes_are_explicit() {
797        let base = policy();
798        let authored = CollectionDirectionalSpeedPolicyV1::new(
799            base.manifest().clone(),
800            base.runtime_set_id().clone(),
801            CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [0.0, 1.0]).unwrap(),
802            CollectionDirectionalSpeedDiagonalBehaviorV1::Preserve,
803            1e-9,
804            CollectionDirectionalSpeedModeV1::Authored {
805                speed_tolerance_mps: 0.0,
806            },
807            vec![
808                CollectionDirectionalSpeedMemberV1::new(
809                    id("com.example/x"),
810                    [1.0, 0.0],
811                    Some(1.0),
812                    None,
813                ),
814                CollectionDirectionalSpeedMemberV1::new(
815                    id("com.example/z"),
816                    [1.0, 1.0],
817                    Some(1.0),
818                    None,
819                ),
820            ],
821        )
822        .unwrap();
823        let authored_evidence = CollectionDirectionalSpeedEvidenceV1::new(
824            authored.manifest().clone(),
825            authored.runtime_set_id().clone(),
826            CollectionRuntimeSetKindV1::DirectionalBlend,
827            CollectionDirectionalSpeedLifecycleV1::Complete,
828            vec![],
829            vec![
830                CollectionDirectionalSpeedEvidenceMemberV1::new(
831                    id("com.example/x"),
832                    Some(1.0),
833                    Some(1.0),
834                    Some(0.0),
835                    Some(1.0),
836                    Some(1.0),
837                ),
838                CollectionDirectionalSpeedEvidenceMemberV1::new(
839                    id("com.example/z"),
840                    Some(1.0),
841                    Some(1.0),
842                    Some(1.0),
843                    Some(1.0),
844                    Some(2.0_f64.sqrt()),
845                ),
846            ],
847        )
848        .unwrap();
849        assert!(
850            evaluate_collection_directional_speed_v1(
851                &authored,
852                InputIdentity::from_bytes(b"p"),
853                InputIdentity::from_bytes(b"e"),
854                &authored_evidence
855            )
856            .unwrap()
857            .findings()
858            .is_empty()
859        );
860
861        let ratios = CollectionDirectionalSpeedPolicyV1::new(
862            base.manifest().clone(),
863            base.runtime_set_id().clone(),
864            CollectionDirectionalSpeedSourceBasisV1::new([1.0, 0.0], [0.0, 1.0]).unwrap(),
865            CollectionDirectionalSpeedDiagonalBehaviorV1::Normalize,
866            0.0,
867            CollectionDirectionalSpeedModeV1::Ratios {
868                reference_member: id("com.example/x"),
869                ratio_tolerance: 0.0,
870            },
871            vec![
872                CollectionDirectionalSpeedMemberV1::new(
873                    id("com.example/x"),
874                    [1.0, 0.0],
875                    None,
876                    Some(1.0),
877                ),
878                CollectionDirectionalSpeedMemberV1::new(
879                    id("com.example/z"),
880                    [0.0, 1.0],
881                    None,
882                    Some(2.0),
883                ),
884            ],
885        )
886        .unwrap();
887        let zero_reference = evidence(0.0, 0.0, 1.0);
888        let zero = evaluate_collection_directional_speed_v1(
889            &ratios,
890            InputIdentity::from_bytes(b"p"),
891            InputIdentity::from_bytes(b"e"),
892            &zero_reference,
893        )
894        .unwrap();
895        assert_eq!(
896            zero.not_evaluated_reason(),
897            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::ZeroReferenceSpeed)
898        );
899        let range_evidence = CollectionDirectionalSpeedEvidenceV1::new(
900            ratios.manifest().clone(),
901            ratios.runtime_set_id().clone(),
902            CollectionRuntimeSetKindV1::DirectionalBlend,
903            CollectionDirectionalSpeedLifecycleV1::Complete,
904            vec![],
905            vec![
906                CollectionDirectionalSpeedEvidenceMemberV1::new(
907                    id("com.example/x"),
908                    Some(1.0),
909                    Some(1.0),
910                    Some(0.0),
911                    Some(1.0),
912                    Some(f64::MIN_POSITIVE),
913                ),
914                CollectionDirectionalSpeedEvidenceMemberV1::new(
915                    id("com.example/z"),
916                    Some(1.0),
917                    Some(0.0),
918                    Some(1.0),
919                    Some(1.0),
920                    Some(f64::MAX),
921                ),
922            ],
923        )
924        .unwrap();
925        let range = evaluate_collection_directional_speed_v1(
926            &ratios,
927            InputIdentity::from_bytes(b"p"),
928            InputIdentity::from_bytes(b"e"),
929            &range_evidence,
930        )
931        .unwrap();
932        assert_eq!(
933            range.not_evaluated_reason(),
934            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::NumericRange)
935        );
936    }
937
938    #[test]
939    fn incomplete_and_binding_errors_are_not_silent() {
940        let policy = policy();
941        let incomplete = CollectionDirectionalSpeedEvidenceV1::new(
942            policy.manifest().clone(),
943            policy.runtime_set_id().clone(),
944            CollectionRuntimeSetKindV1::DirectionalBlend,
945            CollectionDirectionalSpeedLifecycleV1::Incomplete,
946            vec![],
947            vec![
948                CollectionDirectionalSpeedEvidenceMemberV1::new(
949                    id("com.example/x"),
950                    Some(1.0),
951                    Some(1.0),
952                    Some(0.0),
953                    Some(1.0),
954                    None,
955                ),
956                CollectionDirectionalSpeedEvidenceMemberV1::new(
957                    id("com.example/z"),
958                    Some(1.0),
959                    Some(0.0),
960                    Some(1.0),
961                    Some(1.0),
962                    Some(1.0),
963                ),
964            ],
965        )
966        .unwrap();
967        assert_eq!(
968            evaluate_collection_directional_speed_v1(
969                &policy,
970                InputIdentity::from_bytes(b"p"),
971                InputIdentity::from_bytes(b"e"),
972                &incomplete
973            )
974            .unwrap()
975            .not_evaluated_reason(),
976            Some(CollectionDirectionalSpeedNotEvaluatedReasonV1::IncompleteRootTravel)
977        );
978        assert!(
979            CollectionDirectionalSpeedEvidenceV1::new(
980                policy.manifest().clone(),
981                policy.runtime_set_id().clone(),
982                CollectionRuntimeSetKindV1::DirectionalBlend,
983                CollectionDirectionalSpeedLifecycleV1::Incomplete,
984                vec![],
985                vec![
986                    CollectionDirectionalSpeedEvidenceMemberV1::new(
987                        id("com.example/x"),
988                        Some(f64::NAN),
989                        None,
990                        None,
991                        None,
992                        None
993                    ),
994                    CollectionDirectionalSpeedEvidenceMemberV1::new(
995                        id("com.example/z"),
996                        None,
997                        None,
998                        None,
999                        None,
1000                        None
1001                    ),
1002                ]
1003            )
1004            .is_err()
1005        );
1006    }
1007
1008    #[test]
1009    fn scaled_normalization_keeps_max_and_subnormal_headings_defined() {
1010        let max = normalize([f64::MAX, f64::MAX]).unwrap();
1011        let tiny = normalize([f64::from_bits(1), f64::from_bits(1)]).unwrap();
1012        assert_eq!(max, tiny);
1013        assert!(normalize([0.0, 0.0]).is_none());
1014    }
1015
1016    #[test]
1017    fn raw_input_caps_and_complete_root_travel_lifecycle_fail_closed() {
1018        let policy = policy();
1019        let complete = evidence(1.0, 1.0, 1.0);
1020        let at_policy = InputIdentity::from_sha256_digest(
1021            [1; 32],
1022            crate::COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_BYTES,
1023        );
1024        let at_evidence = InputIdentity::from_sha256_digest(
1025            [2; 32],
1026            crate::COLLECTION_DIRECTIONAL_SPEED_EVIDENCE_V1_MAX_BYTES,
1027        );
1028        assert!(
1029            evaluate_collection_directional_speed_v1(&policy, at_policy, at_evidence, &complete)
1030                .is_ok()
1031        );
1032        assert_eq!(
1033            evaluate_collection_directional_speed_v1(
1034                &policy,
1035                InputIdentity::from_sha256_digest(
1036                    [1; 32],
1037                    crate::COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_BYTES + 1
1038                ),
1039                InputIdentity::from_bytes(b"e"),
1040                &complete
1041            ),
1042            Err(CollectionDirectionalSpeedEvaluationControlError::OverBudgetInput)
1043        );
1044        assert_eq!(
1045            evaluate_collection_directional_speed_v1(
1046                &policy,
1047                InputIdentity::from_bytes(b"policy"),
1048                InputIdentity::from_sha256_digest(
1049                    [2; 32],
1050                    crate::COLLECTION_DIRECTIONAL_SPEED_EVIDENCE_V1_MAX_BYTES + 1
1051                ),
1052                &complete
1053            ),
1054            Err(CollectionDirectionalSpeedEvaluationControlError::OverBudgetInput)
1055        );
1056        assert!(
1057            CollectionDirectionalSpeedEvidenceV1::new(
1058                policy.manifest().clone(),
1059                policy.runtime_set_id().clone(),
1060                CollectionRuntimeSetKindV1::DirectionalBlend,
1061                CollectionDirectionalSpeedLifecycleV1::Incomplete,
1062                vec![id("com.example/x")],
1063                complete.members().to_vec()
1064            )
1065            .is_err()
1066        );
1067    }
1068}