1use std::collections::BTreeSet;
10
11use serde::Serialize;
12
13use crate::{
14 COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES, CollectionIdV1, CollectionLogicalIdV1,
15 CollectionManifestV1, CollectionRuntimeSetKindV1, ContactClipReferenceV1, ContactEventKindV1,
16 ContactFragmentV1, ContactPhaseV1, ContactRoleV1, ContactTimeWarpControlPointV1,
17 ContactTransformBindingV1, ContactTransformOperationV1, DependencyClosureIdentityV1,
18 DependencyResourceKeyV1, InputIdentity,
19};
20
21pub const FOOT_CYCLE_PARAMETERIZATION_V1_ID: &str =
23 "urn:animsmith:schema:foot-cycle-parameterization:1";
24pub const FOOT_CYCLE_PARAMETERIZATION_V1_SCHEMA_VERSION: u32 = 1;
26pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_BYTES: u64 = 8 * 1024 * 1024;
28pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS: usize = 4_096;
30pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS: usize =
35 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS * 4;
36pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES: u64 = 32 * 1024 * 1024;
42pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS: usize =
44 crate::CONTACT_TRANSFORM_RESULT_V1_MAX_CONTROL_POINTS;
45pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE: f64 = 1_000_000.0;
47pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M: f64 = 0.01;
49pub const FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG: f64 = 1.0;
51pub const CONTACT_SUPPORT_DETECTOR_V1_ID: &str = "urn:animsmith:contact-support-detector:1";
53
54const CONTACT_SUPPORT_DETECTOR_V1_SCHEMA_VERSION: u32 = 1;
55const CONTACT_SUPPORT_DETECTOR_V1_ALGORITHM: &str = "stance-support-v1";
56const CONTACT_SUPPORT_DETECTOR_V1_SAMPLING: &str = "metric-grid-longest-authored-channel";
57const CONTACT_SUPPORT_DETECTOR_V1_MAX_FRAMES: u64 = 1_000_000;
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct FootCycleManifestBindingV1 {
62 collection_id: CollectionIdV1,
63 input: InputIdentity,
64}
65
66impl FootCycleManifestBindingV1 {
67 pub fn new(
74 collection_id: CollectionIdV1,
75 input: InputIdentity,
76 ) -> Result<Self, FootCycleParameterizationError> {
77 if input.bytes() > COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES {
78 return Err(FootCycleParameterizationError::ManifestTooLarge);
79 }
80 Ok(Self {
81 collection_id,
82 input,
83 })
84 }
85
86 pub fn collection_id(&self) -> &CollectionIdV1 {
88 &self.collection_id
89 }
90
91 pub const fn input(&self) -> &InputIdentity {
93 &self.input
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct FootCycleParameterizationMemberV1 {
100 id: CollectionLogicalIdV1,
101 contact_fragment: DependencyResourceKeyV1,
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize)]
110pub struct FootCycleProofPolicyV1 {
111 max_gait_phase_spread: f64,
112 min_lr_amplitude_m: f64,
113 max_contact_boundary_phase_error: f64,
114}
115
116impl FootCycleProofPolicyV1 {
117 pub fn new(
124 max_gait_phase_spread: f64,
125 min_lr_amplitude_m: f64,
126 max_contact_boundary_phase_error: f64,
127 ) -> Result<Self, FootCycleParameterizationError> {
128 if !max_gait_phase_spread.is_finite()
129 || !(0.0..=0.5).contains(&max_gait_phase_spread)
130 || !min_lr_amplitude_m.is_finite()
131 || min_lr_amplitude_m < 0.0
132 || !max_contact_boundary_phase_error.is_finite()
133 || !(0.0..=0.5).contains(&max_contact_boundary_phase_error)
134 {
135 return Err(FootCycleParameterizationError::InvalidProofPolicy);
136 }
137 Ok(Self {
138 max_gait_phase_spread: canonical_zero(max_gait_phase_spread),
139 min_lr_amplitude_m: canonical_zero(min_lr_amplitude_m),
140 max_contact_boundary_phase_error: canonical_zero(max_contact_boundary_phase_error),
141 })
142 }
143
144 pub const fn max_gait_phase_spread(&self) -> f64 {
146 self.max_gait_phase_spread
147 }
148
149 pub const fn min_lr_amplitude_m(&self) -> f64 {
151 self.min_lr_amplitude_m
152 }
153
154 pub const fn max_contact_boundary_phase_error(&self) -> f64 {
156 self.max_contact_boundary_phase_error
157 }
158}
159
160impl FootCycleParameterizationMemberV1 {
161 pub fn new(id: CollectionLogicalIdV1, contact_fragment: DependencyResourceKeyV1) -> Self {
163 Self {
164 id,
165 contact_fragment,
166 }
167 }
168
169 pub fn id(&self) -> &CollectionLogicalIdV1 {
171 &self.id
172 }
173
174 pub fn contact_fragment(&self) -> &DependencyResourceKeyV1 {
176 &self.contact_fragment
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize)]
182pub struct FootCycleParameterizationV1 {
183 schema: &'static str,
184 schema_version: u32,
185 manifest: FootCycleManifestBindingV1,
186 runtime_set_id: CollectionLogicalIdV1,
187 reference_member: CollectionLogicalIdV1,
188 output_directory: DependencyResourceKeyV1,
189 minimum_segment_slope: f64,
190 maximum_segment_slope: f64,
191 proof: FootCycleProofPolicyV1,
192 members: Vec<FootCycleParameterizationMemberV1>,
193}
194
195impl FootCycleParameterizationV1 {
196 #[allow(clippy::too_many_arguments)]
204 pub fn new(
205 manifest: FootCycleManifestBindingV1,
206 runtime_set_id: CollectionLogicalIdV1,
207 reference_member: CollectionLogicalIdV1,
208 output_directory: DependencyResourceKeyV1,
209 minimum_segment_slope: f64,
210 maximum_segment_slope: f64,
211 proof: FootCycleProofPolicyV1,
212 members: Vec<FootCycleParameterizationMemberV1>,
213 ) -> Result<Self, FootCycleParameterizationError> {
214 if members.len() < 2 {
215 return Err(FootCycleParameterizationError::TooFewMembers {
216 found: members.len(),
217 });
218 }
219 if members.len() > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS {
220 return Err(FootCycleParameterizationError::TooManyMembers {
221 found: members.len(),
222 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS,
223 });
224 }
225 if !minimum_segment_slope.is_finite()
226 || !maximum_segment_slope.is_finite()
227 || minimum_segment_slope <= 0.0
228 || minimum_segment_slope > 1.0
229 || maximum_segment_slope < 1.0
230 || minimum_segment_slope > maximum_segment_slope
231 || maximum_segment_slope > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE
232 {
233 return Err(FootCycleParameterizationError::InvalidSlopeBounds);
234 }
235 let mut ids = BTreeSet::new();
236 let mut paths = BTreeSet::new();
237 let mut has_reference = false;
238 for member in &members {
239 if !ids.insert(member.id.clone()) {
240 return Err(FootCycleParameterizationError::DuplicateMember {
241 member: member.id.as_str().to_owned(),
242 });
243 }
244 if !paths.insert(member.contact_fragment.clone()) {
245 return Err(FootCycleParameterizationError::DuplicateFragmentPath);
246 }
247 if member.contact_fragment == output_directory {
248 return Err(FootCycleParameterizationError::OutputPathCollision);
249 }
250 has_reference |= member.id == reference_member;
251 }
252 if !has_reference {
253 return Err(FootCycleParameterizationError::MissingReferenceMember);
254 }
255 Ok(Self {
256 schema: FOOT_CYCLE_PARAMETERIZATION_V1_ID,
257 schema_version: FOOT_CYCLE_PARAMETERIZATION_V1_SCHEMA_VERSION,
258 manifest,
259 runtime_set_id,
260 reference_member,
261 output_directory,
262 minimum_segment_slope: canonical_zero(minimum_segment_slope),
263 maximum_segment_slope: canonical_zero(maximum_segment_slope),
264 proof,
265 members,
266 })
267 }
268
269 pub const fn schema(&self) -> &'static str {
271 self.schema
272 }
273
274 pub const fn schema_version(&self) -> u32 {
276 self.schema_version
277 }
278
279 pub fn manifest(&self) -> &FootCycleManifestBindingV1 {
281 &self.manifest
282 }
283
284 pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
286 &self.runtime_set_id
287 }
288
289 pub fn reference_member(&self) -> &CollectionLogicalIdV1 {
291 &self.reference_member
292 }
293
294 pub fn output_directory(&self) -> &DependencyResourceKeyV1 {
296 &self.output_directory
297 }
298
299 pub const fn minimum_segment_slope(&self) -> f64 {
301 self.minimum_segment_slope
302 }
303
304 pub const fn maximum_segment_slope(&self) -> f64 {
306 self.maximum_segment_slope
307 }
308
309 pub const fn proof(&self) -> &FootCycleProofPolicyV1 {
311 &self.proof
312 }
313
314 pub fn members(&self) -> &[FootCycleParameterizationMemberV1] {
316 &self.members
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
322pub struct FootCycleRootMotionBindingV1 {
323 artifact: InputIdentity,
324 dependency_closure_identity: DependencyClosureIdentityV1,
325 clip: ContactClipReferenceV1,
326}
327
328impl FootCycleRootMotionBindingV1 {
329 pub fn new(
331 artifact: InputIdentity,
332 dependency_closure_identity: DependencyClosureIdentityV1,
333 clip: ContactClipReferenceV1,
334 ) -> Self {
335 Self {
336 artifact,
337 dependency_closure_identity,
338 clip,
339 }
340 }
341
342 pub const fn artifact(&self) -> &InputIdentity {
344 &self.artifact
345 }
346
347 pub const fn dependency_closure_identity(&self) -> &DependencyClosureIdentityV1 {
349 &self.dependency_closure_identity
350 }
351
352 pub const fn clip(&self) -> &ContactClipReferenceV1 {
354 &self.clip
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Serialize)]
360#[serde(tag = "state", rename_all = "snake_case")]
361pub enum FootCycleRootMotionEvidenceV1 {
362 Measured {
364 binding: FootCycleRootMotionBindingV1,
366 endpoint_displacement_x_m: f64,
368 endpoint_displacement_z_m: f64,
370 accumulated_yaw_deg: f64,
372 },
373 Missing {
375 binding: FootCycleRootMotionBindingV1,
377 },
378 Ambiguous {
380 binding: FootCycleRootMotionBindingV1,
382 },
383 NonFinite {
385 binding: FootCycleRootMotionBindingV1,
387 },
388}
389
390impl FootCycleRootMotionEvidenceV1 {
391 pub fn measured(
393 binding: FootCycleRootMotionBindingV1,
394 endpoint_displacement_x_m: f64,
395 endpoint_displacement_z_m: f64,
396 accumulated_yaw_deg: f64,
397 ) -> Self {
398 Self::Measured {
399 binding,
400 endpoint_displacement_x_m,
401 endpoint_displacement_z_m,
402 accumulated_yaw_deg,
403 }
404 }
405
406 pub fn missing(binding: FootCycleRootMotionBindingV1) -> Self {
408 Self::Missing { binding }
409 }
410
411 pub fn ambiguous(binding: FootCycleRootMotionBindingV1) -> Self {
413 Self::Ambiguous { binding }
414 }
415
416 pub fn non_finite(binding: FootCycleRootMotionBindingV1) -> Self {
418 Self::NonFinite { binding }
419 }
420
421 pub const fn binding(&self) -> &FootCycleRootMotionBindingV1 {
423 match self {
424 Self::Measured { binding, .. }
425 | Self::Missing { binding }
426 | Self::Ambiguous { binding }
427 | Self::NonFinite { binding } => binding,
428 }
429 }
430}
431
432#[derive(Debug, Clone, PartialEq)]
434pub struct FootCycleMemberEvidenceV1 {
435 id: CollectionLogicalIdV1,
436 contact_fragment_path: DependencyResourceKeyV1,
437 input: InputIdentity,
438 fragment: ContactFragmentV1,
439 root_motion: FootCycleRootMotionEvidenceV1,
440}
441
442impl FootCycleMemberEvidenceV1 {
443 pub fn new(
445 id: CollectionLogicalIdV1,
446 contact_fragment_path: DependencyResourceKeyV1,
447 input: InputIdentity,
448 fragment: ContactFragmentV1,
449 root_motion: FootCycleRootMotionEvidenceV1,
450 ) -> Self {
451 Self {
452 id,
453 contact_fragment_path,
454 input,
455 fragment,
456 root_motion,
457 }
458 }
459
460 pub fn id(&self) -> &CollectionLogicalIdV1 {
462 &self.id
463 }
464
465 pub const fn input(&self) -> &InputIdentity {
467 &self.input
468 }
469
470 pub const fn fragment(&self) -> &ContactFragmentV1 {
472 &self.fragment
473 }
474
475 pub const fn root_motion(&self) -> &FootCycleRootMotionEvidenceV1 {
477 &self.root_motion
478 }
479}
480
481#[derive(Debug, Clone, PartialEq)]
483pub struct FootCycleMemberPlanV1 {
484 id: CollectionLogicalIdV1,
485 input: ContactTransformBindingV1,
486 root_motion: FootCycleRootMotionEvidenceV1,
487 operation: ContactTransformOperationV1,
488}
489
490impl FootCycleMemberPlanV1 {
491 pub fn id(&self) -> &CollectionLogicalIdV1 {
493 &self.id
494 }
495
496 pub const fn input(&self) -> &ContactTransformBindingV1 {
498 &self.input
499 }
500
501 pub const fn root_motion(&self) -> &FootCycleRootMotionEvidenceV1 {
503 &self.root_motion
504 }
505
506 pub const fn operation(&self) -> &ContactTransformOperationV1 {
508 &self.operation
509 }
510}
511
512#[cfg(test)]
513pub(crate) fn clip_test_member_plan(
514 operation: ContactTransformOperationV1,
515) -> FootCycleMemberPlanV1 {
516 let artifact = InputIdentity::from_bytes(b"foot-cycle-clip-test");
517 let closure = crate::DependencyClosureBuilderV1::new(
518 artifact.clone(),
519 crate::SourceSetCoverageV1::complete(),
520 0,
521 )
522 .finish()
523 .expect("test dependency closure");
524 let closure_identity = closure.identity().expect("complete closure").clone();
525 let clip =
526 ContactClipReferenceV1::collection("com.example/test", "sources/test.glb", 0, "test")
527 .expect("test clip reference");
528 let root_motion = FootCycleRootMotionEvidenceV1::measured(
529 FootCycleRootMotionBindingV1::new(artifact.clone(), closure_identity.clone(), clip),
530 0.0,
531 0.0,
532 0.0,
533 );
534 FootCycleMemberPlanV1 {
535 id: CollectionLogicalIdV1::new("com.example/test").expect("test logical id"),
536 input: ContactTransformBindingV1::new(
537 artifact,
538 closure_identity,
539 InputIdentity::from_bytes(b"test-contact-fragment"),
540 ),
541 root_motion,
542 operation,
543 }
544}
545
546#[derive(Debug, Clone, PartialEq)]
548pub struct FootCyclePlanV1 {
549 parameterization_input: InputIdentity,
550 manifest_input: InputIdentity,
551 runtime_set_id: CollectionLogicalIdV1,
552 reference_member: CollectionLogicalIdV1,
553 proof: FootCycleProofPolicyV1,
554 members: Vec<FootCycleMemberPlanV1>,
555}
556
557impl FootCyclePlanV1 {
558 pub const fn parameterization_input(&self) -> &InputIdentity {
560 &self.parameterization_input
561 }
562
563 pub const fn manifest_input(&self) -> &InputIdentity {
565 &self.manifest_input
566 }
567
568 pub fn runtime_set_id(&self) -> &CollectionLogicalIdV1 {
570 &self.runtime_set_id
571 }
572
573 pub fn reference_member(&self) -> &CollectionLogicalIdV1 {
575 &self.reference_member
576 }
577
578 pub const fn proof(&self) -> &FootCycleProofPolicyV1 {
580 &self.proof
581 }
582
583 pub fn members(&self) -> &[FootCycleMemberPlanV1] {
585 &self.members
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
591#[non_exhaustive]
592pub enum FootCycleParameterizationError {
593 #[error("manifest identity exceeds the V1 byte limit")]
595 ManifestTooLarge,
596 #[error("parameterization identity exceeds the V1 byte limit")]
598 ParameterizationTooLarge,
599 #[error("foot-cycle parameterization needs at least two members, found {found}")]
601 TooFewMembers {
602 found: usize,
604 },
605 #[error("foot-cycle parameterization has {found} members, exceeding V1 limit {max}")]
607 TooManyMembers {
608 found: usize,
610 max: usize,
612 },
613 #[error("segment-slope bounds must be finite, positive, ordered, include 1, and be within V1")]
615 InvalidSlopeBounds,
616 #[error("proof policy values must be finite and within their V1 ranges")]
618 InvalidProofPolicy,
619 #[error("duplicate foot-cycle member {member:?}")]
621 DuplicateMember {
622 member: String,
624 },
625 #[error("contact-fragment paths must be unique")]
627 DuplicateFragmentPath,
628 #[error("output directory must differ from every contact-fragment path")]
630 OutputPathCollision,
631 #[error("reference_member must name one declared member")]
633 MissingReferenceMember,
634 #[error("parameterization manifest binding does not match current manifest")]
636 ManifestMismatch,
637 #[error("declared runtime set is not present exactly once")]
639 RuntimeSetMismatch,
640 #[error("foot-cycle parameterization runtime set must be gait-group")]
642 WrongRuntimeSetKind,
643 #[error("parameterization members must exactly preserve runtime-set membership and order")]
645 MemberOrderMismatch,
646 #[error("foot-cycle evidence row count does not match declared members")]
648 EvidenceCountMismatch,
649 #[error("foot-cycle evidence has {found} contact events, exceeding V1 aggregate limit {max}")]
651 TooManyContactEvents {
652 found: usize,
654 max: usize,
656 },
657 #[error(
659 "foot-cycle evidence has {found} canonical contact-fragment bytes, exceeding V1 aggregate limit {max}"
660 )]
661 TooManyContactFragmentBytes {
662 found: u64,
664 max: u64,
666 },
667 #[error("foot-cycle evidence does not match declared member order and paths")]
669 EvidenceMemberMismatch,
670 #[error("contact fragment must be supplied as its exact canonical bytes")]
672 NonCanonicalFragment,
673 #[error("contact fragment collection clip witness does not match the manifest")]
675 FragmentClipMismatch,
676 #[error("contact fragment must carry exactly the supported stance detector extension")]
678 UnsupportedContactExtension,
679 #[error("contact fragment stance detector provenance is invalid")]
681 InvalidDetectorProvenance,
682 #[error("contact fragments must use one exact stance-detector threshold across the ring")]
684 DetectorPolicyMismatch,
685 #[error("member {member:?} Root/Hips evidence does not match its exact source clip")]
687 RootMotionBindingMismatch {
688 member: String,
690 },
691 #[error("member {member:?} does not have complete finite Root/Hips in-place evidence")]
693 RootMotionEvidenceUnavailable {
694 member: String,
696 },
697 #[error("member {member:?} exceeds V1 in-place root-motion thresholds")]
699 RootMotionOutOfRange {
700 member: String,
702 },
703 #[error("contact fragment has incomplete or non-alternating bilateral stance topology")]
705 InvalidContactTopology,
706 #[error("contact boundary topology does not match the reference member")]
708 TopologyMismatch,
709 #[error("corresponding contact boundaries do not form a strict monotone map")]
711 NonMonotoneMapping,
712 #[error("piecewise-linear segment slope is outside declared bounds")]
714 SegmentSlopeOutOfRange,
715 #[error("time-warp plan needs {found} control points, exceeding V1 limit {max}")]
717 TooManyControlPoints {
718 found: usize,
720 max: usize,
722 },
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726enum Side {
727 Left,
728 Right,
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
732enum Edge {
733 Onset,
734 Release,
735}
736
737#[derive(Debug, Clone, Copy)]
738struct SupportWindow {
739 side: Side,
740 start: f64,
741 end: f64,
742}
743
744#[derive(Debug, Clone, Copy)]
745struct LogicalSupportWindow {
746 side: Side,
747 onset: f64,
748 release: f64,
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752struct BoundaryKind {
753 side: Side,
754 edge: Edge,
755}
756
757#[derive(Debug, Clone, Copy)]
758struct Boundary {
759 kind: BoundaryKind,
760 time: f64,
761}
762
763struct MemberTopology {
764 evidence_index: usize,
765 signature: Vec<BoundaryKind>,
766 rotated_boundaries: Vec<Boundary>,
767 contact_height_m_bits: u64,
768}
769
770pub fn plan_foot_cycle_parameterization_v1(
784 parameterization: &FootCycleParameterizationV1,
785 parameterization_input: InputIdentity,
786 manifest: &CollectionManifestV1,
787 manifest_input: InputIdentity,
788 evidence: &[FootCycleMemberEvidenceV1],
789) -> Result<FootCyclePlanV1, FootCycleParameterizationError> {
790 if parameterization_input.bytes() > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_BYTES {
791 return Err(FootCycleParameterizationError::ParameterizationTooLarge);
792 }
793 validate_foot_cycle_manifest_binding_v1(parameterization, manifest, &manifest_input)?;
794 if evidence.len() != parameterization.members.len() {
795 return Err(FootCycleParameterizationError::EvidenceCountMismatch);
796 }
797 validate_aggregate_contact_event_budget(
798 evidence.iter().map(|row| row.fragment.events().len()),
799 )?;
800 validate_aggregate_contact_fragment_byte_budget(evidence.iter().map(|row| row.input.bytes()))?;
801
802 let mut topologies = Vec::with_capacity(evidence.len());
803 for (index, (declaration, evidence)) in
804 parameterization.members.iter().zip(evidence).enumerate()
805 {
806 if declaration.id != evidence.id
807 || declaration.contact_fragment != evidence.contact_fragment_path
808 {
809 return Err(FootCycleParameterizationError::EvidenceMemberMismatch);
810 }
811 let canonical = evidence
812 .fragment
813 .canonical_json()
814 .map_err(|_| FootCycleParameterizationError::NonCanonicalFragment)?;
815 if canonical.len() as u64 != evidence.input.bytes()
816 || InputIdentity::from_bytes(&canonical) != evidence.input
817 {
818 return Err(FootCycleParameterizationError::NonCanonicalFragment);
819 }
820 validate_clip_witness(manifest, &declaration.id, &evidence.fragment)?;
821 validate_root_motion(&declaration.id, &evidence.fragment, &evidence.root_motion)?;
822 let topology = topology(&evidence.fragment)?;
823 topologies.push(MemberTopology {
824 evidence_index: index,
825 signature: topology.0,
826 rotated_boundaries: topology.1,
827 contact_height_m_bits: topology.2,
828 });
829 }
830
831 let reference_index = parameterization
832 .members
833 .iter()
834 .position(|member| member.id == parameterization.reference_member)
835 .ok_or(FootCycleParameterizationError::MissingReferenceMember)?;
836 let reference = &topologies[reference_index];
837 for member in &topologies {
838 if member.contact_height_m_bits != reference.contact_height_m_bits {
839 return Err(FootCycleParameterizationError::DetectorPolicyMismatch);
840 }
841 if member.signature != reference.signature {
842 return Err(FootCycleParameterizationError::TopologyMismatch);
843 }
844 }
845
846 let mut plans = Vec::with_capacity(topologies.len());
847 for topology in &topologies {
848 let source = &evidence[topology.evidence_index];
849 let control_points = build_control_points(
850 &topology.rotated_boundaries,
851 &reference.rotated_boundaries,
852 parameterization.minimum_segment_slope,
853 parameterization.maximum_segment_slope,
854 )?;
855 let binding = ContactTransformBindingV1::new(
856 source.fragment.artifact().clone(),
857 source.fragment.dependency_closure_identity().clone(),
858 source.input.clone(),
859 );
860 plans.push(FootCycleMemberPlanV1 {
861 id: source.id.clone(),
862 input: binding,
863 root_motion: source.root_motion.clone(),
864 operation: ContactTransformOperationV1::time_warp(
865 source.fragment.duration_s(),
866 control_points,
867 ),
868 });
869 }
870
871 Ok(FootCyclePlanV1 {
872 parameterization_input,
873 manifest_input,
874 runtime_set_id: parameterization.runtime_set_id.clone(),
875 reference_member: parameterization.reference_member.clone(),
876 proof: parameterization.proof.clone(),
877 members: plans,
878 })
879}
880
881fn validate_root_motion(
882 member: &CollectionLogicalIdV1,
883 fragment: &ContactFragmentV1,
884 evidence: &FootCycleRootMotionEvidenceV1,
885) -> Result<(), FootCycleParameterizationError> {
886 let binding = evidence.binding();
887 if binding.artifact != *fragment.artifact()
888 || binding.dependency_closure_identity != *fragment.dependency_closure_identity()
889 || binding.clip != *fragment.clip()
890 {
891 return Err(FootCycleParameterizationError::RootMotionBindingMismatch {
892 member: member.as_str().to_owned(),
893 });
894 }
895 let FootCycleRootMotionEvidenceV1::Measured {
896 endpoint_displacement_x_m,
897 endpoint_displacement_z_m,
898 accumulated_yaw_deg,
899 ..
900 } = evidence
901 else {
902 return Err(
903 FootCycleParameterizationError::RootMotionEvidenceUnavailable {
904 member: member.as_str().to_owned(),
905 },
906 );
907 };
908 if !endpoint_displacement_x_m.is_finite()
909 || !endpoint_displacement_z_m.is_finite()
910 || !accumulated_yaw_deg.is_finite()
911 {
912 return Err(
913 FootCycleParameterizationError::RootMotionEvidenceUnavailable {
914 member: member.as_str().to_owned(),
915 },
916 );
917 }
918 if endpoint_displacement_x_m.hypot(*endpoint_displacement_z_m)
919 > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M
920 || accumulated_yaw_deg.abs() > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG
921 {
922 return Err(FootCycleParameterizationError::RootMotionOutOfRange {
923 member: member.as_str().to_owned(),
924 });
925 }
926 Ok(())
927}
928
929fn validate_aggregate_contact_event_budget(
930 counts: impl IntoIterator<Item = usize>,
931) -> Result<(), FootCycleParameterizationError> {
932 let mut total = 0usize;
933 for count in counts {
934 total = total
935 .checked_add(count)
936 .unwrap_or(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1);
937 if total > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS {
938 return Err(FootCycleParameterizationError::TooManyContactEvents {
939 found: total.min(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1),
940 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
941 });
942 }
943 }
944 Ok(())
945}
946
947fn validate_aggregate_contact_fragment_byte_budget(
948 counts: impl IntoIterator<Item = u64>,
949) -> Result<(), FootCycleParameterizationError> {
950 let mut total = 0u64;
951 for count in counts {
952 total = total
953 .checked_add(count)
954 .unwrap_or(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1);
955 if total > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES {
956 return Err(
957 FootCycleParameterizationError::TooManyContactFragmentBytes {
958 found: total.min(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1),
959 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
960 },
961 );
962 }
963 }
964 Ok(())
965}
966
967pub fn validate_foot_cycle_manifest_binding_v1(
980 parameterization: &FootCycleParameterizationV1,
981 manifest: &CollectionManifestV1,
982 manifest_input: &InputIdentity,
983) -> Result<(), FootCycleParameterizationError> {
984 if parameterization.manifest.collection_id != *manifest.collection_id()
985 || parameterization.manifest.input != *manifest_input
986 {
987 return Err(FootCycleParameterizationError::ManifestMismatch);
988 }
989 let mut matches = manifest
990 .runtime_sets()
991 .iter()
992 .filter(|set| set.id() == ¶meterization.runtime_set_id);
993 let set = matches
994 .next()
995 .ok_or(FootCycleParameterizationError::RuntimeSetMismatch)?;
996 if matches.next().is_some() {
997 return Err(FootCycleParameterizationError::RuntimeSetMismatch);
998 }
999 if set.kind() != CollectionRuntimeSetKindV1::GaitGroup {
1000 return Err(FootCycleParameterizationError::WrongRuntimeSetKind);
1001 }
1002 if !set
1003 .members()
1004 .iter()
1005 .eq(parameterization.members.iter().map(|member| &member.id))
1006 {
1007 return Err(FootCycleParameterizationError::MemberOrderMismatch);
1008 }
1009 Ok(())
1010}
1011
1012fn validate_clip_witness(
1013 manifest: &CollectionManifestV1,
1014 member: &CollectionLogicalIdV1,
1015 fragment: &ContactFragmentV1,
1016) -> Result<(), FootCycleParameterizationError> {
1017 let clip = manifest
1018 .clips()
1019 .binary_search_by(|clip| clip.id().cmp(member))
1020 .ok()
1021 .and_then(|index| manifest.clips().get(index))
1022 .ok_or(FootCycleParameterizationError::FragmentClipMismatch)?;
1023 match fragment.clip() {
1024 ContactClipReferenceV1::Collection {
1025 logical_id,
1026 source,
1027 take_index,
1028 take_name,
1029 } if logical_id == member.as_str()
1030 && source == clip.source().as_str()
1031 && *take_index == clip.take_index()
1032 && take_name == clip.take_name() =>
1033 {
1034 Ok(())
1035 }
1036 ContactClipReferenceV1::Document { .. } | ContactClipReferenceV1::Collection { .. } => {
1037 Err(FootCycleParameterizationError::FragmentClipMismatch)
1038 }
1039 }
1040}
1041
1042fn topology(
1043 fragment: &ContactFragmentV1,
1044) -> Result<(Vec<BoundaryKind>, Vec<Boundary>, u64), FootCycleParameterizationError> {
1045 let detector = validate_detector_extension(fragment)?;
1046 let mut windows = [Vec::new(), Vec::new()];
1047 let mut markers = [Vec::new(), Vec::new()];
1048 for event in fragment.events() {
1049 let side = event_side(event.role(), detector.roles)
1050 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1051 let side_index = side_index(side);
1052 match (event.phase(), event.kind()) {
1053 (ContactPhaseV1::Begin, ContactEventKindV1::Window(window))
1054 if window.start() < window.end() =>
1055 {
1056 windows[side_index].push(SupportWindow {
1057 side,
1058 start: window.start(),
1059 end: window.end(),
1060 });
1061 }
1062 (ContactPhaseV1::Marker, ContactEventKindV1::Point(time)) => {
1063 markers[side_index].push(time);
1064 }
1065 _ => return Err(FootCycleParameterizationError::InvalidContactTopology),
1066 }
1067 }
1068 let mut logical = Vec::new();
1069 for (side_windows, side_markers) in windows.iter_mut().zip(&mut markers) {
1070 side_windows.sort_by(|left, right| left.start.total_cmp(&right.start));
1071 side_markers.sort_by(|left, right| left.total_cmp(right));
1072 if side_windows.is_empty()
1073 || side_windows.len() != side_markers.len()
1074 || side_windows
1075 .windows(2)
1076 .any(|pair| pair[0].end >= pair[1].start)
1077 || side_windows
1078 .iter()
1079 .zip(side_markers)
1080 .any(|(window, marker)| *marker < window.start || *marker > window.end)
1081 {
1082 return Err(FootCycleParameterizationError::InvalidContactTopology);
1083 }
1084
1085 let first = side_windows[0];
1086 let last = side_windows[side_windows.len() - 1];
1087 if first.start == 0.0 && last.end == 1.0 {
1088 if side_windows.len() == 1 {
1089 return Err(FootCycleParameterizationError::InvalidContactTopology);
1090 }
1091 logical.push(LogicalSupportWindow {
1092 side: first.side,
1093 onset: last.start,
1094 release: first.end,
1095 });
1096 logical.extend(
1097 side_windows[1..side_windows.len() - 1]
1098 .iter()
1099 .map(|window| LogicalSupportWindow {
1100 side: window.side,
1101 onset: window.start,
1102 release: window.end,
1103 }),
1104 );
1105 } else {
1106 logical.extend(side_windows.iter().map(|window| LogicalSupportWindow {
1107 side: window.side,
1108 onset: window.start,
1109 release: window.end,
1110 }));
1111 }
1112 }
1113
1114 let left_count = logical
1115 .iter()
1116 .filter(|window| window.side == Side::Left)
1117 .count();
1118 let right_count = logical.len() - left_count;
1119 if left_count == 0 || left_count != right_count {
1120 return Err(FootCycleParameterizationError::InvalidContactTopology);
1121 }
1122 logical.sort_by(|left, right| {
1123 left.onset
1124 .total_cmp(&right.onset)
1125 .then_with(|| side_index(left.side).cmp(&side_index(right.side)))
1126 });
1127 if logical
1128 .iter()
1129 .zip(logical.iter().cycle().skip(1))
1130 .take(logical.len())
1131 .any(|(left, right)| left.side == right.side)
1132 {
1133 return Err(FootCycleParameterizationError::InvalidContactTopology);
1134 }
1135
1136 let mut boundaries = Vec::with_capacity(logical.len() * 2);
1137 for window in &logical {
1138 boundaries.push(Boundary {
1139 kind: BoundaryKind {
1140 side: window.side,
1141 edge: Edge::Onset,
1142 },
1143 time: window.onset,
1144 });
1145 boundaries.push(Boundary {
1146 kind: BoundaryKind {
1147 side: window.side,
1148 edge: Edge::Release,
1149 },
1150 time: window.release,
1151 });
1152 }
1153 boundaries.sort_by(|left, right| {
1154 left.time
1155 .total_cmp(&right.time)
1156 .then_with(|| boundary_kind_key(left.kind).cmp(&boundary_kind_key(right.kind)))
1157 });
1158 let origin = boundaries
1159 .iter()
1160 .position(|boundary| boundary.kind.side == Side::Left && boundary.kind.edge == Edge::Onset)
1161 .ok_or(FootCycleParameterizationError::InvalidContactTopology)?;
1162 boundaries.rotate_left(origin);
1163 let signature = boundaries.iter().map(|boundary| boundary.kind).collect();
1164 Ok((signature, boundaries, detector.contact_height_m_bits))
1165}
1166
1167const fn side_index(side: Side) -> usize {
1168 match side {
1169 Side::Left => 0,
1170 Side::Right => 1,
1171 }
1172}
1173
1174const fn boundary_kind_key(kind: BoundaryKind) -> (usize, usize) {
1175 (
1176 side_index(kind.side),
1177 match kind.edge {
1178 Edge::Onset => 0,
1179 Edge::Release => 1,
1180 },
1181 )
1182}
1183
1184#[derive(Debug, Clone, Copy)]
1185struct DetectorProvenance {
1186 roles: [ContactRoleV1; 2],
1187 contact_height_m_bits: u64,
1188}
1189
1190fn validate_detector_extension(
1191 fragment: &ContactFragmentV1,
1192) -> Result<DetectorProvenance, FootCycleParameterizationError> {
1193 let [extension] = fragment.extensions() else {
1194 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1195 };
1196 validate_detector_extension_value(extension)
1197}
1198
1199fn validate_detector_extension_value(
1200 extension: &crate::ContactExtensionV1,
1201) -> Result<DetectorProvenance, FootCycleParameterizationError> {
1202 if extension.schema() != CONTACT_SUPPORT_DETECTOR_V1_ID
1203 || extension.schema_version() != CONTACT_SUPPORT_DETECTOR_V1_SCHEMA_VERSION
1204 {
1205 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1206 }
1207 let payload = extension
1208 .payload()
1209 .as_object()
1210 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1211 let expected = [
1212 "algorithm",
1213 "contact_height_m",
1214 "max_frames",
1215 "roles",
1216 "sampling",
1217 ]
1218 .into_iter()
1219 .collect::<BTreeSet<_>>();
1220 let Some(contact_height_m) = payload
1221 .get("contact_height_m")
1222 .and_then(serde_json::Value::as_f64)
1223 else {
1224 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1225 };
1226 if payload.keys().map(String::as_str).collect::<BTreeSet<_>>() != expected
1227 || payload.get("algorithm").and_then(serde_json::Value::as_str)
1228 != Some(CONTACT_SUPPORT_DETECTOR_V1_ALGORITHM)
1229 || payload.get("sampling").and_then(serde_json::Value::as_str)
1230 != Some(CONTACT_SUPPORT_DETECTOR_V1_SAMPLING)
1231 || payload
1232 .get("max_frames")
1233 .and_then(serde_json::Value::as_u64)
1234 != Some(CONTACT_SUPPORT_DETECTOR_V1_MAX_FRAMES)
1235 || !contact_height_m.is_finite()
1236 || contact_height_m < 0.0
1237 {
1238 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1239 }
1240 let roles = payload
1241 .get("roles")
1242 .and_then(serde_json::Value::as_object)
1243 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1244 if roles.keys().map(String::as_str).collect::<BTreeSet<_>>()
1245 != ["left", "right"].into_iter().collect::<BTreeSet<_>>()
1246 {
1247 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1248 }
1249 let left = match roles.get("left").and_then(serde_json::Value::as_str) {
1250 Some("left_foot") => ContactRoleV1::LeftFoot,
1251 Some("left_toe") => ContactRoleV1::LeftToe,
1252 _ => return Err(FootCycleParameterizationError::InvalidDetectorProvenance),
1253 };
1254 let right = match roles.get("right").and_then(serde_json::Value::as_str) {
1255 Some("right_foot") => ContactRoleV1::RightFoot,
1256 Some("right_toe") => ContactRoleV1::RightToe,
1257 _ => return Err(FootCycleParameterizationError::InvalidDetectorProvenance),
1258 };
1259 Ok(DetectorProvenance {
1260 roles: [left, right],
1261 contact_height_m_bits: canonical_zero(contact_height_m).to_bits(),
1262 })
1263}
1264
1265pub fn transform_contact_support_detector_extension_time_warp_v1(
1278 extension: &crate::ContactExtensionV1,
1279 operation: &ContactTransformOperationV1,
1280) -> Result<crate::ContactExtensionV1, FootCycleParameterizationError> {
1281 if !matches!(
1282 operation,
1283 ContactTransformOperationV1::TimeWarp { version: 1, .. }
1284 ) {
1285 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1286 }
1287 let detector = validate_detector_extension_value(extension)?;
1288 let left = detector_role_name(detector.roles[0])
1289 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1290 let right = detector_role_name(detector.roles[1])
1291 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1292 let payload = serde_json::json!({
1293 "algorithm": CONTACT_SUPPORT_DETECTOR_V1_ALGORITHM,
1294 "contact_height_m": f64::from_bits(detector.contact_height_m_bits),
1295 "max_frames": CONTACT_SUPPORT_DETECTOR_V1_MAX_FRAMES,
1296 "roles": {
1297 "left": left,
1298 "right": right,
1299 },
1300 "sampling": CONTACT_SUPPORT_DETECTOR_V1_SAMPLING,
1301 });
1302 crate::ContactExtensionV1::new(
1303 CONTACT_SUPPORT_DETECTOR_V1_ID,
1304 CONTACT_SUPPORT_DETECTOR_V1_SCHEMA_VERSION,
1305 payload,
1306 )
1307 .map_err(|_| FootCycleParameterizationError::InvalidDetectorProvenance)
1308}
1309
1310fn detector_role_name(role: ContactRoleV1) -> Option<&'static str> {
1311 match role {
1312 ContactRoleV1::LeftFoot => Some("left_foot"),
1313 ContactRoleV1::LeftToe => Some("left_toe"),
1314 ContactRoleV1::RightFoot => Some("right_foot"),
1315 ContactRoleV1::RightToe => Some("right_toe"),
1316 _ => None,
1317 }
1318}
1319
1320fn event_side(role: ContactRoleV1, roles: [ContactRoleV1; 2]) -> Option<Side> {
1321 if role == roles[0] {
1322 Some(Side::Left)
1323 } else if role == roles[1] {
1324 Some(Side::Right)
1325 } else {
1326 None
1327 }
1328}
1329
1330fn build_control_points(
1331 source: &[Boundary],
1332 reference: &[Boundary],
1333 minimum_slope: f64,
1334 maximum_slope: f64,
1335) -> Result<Vec<ContactTimeWarpControlPointV1>, FootCycleParameterizationError> {
1336 if source.len() != reference.len()
1337 || source
1338 .iter()
1339 .zip(reference)
1340 .any(|(left, right)| left.kind != right.kind)
1341 {
1342 return Err(FootCycleParameterizationError::TopologyMismatch);
1343 }
1344 let mut points = source
1345 .iter()
1346 .zip(reference)
1347 .map(|(source, reference)| (canonical_zero(source.time), canonical_zero(reference.time)))
1348 .collect::<Vec<_>>();
1349 points.push((0.0, 0.0));
1350 points.push((1.0, 1.0));
1351 points.sort_by(|left, right| {
1352 left.0
1353 .total_cmp(&right.0)
1354 .then_with(|| left.1.total_cmp(&right.1))
1355 });
1356 for group in points.chunk_by(|left, right| left.0 == right.0) {
1357 if group
1358 .first()
1359 .zip(group.last())
1360 .is_some_and(|(first, last)| first.1 != last.1)
1361 {
1362 return Err(FootCycleParameterizationError::NonMonotoneMapping);
1363 }
1364 }
1365 points.dedup();
1366 let needed = points.len();
1367 if needed > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS {
1368 return Err(FootCycleParameterizationError::TooManyControlPoints {
1369 found: needed,
1370 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
1371 });
1372 }
1373
1374 for pair in points.windows(2) {
1375 let (x0, y0) = pair[0];
1376 let (x1, y1) = pair[1];
1377 if !(x0 < x1 && y0 < y1) {
1378 return Err(FootCycleParameterizationError::NonMonotoneMapping);
1379 }
1380 let slope = (y1 - y0) / (x1 - x0);
1381 if !slope.is_finite() || slope < minimum_slope || slope > maximum_slope {
1382 return Err(FootCycleParameterizationError::SegmentSlopeOutOfRange);
1383 }
1384 }
1385 Ok(points
1386 .into_iter()
1387 .map(|(input, output)| ContactTimeWarpControlPointV1::new(input, output))
1388 .collect())
1389}
1390
1391fn canonical_zero(value: f64) -> f64 {
1392 if value == 0.0 { 0.0 } else { value }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397 use super::*;
1398 use crate::{
1399 CollectionClipV1, CollectionRuntimeSetV1, CollectionSourceKeyV1, CollectionSourceV1,
1400 ContactEventV1, ContactEventWindowV1, ContactExtensionV1, ContactProducerV1,
1401 DependencyClosureBuilderV1, ResourceKeySyntaxV1, SourceSetCoverageV1,
1402 };
1403 use serde_json::json;
1404
1405 fn id(value: &str) -> CollectionLogicalIdV1 {
1406 CollectionLogicalIdV1::new(value).unwrap()
1407 }
1408
1409 fn path(value: &str) -> DependencyResourceKeyV1 {
1410 DependencyResourceKeyV1::from_source_str(value, ResourceKeySyntaxV1::ParserRelativePath)
1411 .unwrap()
1412 }
1413
1414 fn manifest() -> (CollectionManifestV1, InputIdentity) {
1415 manifest_with_set(
1416 CollectionRuntimeSetKindV1::GaitGroup,
1417 &["reference", "member"],
1418 )
1419 }
1420
1421 fn manifest_with_set(
1422 kind: CollectionRuntimeSetKindV1,
1423 set_member_names: &[&str],
1424 ) -> (CollectionManifestV1, InputIdentity) {
1425 manifest_for_members(kind, &["reference", "member"], set_member_names)
1426 }
1427
1428 fn manifest_for_members(
1429 kind: CollectionRuntimeSetKindV1,
1430 clip_names: &[&str],
1431 set_member_names: &[&str],
1432 ) -> (CollectionManifestV1, InputIdentity) {
1433 let collection = CollectionIdV1::new("com.example").unwrap();
1434 let source = CollectionSourceKeyV1::new("motions").unwrap();
1435 let clips = clip_names
1436 .iter()
1437 .enumerate()
1438 .map(|(index, name)| {
1439 CollectionClipV1::new(
1440 id(&format!("com.example/{name}")),
1441 source.clone(),
1442 index as u32,
1443 format!("Take {index}"),
1444 )
1445 .unwrap()
1446 })
1447 .collect::<Vec<_>>();
1448 let set_members = set_member_names
1449 .iter()
1450 .map(|name| id(&format!("com.example/{name}")))
1451 .collect();
1452 let manifest = CollectionManifestV1::new(
1453 collection,
1454 None,
1455 vec![CollectionSourceV1::new(
1456 source,
1457 path("motions.glb"),
1458 None,
1459 None,
1460 )],
1461 clips,
1462 vec![CollectionRuntimeSetV1::new(
1463 id("com.example/sets/walk"),
1464 kind,
1465 set_members,
1466 )],
1467 )
1468 .unwrap();
1469 (manifest, InputIdentity::from_bytes(b"manifest"))
1470 }
1471
1472 fn declaration(
1473 manifest_input: &InputIdentity,
1474 minimum_slope: f64,
1475 maximum_slope: f64,
1476 ) -> FootCycleParameterizationV1 {
1477 declaration_with_proof(manifest_input, minimum_slope, maximum_slope, proof_policy())
1478 }
1479
1480 fn declaration_with_proof(
1481 manifest_input: &InputIdentity,
1482 minimum_slope: f64,
1483 maximum_slope: f64,
1484 proof: FootCycleProofPolicyV1,
1485 ) -> FootCycleParameterizationV1 {
1486 FootCycleParameterizationV1::new(
1487 FootCycleManifestBindingV1::new(
1488 CollectionIdV1::new("com.example").unwrap(),
1489 manifest_input.clone(),
1490 )
1491 .unwrap(),
1492 id("com.example/sets/walk"),
1493 id("com.example/reference"),
1494 path("generated/aligned"),
1495 minimum_slope,
1496 maximum_slope,
1497 proof,
1498 vec![
1499 FootCycleParameterizationMemberV1::new(
1500 id("com.example/reference"),
1501 path("contacts/reference.json"),
1502 ),
1503 FootCycleParameterizationMemberV1::new(
1504 id("com.example/member"),
1505 path("contacts/member.json"),
1506 ),
1507 ],
1508 )
1509 .unwrap()
1510 }
1511
1512 fn proof_policy() -> FootCycleProofPolicyV1 {
1513 FootCycleProofPolicyV1::new(0.08, 0.05, 0.01).unwrap()
1514 }
1515
1516 fn detector_extension(left: &str, right: &str) -> ContactExtensionV1 {
1517 detector_extension_with_height(left, right, 0.03)
1518 }
1519
1520 fn detector_extension_with_height(
1521 left: &str,
1522 right: &str,
1523 contact_height_m: f64,
1524 ) -> ContactExtensionV1 {
1525 detector_extension_with_payload(json!({
1526 "algorithm": "stance-support-v1",
1527 "sampling": "metric-grid-longest-authored-channel",
1528 "max_frames": 1_000_000,
1529 "contact_height_m": contact_height_m,
1530 "roles": {"left": left, "right": right},
1531 }))
1532 }
1533
1534 fn detector_extension_with_payload(payload: serde_json::Value) -> ContactExtensionV1 {
1535 ContactExtensionV1::new(CONTACT_SUPPORT_DETECTOR_V1_ID, 1, payload).unwrap()
1536 }
1537
1538 fn fragment(member: &str, take_index: u32, windows: &[(Side, f64, f64)]) -> ContactFragmentV1 {
1539 fragment_with_extensions(
1540 member,
1541 take_index,
1542 windows,
1543 vec![detector_extension("left_foot", "right_foot")],
1544 )
1545 }
1546
1547 fn fragment_with_extensions(
1548 member: &str,
1549 take_index: u32,
1550 windows: &[(Side, f64, f64)],
1551 extensions: Vec<ContactExtensionV1>,
1552 ) -> ContactFragmentV1 {
1553 fragment_with_clip(
1554 member,
1555 "motions",
1556 take_index,
1557 &format!("Take {take_index}"),
1558 1.0,
1559 windows,
1560 None,
1561 None,
1562 extensions,
1563 )
1564 }
1565
1566 #[allow(clippy::too_many_arguments)]
1567 fn fragment_with_clip(
1568 member: &str,
1569 source: &str,
1570 take_index: u32,
1571 take_name: &str,
1572 duration_s: f64,
1573 windows: &[(Side, f64, f64)],
1574 markers: Option<&[(Side, f64)]>,
1575 event_roles: Option<[ContactRoleV1; 2]>,
1576 extensions: Vec<ContactExtensionV1>,
1577 ) -> ContactFragmentV1 {
1578 let mut events = Vec::with_capacity(windows.len() * 2);
1579 for (index, &(side, start, end)) in windows.iter().enumerate() {
1580 let (role, label) = match side {
1581 Side::Left => (
1582 event_roles.map_or(ContactRoleV1::LeftFoot, |roles| roles[0]),
1583 "left",
1584 ),
1585 Side::Right => (
1586 event_roles.map_or(ContactRoleV1::RightFoot, |roles| roles[1]),
1587 "right",
1588 ),
1589 };
1590 events.push(
1591 ContactEventV1::window(
1592 format!("support/{label}/{index}"),
1593 role,
1594 ContactPhaseV1::Begin,
1595 ContactEventWindowV1::new(start, end).unwrap(),
1596 None,
1597 )
1598 .unwrap(),
1599 );
1600 }
1601 let default_markers = windows
1602 .iter()
1603 .map(|&(side, start, end)| (side, (start + end) / 2.0))
1604 .collect::<Vec<_>>();
1605 for (index, &(side, time)) in markers.unwrap_or(&default_markers).iter().enumerate() {
1606 let (role, label) = match side {
1607 Side::Left => (
1608 event_roles.map_or(ContactRoleV1::LeftFoot, |roles| roles[0]),
1609 "left",
1610 ),
1611 Side::Right => (
1612 event_roles.map_or(ContactRoleV1::RightFoot, |roles| roles[1]),
1613 "right",
1614 ),
1615 };
1616 events.push(
1617 ContactEventV1::point(
1618 format!("marker/{label}/{index}"),
1619 role,
1620 ContactPhaseV1::Marker,
1621 time,
1622 None,
1623 )
1624 .unwrap(),
1625 );
1626 }
1627 fragment_with_events(
1628 member, source, take_index, take_name, duration_s, events, extensions,
1629 )
1630 }
1631
1632 #[allow(clippy::too_many_arguments)]
1633 fn fragment_with_events(
1634 member: &str,
1635 source: &str,
1636 take_index: u32,
1637 take_name: &str,
1638 duration_s: f64,
1639 events: Vec<ContactEventV1>,
1640 extensions: Vec<ContactExtensionV1>,
1641 ) -> ContactFragmentV1 {
1642 let artifact = InputIdentity::from_bytes(member.as_bytes());
1643 let closure =
1644 DependencyClosureBuilderV1::new(artifact.clone(), SourceSetCoverageV1::complete(), 0)
1645 .finish()
1646 .unwrap();
1647 ContactFragmentV1::new(
1648 ContactProducerV1::new("animsmith", "0.10.0").unwrap(),
1649 artifact,
1650 closure.identity().unwrap().clone(),
1651 ContactClipReferenceV1::collection(
1652 format!("com.example/{member}"),
1653 source,
1654 take_index,
1655 take_name,
1656 )
1657 .unwrap(),
1658 duration_s,
1659 events,
1660 extensions,
1661 )
1662 .unwrap()
1663 }
1664
1665 fn root_binding(fragment: &ContactFragmentV1) -> FootCycleRootMotionBindingV1 {
1666 FootCycleRootMotionBindingV1::new(
1667 fragment.artifact().clone(),
1668 fragment.dependency_closure_identity().clone(),
1669 fragment.clip().clone(),
1670 )
1671 }
1672
1673 fn measured_root(
1674 fragment: &ContactFragmentV1,
1675 horizontal_endpoint_displacement_m: f64,
1676 accumulated_yaw_deg: f64,
1677 ) -> FootCycleRootMotionEvidenceV1 {
1678 FootCycleRootMotionEvidenceV1::measured(
1679 root_binding(fragment),
1680 horizontal_endpoint_displacement_m,
1681 0.0,
1682 accumulated_yaw_deg,
1683 )
1684 }
1685
1686 fn evidence(
1687 reference_windows: &[(Side, f64, f64)],
1688 member_windows: &[(Side, f64, f64)],
1689 ) -> Vec<FootCycleMemberEvidenceV1> {
1690 [
1691 ("reference", 0, "contacts/reference.json", reference_windows),
1692 ("member", 1, "contacts/member.json", member_windows),
1693 ]
1694 .into_iter()
1695 .map(|(member, take, source, windows)| {
1696 let fragment = fragment(member, take, windows);
1697 let root_motion = measured_root(&fragment, 0.0, 0.0);
1698 FootCycleMemberEvidenceV1::new(
1699 id(&format!("com.example/{member}")),
1700 path(source),
1701 fragment.canonical_identity().unwrap(),
1702 fragment,
1703 root_motion,
1704 )
1705 })
1706 .collect()
1707 }
1708
1709 fn points(plan: &FootCycleMemberPlanV1) -> Vec<(f64, f64)> {
1710 plan.operation()
1711 .control_points()
1712 .unwrap()
1713 .iter()
1714 .map(|point| (point.input_time(), point.output_time()))
1715 .collect()
1716 }
1717
1718 fn alternating_windows(count: usize) -> Vec<(Side, f64, f64)> {
1719 let denominator = (count * 3 + 1) as f64;
1720 (0..count)
1721 .map(|index| {
1722 (
1723 if index % 2 == 0 {
1724 Side::Left
1725 } else {
1726 Side::Right
1727 },
1728 (index * 3 + 1) as f64 / denominator,
1729 (index * 3 + 2) as f64 / denominator,
1730 )
1731 })
1732 .collect()
1733 }
1734
1735 #[test]
1736 fn planner_maps_exact_boundaries_to_reference_phases_and_is_deterministic() {
1737 let (manifest, manifest_input) = manifest();
1738 let declaration = declaration(&manifest_input, 0.5, 2.0);
1739 let mut evidence = evidence(
1740 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
1741 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
1742 );
1743 let reference = fragment_with_clip(
1744 "reference",
1745 "motions",
1746 0,
1747 "Take 0",
1748 3.5,
1749 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
1750 None,
1751 None,
1752 vec![detector_extension("left_foot", "right_foot")],
1753 );
1754 evidence[0].input = reference.canonical_identity().unwrap();
1755 evidence[0].fragment = reference;
1756 let member = fragment_with_clip(
1757 "member",
1758 "motions",
1759 1,
1760 "Take 1",
1761 2.5,
1762 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
1763 None,
1764 None,
1765 vec![detector_extension("left_foot", "right_foot")],
1766 );
1767 evidence[1].input = member.canonical_identity().unwrap();
1768 evidence[1].fragment = member;
1769 let parameterization_input = InputIdentity::from_bytes(b"parameterization");
1770 let first = plan_foot_cycle_parameterization_v1(
1771 &declaration,
1772 parameterization_input.clone(),
1773 &manifest,
1774 manifest_input.clone(),
1775 &evidence,
1776 )
1777 .unwrap();
1778 let second = plan_foot_cycle_parameterization_v1(
1779 &declaration,
1780 parameterization_input.clone(),
1781 &manifest,
1782 manifest_input.clone(),
1783 &evidence,
1784 )
1785 .unwrap();
1786 assert_eq!(first, second);
1787 assert_eq!(first.parameterization_input(), ¶meterization_input);
1788 assert_eq!(first.manifest_input(), &manifest_input);
1789 assert_eq!(first.runtime_set_id(), declaration.runtime_set_id());
1790 assert_eq!(first.reference_member(), declaration.reference_member());
1791 assert_eq!(first.members().len(), 2);
1792 assert_eq!(
1793 points(&first.members()[0]),
1794 [
1795 (0.0, 0.0),
1796 (0.1, 0.1),
1797 (0.2, 0.2),
1798 (0.6, 0.6),
1799 (0.7, 0.7),
1800 (1.0, 1.0)
1801 ]
1802 );
1803 assert_eq!(
1804 points(&first.members()[1]),
1805 [
1806 (0.0, 0.0),
1807 (0.2, 0.1),
1808 (0.3, 0.2),
1809 (0.7, 0.6),
1810 (0.8, 0.7),
1811 (1.0, 1.0)
1812 ]
1813 );
1814 assert_eq!(
1815 first.members()[0].operation().output_duration_s(),
1816 Some(3.5)
1817 );
1818 assert_eq!(
1819 first.members()[1].operation().output_duration_s(),
1820 Some(2.5)
1821 );
1822 for (plan, source) in first.members().iter().zip(&evidence) {
1823 assert_eq!(plan.id(), source.id());
1824 assert_eq!(plan.input().artifact(), source.fragment().artifact());
1825 assert_eq!(
1826 plan.input().dependency_closure_identity(),
1827 source.fragment().dependency_closure_identity()
1828 );
1829 assert_eq!(plan.input().fragment(), source.input());
1830 assert_eq!(plan.root_motion(), source.root_motion());
1831 assert_eq!(
1832 plan.operation().output_duration_s(),
1833 Some(source.fragment().duration_s())
1834 );
1835 }
1836 }
1837
1838 #[test]
1839 fn planner_positionally_maps_every_boundary_in_a_larger_ring() {
1840 let reference = [
1841 (Side::Left, 0.05, 0.1),
1842 (Side::Right, 0.25, 0.3),
1843 (Side::Left, 0.5, 0.55),
1844 (Side::Right, 0.75, 0.8),
1845 ];
1846 let member = [
1847 (Side::Left, 0.08, 0.14),
1848 (Side::Right, 0.3, 0.36),
1849 (Side::Left, 0.56, 0.62),
1850 (Side::Right, 0.82, 0.88),
1851 ];
1852 let (manifest, manifest_input) = manifest();
1853 let plan = plan_foot_cycle_parameterization_v1(
1854 &declaration(&manifest_input, 0.1, 10.0),
1855 InputIdentity::from_bytes(b"parameterization"),
1856 &manifest,
1857 manifest_input,
1858 &evidence(&reference, &member),
1859 )
1860 .unwrap();
1861 assert_eq!(
1862 points(&plan.members()[1]),
1863 [
1864 (0.0, 0.0),
1865 (0.08, 0.05),
1866 (0.14, 0.1),
1867 (0.3, 0.25),
1868 (0.36, 0.3),
1869 (0.56, 0.5),
1870 (0.62, 0.55),
1871 (0.82, 0.75),
1872 (0.88, 0.8),
1873 (1.0, 1.0),
1874 ]
1875 );
1876 }
1877
1878 #[test]
1879 fn root_motion_gate_is_inclusive_and_refuses_unavailable_or_excess_evidence() {
1880 assert_eq!(
1881 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M,
1882 0.01
1883 );
1884 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG, 1.0);
1885 let (manifest, manifest_input) = manifest();
1886 let declaration = declaration(&manifest_input, 0.5, 2.0);
1887 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
1888 let parameterization_input = || InputIdentity::from_bytes(b"parameterization");
1889
1890 for member_index in 0..2 {
1891 let mut accepted = evidence(&windows, &windows);
1892 let binding = accepted[member_index].root_motion.binding().clone();
1893 accepted[member_index].root_motion = FootCycleRootMotionEvidenceV1::measured(
1894 binding,
1895 0.006,
1896 -0.008,
1897 -FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG,
1898 );
1899 let plan = plan_foot_cycle_parameterization_v1(
1900 &declaration,
1901 parameterization_input(),
1902 &manifest,
1903 manifest_input.clone(),
1904 &accepted,
1905 )
1906 .unwrap();
1907 assert!(matches!(
1908 plan.members()[member_index].root_motion(),
1909 FootCycleRootMotionEvidenceV1::Measured {
1910 endpoint_displacement_x_m: 0.006,
1911 endpoint_displacement_z_m: -0.008,
1912 accumulated_yaw_deg: -1.0,
1913 ..
1914 }
1915 ));
1916 }
1917
1918 for member_index in 0..2 {
1919 for unavailable_index in 0..9 {
1920 let mut source = evidence(&windows, &windows);
1921 let binding = source[member_index].root_motion.binding().clone();
1922 let unavailable = match unavailable_index {
1923 0 => FootCycleRootMotionEvidenceV1::missing(binding),
1924 1 => FootCycleRootMotionEvidenceV1::ambiguous(binding),
1925 2 => FootCycleRootMotionEvidenceV1::non_finite(binding),
1926 3 => FootCycleRootMotionEvidenceV1::measured(binding, f64::NAN, 0.0, 0.0),
1927 4 => FootCycleRootMotionEvidenceV1::measured(binding, 0.0, f64::NAN, 0.0),
1928 5 => FootCycleRootMotionEvidenceV1::measured(binding, 0.0, 0.0, f64::NAN),
1929 6 => FootCycleRootMotionEvidenceV1::measured(binding, f64::INFINITY, 0.0, 0.0),
1930 7 => FootCycleRootMotionEvidenceV1::measured(
1931 binding,
1932 0.0,
1933 f64::NEG_INFINITY,
1934 0.0,
1935 ),
1936 8 => FootCycleRootMotionEvidenceV1::measured(
1937 binding,
1938 0.0,
1939 0.0,
1940 f64::NEG_INFINITY,
1941 ),
1942 _ => unreachable!(),
1943 };
1944 source[member_index].root_motion = unavailable;
1945 assert!(matches!(
1946 plan_foot_cycle_parameterization_v1(
1947 &declaration,
1948 parameterization_input(),
1949 &manifest,
1950 manifest_input.clone(),
1951 &source,
1952 ),
1953 Err(FootCycleParameterizationError::RootMotionEvidenceUnavailable { .. })
1954 ));
1955 }
1956 }
1957
1958 for (endpoint_x, endpoint_z, yaw) in [
1959 (
1960 f64::from_bits(
1961 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M.to_bits() + 1,
1962 ),
1963 0.0,
1964 0.0,
1965 ),
1966 (
1967 0.0,
1968 f64::from_bits(
1969 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M.to_bits() + 1,
1970 ),
1971 0.0,
1972 ),
1973 (
1974 0.0,
1975 0.0,
1976 f64::from_bits(
1977 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG.to_bits() + 1,
1978 ),
1979 ),
1980 (
1981 0.0,
1982 0.0,
1983 -f64::from_bits(
1984 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG.to_bits() + 1,
1985 ),
1986 ),
1987 ] {
1988 for member_index in 0..2 {
1989 let mut source = evidence(&windows, &windows);
1990 let binding = source[member_index].root_motion.binding().clone();
1991 source[member_index].root_motion =
1992 FootCycleRootMotionEvidenceV1::measured(binding, endpoint_x, endpoint_z, yaw);
1993 assert!(matches!(
1994 plan_foot_cycle_parameterization_v1(
1995 &declaration,
1996 parameterization_input(),
1997 &manifest,
1998 manifest_input.clone(),
1999 &source,
2000 ),
2001 Err(FootCycleParameterizationError::RootMotionOutOfRange { .. })
2002 ));
2003 }
2004 }
2005 }
2006
2007 #[test]
2008 fn root_motion_artifact_closure_and_clip_witnesses_cannot_be_cross_wired() {
2009 let (manifest, manifest_input) = manifest();
2010 let declaration = declaration(&manifest_input, 0.5, 2.0);
2011 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2012 for member_index in 0..2 {
2013 let other_index = 1 - member_index;
2014 for coordinate in 0..3 {
2015 let mut source = evidence(&windows, &windows);
2016 let other = source[other_index].root_motion.binding().clone();
2017 let FootCycleRootMotionEvidenceV1::Measured { binding, .. } =
2018 &mut source[member_index].root_motion
2019 else {
2020 unreachable!();
2021 };
2022 match coordinate {
2023 0 => binding.artifact = other.artifact,
2024 1 => binding.dependency_closure_identity = other.dependency_closure_identity,
2025 2 => binding.clip = other.clip,
2026 _ => unreachable!(),
2027 }
2028 assert!(matches!(
2029 plan_foot_cycle_parameterization_v1(
2030 &declaration,
2031 InputIdentity::from_bytes(b"parameterization"),
2032 &manifest,
2033 manifest_input.clone(),
2034 &source,
2035 ),
2036 Err(FootCycleParameterizationError::RootMotionBindingMismatch { .. })
2037 ));
2038 }
2039 }
2040 }
2041
2042 #[test]
2043 fn cyclic_topology_is_compared_from_each_first_left_onset() {
2044 let (manifest, manifest_input) = manifest();
2045 let declaration = declaration(&manifest_input, 0.5, 2.0);
2046 let evidence = evidence(
2047 &[(Side::Right, 0.1, 0.2), (Side::Left, 0.6, 0.7)],
2048 &[(Side::Right, 0.15, 0.25), (Side::Left, 0.65, 0.75)],
2049 );
2050 let plan = plan_foot_cycle_parameterization_v1(
2051 &declaration,
2052 InputIdentity::from_bytes(b"parameterization"),
2053 &manifest,
2054 manifest_input.clone(),
2055 &evidence,
2056 )
2057 .unwrap();
2058 assert_eq!(
2059 points(&plan.members()[0]),
2060 [
2061 (0.0, 0.0),
2062 (0.1, 0.1),
2063 (0.2, 0.2),
2064 (0.6, 0.6),
2065 (0.7, 0.7),
2066 (1.0, 1.0),
2067 ]
2068 );
2069 assert_eq!(
2070 points(&plan.members()[1]),
2071 [
2072 (0.0, 0.0),
2073 (0.15, 0.1),
2074 (0.25, 0.2),
2075 (0.65, 0.6),
2076 (0.75, 0.7),
2077 (1.0, 1.0),
2078 ]
2079 );
2080 }
2081
2082 #[test]
2083 fn nonfirst_reference_member_owns_exact_phases_without_reordering_plans() {
2084 let (manifest, manifest_input) = manifest();
2085 let declaration = FootCycleParameterizationV1::new(
2086 FootCycleManifestBindingV1::new(
2087 CollectionIdV1::new("com.example").unwrap(),
2088 manifest_input.clone(),
2089 )
2090 .unwrap(),
2091 id("com.example/sets/walk"),
2092 id("com.example/member"),
2093 path("generated/aligned"),
2094 0.5,
2095 2.0,
2096 proof_policy(),
2097 vec![
2098 FootCycleParameterizationMemberV1::new(
2099 id("com.example/reference"),
2100 path("contacts/reference.json"),
2101 ),
2102 FootCycleParameterizationMemberV1::new(
2103 id("com.example/member"),
2104 path("contacts/member.json"),
2105 ),
2106 ],
2107 )
2108 .unwrap();
2109 let plan = plan_foot_cycle_parameterization_v1(
2110 &declaration,
2111 InputIdentity::from_bytes(b"parameterization"),
2112 &manifest,
2113 manifest_input,
2114 &evidence(
2115 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2116 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
2117 ),
2118 )
2119 .unwrap();
2120 assert_eq!(plan.reference_member(), &id("com.example/member"));
2121 assert_eq!(plan.members()[0].id(), &id("com.example/reference"));
2122 assert_eq!(plan.members()[1].id(), &id("com.example/member"));
2123 assert_eq!(
2124 points(&plan.members()[0]),
2125 [
2126 (0.0, 0.0),
2127 (0.1, 0.2),
2128 (0.2, 0.3),
2129 (0.6, 0.7),
2130 (0.7, 0.8),
2131 (1.0, 1.0),
2132 ]
2133 );
2134 assert_eq!(
2135 points(&plan.members()[1]),
2136 [
2137 (0.0, 0.0),
2138 (0.2, 0.2),
2139 (0.3, 0.3),
2140 (0.7, 0.7),
2141 (0.8, 0.8),
2142 (1.0, 1.0),
2143 ]
2144 );
2145 }
2146
2147 #[test]
2148 fn planner_refuses_missing_and_extra_evidence_rows() {
2149 let (manifest, manifest_input) = manifest();
2150 let declaration = declaration(&manifest_input, 0.5, 2.0);
2151 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2152 let source = evidence(&windows, &windows);
2153 for invalid in [source[..1].to_vec(), {
2154 let mut extra = source.clone();
2155 extra.push(source[0].clone());
2156 extra
2157 }] {
2158 assert_eq!(
2159 plan_foot_cycle_parameterization_v1(
2160 &declaration,
2161 InputIdentity::from_bytes(b"parameterization"),
2162 &manifest,
2163 manifest_input.clone(),
2164 &invalid,
2165 ),
2166 Err(FootCycleParameterizationError::EvidenceCountMismatch)
2167 );
2168 }
2169 }
2170
2171 #[test]
2172 fn phase_rotation_that_cannot_preserve_endpoints_refuses() {
2173 let (first_manifest, first_manifest_input) = manifest();
2174 let first_declaration = declaration(&first_manifest_input, 0.01, 100.0);
2175 let first_evidence = evidence(
2176 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2177 &[(Side::Right, 0.1, 0.2), (Side::Left, 0.6, 0.7)],
2178 );
2179 assert_eq!(
2180 plan_foot_cycle_parameterization_v1(
2181 &first_declaration,
2182 InputIdentity::from_bytes(b"parameterization"),
2183 &first_manifest,
2184 first_manifest_input,
2185 &first_evidence,
2186 ),
2187 Err(FootCycleParameterizationError::NonMonotoneMapping)
2188 );
2189
2190 let (manifest, manifest_input) = manifest();
2191 let declaration = declaration(&manifest_input, 0.01, 100.0);
2192 let reference = [
2193 (Side::Left, 0.05, 0.1),
2194 (Side::Right, 0.25, 0.3),
2195 (Side::Left, 0.5, 0.55),
2196 (Side::Right, 0.75, 0.8),
2197 ];
2198 let rotated = [
2199 (Side::Right, 0.05, 0.1),
2200 (Side::Left, 0.25, 0.3),
2201 (Side::Right, 0.5, 0.55),
2202 (Side::Left, 0.75, 0.8),
2203 ];
2204 assert_eq!(
2205 plan_foot_cycle_parameterization_v1(
2206 &declaration,
2207 InputIdentity::from_bytes(b"parameterization"),
2208 &manifest,
2209 manifest_input,
2210 &evidence(&reference, &rotated),
2211 ),
2212 Err(FootCycleParameterizationError::NonMonotoneMapping)
2213 );
2214 }
2215
2216 #[test]
2217 fn same_side_overlap_touch_nonalternation_and_missing_runs_refuse() {
2218 let reference = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2219 for invalid in [
2220 vec![
2221 (Side::Left, 0.1, 0.3),
2222 (Side::Left, 0.2, 0.4),
2223 (Side::Right, 0.5, 0.6),
2224 (Side::Right, 0.7, 0.8),
2225 ],
2226 vec![
2227 (Side::Left, 0.1, 0.3),
2228 (Side::Left, 0.3, 0.4),
2229 (Side::Right, 0.5, 0.6),
2230 (Side::Right, 0.7, 0.8),
2231 ],
2232 vec![
2233 (Side::Left, 0.1, 0.15),
2234 (Side::Left, 0.2, 0.25),
2235 (Side::Right, 0.4, 0.45),
2236 (Side::Right, 0.6, 0.65),
2237 ],
2238 vec![
2239 (Side::Left, 0.1, 0.2),
2240 (Side::Left, 0.4, 0.5),
2241 (Side::Right, 0.6, 0.7),
2242 ],
2243 vec![(Side::Left, 0.1, 0.2)],
2244 vec![(Side::Left, 0.0, 1.0), (Side::Right, 0.3, 0.7)],
2245 ] {
2246 for invalid_index in 0..2 {
2247 let (manifest, manifest_input) = manifest();
2248 let declaration = declaration(&manifest_input, 0.01, 100.0);
2249 let source = if invalid_index == 0 {
2250 evidence(&invalid, &reference)
2251 } else {
2252 evidence(&reference, &invalid)
2253 };
2254 assert_eq!(
2255 plan_foot_cycle_parameterization_v1(
2256 &declaration,
2257 InputIdentity::from_bytes(b"parameterization"),
2258 &manifest,
2259 manifest_input,
2260 &source,
2261 ),
2262 Err(FootCycleParameterizationError::InvalidContactTopology)
2263 );
2264 }
2265 }
2266 }
2267
2268 #[test]
2269 fn opposite_side_overlap_and_matching_touch_are_admitted() {
2270 let (manifest, manifest_input) = manifest();
2271 let declaration = declaration(&manifest_input, 0.01, 100.0);
2272 let overlap = evidence(
2273 &[(Side::Left, 0.1, 0.45), (Side::Right, 0.3, 0.7)],
2274 &[(Side::Left, 0.15, 0.5), (Side::Right, 0.35, 0.75)],
2275 );
2276 assert!(
2277 plan_foot_cycle_parameterization_v1(
2278 &declaration,
2279 InputIdentity::from_bytes(b"parameterization"),
2280 &manifest,
2281 manifest_input.clone(),
2282 &overlap,
2283 )
2284 .is_ok()
2285 );
2286
2287 let touching = evidence(
2288 &[(Side::Left, 0.1, 0.3), (Side::Right, 0.3, 0.6)],
2289 &[(Side::Left, 0.2, 0.4), (Side::Right, 0.4, 0.7)],
2290 );
2291 let plan = plan_foot_cycle_parameterization_v1(
2292 &declaration,
2293 InputIdentity::from_bytes(b"parameterization"),
2294 &manifest,
2295 manifest_input,
2296 &touching,
2297 )
2298 .unwrap();
2299 assert_eq!(
2300 points(&plan.members()[1]),
2301 [(0.0, 0.0), (0.2, 0.1), (0.4, 0.3), (0.7, 0.6), (1.0, 1.0)]
2302 );
2303 }
2304
2305 #[test]
2306 fn one_and_both_side_seam_stances_are_logically_coalesced() {
2307 let (manifest, manifest_input) = manifest();
2308 let declaration = declaration(&manifest_input, 0.01, 100.0);
2309 let one_side = [
2310 (Side::Left, 0.0, 0.3),
2311 (Side::Right, 0.2, 0.8),
2312 (Side::Left, 0.7, 1.0),
2313 ];
2314 let plan = plan_foot_cycle_parameterization_v1(
2315 &declaration,
2316 InputIdentity::from_bytes(b"parameterization"),
2317 &manifest,
2318 manifest_input.clone(),
2319 &evidence(&one_side, &one_side),
2320 )
2321 .unwrap();
2322 assert_eq!(
2323 points(&plan.members()[0]),
2324 [
2325 (0.0, 0.0),
2326 (0.2, 0.2),
2327 (0.3, 0.3),
2328 (0.7, 0.7),
2329 (0.8, 0.8),
2330 (1.0, 1.0)
2331 ]
2332 );
2333
2334 let both_sides = [
2335 (Side::Left, 0.0, 0.2),
2336 (Side::Right, 0.0, 0.3),
2337 (Side::Left, 0.7, 1.0),
2338 (Side::Right, 0.8, 1.0),
2339 ];
2340 let plan = plan_foot_cycle_parameterization_v1(
2341 &declaration,
2342 InputIdentity::from_bytes(b"parameterization"),
2343 &manifest,
2344 manifest_input,
2345 &evidence(&both_sides, &both_sides),
2346 )
2347 .unwrap();
2348 assert_eq!(
2349 points(&plan.members()[0]),
2350 [
2351 (0.0, 0.0),
2352 (0.2, 0.2),
2353 (0.3, 0.3),
2354 (0.7, 0.7),
2355 (0.8, 0.8),
2356 (1.0, 1.0)
2357 ]
2358 );
2359 }
2360
2361 #[test]
2362 fn seam_topology_contains_only_true_boundaries_in_cyclic_time_order() {
2363 let fragment = fragment(
2364 "member",
2365 0,
2366 &[
2367 (Side::Left, 0.0, 0.3),
2368 (Side::Right, 0.2, 0.8),
2369 (Side::Left, 0.7, 1.0),
2370 ],
2371 );
2372
2373 let (signature, boundaries, _) = topology(&fragment).unwrap();
2374 let observed = boundaries
2375 .iter()
2376 .map(|boundary| (boundary.kind.side, boundary.kind.edge, boundary.time))
2377 .collect::<Vec<_>>();
2378
2379 assert_eq!(
2380 observed,
2381 [
2382 (Side::Left, Edge::Onset, 0.7),
2383 (Side::Right, Edge::Release, 0.8),
2384 (Side::Right, Edge::Onset, 0.2),
2385 (Side::Left, Edge::Release, 0.3),
2386 ]
2387 );
2388 assert_eq!(
2389 signature,
2390 observed
2391 .iter()
2392 .map(|&(side, edge, _)| BoundaryKind { side, edge })
2393 .collect::<Vec<_>>()
2394 );
2395 }
2396
2397 #[test]
2398 fn simultaneous_boundary_groups_must_match_on_both_axes() {
2399 let simultaneous = [(Side::Left, 0.1, 0.3), (Side::Right, 0.3, 0.6)];
2400 let separated = [(Side::Left, 0.1, 0.25), (Side::Right, 0.3, 0.6)];
2401 for (reference, member) in [
2402 (&simultaneous[..], &separated[..]),
2403 (&separated[..], &simultaneous[..]),
2404 ] {
2405 let (manifest, manifest_input) = manifest();
2406 assert_eq!(
2407 plan_foot_cycle_parameterization_v1(
2408 &declaration(&manifest_input, 0.01, 100.0),
2409 InputIdentity::from_bytes(b"parameterization"),
2410 &manifest,
2411 manifest_input,
2412 &evidence(reference, member),
2413 ),
2414 Err(FootCycleParameterizationError::NonMonotoneMapping)
2415 );
2416 }
2417 }
2418
2419 #[test]
2420 fn incompatible_wrap_and_nonwrap_correspondence_stays_nonmonotone() {
2421 let left_wrap = [
2422 (Side::Left, 0.0, 0.3),
2423 (Side::Right, 0.2, 0.8),
2424 (Side::Left, 0.7, 1.0),
2425 ];
2426 let right_wrap = [
2427 (Side::Right, 0.0, 0.3),
2428 (Side::Left, 0.2, 0.8),
2429 (Side::Right, 0.7, 1.0),
2430 ];
2431 let (manifest, manifest_input) = manifest();
2432 assert_eq!(
2433 plan_foot_cycle_parameterization_v1(
2434 &declaration(&manifest_input, 0.01, 100.0),
2435 InputIdentity::from_bytes(b"parameterization"),
2436 &manifest,
2437 manifest_input,
2438 &evidence(&left_wrap, &right_wrap),
2439 ),
2440 Err(FootCycleParameterizationError::NonMonotoneMapping)
2441 );
2442 }
2443
2444 #[test]
2445 fn missing_extra_and_misplaced_markers_refuse() {
2446 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2447 let marker_cases = [
2448 Vec::new(),
2449 vec![(Side::Left, 0.15), (Side::Right, 0.65), (Side::Left, 0.18)],
2450 vec![(Side::Left, 0.65), (Side::Right, 0.15)],
2451 ];
2452 for markers in marker_cases {
2453 for invalid_index in 0..2 {
2454 let (manifest, manifest_input) = manifest();
2455 let declaration = declaration(&manifest_input, 0.01, 100.0);
2456 let (member_id, take_index, take_name) = if invalid_index == 0 {
2457 ("reference", 0, "Take 0")
2458 } else {
2459 ("member", 1, "Take 1")
2460 };
2461 let invalid = fragment_with_clip(
2462 member_id,
2463 "motions",
2464 take_index,
2465 take_name,
2466 1.0,
2467 &windows,
2468 Some(&markers),
2469 None,
2470 vec![detector_extension("left_foot", "right_foot")],
2471 );
2472 let mut source = evidence(&windows, &windows);
2473 source[invalid_index].input = invalid.canonical_identity().unwrap();
2474 source[invalid_index].fragment = invalid;
2475 assert_eq!(
2476 plan_foot_cycle_parameterization_v1(
2477 &declaration,
2478 InputIdentity::from_bytes(b"parameterization"),
2479 &manifest,
2480 manifest_input,
2481 &source,
2482 ),
2483 Err(FootCycleParameterizationError::InvalidContactTopology)
2484 );
2485 }
2486 }
2487
2488 for marker_as_window in [false, true] {
2489 for invalid_index in 0..2 {
2490 let (member_id, take_index, take_name) = if invalid_index == 0 {
2491 ("reference", 0, "Take 0")
2492 } else {
2493 ("member", 1, "Take 1")
2494 };
2495 let invalid_event = if marker_as_window {
2496 ContactEventV1::window(
2497 "invalid/marker-window",
2498 ContactRoleV1::LeftFoot,
2499 ContactPhaseV1::Marker,
2500 ContactEventWindowV1::new(0.1, 0.2).unwrap(),
2501 None,
2502 )
2503 .unwrap()
2504 } else {
2505 ContactEventV1::point(
2506 "invalid/begin-point",
2507 ContactRoleV1::LeftFoot,
2508 ContactPhaseV1::Begin,
2509 0.15,
2510 None,
2511 )
2512 .unwrap()
2513 };
2514 let invalid = fragment_with_events(
2515 member_id,
2516 "motions",
2517 take_index,
2518 take_name,
2519 1.0,
2520 vec![
2521 ContactEventV1::window(
2522 "support/left/0",
2523 ContactRoleV1::LeftFoot,
2524 ContactPhaseV1::Begin,
2525 ContactEventWindowV1::new(0.1, 0.2).unwrap(),
2526 None,
2527 )
2528 .unwrap(),
2529 ContactEventV1::window(
2530 "support/right/1",
2531 ContactRoleV1::RightFoot,
2532 ContactPhaseV1::Begin,
2533 ContactEventWindowV1::new(0.6, 0.7).unwrap(),
2534 None,
2535 )
2536 .unwrap(),
2537 invalid_event,
2538 ContactEventV1::point(
2539 "marker/right/1",
2540 ContactRoleV1::RightFoot,
2541 ContactPhaseV1::Marker,
2542 0.65,
2543 None,
2544 )
2545 .unwrap(),
2546 ],
2547 vec![detector_extension("left_foot", "right_foot")],
2548 );
2549 let mut source = evidence(&windows, &windows);
2550 source[invalid_index].input = invalid.canonical_identity().unwrap();
2551 source[invalid_index].fragment = invalid;
2552 let (manifest, manifest_input) = manifest();
2553 assert_eq!(
2554 plan_foot_cycle_parameterization_v1(
2555 &declaration(&manifest_input, 0.01, 100.0),
2556 InputIdentity::from_bytes(b"parameterization"),
2557 &manifest,
2558 manifest_input,
2559 &source,
2560 ),
2561 Err(FootCycleParameterizationError::InvalidContactTopology)
2562 );
2563 }
2564 }
2565 }
2566
2567 #[test]
2568 fn topology_count_mismatch_refuses_without_pairing_heuristics() {
2569 let (manifest, manifest_input) = manifest();
2570 let declaration = declaration(&manifest_input, 0.01, 100.0);
2571 let reference = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2572 let member = [
2573 (Side::Left, 0.05, 0.1),
2574 (Side::Right, 0.2, 0.3),
2575 (Side::Left, 0.5, 0.6),
2576 (Side::Right, 0.8, 0.9),
2577 ];
2578 assert_eq!(
2579 plan_foot_cycle_parameterization_v1(
2580 &declaration,
2581 InputIdentity::from_bytes(b"parameterization"),
2582 &manifest,
2583 manifest_input,
2584 &evidence(&reference, &member),
2585 ),
2586 Err(FootCycleParameterizationError::TopologyMismatch)
2587 );
2588 }
2589
2590 #[test]
2591 fn inclusive_slope_bounds_accept_exact_and_reject_successor() {
2592 let reference = [(Side::Left, 0.125, 0.25), (Side::Right, 0.5, 0.625)];
2593 let member = [(Side::Left, 0.25, 0.375), (Side::Right, 0.625, 0.75)];
2594 let (manifest, manifest_input) = manifest();
2595 let accepted = declaration(&manifest_input, 0.5, 1.5);
2596 assert!(
2597 plan_foot_cycle_parameterization_v1(
2598 &accepted,
2599 InputIdentity::from_bytes(b"parameterization"),
2600 &manifest,
2601 manifest_input.clone(),
2602 &evidence(&reference, &member),
2603 )
2604 .is_ok()
2605 );
2606 let refused = declaration(&manifest_input, f64::from_bits(0.5_f64.to_bits() + 1), 1.5);
2607 assert_eq!(
2608 plan_foot_cycle_parameterization_v1(
2609 &refused,
2610 InputIdentity::from_bytes(b"parameterization"),
2611 &manifest,
2612 manifest_input.clone(),
2613 &evidence(&reference, &member),
2614 ),
2615 Err(FootCycleParameterizationError::SegmentSlopeOutOfRange)
2616 );
2617 let refused = declaration(&manifest_input, 0.5, f64::from_bits(1.5_f64.to_bits() - 1));
2618 assert_eq!(
2619 plan_foot_cycle_parameterization_v1(
2620 &refused,
2621 InputIdentity::from_bytes(b"parameterization"),
2622 &manifest,
2623 manifest_input,
2624 &evidence(&reference, &member),
2625 ),
2626 Err(FootCycleParameterizationError::SegmentSlopeOutOfRange)
2627 );
2628 }
2629
2630 #[test]
2631 fn exact_manifest_member_path_and_canonical_fragment_bindings_are_required() {
2632 let (manifest, manifest_input) = manifest();
2633 let declaration = declaration(&manifest_input, 0.5, 2.0);
2634 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2635 let source = evidence(&windows, &windows);
2636 let mut cases = Vec::new();
2637
2638 let mut stale = source.clone();
2639 stale[1].input = InputIdentity::from_bytes(b"noncanonical");
2640 cases.push((stale, FootCycleParameterizationError::NonCanonicalFragment));
2641
2642 let mut stale_reference = source.clone();
2643 stale_reference[0].input = InputIdentity::from_bytes(b"noncanonical-reference");
2644 cases.push((
2645 stale_reference,
2646 FootCycleParameterizationError::NonCanonicalFragment,
2647 ));
2648
2649 for member_index in 0..2 {
2650 let mut wrong_digest = source.clone();
2651 wrong_digest[member_index].input = InputIdentity::from_sha256_digest(
2652 [0; 32],
2653 wrong_digest[member_index].input.bytes(),
2654 );
2655 cases.push((
2656 wrong_digest,
2657 FootCycleParameterizationError::NonCanonicalFragment,
2658 ));
2659
2660 let mut wrong_id = source.clone();
2661 wrong_id[member_index].id = id("com.example/wrong");
2662 cases.push((
2663 wrong_id,
2664 FootCycleParameterizationError::EvidenceMemberMismatch,
2665 ));
2666
2667 let mut wrong_path = source.clone();
2668 wrong_path[member_index].contact_fragment_path = path("contacts/other.json");
2669 cases.push((
2670 wrong_path,
2671 FootCycleParameterizationError::EvidenceMemberMismatch,
2672 ));
2673 }
2674
2675 let mut wrong_clip = source.clone();
2676 wrong_clip[1].fragment = fragment("reference", 0, &windows);
2677 wrong_clip[1].input = wrong_clip[1].fragment.canonical_identity().unwrap();
2678 cases.push((
2679 wrong_clip,
2680 FootCycleParameterizationError::FragmentClipMismatch,
2681 ));
2682
2683 let mut wrong_reference = source.clone();
2684 wrong_reference[0].fragment = fragment("member", 1, &windows);
2685 wrong_reference[0].input = wrong_reference[0].fragment.canonical_identity().unwrap();
2686 cases.push((
2687 wrong_reference,
2688 FootCycleParameterizationError::FragmentClipMismatch,
2689 ));
2690
2691 let wrong_reference_source = fragment_with_clip(
2692 "reference",
2693 "other-source",
2694 0,
2695 "Take 0",
2696 1.0,
2697 &windows,
2698 None,
2699 None,
2700 vec![detector_extension("left_foot", "right_foot")],
2701 );
2702 let mut wrong_reference_witness = source.clone();
2703 wrong_reference_witness[0].input = wrong_reference_source.canonical_identity().unwrap();
2704 wrong_reference_witness[0].fragment = wrong_reference_source;
2705 cases.push((
2706 wrong_reference_witness,
2707 FootCycleParameterizationError::FragmentClipMismatch,
2708 ));
2709
2710 for fragment in [
2711 fragment_with_clip(
2712 "member",
2713 "other-source",
2714 1,
2715 "Take 1",
2716 1.0,
2717 &windows,
2718 None,
2719 None,
2720 vec![detector_extension("left_foot", "right_foot")],
2721 ),
2722 fragment_with_clip(
2723 "member",
2724 "motions",
2725 0,
2726 "Take 1",
2727 1.0,
2728 &windows,
2729 None,
2730 None,
2731 vec![detector_extension("left_foot", "right_foot")],
2732 ),
2733 fragment_with_clip(
2734 "member",
2735 "motions",
2736 1,
2737 "Other take",
2738 1.0,
2739 &windows,
2740 None,
2741 None,
2742 vec![detector_extension("left_foot", "right_foot")],
2743 ),
2744 ] {
2745 let mut wrong_witness = source.clone();
2746 wrong_witness[1].input = fragment.canonical_identity().unwrap();
2747 wrong_witness[1].fragment = fragment;
2748 cases.push((
2749 wrong_witness,
2750 FootCycleParameterizationError::FragmentClipMismatch,
2751 ));
2752 }
2753
2754 for (evidence, expected) in cases {
2755 assert_eq!(
2756 plan_foot_cycle_parameterization_v1(
2757 &declaration,
2758 InputIdentity::from_bytes(b"parameterization"),
2759 &manifest,
2760 manifest_input.clone(),
2761 &evidence,
2762 ),
2763 Err(expected)
2764 );
2765 }
2766 assert_eq!(
2767 plan_foot_cycle_parameterization_v1(
2768 &declaration,
2769 InputIdentity::from_bytes(b"parameterization"),
2770 &manifest,
2771 InputIdentity::from_bytes(b"stale-manifest"),
2772 &source,
2773 ),
2774 Err(FootCycleParameterizationError::ManifestMismatch)
2775 );
2776 assert_eq!(
2777 plan_foot_cycle_parameterization_v1(
2778 &declaration,
2779 InputIdentity::from_bytes(b"parameterization"),
2780 &manifest,
2781 InputIdentity::from_sha256_digest([0; 32], manifest_input.bytes()),
2782 &source,
2783 ),
2784 Err(FootCycleParameterizationError::ManifestMismatch)
2785 );
2786 assert_eq!(
2787 plan_foot_cycle_parameterization_v1(
2788 &declaration,
2789 InputIdentity::from_sha256_digest(
2790 [0; 32],
2791 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_BYTES + 1,
2792 ),
2793 &manifest,
2794 manifest_input,
2795 &source,
2796 ),
2797 Err(FootCycleParameterizationError::ParameterizationTooLarge)
2798 );
2799 }
2800
2801 #[test]
2802 fn manifest_collection_kind_and_exact_member_order_are_required() {
2803 let (manifest, manifest_input) = manifest();
2804 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2805 let source = evidence(&windows, &windows);
2806 let parameterization_input = || InputIdentity::from_bytes(b"parameterization");
2807
2808 let mut missing_set = declaration(&manifest_input, 0.5, 2.0);
2809 missing_set.runtime_set_id = id("com.example/sets/missing");
2810 assert_eq!(
2811 validate_foot_cycle_manifest_binding_v1(&missing_set, &manifest, &manifest_input,),
2812 Err(FootCycleParameterizationError::RuntimeSetMismatch)
2813 );
2814 assert_eq!(
2815 plan_foot_cycle_parameterization_v1(
2816 &missing_set,
2817 parameterization_input(),
2818 &manifest,
2819 manifest_input.clone(),
2820 &source,
2821 ),
2822 Err(FootCycleParameterizationError::RuntimeSetMismatch)
2823 );
2824
2825 let mut wrong_collection = declaration(&manifest_input, 0.5, 2.0);
2826 wrong_collection.manifest.collection_id = CollectionIdV1::new("com.other").unwrap();
2827 assert_eq!(
2828 plan_foot_cycle_parameterization_v1(
2829 &wrong_collection,
2830 parameterization_input(),
2831 &manifest,
2832 manifest_input.clone(),
2833 &source,
2834 ),
2835 Err(FootCycleParameterizationError::ManifestMismatch)
2836 );
2837
2838 let (wrong_kind, _) = manifest_with_set(
2839 CollectionRuntimeSetKindV1::SyncGroup,
2840 &["reference", "member"],
2841 );
2842 assert_eq!(
2843 plan_foot_cycle_parameterization_v1(
2844 &declaration(&manifest_input, 0.5, 2.0),
2845 parameterization_input(),
2846 &wrong_kind,
2847 manifest_input.clone(),
2848 &source,
2849 ),
2850 Err(FootCycleParameterizationError::WrongRuntimeSetKind)
2851 );
2852
2853 let (reordered, _) = manifest_with_set(
2854 CollectionRuntimeSetKindV1::GaitGroup,
2855 &["member", "reference"],
2856 );
2857 assert_eq!(
2858 plan_foot_cycle_parameterization_v1(
2859 &declaration(&manifest_input, 0.5, 2.0),
2860 parameterization_input(),
2861 &reordered,
2862 manifest_input,
2863 &source,
2864 ),
2865 Err(FootCycleParameterizationError::MemberOrderMismatch)
2866 );
2867 }
2868
2869 #[test]
2870 fn manifest_clip_lookup_accepts_late_maximum_long_prefix_member() {
2871 let prefix = "x".repeat(220);
2872 let names = (0..FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS)
2873 .map(|index| format!("{prefix}{index:04}"))
2874 .collect::<Vec<_>>();
2875 let name_refs = names.iter().map(String::as_str).collect::<Vec<_>>();
2876 let (manifest, _) = manifest_for_members(
2877 CollectionRuntimeSetKindV1::GaitGroup,
2878 &name_refs,
2879 &name_refs[..2],
2880 );
2881 let last_index = FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS - 1;
2882 let fragment = fragment(
2883 &names[last_index],
2884 last_index as u32,
2885 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2886 );
2887 assert_eq!(
2888 validate_clip_witness(
2889 &manifest,
2890 &id(&format!("com.example/{}", names[last_index])),
2891 &fragment,
2892 ),
2893 Ok(())
2894 );
2895 }
2896
2897 #[test]
2898 fn unsupported_or_malformed_detector_extension_refuses() {
2899 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2900 let (manifest, manifest_input) = manifest();
2901 let declaration = declaration(&manifest_input, 0.5, 2.0);
2902 for invalid_index in 0..2 {
2903 let (member, take_index) = if invalid_index == 0 {
2904 ("reference", 0)
2905 } else {
2906 ("member", 1)
2907 };
2908 let invalid = fragment_with_extensions(member, take_index, &windows, Vec::new());
2909 let mut source = evidence(&windows, &windows);
2910 source[invalid_index].input = invalid.canonical_identity().unwrap();
2911 source[invalid_index].fragment = invalid;
2912 assert_eq!(
2913 plan_foot_cycle_parameterization_v1(
2914 &declaration,
2915 InputIdentity::from_bytes(b"parameterization"),
2916 &manifest,
2917 manifest_input.clone(),
2918 &source,
2919 ),
2920 Err(FootCycleParameterizationError::UnsupportedContactExtension)
2921 );
2922 }
2923
2924 let payload = json!({
2925 "algorithm": "stance-support-v1",
2926 "sampling": "metric-grid-longest-authored-channel",
2927 "max_frames": 1_000_000,
2928 "contact_height_m": 0.03,
2929 "roles": {"left": "left_foot", "right": "right_foot"},
2930 });
2931 let detector = detector_extension("left_foot", "right_foot");
2932 for extensions in [
2933 vec![ContactExtensionV1::new("urn:other:detector:1", 1, payload.clone()).unwrap()],
2934 vec![
2935 ContactExtensionV1::new(CONTACT_SUPPORT_DETECTOR_V1_ID, 2, payload.clone())
2936 .unwrap(),
2937 ],
2938 vec![detector.clone(), detector],
2939 ] {
2940 for invalid_index in 0..2 {
2941 let (member, take_index) = if invalid_index == 0 {
2942 ("reference", 0)
2943 } else {
2944 ("member", 1)
2945 };
2946 let invalid =
2947 fragment_with_extensions(member, take_index, &windows, extensions.clone());
2948 let mut source = evidence(&windows, &windows);
2949 source[invalid_index].input = invalid.canonical_identity().unwrap();
2950 source[invalid_index].fragment = invalid;
2951 assert_eq!(
2952 plan_foot_cycle_parameterization_v1(
2953 &declaration,
2954 InputIdentity::from_bytes(b"parameterization"),
2955 &manifest,
2956 manifest_input.clone(),
2957 &source,
2958 ),
2959 Err(FootCycleParameterizationError::UnsupportedContactExtension)
2960 );
2961 }
2962 }
2963
2964 for member_index in 0..2 {
2965 let (member, take_index, take_name) = if member_index == 0 {
2966 ("reference", 0, "Take 0")
2967 } else {
2968 ("member", 1, "Take 1")
2969 };
2970 let toe_fragment = fragment_with_clip(
2971 member,
2972 "motions",
2973 take_index,
2974 take_name,
2975 1.0,
2976 &windows,
2977 None,
2978 Some([ContactRoleV1::LeftToe, ContactRoleV1::RightToe]),
2979 vec![detector_extension("left_toe", "right_toe")],
2980 );
2981 let mut source = evidence(&windows, &windows);
2982 source[member_index].input = toe_fragment.canonical_identity().unwrap();
2983 source[member_index].fragment = toe_fragment;
2984 assert!(
2985 plan_foot_cycle_parameterization_v1(
2986 &declaration,
2987 InputIdentity::from_bytes(b"parameterization"),
2988 &manifest,
2989 manifest_input.clone(),
2990 &source,
2991 )
2992 .is_ok()
2993 );
2994 }
2995
2996 let invalid_payloads = [
2997 json!({
2998 "algorithm": "other",
2999 "sampling": "metric-grid-longest-authored-channel",
3000 "max_frames": 1_000_000,
3001 "contact_height_m": 0.03,
3002 "roles": {"left": "left_foot", "right": "right_foot"},
3003 }),
3004 json!({
3005 "algorithm": "stance-support-v1",
3006 "sampling": "other",
3007 "max_frames": 1_000_000,
3008 "contact_height_m": 0.03,
3009 "roles": {"left": "left_foot", "right": "right_foot"},
3010 }),
3011 json!({
3012 "algorithm": "stance-support-v1",
3013 "sampling": "metric-grid-longest-authored-channel",
3014 "max_frames": 999_999,
3015 "contact_height_m": 0.03,
3016 "roles": {"left": "left_foot", "right": "right_foot"},
3017 }),
3018 json!({
3019 "algorithm": "stance-support-v1",
3020 "sampling": "metric-grid-longest-authored-channel",
3021 "max_frames": 1_000_000,
3022 "contact_height_m": -0.01,
3023 "roles": {"left": "left_foot", "right": "right_foot"},
3024 }),
3025 json!({
3026 "algorithm": "stance-support-v1",
3027 "sampling": "metric-grid-longest-authored-channel",
3028 "max_frames": 1_000_000,
3029 "contact_height_m": 0.03,
3030 "roles": {"left": "left_foot", "right": "right_foot"},
3031 "extra": true,
3032 }),
3033 json!({
3034 "algorithm": "stance-support-v1",
3035 "sampling": "metric-grid-longest-authored-channel",
3036 "max_frames": 1_000_000,
3037 "contact_height_m": 0.03,
3038 "roles": {
3039 "left": "left_foot",
3040 "right": "right_foot",
3041 "extra": "unexpected",
3042 },
3043 }),
3044 ];
3045 for payload in invalid_payloads {
3046 for invalid_index in 0..2 {
3047 let (member, take_index) = if invalid_index == 0 {
3048 ("reference", 0)
3049 } else {
3050 ("member", 1)
3051 };
3052 let invalid = fragment_with_extensions(
3053 member,
3054 take_index,
3055 &windows,
3056 vec![detector_extension_with_payload(payload.clone())],
3057 );
3058 let mut source = evidence(&windows, &windows);
3059 source[invalid_index].input = invalid.canonical_identity().unwrap();
3060 source[invalid_index].fragment = invalid;
3061 assert_eq!(
3062 plan_foot_cycle_parameterization_v1(
3063 &declaration,
3064 InputIdentity::from_bytes(b"parameterization"),
3065 &manifest,
3066 manifest_input.clone(),
3067 &source,
3068 ),
3069 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
3070 );
3071 }
3072 }
3073
3074 for invalid_index in 0..2 {
3075 let (member, take_index) = if invalid_index == 0 {
3076 ("reference", 0)
3077 } else {
3078 ("member", 1)
3079 };
3080 let mismatched_roles = fragment_with_extensions(
3081 member,
3082 take_index,
3083 &windows,
3084 vec![detector_extension("left_foot", "right_toe")],
3085 );
3086 let mut source = evidence(&windows, &windows);
3087 source[invalid_index].input = mismatched_roles.canonical_identity().unwrap();
3088 source[invalid_index].fragment = mismatched_roles;
3089 assert_eq!(
3090 plan_foot_cycle_parameterization_v1(
3091 &declaration,
3092 InputIdentity::from_bytes(b"parameterization"),
3093 &manifest,
3094 manifest_input.clone(),
3095 &source,
3096 ),
3097 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
3098 );
3099 }
3100 }
3101
3102 #[test]
3103 fn mixed_detector_thresholds_refuse_in_either_member_order() {
3104 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
3105 let (manifest, manifest_input) = manifest();
3106 let declaration = declaration(&manifest_input, 0.5, 2.0);
3107 let threshold_successor = f64::from_bits(0.03_f64.to_bits() + 1);
3108
3109 let mut common_non_default = evidence(&windows, &windows);
3110 for (member_index, (member, take_index)) in
3111 [("reference", 0), ("member", 1)].into_iter().enumerate()
3112 {
3113 let changed = fragment_with_extensions(
3114 member,
3115 take_index,
3116 &windows,
3117 vec![detector_extension_with_height(
3118 "left_foot",
3119 "right_foot",
3120 threshold_successor,
3121 )],
3122 );
3123 common_non_default[member_index].input = changed.canonical_identity().unwrap();
3124 common_non_default[member_index].root_motion = measured_root(&changed, 0.0, 0.0);
3125 common_non_default[member_index].fragment = changed;
3126 }
3127 assert!(
3128 plan_foot_cycle_parameterization_v1(
3129 &declaration,
3130 InputIdentity::from_bytes(b"parameterization"),
3131 &manifest,
3132 manifest_input.clone(),
3133 &common_non_default,
3134 )
3135 .is_ok()
3136 );
3137
3138 for changed_index in 0..2 {
3139 let (member, take_index) = if changed_index == 0 {
3140 ("reference", 0)
3141 } else {
3142 ("member", 1)
3143 };
3144 let changed = fragment_with_extensions(
3145 member,
3146 take_index,
3147 &windows,
3148 vec![detector_extension_with_height(
3149 "left_foot",
3150 "right_foot",
3151 threshold_successor,
3152 )],
3153 );
3154 let mut source = evidence(&windows, &windows);
3155 source[changed_index].input = changed.canonical_identity().unwrap();
3156 source[changed_index].fragment = changed;
3157 assert_eq!(
3158 plan_foot_cycle_parameterization_v1(
3159 &declaration,
3160 InputIdentity::from_bytes(b"parameterization"),
3161 &manifest,
3162 manifest_input.clone(),
3163 &source,
3164 ),
3165 Err(FootCycleParameterizationError::DetectorPolicyMismatch)
3166 );
3167 }
3168 }
3169
3170 #[test]
3171 fn stance_extension_time_warp_handler_revalidates_closed_payload() {
3172 let extension = detector_extension("left_foot", "right_foot");
3173 let operation = ContactTransformOperationV1::time_warp(
3174 1.0,
3175 vec![
3176 ContactTimeWarpControlPointV1::new(0.0, 0.0),
3177 ContactTimeWarpControlPointV1::new(1.0, 1.0),
3178 ],
3179 );
3180 assert_eq!(
3181 transform_contact_support_detector_extension_time_warp_v1(&extension, &operation)
3182 .unwrap(),
3183 extension
3184 );
3185 for (left, right) in [
3186 ("left_toe", "right_toe"),
3187 ("left_toe", "right_foot"),
3188 ("left_foot", "right_toe"),
3189 ] {
3190 let extension = detector_extension(left, right);
3191 assert_eq!(
3192 transform_contact_support_detector_extension_time_warp_v1(&extension, &operation,)
3193 .unwrap(),
3194 extension
3195 );
3196 }
3197 let negative_zero = detector_extension_with_payload(json!({
3198 "algorithm": "stance-support-v1",
3199 "sampling": "metric-grid-longest-authored-channel",
3200 "max_frames": 1_000_000,
3201 "contact_height_m": -0.0,
3202 "roles": {"left": "left_foot", "right": "right_foot"},
3203 }));
3204 let reconstructed =
3205 transform_contact_support_detector_extension_time_warp_v1(&negative_zero, &operation)
3206 .unwrap();
3207 assert_eq!(
3208 reconstructed
3209 .payload()
3210 .get("contact_height_m")
3211 .and_then(serde_json::Value::as_f64)
3212 .unwrap()
3213 .to_bits(),
3214 0.0_f64.to_bits()
3215 );
3216 assert_eq!(
3217 transform_contact_support_detector_extension_time_warp_v1(
3218 &extension,
3219 &ContactTransformOperationV1::resample(),
3220 ),
3221 Err(FootCycleParameterizationError::UnsupportedContactExtension)
3222 );
3223 let opaque_lookalike = detector_extension_with_payload(json!({
3224 "algorithm": "stance-support-v1",
3225 "sampling": "metric-grid-longest-authored-channel",
3226 "max_frames": 1_000_000,
3227 "contact_height_m": 0.03,
3228 "roles": {"left": "left_foot", "right": "right_foot"},
3229 "opaque": true,
3230 }));
3231 assert_eq!(
3232 transform_contact_support_detector_extension_time_warp_v1(
3233 &opaque_lookalike,
3234 &operation,
3235 ),
3236 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
3237 );
3238 }
3239
3240 #[test]
3241 fn control_point_cap_accepts_exact_and_rejects_first_excess() {
3242 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS, 4_096);
3243 fn boundaries(count: usize) -> Vec<Boundary> {
3244 (0..count)
3245 .map(|index| Boundary {
3246 kind: BoundaryKind {
3247 side: if index % 2 == 0 {
3248 Side::Left
3249 } else {
3250 Side::Right
3251 },
3252 edge: if index % 2 == 0 {
3253 Edge::Onset
3254 } else {
3255 Edge::Release
3256 },
3257 },
3258 time: (index + 1) as f64 / (count + 1) as f64,
3259 })
3260 .collect()
3261 }
3262
3263 let exact = boundaries(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS - 2);
3264 assert_eq!(
3265 build_control_points(&exact, &exact, 1.0, 1.0)
3266 .unwrap()
3267 .len(),
3268 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS
3269 );
3270
3271 let excess = boundaries(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS - 1);
3272 assert_eq!(
3273 build_control_points(&excess, &excess, 1.0, 1.0),
3274 Err(FootCycleParameterizationError::TooManyControlPoints {
3275 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS + 1,
3276 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
3277 })
3278 );
3279 }
3280
3281 #[test]
3282 fn planner_enforces_control_point_cap_on_supplied_ring() {
3283 let windows = alternating_windows(2_048);
3284 let (manifest, manifest_input) = manifest();
3285 assert_eq!(
3286 plan_foot_cycle_parameterization_v1(
3287 &declaration(&manifest_input, 0.5, 2.0),
3288 InputIdentity::from_bytes(b"parameterization"),
3289 &manifest,
3290 manifest_input,
3291 &evidence(&windows, &windows),
3292 ),
3293 Err(FootCycleParameterizationError::TooManyControlPoints {
3294 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS + 2,
3295 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
3296 })
3297 );
3298 }
3299
3300 #[test]
3301 fn aggregate_contact_event_budget_accepts_exact_and_stops_at_first_excess() {
3302 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS, 16_384);
3303 assert_eq!(
3304 validate_aggregate_contact_event_budget([
3305 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS - 1,
3306 1,
3307 ]),
3308 Ok(())
3309 );
3310 assert_eq!(
3311 validate_aggregate_contact_event_budget([
3312 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3313 usize::MAX,
3314 ]),
3315 Err(FootCycleParameterizationError::TooManyContactEvents {
3316 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1,
3317 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3318 })
3319 );
3320 }
3321
3322 #[test]
3323 fn aggregate_fragment_byte_budget_accepts_exact_and_stops_at_first_excess() {
3324 assert_eq!(
3325 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3326 32 * 1024 * 1024
3327 );
3328 assert_eq!(
3329 validate_aggregate_contact_fragment_byte_budget([
3330 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - 1,
3331 1,
3332 ]),
3333 Ok(())
3334 );
3335 assert_eq!(
3336 validate_aggregate_contact_fragment_byte_budget([
3337 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3338 u64::MAX,
3339 ]),
3340 Err(
3341 FootCycleParameterizationError::TooManyContactFragmentBytes {
3342 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1,
3343 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3344 }
3345 )
3346 );
3347 }
3348
3349 #[test]
3350 fn planner_preflights_fragment_byte_budget_before_canonicalization() {
3351 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
3352 let (manifest, manifest_input) = manifest();
3353 let declaration = declaration(&manifest_input, 0.5, 2.0);
3354 let mut source = evidence(&windows, &windows);
3355 let second_bytes = source[1].input.bytes();
3356 source[0].input = InputIdentity::from_sha256_digest(
3357 [0; 32],
3358 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - second_bytes,
3359 );
3360 assert_eq!(
3361 plan_foot_cycle_parameterization_v1(
3362 &declaration,
3363 InputIdentity::from_bytes(b"parameterization"),
3364 &manifest,
3365 manifest_input.clone(),
3366 &source,
3367 ),
3368 Err(FootCycleParameterizationError::NonCanonicalFragment)
3369 );
3370
3371 source[0].input = InputIdentity::from_sha256_digest(
3372 [0; 32],
3373 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - second_bytes + 1,
3374 );
3375 assert_eq!(
3376 plan_foot_cycle_parameterization_v1(
3377 &declaration,
3378 InputIdentity::from_bytes(b"parameterization"),
3379 &manifest,
3380 manifest_input,
3381 &source,
3382 ),
3383 Err(
3384 FootCycleParameterizationError::TooManyContactFragmentBytes {
3385 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1,
3386 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3387 }
3388 )
3389 );
3390 }
3391
3392 #[test]
3393 fn planner_preflights_aggregate_contact_events_before_topology_retention() {
3394 let names = ["reference", "member-1", "member-2", "member-3", "member-4"];
3395 let (manifest, manifest_input) =
3396 manifest_for_members(CollectionRuntimeSetKindV1::GaitGroup, &names, &names);
3397 let declaration = FootCycleParameterizationV1::new(
3398 FootCycleManifestBindingV1::new(
3399 CollectionIdV1::new("com.example").unwrap(),
3400 manifest_input.clone(),
3401 )
3402 .unwrap(),
3403 id("com.example/sets/walk"),
3404 id("com.example/reference"),
3405 path("generated/aligned"),
3406 0.5,
3407 2.0,
3408 proof_policy(),
3409 names
3410 .iter()
3411 .map(|name| {
3412 FootCycleParameterizationMemberV1::new(
3413 id(&format!("com.example/{name}")),
3414 path(&format!("contacts/{name}.json")),
3415 )
3416 })
3417 .collect(),
3418 )
3419 .unwrap();
3420 let windows = alternating_windows(2_048);
3421 let oversized_fragment = fragment("reference", 0, &windows);
3422 let fragment_input = oversized_fragment.canonical_identity().unwrap();
3423 let evidence = names
3424 .iter()
3425 .map(|name| {
3426 FootCycleMemberEvidenceV1::new(
3427 id(&format!("com.example/{name}")),
3428 path(&format!("contacts/{name}.json")),
3429 fragment_input.clone(),
3430 oversized_fragment.clone(),
3431 measured_root(&oversized_fragment, 0.0, 0.0),
3432 )
3433 })
3434 .collect::<Vec<_>>();
3435 assert_eq!(
3436 plan_foot_cycle_parameterization_v1(
3437 &declaration,
3438 InputIdentity::from_bytes(b"parameterization"),
3439 &manifest,
3440 manifest_input,
3441 &evidence,
3442 ),
3443 Err(FootCycleParameterizationError::TooManyContactEvents {
3444 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1,
3445 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3446 })
3447 );
3448 }
3449
3450 #[test]
3451 fn proof_policy_requires_complete_finite_values_at_exact_inclusive_boundaries() {
3452 let lower = FootCycleProofPolicyV1::new(0.0, 0.0, 0.0).unwrap();
3453 assert_eq!(lower.max_gait_phase_spread(), 0.0);
3454 assert_eq!(lower.min_lr_amplitude_m(), 0.0);
3455 assert_eq!(lower.max_contact_boundary_phase_error(), 0.0);
3456
3457 let upper = FootCycleProofPolicyV1::new(0.5, 0.05, 0.5).unwrap();
3458 assert_eq!(upper.max_gait_phase_spread(), 0.5);
3459 assert_eq!(upper.min_lr_amplitude_m(), 0.05);
3460 assert_eq!(upper.max_contact_boundary_phase_error(), 0.5);
3461 assert_eq!(
3462 serde_json::to_value(&upper).unwrap(),
3463 json!({
3464 "max_gait_phase_spread": 0.5,
3465 "min_lr_amplitude_m": 0.05,
3466 "max_contact_boundary_phase_error": 0.5,
3467 })
3468 );
3469
3470 let above_half = f64::from_bits(0.5_f64.to_bits() + 1);
3471 for result in [
3472 FootCycleProofPolicyV1::new(above_half, 0.0, 0.0),
3473 FootCycleProofPolicyV1::new(0.0, -f64::MIN_POSITIVE, 0.0),
3474 FootCycleProofPolicyV1::new(0.0, 0.0, above_half),
3475 FootCycleProofPolicyV1::new(f64::NAN, 0.0, 0.0),
3476 FootCycleProofPolicyV1::new(0.0, f64::INFINITY, 0.0),
3477 FootCycleProofPolicyV1::new(0.0, 0.0, f64::NEG_INFINITY),
3478 ] {
3479 assert_eq!(
3480 result,
3481 Err(FootCycleParameterizationError::InvalidProofPolicy)
3482 );
3483 }
3484 }
3485
3486 #[test]
3487 fn plan_retains_identity_bound_proof_policy() {
3488 let (manifest, manifest_input) = manifest();
3489 let distinct = FootCycleProofPolicyV1::new(0.321, 0.123, 0.234).unwrap();
3490 let declaration = declaration_with_proof(&manifest_input, 0.5, 2.0, distinct.clone());
3491 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
3492 let parameterization_input = InputIdentity::from_bytes(b"parameterization-with-proof");
3493 let plan = plan_foot_cycle_parameterization_v1(
3494 &declaration,
3495 parameterization_input.clone(),
3496 &manifest,
3497 manifest_input,
3498 &evidence(&windows, &windows),
3499 )
3500 .unwrap();
3501 assert_eq!(plan.parameterization_input(), ¶meterization_input);
3502 assert_eq!(plan.proof(), &distinct);
3503 assert_eq!(plan.proof(), declaration.proof());
3504 }
3505
3506 #[test]
3507 fn declaration_rejects_invalid_slopes_duplicates_and_path_collisions() {
3508 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE, 1_000_000.0);
3509 let (_, manifest_input) = manifest();
3510 assert!(
3511 FootCycleManifestBindingV1::new(
3512 CollectionIdV1::new("com.example").unwrap(),
3513 InputIdentity::from_sha256_digest(
3514 [0; 32],
3515 COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
3516 ),
3517 )
3518 .is_ok()
3519 );
3520 assert_eq!(
3521 FootCycleManifestBindingV1::new(
3522 CollectionIdV1::new("com.example").unwrap(),
3523 InputIdentity::from_sha256_digest(
3524 [0; 32],
3525 COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES + 1,
3526 ),
3527 ),
3528 Err(FootCycleParameterizationError::ManifestTooLarge)
3529 );
3530 for (minimum, maximum) in [
3531 (0.0, 1.0),
3532 (-1.0, 1.0),
3533 (f64::NAN, 1.0),
3534 (0.5, f64::NAN),
3535 (f64::NEG_INFINITY, 1.0),
3536 (0.5, f64::INFINITY),
3537 (1.0 + f64::EPSILON, 2.0),
3538 (0.5, 1.0 - f64::EPSILON),
3539 (2.0, 1.0),
3540 (1.0, FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE * 2.0),
3541 ] {
3542 assert_eq!(
3543 FootCycleParameterizationV1::new(
3544 FootCycleManifestBindingV1::new(
3545 CollectionIdV1::new("com.example").unwrap(),
3546 manifest_input.clone(),
3547 )
3548 .unwrap(),
3549 id("com.example/sets/walk"),
3550 id("com.example/reference"),
3551 path("generated/aligned"),
3552 minimum,
3553 maximum,
3554 proof_policy(),
3555 vec![
3556 FootCycleParameterizationMemberV1::new(
3557 id("com.example/reference"),
3558 path("contacts/reference.json"),
3559 ),
3560 FootCycleParameterizationMemberV1::new(
3561 id("com.example/member"),
3562 path("contacts/member.json"),
3563 ),
3564 ],
3565 ),
3566 Err(FootCycleParameterizationError::InvalidSlopeBounds)
3567 );
3568 }
3569
3570 let binding = || {
3571 FootCycleManifestBindingV1::new(
3572 CollectionIdV1::new("com.example").unwrap(),
3573 manifest_input.clone(),
3574 )
3575 .unwrap()
3576 };
3577 for members in [
3578 Vec::new(),
3579 vec![FootCycleParameterizationMemberV1::new(
3580 id("com.example/reference"),
3581 path("contacts/reference.json"),
3582 )],
3583 ] {
3584 let found = members.len();
3585 assert_eq!(
3586 FootCycleParameterizationV1::new(
3587 binding(),
3588 id("com.example/sets/walk"),
3589 id("com.example/reference"),
3590 path("generated/aligned"),
3591 0.5,
3592 2.0,
3593 proof_policy(),
3594 members,
3595 ),
3596 Err(FootCycleParameterizationError::TooFewMembers { found })
3597 );
3598 }
3599 assert!(matches!(
3600 FootCycleParameterizationV1::new(
3601 binding(),
3602 id("com.example/sets/walk"),
3603 id("com.example/reference"),
3604 path("generated/aligned"),
3605 0.5,
3606 2.0,
3607 proof_policy(),
3608 vec![
3609 FootCycleParameterizationMemberV1::new(
3610 id("com.example/reference"),
3611 path("contacts/reference.json"),
3612 ),
3613 FootCycleParameterizationMemberV1::new(
3614 id("com.example/reference"),
3615 path("contacts/other.json"),
3616 ),
3617 ],
3618 ),
3619 Err(FootCycleParameterizationError::DuplicateMember { .. })
3620 ));
3621 assert_eq!(
3622 FootCycleParameterizationV1::new(
3623 binding(),
3624 id("com.example/sets/walk"),
3625 id("com.example/reference"),
3626 path("generated/aligned"),
3627 0.5,
3628 2.0,
3629 proof_policy(),
3630 vec![
3631 FootCycleParameterizationMemberV1::new(
3632 id("com.example/reference"),
3633 path("contacts/shared.json"),
3634 ),
3635 FootCycleParameterizationMemberV1::new(
3636 id("com.example/member"),
3637 path("contacts/shared.json"),
3638 ),
3639 ],
3640 ),
3641 Err(FootCycleParameterizationError::DuplicateFragmentPath)
3642 );
3643 assert_eq!(
3644 FootCycleParameterizationV1::new(
3645 binding(),
3646 id("com.example/sets/walk"),
3647 id("com.example/reference"),
3648 path("contacts/member.json"),
3649 0.5,
3650 2.0,
3651 proof_policy(),
3652 vec![
3653 FootCycleParameterizationMemberV1::new(
3654 id("com.example/reference"),
3655 path("contacts/reference.json"),
3656 ),
3657 FootCycleParameterizationMemberV1::new(
3658 id("com.example/member"),
3659 path("contacts/member.json"),
3660 ),
3661 ],
3662 ),
3663 Err(FootCycleParameterizationError::OutputPathCollision)
3664 );
3665 assert_eq!(
3666 FootCycleParameterizationV1::new(
3667 binding(),
3668 id("com.example/sets/walk"),
3669 id("com.example/missing"),
3670 path("generated/aligned"),
3671 0.5,
3672 2.0,
3673 proof_policy(),
3674 vec![
3675 FootCycleParameterizationMemberV1::new(
3676 id("com.example/reference"),
3677 path("contacts/reference.json"),
3678 ),
3679 FootCycleParameterizationMemberV1::new(
3680 id("com.example/member"),
3681 path("contacts/member.json"),
3682 ),
3683 ],
3684 ),
3685 Err(FootCycleParameterizationError::MissingReferenceMember)
3686 );
3687 }
3688
3689 #[test]
3690 fn declaration_member_cap_accepts_exact_and_rejects_n_plus_one() {
3691 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS, 4_096);
3692 let mut members = (0..FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS)
3693 .map(|index| {
3694 FootCycleParameterizationMemberV1::new(
3695 id(&format!("com.example/member-{index}")),
3696 path(&format!("contacts/member-{index}.json")),
3697 )
3698 })
3699 .collect::<Vec<_>>();
3700 let binding = || {
3701 FootCycleManifestBindingV1::new(
3702 CollectionIdV1::new("com.example").unwrap(),
3703 InputIdentity::from_bytes(b"manifest"),
3704 )
3705 .unwrap()
3706 };
3707 let construct = |members| {
3708 FootCycleParameterizationV1::new(
3709 binding(),
3710 id("com.example/sets/walk"),
3711 id("com.example/member-0"),
3712 path("generated/aligned"),
3713 0.5,
3714 2.0,
3715 proof_policy(),
3716 members,
3717 )
3718 };
3719
3720 assert!(construct(members.clone()).is_ok());
3721 let index = FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS;
3722 members.push(FootCycleParameterizationMemberV1::new(
3723 id(&format!("com.example/member-{index}")),
3724 path(&format!("contacts/member-{index}.json")),
3725 ));
3726 assert_eq!(
3727 construct(members),
3728 Err(FootCycleParameterizationError::TooManyMembers {
3729 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS + 1,
3730 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS,
3731 })
3732 );
3733 }
3734}