1use serde::Serialize;
8use std::collections::BTreeSet;
9
10use crate::{
11 CollectionDirectionalSpeedDiagonalBehaviorV1, CollectionDirectionalSpeedManifestIdentityV1,
12 CollectionDirectionalSpeedModeV1, CollectionDirectionalSpeedPolicyV1, CollectionLogicalIdV1,
13 CollectionRuntimeSetKindV1, InputIdentity,
14};
15
16pub const COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_ID: &str =
18 "urn:animsmith:schema:collection-directional-speed-evaluation:1";
19pub const COLLECTION_DIRECTIONAL_SPEED_EVALUATION_V1_SCHEMA_VERSION: u32 = 1;
21
22#[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#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct CollectionDirectionalSpeedEvidenceMemberV1 {
35 id: CollectionLogicalIdV1,
36 pub duration_s: Option<f64>,
38 pub horizontal_displacement_x_m: Option<f64>,
40 pub horizontal_displacement_z_m: Option<f64>,
42 pub horizontal_travel_m: Option<f64>,
44 pub speed_mps: Option<f64>,
46}
47
48impl CollectionDirectionalSpeedEvidenceMemberV1 {
49 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 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#[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 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 pub fn manifest(&self) -> &CollectionDirectionalSpeedManifestIdentityV1 {
147 &self.manifest
148 }
149 pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
151 &self.runtime_set_id
152 }
153 pub const fn kind(&self) -> CollectionRuntimeSetKindV1 {
155 self.kind
156 }
157 pub const fn lifecycle(&self) -> CollectionDirectionalSpeedLifecycleV1 {
159 self.lifecycle
160 }
161 pub fn gaps(&self) -> &[CollectionLogicalIdV1] {
163 &self.gaps
164 }
165 pub fn members(&self) -> &[CollectionDirectionalSpeedEvidenceMemberV1] {
167 &self.members
168 }
169}
170
171#[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#[derive(Debug, Clone, PartialEq, Serialize)]
185#[allow(missing_docs)]
186#[serde(tag = "kind", rename_all = "snake_case")]
187pub enum CollectionDirectionalSpeedFindingV1 {
188 Direction {
190 member_id: CollectionLogicalIdV1,
191 angle_deg: f64,
192 tolerance_deg: f64,
193 },
194 Speed {
196 member_id: CollectionLogicalIdV1,
197 measured_speed_mps: f64,
198 expected_speed_mps: f64,
199 tolerance_mps: f64,
200 },
201 Ratio {
203 member_id: CollectionLogicalIdV1,
204 measured_ratio: f64,
205 expected_ratio: f64,
206 tolerance: f64,
207 },
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize)]
214pub struct CollectionDirectionalSpeedMemberEvaluationV1 {
215 pub coordinate: [f64; 2],
217 pub evidence: CollectionDirectionalSpeedEvidenceMemberV1,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub projected_heading: Option<[f64; 2]>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub angle_deg: Option<f64>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub expected_speed_mps: Option<f64>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub measured_ratio: Option<f64>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub expected_ratio: Option<f64>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub magnitude_tolerance: Option<f64>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub magnitude_deviation: Option<f64>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub direction_passed: Option<bool>,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub magnitude_passed: Option<bool>,
246}
247
248#[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 pub const fn schema(&self) -> &'static str {
269 self.schema
270 }
271 pub const fn schema_version(&self) -> u32 {
273 self.schema_version
274 }
275 pub fn manifest(&self) -> &CollectionDirectionalSpeedManifestIdentityV1 {
277 &self.manifest
278 }
279 pub const fn policy_input(&self) -> &InputIdentity {
281 &self.policy_input
282 }
283 pub const fn evidence_input(&self) -> &InputIdentity {
285 &self.evidence_input
286 }
287 pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
289 &self.runtime_set_id
290 }
291 pub const fn lifecycle(&self) -> CollectionDirectionalSpeedLifecycleV1 {
293 self.lifecycle
294 }
295 pub fn gaps(&self) -> &[CollectionLogicalIdV1] {
297 &self.gaps
298 }
299 pub const fn not_evaluated_reason(
301 &self,
302 ) -> Option<CollectionDirectionalSpeedNotEvaluatedReasonV1> {
303 self.not_evaluated_reason
304 }
305 pub fn findings(&self) -> &[CollectionDirectionalSpeedFindingV1] {
307 &self.findings
308 }
309 pub fn members(&self) -> &[CollectionDirectionalSpeedMemberEvaluationV1] {
311 &self.members
312 }
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
318#[non_exhaustive]
319pub enum CollectionDirectionalSpeedEvaluationControlError {
320 #[error("directional-speed raw input identity exceeds its byte limit")]
322 OverBudgetInput,
323 #[error("policy does not bind to directional-speed evidence")]
325 InvalidBinding,
326 #[error("collection-output root-travel evidence is contradictory")]
328 ContradictoryEvidence,
329}
330
331pub 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 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}