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, PartialEq, Eq)]
745struct BoundaryKind {
746 side: Side,
747 edge: Edge,
748}
749
750#[derive(Debug, Clone, Copy)]
751struct Boundary {
752 kind: BoundaryKind,
753 time: f64,
754}
755
756struct MemberTopology {
757 evidence_index: usize,
758 signature: Vec<BoundaryKind>,
759 rotated_boundaries: Vec<Boundary>,
760 contact_height_m_bits: u64,
761}
762
763pub fn plan_foot_cycle_parameterization_v1(
777 parameterization: &FootCycleParameterizationV1,
778 parameterization_input: InputIdentity,
779 manifest: &CollectionManifestV1,
780 manifest_input: InputIdentity,
781 evidence: &[FootCycleMemberEvidenceV1],
782) -> Result<FootCyclePlanV1, FootCycleParameterizationError> {
783 if parameterization_input.bytes() > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_BYTES {
784 return Err(FootCycleParameterizationError::ParameterizationTooLarge);
785 }
786 validate_foot_cycle_manifest_binding_v1(parameterization, manifest, &manifest_input)?;
787 if evidence.len() != parameterization.members.len() {
788 return Err(FootCycleParameterizationError::EvidenceCountMismatch);
789 }
790 validate_aggregate_contact_event_budget(
791 evidence.iter().map(|row| row.fragment.events().len()),
792 )?;
793 validate_aggregate_contact_fragment_byte_budget(evidence.iter().map(|row| row.input.bytes()))?;
794
795 let mut topologies = Vec::with_capacity(evidence.len());
796 for (index, (declaration, evidence)) in
797 parameterization.members.iter().zip(evidence).enumerate()
798 {
799 if declaration.id != evidence.id
800 || declaration.contact_fragment != evidence.contact_fragment_path
801 {
802 return Err(FootCycleParameterizationError::EvidenceMemberMismatch);
803 }
804 let canonical = evidence
805 .fragment
806 .canonical_json()
807 .map_err(|_| FootCycleParameterizationError::NonCanonicalFragment)?;
808 if canonical.len() as u64 != evidence.input.bytes()
809 || InputIdentity::from_bytes(&canonical) != evidence.input
810 {
811 return Err(FootCycleParameterizationError::NonCanonicalFragment);
812 }
813 validate_clip_witness(manifest, &declaration.id, &evidence.fragment)?;
814 validate_root_motion(&declaration.id, &evidence.fragment, &evidence.root_motion)?;
815 let topology = topology(&evidence.fragment)?;
816 topologies.push(MemberTopology {
817 evidence_index: index,
818 signature: topology.0,
819 rotated_boundaries: topology.1,
820 contact_height_m_bits: topology.2,
821 });
822 }
823
824 let reference_index = parameterization
825 .members
826 .iter()
827 .position(|member| member.id == parameterization.reference_member)
828 .ok_or(FootCycleParameterizationError::MissingReferenceMember)?;
829 let reference = &topologies[reference_index];
830 for member in &topologies {
831 if member.contact_height_m_bits != reference.contact_height_m_bits {
832 return Err(FootCycleParameterizationError::DetectorPolicyMismatch);
833 }
834 if member.signature != reference.signature {
835 return Err(FootCycleParameterizationError::TopologyMismatch);
836 }
837 }
838
839 let mut plans = Vec::with_capacity(topologies.len());
840 for topology in &topologies {
841 let source = &evidence[topology.evidence_index];
842 let control_points = build_control_points(
843 &topology.rotated_boundaries,
844 &reference.rotated_boundaries,
845 parameterization.minimum_segment_slope,
846 parameterization.maximum_segment_slope,
847 )?;
848 let binding = ContactTransformBindingV1::new(
849 source.fragment.artifact().clone(),
850 source.fragment.dependency_closure_identity().clone(),
851 source.input.clone(),
852 );
853 plans.push(FootCycleMemberPlanV1 {
854 id: source.id.clone(),
855 input: binding,
856 root_motion: source.root_motion.clone(),
857 operation: ContactTransformOperationV1::time_warp(
858 source.fragment.duration_s(),
859 control_points,
860 ),
861 });
862 }
863
864 Ok(FootCyclePlanV1 {
865 parameterization_input,
866 manifest_input,
867 runtime_set_id: parameterization.runtime_set_id.clone(),
868 reference_member: parameterization.reference_member.clone(),
869 proof: parameterization.proof.clone(),
870 members: plans,
871 })
872}
873
874fn validate_root_motion(
875 member: &CollectionLogicalIdV1,
876 fragment: &ContactFragmentV1,
877 evidence: &FootCycleRootMotionEvidenceV1,
878) -> Result<(), FootCycleParameterizationError> {
879 let binding = evidence.binding();
880 if binding.artifact != *fragment.artifact()
881 || binding.dependency_closure_identity != *fragment.dependency_closure_identity()
882 || binding.clip != *fragment.clip()
883 {
884 return Err(FootCycleParameterizationError::RootMotionBindingMismatch {
885 member: member.as_str().to_owned(),
886 });
887 }
888 let FootCycleRootMotionEvidenceV1::Measured {
889 endpoint_displacement_x_m,
890 endpoint_displacement_z_m,
891 accumulated_yaw_deg,
892 ..
893 } = evidence
894 else {
895 return Err(
896 FootCycleParameterizationError::RootMotionEvidenceUnavailable {
897 member: member.as_str().to_owned(),
898 },
899 );
900 };
901 if !endpoint_displacement_x_m.is_finite()
902 || !endpoint_displacement_z_m.is_finite()
903 || !accumulated_yaw_deg.is_finite()
904 {
905 return Err(
906 FootCycleParameterizationError::RootMotionEvidenceUnavailable {
907 member: member.as_str().to_owned(),
908 },
909 );
910 }
911 if endpoint_displacement_x_m.hypot(*endpoint_displacement_z_m)
912 > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M
913 || accumulated_yaw_deg.abs() > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG
914 {
915 return Err(FootCycleParameterizationError::RootMotionOutOfRange {
916 member: member.as_str().to_owned(),
917 });
918 }
919 Ok(())
920}
921
922fn validate_aggregate_contact_event_budget(
923 counts: impl IntoIterator<Item = usize>,
924) -> Result<(), FootCycleParameterizationError> {
925 let mut total = 0usize;
926 for count in counts {
927 total = total
928 .checked_add(count)
929 .unwrap_or(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1);
930 if total > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS {
931 return Err(FootCycleParameterizationError::TooManyContactEvents {
932 found: total.min(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1),
933 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
934 });
935 }
936 }
937 Ok(())
938}
939
940fn validate_aggregate_contact_fragment_byte_budget(
941 counts: impl IntoIterator<Item = u64>,
942) -> Result<(), FootCycleParameterizationError> {
943 let mut total = 0u64;
944 for count in counts {
945 total = total
946 .checked_add(count)
947 .unwrap_or(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1);
948 if total > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES {
949 return Err(
950 FootCycleParameterizationError::TooManyContactFragmentBytes {
951 found: total.min(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1),
952 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
953 },
954 );
955 }
956 }
957 Ok(())
958}
959
960pub fn validate_foot_cycle_manifest_binding_v1(
973 parameterization: &FootCycleParameterizationV1,
974 manifest: &CollectionManifestV1,
975 manifest_input: &InputIdentity,
976) -> Result<(), FootCycleParameterizationError> {
977 if parameterization.manifest.collection_id != *manifest.collection_id()
978 || parameterization.manifest.input != *manifest_input
979 {
980 return Err(FootCycleParameterizationError::ManifestMismatch);
981 }
982 let mut matches = manifest
983 .runtime_sets()
984 .iter()
985 .filter(|set| set.id() == ¶meterization.runtime_set_id);
986 let set = matches
987 .next()
988 .ok_or(FootCycleParameterizationError::RuntimeSetMismatch)?;
989 if matches.next().is_some() {
990 return Err(FootCycleParameterizationError::RuntimeSetMismatch);
991 }
992 if set.kind() != CollectionRuntimeSetKindV1::GaitGroup {
993 return Err(FootCycleParameterizationError::WrongRuntimeSetKind);
994 }
995 if !set
996 .members()
997 .iter()
998 .eq(parameterization.members.iter().map(|member| &member.id))
999 {
1000 return Err(FootCycleParameterizationError::MemberOrderMismatch);
1001 }
1002 Ok(())
1003}
1004
1005fn validate_clip_witness(
1006 manifest: &CollectionManifestV1,
1007 member: &CollectionLogicalIdV1,
1008 fragment: &ContactFragmentV1,
1009) -> Result<(), FootCycleParameterizationError> {
1010 let clip = manifest
1011 .clips()
1012 .binary_search_by(|clip| clip.id().cmp(member))
1013 .ok()
1014 .and_then(|index| manifest.clips().get(index))
1015 .ok_or(FootCycleParameterizationError::FragmentClipMismatch)?;
1016 match fragment.clip() {
1017 ContactClipReferenceV1::Collection {
1018 logical_id,
1019 source,
1020 take_index,
1021 take_name,
1022 } if logical_id == member.as_str()
1023 && source == clip.source().as_str()
1024 && *take_index == clip.take_index()
1025 && take_name == clip.take_name() =>
1026 {
1027 Ok(())
1028 }
1029 ContactClipReferenceV1::Document { .. } | ContactClipReferenceV1::Collection { .. } => {
1030 Err(FootCycleParameterizationError::FragmentClipMismatch)
1031 }
1032 }
1033}
1034
1035fn topology(
1036 fragment: &ContactFragmentV1,
1037) -> Result<(Vec<BoundaryKind>, Vec<Boundary>, u64), FootCycleParameterizationError> {
1038 let detector = validate_detector_extension(fragment)?;
1039 let mut windows = Vec::new();
1040 let mut markers = Vec::new();
1041 for event in fragment.events() {
1042 let side = event_side(event.role(), detector.roles)
1043 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1044 match (event.phase(), event.kind()) {
1045 (ContactPhaseV1::Begin, ContactEventKindV1::Window(window))
1046 if window.start() < window.end() =>
1047 {
1048 windows.push(SupportWindow {
1049 side,
1050 start: window.start(),
1051 end: window.end(),
1052 });
1053 }
1054 (ContactPhaseV1::Marker, ContactEventKindV1::Point(time)) => {
1055 markers.push((side, time));
1056 }
1057 _ => return Err(FootCycleParameterizationError::InvalidContactTopology),
1058 }
1059 }
1060 if windows.is_empty() {
1061 return Err(FootCycleParameterizationError::InvalidContactTopology);
1062 }
1063 windows.sort_by(|left, right| left.start.total_cmp(&right.start));
1064 markers.sort_by(|left, right| left.1.total_cmp(&right.1));
1065 let left_count = windows
1066 .iter()
1067 .filter(|window| window.side == Side::Left)
1068 .count();
1069 let right_count = windows.len() - left_count;
1070 if left_count == 0 || left_count != right_count {
1071 return Err(FootCycleParameterizationError::InvalidContactTopology);
1072 }
1073 for pair in windows.windows(2) {
1074 if pair[0].end >= pair[1].start || pair[0].side == pair[1].side {
1075 return Err(FootCycleParameterizationError::InvalidContactTopology);
1076 }
1077 }
1078 if windows
1079 .first()
1080 .zip(windows.last())
1081 .is_none_or(|(first, last)| {
1082 first.side == last.side || first.start == 0.0 && last.end == 1.0
1083 })
1084 {
1085 return Err(FootCycleParameterizationError::InvalidContactTopology);
1086 }
1087 if markers.len() != windows.len()
1088 || windows.iter().zip(&markers).any(|(window, (side, time))| {
1089 window.side != *side || *time < window.start || *time > window.end
1090 })
1091 {
1092 return Err(FootCycleParameterizationError::InvalidContactTopology);
1093 }
1094
1095 let origin = windows
1096 .iter()
1097 .position(|window| window.side == Side::Left)
1098 .ok_or(FootCycleParameterizationError::InvalidContactTopology)?;
1099 let mut signature = Vec::with_capacity(windows.len() * 2);
1100 let mut boundaries = Vec::with_capacity(windows.len() * 2);
1101 for window in windows.iter().cycle().skip(origin).take(windows.len()) {
1102 for (edge, time) in [(Edge::Onset, window.start), (Edge::Release, window.end)] {
1103 let kind = BoundaryKind {
1104 side: window.side,
1105 edge,
1106 };
1107 signature.push(kind);
1108 boundaries.push(Boundary { kind, time });
1109 }
1110 }
1111 Ok((signature, boundaries, detector.contact_height_m_bits))
1112}
1113
1114#[derive(Debug, Clone, Copy)]
1115struct DetectorProvenance {
1116 roles: [ContactRoleV1; 2],
1117 contact_height_m_bits: u64,
1118}
1119
1120fn validate_detector_extension(
1121 fragment: &ContactFragmentV1,
1122) -> Result<DetectorProvenance, FootCycleParameterizationError> {
1123 let [extension] = fragment.extensions() else {
1124 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1125 };
1126 validate_detector_extension_value(extension)
1127}
1128
1129fn validate_detector_extension_value(
1130 extension: &crate::ContactExtensionV1,
1131) -> Result<DetectorProvenance, FootCycleParameterizationError> {
1132 if extension.schema() != CONTACT_SUPPORT_DETECTOR_V1_ID
1133 || extension.schema_version() != CONTACT_SUPPORT_DETECTOR_V1_SCHEMA_VERSION
1134 {
1135 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1136 }
1137 let payload = extension
1138 .payload()
1139 .as_object()
1140 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1141 let expected = [
1142 "algorithm",
1143 "contact_height_m",
1144 "max_frames",
1145 "roles",
1146 "sampling",
1147 ]
1148 .into_iter()
1149 .collect::<BTreeSet<_>>();
1150 let Some(contact_height_m) = payload
1151 .get("contact_height_m")
1152 .and_then(serde_json::Value::as_f64)
1153 else {
1154 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1155 };
1156 if payload.keys().map(String::as_str).collect::<BTreeSet<_>>() != expected
1157 || payload.get("algorithm").and_then(serde_json::Value::as_str)
1158 != Some(CONTACT_SUPPORT_DETECTOR_V1_ALGORITHM)
1159 || payload.get("sampling").and_then(serde_json::Value::as_str)
1160 != Some(CONTACT_SUPPORT_DETECTOR_V1_SAMPLING)
1161 || payload
1162 .get("max_frames")
1163 .and_then(serde_json::Value::as_u64)
1164 != Some(CONTACT_SUPPORT_DETECTOR_V1_MAX_FRAMES)
1165 || !contact_height_m.is_finite()
1166 || contact_height_m < 0.0
1167 {
1168 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1169 }
1170 let roles = payload
1171 .get("roles")
1172 .and_then(serde_json::Value::as_object)
1173 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1174 if roles.keys().map(String::as_str).collect::<BTreeSet<_>>()
1175 != ["left", "right"].into_iter().collect::<BTreeSet<_>>()
1176 {
1177 return Err(FootCycleParameterizationError::InvalidDetectorProvenance);
1178 }
1179 let left = match roles.get("left").and_then(serde_json::Value::as_str) {
1180 Some("left_foot") => ContactRoleV1::LeftFoot,
1181 Some("left_toe") => ContactRoleV1::LeftToe,
1182 _ => return Err(FootCycleParameterizationError::InvalidDetectorProvenance),
1183 };
1184 let right = match roles.get("right").and_then(serde_json::Value::as_str) {
1185 Some("right_foot") => ContactRoleV1::RightFoot,
1186 Some("right_toe") => ContactRoleV1::RightToe,
1187 _ => return Err(FootCycleParameterizationError::InvalidDetectorProvenance),
1188 };
1189 Ok(DetectorProvenance {
1190 roles: [left, right],
1191 contact_height_m_bits: canonical_zero(contact_height_m).to_bits(),
1192 })
1193}
1194
1195pub fn transform_contact_support_detector_extension_time_warp_v1(
1208 extension: &crate::ContactExtensionV1,
1209 operation: &ContactTransformOperationV1,
1210) -> Result<crate::ContactExtensionV1, FootCycleParameterizationError> {
1211 if !matches!(
1212 operation,
1213 ContactTransformOperationV1::TimeWarp { version: 1, .. }
1214 ) {
1215 return Err(FootCycleParameterizationError::UnsupportedContactExtension);
1216 }
1217 let detector = validate_detector_extension_value(extension)?;
1218 let left = detector_role_name(detector.roles[0])
1219 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1220 let right = detector_role_name(detector.roles[1])
1221 .ok_or(FootCycleParameterizationError::InvalidDetectorProvenance)?;
1222 let payload = serde_json::json!({
1223 "algorithm": CONTACT_SUPPORT_DETECTOR_V1_ALGORITHM,
1224 "contact_height_m": f64::from_bits(detector.contact_height_m_bits),
1225 "max_frames": CONTACT_SUPPORT_DETECTOR_V1_MAX_FRAMES,
1226 "roles": {
1227 "left": left,
1228 "right": right,
1229 },
1230 "sampling": CONTACT_SUPPORT_DETECTOR_V1_SAMPLING,
1231 });
1232 crate::ContactExtensionV1::new(
1233 CONTACT_SUPPORT_DETECTOR_V1_ID,
1234 CONTACT_SUPPORT_DETECTOR_V1_SCHEMA_VERSION,
1235 payload,
1236 )
1237 .map_err(|_| FootCycleParameterizationError::InvalidDetectorProvenance)
1238}
1239
1240fn detector_role_name(role: ContactRoleV1) -> Option<&'static str> {
1241 match role {
1242 ContactRoleV1::LeftFoot => Some("left_foot"),
1243 ContactRoleV1::LeftToe => Some("left_toe"),
1244 ContactRoleV1::RightFoot => Some("right_foot"),
1245 ContactRoleV1::RightToe => Some("right_toe"),
1246 _ => None,
1247 }
1248}
1249
1250fn event_side(role: ContactRoleV1, roles: [ContactRoleV1; 2]) -> Option<Side> {
1251 if role == roles[0] {
1252 Some(Side::Left)
1253 } else if role == roles[1] {
1254 Some(Side::Right)
1255 } else {
1256 None
1257 }
1258}
1259
1260fn build_control_points(
1261 source: &[Boundary],
1262 reference: &[Boundary],
1263 minimum_slope: f64,
1264 maximum_slope: f64,
1265) -> Result<Vec<ContactTimeWarpControlPointV1>, FootCycleParameterizationError> {
1266 if source.len() != reference.len()
1267 || source
1268 .iter()
1269 .zip(reference)
1270 .any(|(left, right)| left.kind != right.kind)
1271 {
1272 return Err(FootCycleParameterizationError::TopologyMismatch);
1273 }
1274 let needed = source
1275 .iter()
1276 .zip(reference)
1277 .filter(|(source, reference)| {
1278 !((source.time == 0.0 && reference.time == 0.0)
1279 || (source.time == 1.0 && reference.time == 1.0))
1280 })
1281 .try_fold(2_usize, |count, _| count.checked_add(1))
1282 .ok_or(FootCycleParameterizationError::TooManyControlPoints {
1283 found: usize::MAX,
1284 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
1285 })?;
1286 if needed > FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS {
1287 return Err(FootCycleParameterizationError::TooManyControlPoints {
1288 found: needed,
1289 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
1290 });
1291 }
1292
1293 let mut pairs = source
1294 .iter()
1295 .zip(reference)
1296 .map(|(source, reference)| (source.time, reference.time))
1297 .collect::<Vec<_>>();
1298 pairs.sort_by(|left, right| left.0.total_cmp(&right.0));
1299 let mut points = Vec::with_capacity(needed);
1300 points.push((0.0, 0.0));
1301 for (input, output) in pairs {
1302 if input == 0.0 && output == 0.0 || input == 1.0 && output == 1.0 {
1303 continue;
1304 }
1305 points.push((canonical_zero(input), canonical_zero(output)));
1306 }
1307 points.push((1.0, 1.0));
1308 for pair in points.windows(2) {
1309 let (x0, y0) = pair[0];
1310 let (x1, y1) = pair[1];
1311 if !(x0 < x1 && y0 < y1) {
1312 return Err(FootCycleParameterizationError::NonMonotoneMapping);
1313 }
1314 let slope = (y1 - y0) / (x1 - x0);
1315 if !slope.is_finite() || slope < minimum_slope || slope > maximum_slope {
1316 return Err(FootCycleParameterizationError::SegmentSlopeOutOfRange);
1317 }
1318 }
1319 Ok(points
1320 .into_iter()
1321 .map(|(input, output)| ContactTimeWarpControlPointV1::new(input, output))
1322 .collect())
1323}
1324
1325fn canonical_zero(value: f64) -> f64 {
1326 if value == 0.0 { 0.0 } else { value }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use crate::{
1333 CollectionClipV1, CollectionRuntimeSetV1, CollectionSourceKeyV1, CollectionSourceV1,
1334 ContactEventV1, ContactEventWindowV1, ContactExtensionV1, ContactProducerV1,
1335 DependencyClosureBuilderV1, ResourceKeySyntaxV1, SourceSetCoverageV1,
1336 };
1337 use serde_json::json;
1338
1339 fn id(value: &str) -> CollectionLogicalIdV1 {
1340 CollectionLogicalIdV1::new(value).unwrap()
1341 }
1342
1343 fn path(value: &str) -> DependencyResourceKeyV1 {
1344 DependencyResourceKeyV1::from_source_str(value, ResourceKeySyntaxV1::ParserRelativePath)
1345 .unwrap()
1346 }
1347
1348 fn manifest() -> (CollectionManifestV1, InputIdentity) {
1349 manifest_with_set(
1350 CollectionRuntimeSetKindV1::GaitGroup,
1351 &["reference", "member"],
1352 )
1353 }
1354
1355 fn manifest_with_set(
1356 kind: CollectionRuntimeSetKindV1,
1357 set_member_names: &[&str],
1358 ) -> (CollectionManifestV1, InputIdentity) {
1359 manifest_for_members(kind, &["reference", "member"], set_member_names)
1360 }
1361
1362 fn manifest_for_members(
1363 kind: CollectionRuntimeSetKindV1,
1364 clip_names: &[&str],
1365 set_member_names: &[&str],
1366 ) -> (CollectionManifestV1, InputIdentity) {
1367 let collection = CollectionIdV1::new("com.example").unwrap();
1368 let source = CollectionSourceKeyV1::new("motions").unwrap();
1369 let clips = clip_names
1370 .iter()
1371 .enumerate()
1372 .map(|(index, name)| {
1373 CollectionClipV1::new(
1374 id(&format!("com.example/{name}")),
1375 source.clone(),
1376 index as u32,
1377 format!("Take {index}"),
1378 )
1379 .unwrap()
1380 })
1381 .collect::<Vec<_>>();
1382 let set_members = set_member_names
1383 .iter()
1384 .map(|name| id(&format!("com.example/{name}")))
1385 .collect();
1386 let manifest = CollectionManifestV1::new(
1387 collection,
1388 None,
1389 vec![CollectionSourceV1::new(
1390 source,
1391 path("motions.glb"),
1392 None,
1393 None,
1394 )],
1395 clips,
1396 vec![CollectionRuntimeSetV1::new(
1397 id("com.example/sets/walk"),
1398 kind,
1399 set_members,
1400 )],
1401 )
1402 .unwrap();
1403 (manifest, InputIdentity::from_bytes(b"manifest"))
1404 }
1405
1406 fn declaration(
1407 manifest_input: &InputIdentity,
1408 minimum_slope: f64,
1409 maximum_slope: f64,
1410 ) -> FootCycleParameterizationV1 {
1411 declaration_with_proof(manifest_input, minimum_slope, maximum_slope, proof_policy())
1412 }
1413
1414 fn declaration_with_proof(
1415 manifest_input: &InputIdentity,
1416 minimum_slope: f64,
1417 maximum_slope: f64,
1418 proof: FootCycleProofPolicyV1,
1419 ) -> FootCycleParameterizationV1 {
1420 FootCycleParameterizationV1::new(
1421 FootCycleManifestBindingV1::new(
1422 CollectionIdV1::new("com.example").unwrap(),
1423 manifest_input.clone(),
1424 )
1425 .unwrap(),
1426 id("com.example/sets/walk"),
1427 id("com.example/reference"),
1428 path("generated/aligned"),
1429 minimum_slope,
1430 maximum_slope,
1431 proof,
1432 vec![
1433 FootCycleParameterizationMemberV1::new(
1434 id("com.example/reference"),
1435 path("contacts/reference.json"),
1436 ),
1437 FootCycleParameterizationMemberV1::new(
1438 id("com.example/member"),
1439 path("contacts/member.json"),
1440 ),
1441 ],
1442 )
1443 .unwrap()
1444 }
1445
1446 fn proof_policy() -> FootCycleProofPolicyV1 {
1447 FootCycleProofPolicyV1::new(0.08, 0.05, 0.01).unwrap()
1448 }
1449
1450 fn detector_extension(left: &str, right: &str) -> ContactExtensionV1 {
1451 detector_extension_with_height(left, right, 0.03)
1452 }
1453
1454 fn detector_extension_with_height(
1455 left: &str,
1456 right: &str,
1457 contact_height_m: f64,
1458 ) -> ContactExtensionV1 {
1459 detector_extension_with_payload(json!({
1460 "algorithm": "stance-support-v1",
1461 "sampling": "metric-grid-longest-authored-channel",
1462 "max_frames": 1_000_000,
1463 "contact_height_m": contact_height_m,
1464 "roles": {"left": left, "right": right},
1465 }))
1466 }
1467
1468 fn detector_extension_with_payload(payload: serde_json::Value) -> ContactExtensionV1 {
1469 ContactExtensionV1::new(CONTACT_SUPPORT_DETECTOR_V1_ID, 1, payload).unwrap()
1470 }
1471
1472 fn fragment(member: &str, take_index: u32, windows: &[(Side, f64, f64)]) -> ContactFragmentV1 {
1473 fragment_with_extensions(
1474 member,
1475 take_index,
1476 windows,
1477 vec![detector_extension("left_foot", "right_foot")],
1478 )
1479 }
1480
1481 fn fragment_with_extensions(
1482 member: &str,
1483 take_index: u32,
1484 windows: &[(Side, f64, f64)],
1485 extensions: Vec<ContactExtensionV1>,
1486 ) -> ContactFragmentV1 {
1487 fragment_with_clip(
1488 member,
1489 "motions",
1490 take_index,
1491 &format!("Take {take_index}"),
1492 1.0,
1493 windows,
1494 None,
1495 None,
1496 extensions,
1497 )
1498 }
1499
1500 #[allow(clippy::too_many_arguments)]
1501 fn fragment_with_clip(
1502 member: &str,
1503 source: &str,
1504 take_index: u32,
1505 take_name: &str,
1506 duration_s: f64,
1507 windows: &[(Side, f64, f64)],
1508 markers: Option<&[(Side, f64)]>,
1509 event_roles: Option<[ContactRoleV1; 2]>,
1510 extensions: Vec<ContactExtensionV1>,
1511 ) -> ContactFragmentV1 {
1512 let mut events = Vec::with_capacity(windows.len() * 2);
1513 for (index, &(side, start, end)) in windows.iter().enumerate() {
1514 let (role, label) = match side {
1515 Side::Left => (
1516 event_roles.map_or(ContactRoleV1::LeftFoot, |roles| roles[0]),
1517 "left",
1518 ),
1519 Side::Right => (
1520 event_roles.map_or(ContactRoleV1::RightFoot, |roles| roles[1]),
1521 "right",
1522 ),
1523 };
1524 events.push(
1525 ContactEventV1::window(
1526 format!("support/{label}/{index}"),
1527 role,
1528 ContactPhaseV1::Begin,
1529 ContactEventWindowV1::new(start, end).unwrap(),
1530 None,
1531 )
1532 .unwrap(),
1533 );
1534 }
1535 let default_markers = windows
1536 .iter()
1537 .map(|&(side, start, end)| (side, (start + end) / 2.0))
1538 .collect::<Vec<_>>();
1539 for (index, &(side, time)) in markers.unwrap_or(&default_markers).iter().enumerate() {
1540 let (role, label) = match side {
1541 Side::Left => (
1542 event_roles.map_or(ContactRoleV1::LeftFoot, |roles| roles[0]),
1543 "left",
1544 ),
1545 Side::Right => (
1546 event_roles.map_or(ContactRoleV1::RightFoot, |roles| roles[1]),
1547 "right",
1548 ),
1549 };
1550 events.push(
1551 ContactEventV1::point(
1552 format!("marker/{label}/{index}"),
1553 role,
1554 ContactPhaseV1::Marker,
1555 time,
1556 None,
1557 )
1558 .unwrap(),
1559 );
1560 }
1561 fragment_with_events(
1562 member, source, take_index, take_name, duration_s, events, extensions,
1563 )
1564 }
1565
1566 #[allow(clippy::too_many_arguments)]
1567 fn fragment_with_events(
1568 member: &str,
1569 source: &str,
1570 take_index: u32,
1571 take_name: &str,
1572 duration_s: f64,
1573 events: Vec<ContactEventV1>,
1574 extensions: Vec<ContactExtensionV1>,
1575 ) -> ContactFragmentV1 {
1576 let artifact = InputIdentity::from_bytes(member.as_bytes());
1577 let closure =
1578 DependencyClosureBuilderV1::new(artifact.clone(), SourceSetCoverageV1::complete(), 0)
1579 .finish()
1580 .unwrap();
1581 ContactFragmentV1::new(
1582 ContactProducerV1::new("animsmith", "0.10.0").unwrap(),
1583 artifact,
1584 closure.identity().unwrap().clone(),
1585 ContactClipReferenceV1::collection(
1586 format!("com.example/{member}"),
1587 source,
1588 take_index,
1589 take_name,
1590 )
1591 .unwrap(),
1592 duration_s,
1593 events,
1594 extensions,
1595 )
1596 .unwrap()
1597 }
1598
1599 fn root_binding(fragment: &ContactFragmentV1) -> FootCycleRootMotionBindingV1 {
1600 FootCycleRootMotionBindingV1::new(
1601 fragment.artifact().clone(),
1602 fragment.dependency_closure_identity().clone(),
1603 fragment.clip().clone(),
1604 )
1605 }
1606
1607 fn measured_root(
1608 fragment: &ContactFragmentV1,
1609 horizontal_endpoint_displacement_m: f64,
1610 accumulated_yaw_deg: f64,
1611 ) -> FootCycleRootMotionEvidenceV1 {
1612 FootCycleRootMotionEvidenceV1::measured(
1613 root_binding(fragment),
1614 horizontal_endpoint_displacement_m,
1615 0.0,
1616 accumulated_yaw_deg,
1617 )
1618 }
1619
1620 fn evidence(
1621 reference_windows: &[(Side, f64, f64)],
1622 member_windows: &[(Side, f64, f64)],
1623 ) -> Vec<FootCycleMemberEvidenceV1> {
1624 [
1625 ("reference", 0, "contacts/reference.json", reference_windows),
1626 ("member", 1, "contacts/member.json", member_windows),
1627 ]
1628 .into_iter()
1629 .map(|(member, take, source, windows)| {
1630 let fragment = fragment(member, take, windows);
1631 let root_motion = measured_root(&fragment, 0.0, 0.0);
1632 FootCycleMemberEvidenceV1::new(
1633 id(&format!("com.example/{member}")),
1634 path(source),
1635 fragment.canonical_identity().unwrap(),
1636 fragment,
1637 root_motion,
1638 )
1639 })
1640 .collect()
1641 }
1642
1643 fn points(plan: &FootCycleMemberPlanV1) -> Vec<(f64, f64)> {
1644 plan.operation()
1645 .control_points()
1646 .unwrap()
1647 .iter()
1648 .map(|point| (point.input_time(), point.output_time()))
1649 .collect()
1650 }
1651
1652 fn alternating_windows(count: usize) -> Vec<(Side, f64, f64)> {
1653 let denominator = (count * 3 + 1) as f64;
1654 (0..count)
1655 .map(|index| {
1656 (
1657 if index % 2 == 0 {
1658 Side::Left
1659 } else {
1660 Side::Right
1661 },
1662 (index * 3 + 1) as f64 / denominator,
1663 (index * 3 + 2) as f64 / denominator,
1664 )
1665 })
1666 .collect()
1667 }
1668
1669 #[test]
1670 fn planner_maps_exact_boundaries_to_reference_phases_and_is_deterministic() {
1671 let (manifest, manifest_input) = manifest();
1672 let declaration = declaration(&manifest_input, 0.5, 2.0);
1673 let mut evidence = evidence(
1674 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
1675 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
1676 );
1677 let reference = fragment_with_clip(
1678 "reference",
1679 "motions",
1680 0,
1681 "Take 0",
1682 3.5,
1683 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
1684 None,
1685 None,
1686 vec![detector_extension("left_foot", "right_foot")],
1687 );
1688 evidence[0].input = reference.canonical_identity().unwrap();
1689 evidence[0].fragment = reference;
1690 let member = fragment_with_clip(
1691 "member",
1692 "motions",
1693 1,
1694 "Take 1",
1695 2.5,
1696 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
1697 None,
1698 None,
1699 vec![detector_extension("left_foot", "right_foot")],
1700 );
1701 evidence[1].input = member.canonical_identity().unwrap();
1702 evidence[1].fragment = member;
1703 let parameterization_input = InputIdentity::from_bytes(b"parameterization");
1704 let first = plan_foot_cycle_parameterization_v1(
1705 &declaration,
1706 parameterization_input.clone(),
1707 &manifest,
1708 manifest_input.clone(),
1709 &evidence,
1710 )
1711 .unwrap();
1712 let second = plan_foot_cycle_parameterization_v1(
1713 &declaration,
1714 parameterization_input.clone(),
1715 &manifest,
1716 manifest_input.clone(),
1717 &evidence,
1718 )
1719 .unwrap();
1720 assert_eq!(first, second);
1721 assert_eq!(first.parameterization_input(), ¶meterization_input);
1722 assert_eq!(first.manifest_input(), &manifest_input);
1723 assert_eq!(first.runtime_set_id(), declaration.runtime_set_id());
1724 assert_eq!(first.reference_member(), declaration.reference_member());
1725 assert_eq!(first.members().len(), 2);
1726 assert_eq!(
1727 points(&first.members()[0]),
1728 [
1729 (0.0, 0.0),
1730 (0.1, 0.1),
1731 (0.2, 0.2),
1732 (0.6, 0.6),
1733 (0.7, 0.7),
1734 (1.0, 1.0)
1735 ]
1736 );
1737 assert_eq!(
1738 points(&first.members()[1]),
1739 [
1740 (0.0, 0.0),
1741 (0.2, 0.1),
1742 (0.3, 0.2),
1743 (0.7, 0.6),
1744 (0.8, 0.7),
1745 (1.0, 1.0)
1746 ]
1747 );
1748 assert_eq!(
1749 first.members()[0].operation().output_duration_s(),
1750 Some(3.5)
1751 );
1752 assert_eq!(
1753 first.members()[1].operation().output_duration_s(),
1754 Some(2.5)
1755 );
1756 for (plan, source) in first.members().iter().zip(&evidence) {
1757 assert_eq!(plan.id(), source.id());
1758 assert_eq!(plan.input().artifact(), source.fragment().artifact());
1759 assert_eq!(
1760 plan.input().dependency_closure_identity(),
1761 source.fragment().dependency_closure_identity()
1762 );
1763 assert_eq!(plan.input().fragment(), source.input());
1764 assert_eq!(plan.root_motion(), source.root_motion());
1765 assert_eq!(
1766 plan.operation().output_duration_s(),
1767 Some(source.fragment().duration_s())
1768 );
1769 }
1770 }
1771
1772 #[test]
1773 fn planner_positionally_maps_every_boundary_in_a_larger_ring() {
1774 let reference = [
1775 (Side::Left, 0.05, 0.1),
1776 (Side::Right, 0.25, 0.3),
1777 (Side::Left, 0.5, 0.55),
1778 (Side::Right, 0.75, 0.8),
1779 ];
1780 let member = [
1781 (Side::Left, 0.08, 0.14),
1782 (Side::Right, 0.3, 0.36),
1783 (Side::Left, 0.56, 0.62),
1784 (Side::Right, 0.82, 0.88),
1785 ];
1786 let (manifest, manifest_input) = manifest();
1787 let plan = plan_foot_cycle_parameterization_v1(
1788 &declaration(&manifest_input, 0.1, 10.0),
1789 InputIdentity::from_bytes(b"parameterization"),
1790 &manifest,
1791 manifest_input,
1792 &evidence(&reference, &member),
1793 )
1794 .unwrap();
1795 assert_eq!(
1796 points(&plan.members()[1]),
1797 [
1798 (0.0, 0.0),
1799 (0.08, 0.05),
1800 (0.14, 0.1),
1801 (0.3, 0.25),
1802 (0.36, 0.3),
1803 (0.56, 0.5),
1804 (0.62, 0.55),
1805 (0.82, 0.75),
1806 (0.88, 0.8),
1807 (1.0, 1.0),
1808 ]
1809 );
1810 }
1811
1812 #[test]
1813 fn root_motion_gate_is_inclusive_and_refuses_unavailable_or_excess_evidence() {
1814 assert_eq!(
1815 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M,
1816 0.01
1817 );
1818 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG, 1.0);
1819 let (manifest, manifest_input) = manifest();
1820 let declaration = declaration(&manifest_input, 0.5, 2.0);
1821 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
1822 let parameterization_input = || InputIdentity::from_bytes(b"parameterization");
1823
1824 for member_index in 0..2 {
1825 let mut accepted = evidence(&windows, &windows);
1826 let binding = accepted[member_index].root_motion.binding().clone();
1827 accepted[member_index].root_motion = FootCycleRootMotionEvidenceV1::measured(
1828 binding,
1829 0.006,
1830 -0.008,
1831 -FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG,
1832 );
1833 let plan = plan_foot_cycle_parameterization_v1(
1834 &declaration,
1835 parameterization_input(),
1836 &manifest,
1837 manifest_input.clone(),
1838 &accepted,
1839 )
1840 .unwrap();
1841 assert!(matches!(
1842 plan.members()[member_index].root_motion(),
1843 FootCycleRootMotionEvidenceV1::Measured {
1844 endpoint_displacement_x_m: 0.006,
1845 endpoint_displacement_z_m: -0.008,
1846 accumulated_yaw_deg: -1.0,
1847 ..
1848 }
1849 ));
1850 }
1851
1852 for member_index in 0..2 {
1853 for unavailable_index in 0..9 {
1854 let mut source = evidence(&windows, &windows);
1855 let binding = source[member_index].root_motion.binding().clone();
1856 let unavailable = match unavailable_index {
1857 0 => FootCycleRootMotionEvidenceV1::missing(binding),
1858 1 => FootCycleRootMotionEvidenceV1::ambiguous(binding),
1859 2 => FootCycleRootMotionEvidenceV1::non_finite(binding),
1860 3 => FootCycleRootMotionEvidenceV1::measured(binding, f64::NAN, 0.0, 0.0),
1861 4 => FootCycleRootMotionEvidenceV1::measured(binding, 0.0, f64::NAN, 0.0),
1862 5 => FootCycleRootMotionEvidenceV1::measured(binding, 0.0, 0.0, f64::NAN),
1863 6 => FootCycleRootMotionEvidenceV1::measured(binding, f64::INFINITY, 0.0, 0.0),
1864 7 => FootCycleRootMotionEvidenceV1::measured(
1865 binding,
1866 0.0,
1867 f64::NEG_INFINITY,
1868 0.0,
1869 ),
1870 8 => FootCycleRootMotionEvidenceV1::measured(
1871 binding,
1872 0.0,
1873 0.0,
1874 f64::NEG_INFINITY,
1875 ),
1876 _ => unreachable!(),
1877 };
1878 source[member_index].root_motion = unavailable;
1879 assert!(matches!(
1880 plan_foot_cycle_parameterization_v1(
1881 &declaration,
1882 parameterization_input(),
1883 &manifest,
1884 manifest_input.clone(),
1885 &source,
1886 ),
1887 Err(FootCycleParameterizationError::RootMotionEvidenceUnavailable { .. })
1888 ));
1889 }
1890 }
1891
1892 for (endpoint_x, endpoint_z, yaw) in [
1893 (
1894 f64::from_bits(
1895 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M.to_bits() + 1,
1896 ),
1897 0.0,
1898 0.0,
1899 ),
1900 (
1901 0.0,
1902 f64::from_bits(
1903 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_HORIZONTAL_DISPLACEMENT_M.to_bits() + 1,
1904 ),
1905 0.0,
1906 ),
1907 (
1908 0.0,
1909 0.0,
1910 f64::from_bits(
1911 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG.to_bits() + 1,
1912 ),
1913 ),
1914 (
1915 0.0,
1916 0.0,
1917 -f64::from_bits(
1918 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_ACCUMULATED_YAW_DEG.to_bits() + 1,
1919 ),
1920 ),
1921 ] {
1922 for member_index in 0..2 {
1923 let mut source = evidence(&windows, &windows);
1924 let binding = source[member_index].root_motion.binding().clone();
1925 source[member_index].root_motion =
1926 FootCycleRootMotionEvidenceV1::measured(binding, endpoint_x, endpoint_z, yaw);
1927 assert!(matches!(
1928 plan_foot_cycle_parameterization_v1(
1929 &declaration,
1930 parameterization_input(),
1931 &manifest,
1932 manifest_input.clone(),
1933 &source,
1934 ),
1935 Err(FootCycleParameterizationError::RootMotionOutOfRange { .. })
1936 ));
1937 }
1938 }
1939 }
1940
1941 #[test]
1942 fn root_motion_artifact_closure_and_clip_witnesses_cannot_be_cross_wired() {
1943 let (manifest, manifest_input) = manifest();
1944 let declaration = declaration(&manifest_input, 0.5, 2.0);
1945 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
1946 for member_index in 0..2 {
1947 let other_index = 1 - member_index;
1948 for coordinate in 0..3 {
1949 let mut source = evidence(&windows, &windows);
1950 let other = source[other_index].root_motion.binding().clone();
1951 let FootCycleRootMotionEvidenceV1::Measured { binding, .. } =
1952 &mut source[member_index].root_motion
1953 else {
1954 unreachable!();
1955 };
1956 match coordinate {
1957 0 => binding.artifact = other.artifact,
1958 1 => binding.dependency_closure_identity = other.dependency_closure_identity,
1959 2 => binding.clip = other.clip,
1960 _ => unreachable!(),
1961 }
1962 assert!(matches!(
1963 plan_foot_cycle_parameterization_v1(
1964 &declaration,
1965 InputIdentity::from_bytes(b"parameterization"),
1966 &manifest,
1967 manifest_input.clone(),
1968 &source,
1969 ),
1970 Err(FootCycleParameterizationError::RootMotionBindingMismatch { .. })
1971 ));
1972 }
1973 }
1974 }
1975
1976 #[test]
1977 fn cyclic_topology_is_compared_from_each_first_left_onset() {
1978 let (manifest, manifest_input) = manifest();
1979 let declaration = declaration(&manifest_input, 0.5, 2.0);
1980 let evidence = evidence(
1981 &[(Side::Right, 0.1, 0.2), (Side::Left, 0.6, 0.7)],
1982 &[(Side::Right, 0.15, 0.25), (Side::Left, 0.65, 0.75)],
1983 );
1984 let plan = plan_foot_cycle_parameterization_v1(
1985 &declaration,
1986 InputIdentity::from_bytes(b"parameterization"),
1987 &manifest,
1988 manifest_input.clone(),
1989 &evidence,
1990 )
1991 .unwrap();
1992 assert_eq!(
1993 points(&plan.members()[0]),
1994 [
1995 (0.0, 0.0),
1996 (0.1, 0.1),
1997 (0.2, 0.2),
1998 (0.6, 0.6),
1999 (0.7, 0.7),
2000 (1.0, 1.0),
2001 ]
2002 );
2003 assert_eq!(
2004 points(&plan.members()[1]),
2005 [
2006 (0.0, 0.0),
2007 (0.15, 0.1),
2008 (0.25, 0.2),
2009 (0.65, 0.6),
2010 (0.75, 0.7),
2011 (1.0, 1.0),
2012 ]
2013 );
2014 }
2015
2016 #[test]
2017 fn nonfirst_reference_member_owns_exact_phases_without_reordering_plans() {
2018 let (manifest, manifest_input) = manifest();
2019 let declaration = FootCycleParameterizationV1::new(
2020 FootCycleManifestBindingV1::new(
2021 CollectionIdV1::new("com.example").unwrap(),
2022 manifest_input.clone(),
2023 )
2024 .unwrap(),
2025 id("com.example/sets/walk"),
2026 id("com.example/member"),
2027 path("generated/aligned"),
2028 0.5,
2029 2.0,
2030 proof_policy(),
2031 vec![
2032 FootCycleParameterizationMemberV1::new(
2033 id("com.example/reference"),
2034 path("contacts/reference.json"),
2035 ),
2036 FootCycleParameterizationMemberV1::new(
2037 id("com.example/member"),
2038 path("contacts/member.json"),
2039 ),
2040 ],
2041 )
2042 .unwrap();
2043 let plan = plan_foot_cycle_parameterization_v1(
2044 &declaration,
2045 InputIdentity::from_bytes(b"parameterization"),
2046 &manifest,
2047 manifest_input,
2048 &evidence(
2049 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2050 &[(Side::Left, 0.2, 0.3), (Side::Right, 0.7, 0.8)],
2051 ),
2052 )
2053 .unwrap();
2054 assert_eq!(plan.reference_member(), &id("com.example/member"));
2055 assert_eq!(plan.members()[0].id(), &id("com.example/reference"));
2056 assert_eq!(plan.members()[1].id(), &id("com.example/member"));
2057 assert_eq!(
2058 points(&plan.members()[0]),
2059 [
2060 (0.0, 0.0),
2061 (0.1, 0.2),
2062 (0.2, 0.3),
2063 (0.6, 0.7),
2064 (0.7, 0.8),
2065 (1.0, 1.0),
2066 ]
2067 );
2068 assert_eq!(
2069 points(&plan.members()[1]),
2070 [
2071 (0.0, 0.0),
2072 (0.2, 0.2),
2073 (0.3, 0.3),
2074 (0.7, 0.7),
2075 (0.8, 0.8),
2076 (1.0, 1.0),
2077 ]
2078 );
2079 }
2080
2081 #[test]
2082 fn planner_refuses_missing_and_extra_evidence_rows() {
2083 let (manifest, manifest_input) = manifest();
2084 let declaration = declaration(&manifest_input, 0.5, 2.0);
2085 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2086 let source = evidence(&windows, &windows);
2087 for invalid in [source[..1].to_vec(), {
2088 let mut extra = source.clone();
2089 extra.push(source[0].clone());
2090 extra
2091 }] {
2092 assert_eq!(
2093 plan_foot_cycle_parameterization_v1(
2094 &declaration,
2095 InputIdentity::from_bytes(b"parameterization"),
2096 &manifest,
2097 manifest_input.clone(),
2098 &invalid,
2099 ),
2100 Err(FootCycleParameterizationError::EvidenceCountMismatch)
2101 );
2102 }
2103 }
2104
2105 #[test]
2106 fn phase_rotation_that_cannot_preserve_endpoints_refuses() {
2107 let (first_manifest, first_manifest_input) = manifest();
2108 let first_declaration = declaration(&first_manifest_input, 0.01, 100.0);
2109 let first_evidence = evidence(
2110 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2111 &[(Side::Right, 0.1, 0.2), (Side::Left, 0.6, 0.7)],
2112 );
2113 assert_eq!(
2114 plan_foot_cycle_parameterization_v1(
2115 &first_declaration,
2116 InputIdentity::from_bytes(b"parameterization"),
2117 &first_manifest,
2118 first_manifest_input,
2119 &first_evidence,
2120 ),
2121 Err(FootCycleParameterizationError::NonMonotoneMapping)
2122 );
2123
2124 let (manifest, manifest_input) = manifest();
2125 let declaration = declaration(&manifest_input, 0.01, 100.0);
2126 let reference = [
2127 (Side::Left, 0.05, 0.1),
2128 (Side::Right, 0.25, 0.3),
2129 (Side::Left, 0.5, 0.55),
2130 (Side::Right, 0.75, 0.8),
2131 ];
2132 let rotated = [
2133 (Side::Right, 0.05, 0.1),
2134 (Side::Left, 0.25, 0.3),
2135 (Side::Right, 0.5, 0.55),
2136 (Side::Left, 0.75, 0.8),
2137 ];
2138 assert_eq!(
2139 plan_foot_cycle_parameterization_v1(
2140 &declaration,
2141 InputIdentity::from_bytes(b"parameterization"),
2142 &manifest,
2143 manifest_input,
2144 &evidence(&reference, &rotated),
2145 ),
2146 Err(FootCycleParameterizationError::NonMonotoneMapping)
2147 );
2148 }
2149
2150 #[test]
2151 fn overlapping_simultaneous_repeated_and_missing_runs_refuse() {
2152 let reference = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2153 for invalid in [
2154 vec![(Side::Left, 0.1, 0.4), (Side::Right, 0.3, 0.6)],
2155 vec![(Side::Left, 0.1, 0.3), (Side::Right, 0.3, 0.6)],
2156 vec![
2157 (Side::Left, 0.1, 0.2),
2158 (Side::Left, 0.3, 0.4),
2159 (Side::Right, 0.6, 0.7),
2160 (Side::Right, 0.8, 0.9),
2161 ],
2162 vec![(Side::Left, 0.0, 0.2), (Side::Right, 0.7, 1.0)],
2163 vec![(Side::Left, 0.1, 0.2)],
2164 ] {
2165 for invalid_index in 0..2 {
2166 let (manifest, manifest_input) = manifest();
2167 let declaration = declaration(&manifest_input, 0.01, 100.0);
2168 let source = if invalid_index == 0 {
2169 evidence(&invalid, &reference)
2170 } else {
2171 evidence(&reference, &invalid)
2172 };
2173 assert_eq!(
2174 plan_foot_cycle_parameterization_v1(
2175 &declaration,
2176 InputIdentity::from_bytes(b"parameterization"),
2177 &manifest,
2178 manifest_input,
2179 &source,
2180 ),
2181 Err(FootCycleParameterizationError::InvalidContactTopology)
2182 );
2183 }
2184 }
2185 }
2186
2187 #[test]
2188 fn missing_extra_and_misplaced_markers_refuse() {
2189 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2190 let marker_cases = [
2191 Vec::new(),
2192 vec![(Side::Left, 0.15), (Side::Right, 0.65), (Side::Left, 0.18)],
2193 vec![(Side::Left, 0.65), (Side::Right, 0.15)],
2194 ];
2195 for markers in marker_cases {
2196 for invalid_index in 0..2 {
2197 let (manifest, manifest_input) = manifest();
2198 let declaration = declaration(&manifest_input, 0.01, 100.0);
2199 let (member_id, take_index, take_name) = if invalid_index == 0 {
2200 ("reference", 0, "Take 0")
2201 } else {
2202 ("member", 1, "Take 1")
2203 };
2204 let invalid = fragment_with_clip(
2205 member_id,
2206 "motions",
2207 take_index,
2208 take_name,
2209 1.0,
2210 &windows,
2211 Some(&markers),
2212 None,
2213 vec![detector_extension("left_foot", "right_foot")],
2214 );
2215 let mut source = evidence(&windows, &windows);
2216 source[invalid_index].input = invalid.canonical_identity().unwrap();
2217 source[invalid_index].fragment = invalid;
2218 assert_eq!(
2219 plan_foot_cycle_parameterization_v1(
2220 &declaration,
2221 InputIdentity::from_bytes(b"parameterization"),
2222 &manifest,
2223 manifest_input,
2224 &source,
2225 ),
2226 Err(FootCycleParameterizationError::InvalidContactTopology)
2227 );
2228 }
2229 }
2230
2231 for marker_as_window in [false, true] {
2232 for invalid_index in 0..2 {
2233 let (member_id, take_index, take_name) = if invalid_index == 0 {
2234 ("reference", 0, "Take 0")
2235 } else {
2236 ("member", 1, "Take 1")
2237 };
2238 let invalid_event = if marker_as_window {
2239 ContactEventV1::window(
2240 "invalid/marker-window",
2241 ContactRoleV1::LeftFoot,
2242 ContactPhaseV1::Marker,
2243 ContactEventWindowV1::new(0.1, 0.2).unwrap(),
2244 None,
2245 )
2246 .unwrap()
2247 } else {
2248 ContactEventV1::point(
2249 "invalid/begin-point",
2250 ContactRoleV1::LeftFoot,
2251 ContactPhaseV1::Begin,
2252 0.15,
2253 None,
2254 )
2255 .unwrap()
2256 };
2257 let invalid = fragment_with_events(
2258 member_id,
2259 "motions",
2260 take_index,
2261 take_name,
2262 1.0,
2263 vec![
2264 ContactEventV1::window(
2265 "support/left/0",
2266 ContactRoleV1::LeftFoot,
2267 ContactPhaseV1::Begin,
2268 ContactEventWindowV1::new(0.1, 0.2).unwrap(),
2269 None,
2270 )
2271 .unwrap(),
2272 ContactEventV1::window(
2273 "support/right/1",
2274 ContactRoleV1::RightFoot,
2275 ContactPhaseV1::Begin,
2276 ContactEventWindowV1::new(0.6, 0.7).unwrap(),
2277 None,
2278 )
2279 .unwrap(),
2280 invalid_event,
2281 ContactEventV1::point(
2282 "marker/right/1",
2283 ContactRoleV1::RightFoot,
2284 ContactPhaseV1::Marker,
2285 0.65,
2286 None,
2287 )
2288 .unwrap(),
2289 ],
2290 vec![detector_extension("left_foot", "right_foot")],
2291 );
2292 let mut source = evidence(&windows, &windows);
2293 source[invalid_index].input = invalid.canonical_identity().unwrap();
2294 source[invalid_index].fragment = invalid;
2295 let (manifest, manifest_input) = manifest();
2296 assert_eq!(
2297 plan_foot_cycle_parameterization_v1(
2298 &declaration(&manifest_input, 0.01, 100.0),
2299 InputIdentity::from_bytes(b"parameterization"),
2300 &manifest,
2301 manifest_input,
2302 &source,
2303 ),
2304 Err(FootCycleParameterizationError::InvalidContactTopology)
2305 );
2306 }
2307 }
2308 }
2309
2310 #[test]
2311 fn topology_count_mismatch_refuses_without_pairing_heuristics() {
2312 let (manifest, manifest_input) = manifest();
2313 let declaration = declaration(&manifest_input, 0.01, 100.0);
2314 let reference = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2315 let member = [
2316 (Side::Left, 0.05, 0.1),
2317 (Side::Right, 0.2, 0.3),
2318 (Side::Left, 0.5, 0.6),
2319 (Side::Right, 0.8, 0.9),
2320 ];
2321 assert_eq!(
2322 plan_foot_cycle_parameterization_v1(
2323 &declaration,
2324 InputIdentity::from_bytes(b"parameterization"),
2325 &manifest,
2326 manifest_input,
2327 &evidence(&reference, &member),
2328 ),
2329 Err(FootCycleParameterizationError::TopologyMismatch)
2330 );
2331 }
2332
2333 #[test]
2334 fn inclusive_slope_bounds_accept_exact_and_reject_successor() {
2335 let reference = [(Side::Left, 0.125, 0.25), (Side::Right, 0.5, 0.625)];
2336 let member = [(Side::Left, 0.25, 0.375), (Side::Right, 0.625, 0.75)];
2337 let (manifest, manifest_input) = manifest();
2338 let accepted = declaration(&manifest_input, 0.5, 1.5);
2339 assert!(
2340 plan_foot_cycle_parameterization_v1(
2341 &accepted,
2342 InputIdentity::from_bytes(b"parameterization"),
2343 &manifest,
2344 manifest_input.clone(),
2345 &evidence(&reference, &member),
2346 )
2347 .is_ok()
2348 );
2349 let refused = declaration(&manifest_input, f64::from_bits(0.5_f64.to_bits() + 1), 1.5);
2350 assert_eq!(
2351 plan_foot_cycle_parameterization_v1(
2352 &refused,
2353 InputIdentity::from_bytes(b"parameterization"),
2354 &manifest,
2355 manifest_input.clone(),
2356 &evidence(&reference, &member),
2357 ),
2358 Err(FootCycleParameterizationError::SegmentSlopeOutOfRange)
2359 );
2360 let refused = declaration(&manifest_input, 0.5, f64::from_bits(1.5_f64.to_bits() - 1));
2361 assert_eq!(
2362 plan_foot_cycle_parameterization_v1(
2363 &refused,
2364 InputIdentity::from_bytes(b"parameterization"),
2365 &manifest,
2366 manifest_input,
2367 &evidence(&reference, &member),
2368 ),
2369 Err(FootCycleParameterizationError::SegmentSlopeOutOfRange)
2370 );
2371 }
2372
2373 #[test]
2374 fn exact_manifest_member_path_and_canonical_fragment_bindings_are_required() {
2375 let (manifest, manifest_input) = manifest();
2376 let declaration = declaration(&manifest_input, 0.5, 2.0);
2377 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2378 let source = evidence(&windows, &windows);
2379 let mut cases = Vec::new();
2380
2381 let mut stale = source.clone();
2382 stale[1].input = InputIdentity::from_bytes(b"noncanonical");
2383 cases.push((stale, FootCycleParameterizationError::NonCanonicalFragment));
2384
2385 let mut stale_reference = source.clone();
2386 stale_reference[0].input = InputIdentity::from_bytes(b"noncanonical-reference");
2387 cases.push((
2388 stale_reference,
2389 FootCycleParameterizationError::NonCanonicalFragment,
2390 ));
2391
2392 for member_index in 0..2 {
2393 let mut wrong_digest = source.clone();
2394 wrong_digest[member_index].input = InputIdentity::from_sha256_digest(
2395 [0; 32],
2396 wrong_digest[member_index].input.bytes(),
2397 );
2398 cases.push((
2399 wrong_digest,
2400 FootCycleParameterizationError::NonCanonicalFragment,
2401 ));
2402
2403 let mut wrong_id = source.clone();
2404 wrong_id[member_index].id = id("com.example/wrong");
2405 cases.push((
2406 wrong_id,
2407 FootCycleParameterizationError::EvidenceMemberMismatch,
2408 ));
2409
2410 let mut wrong_path = source.clone();
2411 wrong_path[member_index].contact_fragment_path = path("contacts/other.json");
2412 cases.push((
2413 wrong_path,
2414 FootCycleParameterizationError::EvidenceMemberMismatch,
2415 ));
2416 }
2417
2418 let mut wrong_clip = source.clone();
2419 wrong_clip[1].fragment = fragment("reference", 0, &windows);
2420 wrong_clip[1].input = wrong_clip[1].fragment.canonical_identity().unwrap();
2421 cases.push((
2422 wrong_clip,
2423 FootCycleParameterizationError::FragmentClipMismatch,
2424 ));
2425
2426 let mut wrong_reference = source.clone();
2427 wrong_reference[0].fragment = fragment("member", 1, &windows);
2428 wrong_reference[0].input = wrong_reference[0].fragment.canonical_identity().unwrap();
2429 cases.push((
2430 wrong_reference,
2431 FootCycleParameterizationError::FragmentClipMismatch,
2432 ));
2433
2434 let wrong_reference_source = fragment_with_clip(
2435 "reference",
2436 "other-source",
2437 0,
2438 "Take 0",
2439 1.0,
2440 &windows,
2441 None,
2442 None,
2443 vec![detector_extension("left_foot", "right_foot")],
2444 );
2445 let mut wrong_reference_witness = source.clone();
2446 wrong_reference_witness[0].input = wrong_reference_source.canonical_identity().unwrap();
2447 wrong_reference_witness[0].fragment = wrong_reference_source;
2448 cases.push((
2449 wrong_reference_witness,
2450 FootCycleParameterizationError::FragmentClipMismatch,
2451 ));
2452
2453 for fragment in [
2454 fragment_with_clip(
2455 "member",
2456 "other-source",
2457 1,
2458 "Take 1",
2459 1.0,
2460 &windows,
2461 None,
2462 None,
2463 vec![detector_extension("left_foot", "right_foot")],
2464 ),
2465 fragment_with_clip(
2466 "member",
2467 "motions",
2468 0,
2469 "Take 1",
2470 1.0,
2471 &windows,
2472 None,
2473 None,
2474 vec![detector_extension("left_foot", "right_foot")],
2475 ),
2476 fragment_with_clip(
2477 "member",
2478 "motions",
2479 1,
2480 "Other take",
2481 1.0,
2482 &windows,
2483 None,
2484 None,
2485 vec![detector_extension("left_foot", "right_foot")],
2486 ),
2487 ] {
2488 let mut wrong_witness = source.clone();
2489 wrong_witness[1].input = fragment.canonical_identity().unwrap();
2490 wrong_witness[1].fragment = fragment;
2491 cases.push((
2492 wrong_witness,
2493 FootCycleParameterizationError::FragmentClipMismatch,
2494 ));
2495 }
2496
2497 for (evidence, expected) in cases {
2498 assert_eq!(
2499 plan_foot_cycle_parameterization_v1(
2500 &declaration,
2501 InputIdentity::from_bytes(b"parameterization"),
2502 &manifest,
2503 manifest_input.clone(),
2504 &evidence,
2505 ),
2506 Err(expected)
2507 );
2508 }
2509 assert_eq!(
2510 plan_foot_cycle_parameterization_v1(
2511 &declaration,
2512 InputIdentity::from_bytes(b"parameterization"),
2513 &manifest,
2514 InputIdentity::from_bytes(b"stale-manifest"),
2515 &source,
2516 ),
2517 Err(FootCycleParameterizationError::ManifestMismatch)
2518 );
2519 assert_eq!(
2520 plan_foot_cycle_parameterization_v1(
2521 &declaration,
2522 InputIdentity::from_bytes(b"parameterization"),
2523 &manifest,
2524 InputIdentity::from_sha256_digest([0; 32], manifest_input.bytes()),
2525 &source,
2526 ),
2527 Err(FootCycleParameterizationError::ManifestMismatch)
2528 );
2529 assert_eq!(
2530 plan_foot_cycle_parameterization_v1(
2531 &declaration,
2532 InputIdentity::from_sha256_digest(
2533 [0; 32],
2534 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_BYTES + 1,
2535 ),
2536 &manifest,
2537 manifest_input,
2538 &source,
2539 ),
2540 Err(FootCycleParameterizationError::ParameterizationTooLarge)
2541 );
2542 }
2543
2544 #[test]
2545 fn manifest_collection_kind_and_exact_member_order_are_required() {
2546 let (manifest, manifest_input) = manifest();
2547 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2548 let source = evidence(&windows, &windows);
2549 let parameterization_input = || InputIdentity::from_bytes(b"parameterization");
2550
2551 let mut missing_set = declaration(&manifest_input, 0.5, 2.0);
2552 missing_set.runtime_set_id = id("com.example/sets/missing");
2553 assert_eq!(
2554 validate_foot_cycle_manifest_binding_v1(&missing_set, &manifest, &manifest_input,),
2555 Err(FootCycleParameterizationError::RuntimeSetMismatch)
2556 );
2557 assert_eq!(
2558 plan_foot_cycle_parameterization_v1(
2559 &missing_set,
2560 parameterization_input(),
2561 &manifest,
2562 manifest_input.clone(),
2563 &source,
2564 ),
2565 Err(FootCycleParameterizationError::RuntimeSetMismatch)
2566 );
2567
2568 let mut wrong_collection = declaration(&manifest_input, 0.5, 2.0);
2569 wrong_collection.manifest.collection_id = CollectionIdV1::new("com.other").unwrap();
2570 assert_eq!(
2571 plan_foot_cycle_parameterization_v1(
2572 &wrong_collection,
2573 parameterization_input(),
2574 &manifest,
2575 manifest_input.clone(),
2576 &source,
2577 ),
2578 Err(FootCycleParameterizationError::ManifestMismatch)
2579 );
2580
2581 let (wrong_kind, _) = manifest_with_set(
2582 CollectionRuntimeSetKindV1::SyncGroup,
2583 &["reference", "member"],
2584 );
2585 assert_eq!(
2586 plan_foot_cycle_parameterization_v1(
2587 &declaration(&manifest_input, 0.5, 2.0),
2588 parameterization_input(),
2589 &wrong_kind,
2590 manifest_input.clone(),
2591 &source,
2592 ),
2593 Err(FootCycleParameterizationError::WrongRuntimeSetKind)
2594 );
2595
2596 let (reordered, _) = manifest_with_set(
2597 CollectionRuntimeSetKindV1::GaitGroup,
2598 &["member", "reference"],
2599 );
2600 assert_eq!(
2601 plan_foot_cycle_parameterization_v1(
2602 &declaration(&manifest_input, 0.5, 2.0),
2603 parameterization_input(),
2604 &reordered,
2605 manifest_input,
2606 &source,
2607 ),
2608 Err(FootCycleParameterizationError::MemberOrderMismatch)
2609 );
2610 }
2611
2612 #[test]
2613 fn manifest_clip_lookup_accepts_late_maximum_long_prefix_member() {
2614 let prefix = "x".repeat(220);
2615 let names = (0..FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS)
2616 .map(|index| format!("{prefix}{index:04}"))
2617 .collect::<Vec<_>>();
2618 let name_refs = names.iter().map(String::as_str).collect::<Vec<_>>();
2619 let (manifest, _) = manifest_for_members(
2620 CollectionRuntimeSetKindV1::GaitGroup,
2621 &name_refs,
2622 &name_refs[..2],
2623 );
2624 let last_index = FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS - 1;
2625 let fragment = fragment(
2626 &names[last_index],
2627 last_index as u32,
2628 &[(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)],
2629 );
2630 assert_eq!(
2631 validate_clip_witness(
2632 &manifest,
2633 &id(&format!("com.example/{}", names[last_index])),
2634 &fragment,
2635 ),
2636 Ok(())
2637 );
2638 }
2639
2640 #[test]
2641 fn unsupported_or_malformed_detector_extension_refuses() {
2642 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2643 let (manifest, manifest_input) = manifest();
2644 let declaration = declaration(&manifest_input, 0.5, 2.0);
2645 for invalid_index in 0..2 {
2646 let (member, take_index) = if invalid_index == 0 {
2647 ("reference", 0)
2648 } else {
2649 ("member", 1)
2650 };
2651 let invalid = fragment_with_extensions(member, take_index, &windows, Vec::new());
2652 let mut source = evidence(&windows, &windows);
2653 source[invalid_index].input = invalid.canonical_identity().unwrap();
2654 source[invalid_index].fragment = invalid;
2655 assert_eq!(
2656 plan_foot_cycle_parameterization_v1(
2657 &declaration,
2658 InputIdentity::from_bytes(b"parameterization"),
2659 &manifest,
2660 manifest_input.clone(),
2661 &source,
2662 ),
2663 Err(FootCycleParameterizationError::UnsupportedContactExtension)
2664 );
2665 }
2666
2667 let payload = json!({
2668 "algorithm": "stance-support-v1",
2669 "sampling": "metric-grid-longest-authored-channel",
2670 "max_frames": 1_000_000,
2671 "contact_height_m": 0.03,
2672 "roles": {"left": "left_foot", "right": "right_foot"},
2673 });
2674 let detector = detector_extension("left_foot", "right_foot");
2675 for extensions in [
2676 vec![ContactExtensionV1::new("urn:other:detector:1", 1, payload.clone()).unwrap()],
2677 vec![
2678 ContactExtensionV1::new(CONTACT_SUPPORT_DETECTOR_V1_ID, 2, payload.clone())
2679 .unwrap(),
2680 ],
2681 vec![detector.clone(), detector],
2682 ] {
2683 for invalid_index in 0..2 {
2684 let (member, take_index) = if invalid_index == 0 {
2685 ("reference", 0)
2686 } else {
2687 ("member", 1)
2688 };
2689 let invalid =
2690 fragment_with_extensions(member, take_index, &windows, extensions.clone());
2691 let mut source = evidence(&windows, &windows);
2692 source[invalid_index].input = invalid.canonical_identity().unwrap();
2693 source[invalid_index].fragment = invalid;
2694 assert_eq!(
2695 plan_foot_cycle_parameterization_v1(
2696 &declaration,
2697 InputIdentity::from_bytes(b"parameterization"),
2698 &manifest,
2699 manifest_input.clone(),
2700 &source,
2701 ),
2702 Err(FootCycleParameterizationError::UnsupportedContactExtension)
2703 );
2704 }
2705 }
2706
2707 for member_index in 0..2 {
2708 let (member, take_index, take_name) = if member_index == 0 {
2709 ("reference", 0, "Take 0")
2710 } else {
2711 ("member", 1, "Take 1")
2712 };
2713 let toe_fragment = fragment_with_clip(
2714 member,
2715 "motions",
2716 take_index,
2717 take_name,
2718 1.0,
2719 &windows,
2720 None,
2721 Some([ContactRoleV1::LeftToe, ContactRoleV1::RightToe]),
2722 vec![detector_extension("left_toe", "right_toe")],
2723 );
2724 let mut source = evidence(&windows, &windows);
2725 source[member_index].input = toe_fragment.canonical_identity().unwrap();
2726 source[member_index].fragment = toe_fragment;
2727 assert!(
2728 plan_foot_cycle_parameterization_v1(
2729 &declaration,
2730 InputIdentity::from_bytes(b"parameterization"),
2731 &manifest,
2732 manifest_input.clone(),
2733 &source,
2734 )
2735 .is_ok()
2736 );
2737 }
2738
2739 let invalid_payloads = [
2740 json!({
2741 "algorithm": "other",
2742 "sampling": "metric-grid-longest-authored-channel",
2743 "max_frames": 1_000_000,
2744 "contact_height_m": 0.03,
2745 "roles": {"left": "left_foot", "right": "right_foot"},
2746 }),
2747 json!({
2748 "algorithm": "stance-support-v1",
2749 "sampling": "other",
2750 "max_frames": 1_000_000,
2751 "contact_height_m": 0.03,
2752 "roles": {"left": "left_foot", "right": "right_foot"},
2753 }),
2754 json!({
2755 "algorithm": "stance-support-v1",
2756 "sampling": "metric-grid-longest-authored-channel",
2757 "max_frames": 999_999,
2758 "contact_height_m": 0.03,
2759 "roles": {"left": "left_foot", "right": "right_foot"},
2760 }),
2761 json!({
2762 "algorithm": "stance-support-v1",
2763 "sampling": "metric-grid-longest-authored-channel",
2764 "max_frames": 1_000_000,
2765 "contact_height_m": -0.01,
2766 "roles": {"left": "left_foot", "right": "right_foot"},
2767 }),
2768 json!({
2769 "algorithm": "stance-support-v1",
2770 "sampling": "metric-grid-longest-authored-channel",
2771 "max_frames": 1_000_000,
2772 "contact_height_m": 0.03,
2773 "roles": {"left": "left_foot", "right": "right_foot"},
2774 "extra": true,
2775 }),
2776 json!({
2777 "algorithm": "stance-support-v1",
2778 "sampling": "metric-grid-longest-authored-channel",
2779 "max_frames": 1_000_000,
2780 "contact_height_m": 0.03,
2781 "roles": {
2782 "left": "left_foot",
2783 "right": "right_foot",
2784 "extra": "unexpected",
2785 },
2786 }),
2787 ];
2788 for payload in invalid_payloads {
2789 for invalid_index in 0..2 {
2790 let (member, take_index) = if invalid_index == 0 {
2791 ("reference", 0)
2792 } else {
2793 ("member", 1)
2794 };
2795 let invalid = fragment_with_extensions(
2796 member,
2797 take_index,
2798 &windows,
2799 vec![detector_extension_with_payload(payload.clone())],
2800 );
2801 let mut source = evidence(&windows, &windows);
2802 source[invalid_index].input = invalid.canonical_identity().unwrap();
2803 source[invalid_index].fragment = invalid;
2804 assert_eq!(
2805 plan_foot_cycle_parameterization_v1(
2806 &declaration,
2807 InputIdentity::from_bytes(b"parameterization"),
2808 &manifest,
2809 manifest_input.clone(),
2810 &source,
2811 ),
2812 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
2813 );
2814 }
2815 }
2816
2817 for invalid_index in 0..2 {
2818 let (member, take_index) = if invalid_index == 0 {
2819 ("reference", 0)
2820 } else {
2821 ("member", 1)
2822 };
2823 let mismatched_roles = fragment_with_extensions(
2824 member,
2825 take_index,
2826 &windows,
2827 vec![detector_extension("left_foot", "right_toe")],
2828 );
2829 let mut source = evidence(&windows, &windows);
2830 source[invalid_index].input = mismatched_roles.canonical_identity().unwrap();
2831 source[invalid_index].fragment = mismatched_roles;
2832 assert_eq!(
2833 plan_foot_cycle_parameterization_v1(
2834 &declaration,
2835 InputIdentity::from_bytes(b"parameterization"),
2836 &manifest,
2837 manifest_input.clone(),
2838 &source,
2839 ),
2840 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
2841 );
2842 }
2843 }
2844
2845 #[test]
2846 fn mixed_detector_thresholds_refuse_in_either_member_order() {
2847 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
2848 let (manifest, manifest_input) = manifest();
2849 let declaration = declaration(&manifest_input, 0.5, 2.0);
2850 let threshold_successor = f64::from_bits(0.03_f64.to_bits() + 1);
2851
2852 let mut common_non_default = evidence(&windows, &windows);
2853 for (member_index, (member, take_index)) in
2854 [("reference", 0), ("member", 1)].into_iter().enumerate()
2855 {
2856 let changed = fragment_with_extensions(
2857 member,
2858 take_index,
2859 &windows,
2860 vec![detector_extension_with_height(
2861 "left_foot",
2862 "right_foot",
2863 threshold_successor,
2864 )],
2865 );
2866 common_non_default[member_index].input = changed.canonical_identity().unwrap();
2867 common_non_default[member_index].root_motion = measured_root(&changed, 0.0, 0.0);
2868 common_non_default[member_index].fragment = changed;
2869 }
2870 assert!(
2871 plan_foot_cycle_parameterization_v1(
2872 &declaration,
2873 InputIdentity::from_bytes(b"parameterization"),
2874 &manifest,
2875 manifest_input.clone(),
2876 &common_non_default,
2877 )
2878 .is_ok()
2879 );
2880
2881 for changed_index in 0..2 {
2882 let (member, take_index) = if changed_index == 0 {
2883 ("reference", 0)
2884 } else {
2885 ("member", 1)
2886 };
2887 let changed = fragment_with_extensions(
2888 member,
2889 take_index,
2890 &windows,
2891 vec![detector_extension_with_height(
2892 "left_foot",
2893 "right_foot",
2894 threshold_successor,
2895 )],
2896 );
2897 let mut source = evidence(&windows, &windows);
2898 source[changed_index].input = changed.canonical_identity().unwrap();
2899 source[changed_index].fragment = changed;
2900 assert_eq!(
2901 plan_foot_cycle_parameterization_v1(
2902 &declaration,
2903 InputIdentity::from_bytes(b"parameterization"),
2904 &manifest,
2905 manifest_input.clone(),
2906 &source,
2907 ),
2908 Err(FootCycleParameterizationError::DetectorPolicyMismatch)
2909 );
2910 }
2911 }
2912
2913 #[test]
2914 fn stance_extension_time_warp_handler_revalidates_closed_payload() {
2915 let extension = detector_extension("left_foot", "right_foot");
2916 let operation = ContactTransformOperationV1::time_warp(
2917 1.0,
2918 vec![
2919 ContactTimeWarpControlPointV1::new(0.0, 0.0),
2920 ContactTimeWarpControlPointV1::new(1.0, 1.0),
2921 ],
2922 );
2923 assert_eq!(
2924 transform_contact_support_detector_extension_time_warp_v1(&extension, &operation)
2925 .unwrap(),
2926 extension
2927 );
2928 for (left, right) in [
2929 ("left_toe", "right_toe"),
2930 ("left_toe", "right_foot"),
2931 ("left_foot", "right_toe"),
2932 ] {
2933 let extension = detector_extension(left, right);
2934 assert_eq!(
2935 transform_contact_support_detector_extension_time_warp_v1(&extension, &operation,)
2936 .unwrap(),
2937 extension
2938 );
2939 }
2940 let negative_zero = detector_extension_with_payload(json!({
2941 "algorithm": "stance-support-v1",
2942 "sampling": "metric-grid-longest-authored-channel",
2943 "max_frames": 1_000_000,
2944 "contact_height_m": -0.0,
2945 "roles": {"left": "left_foot", "right": "right_foot"},
2946 }));
2947 let reconstructed =
2948 transform_contact_support_detector_extension_time_warp_v1(&negative_zero, &operation)
2949 .unwrap();
2950 assert_eq!(
2951 reconstructed
2952 .payload()
2953 .get("contact_height_m")
2954 .and_then(serde_json::Value::as_f64)
2955 .unwrap()
2956 .to_bits(),
2957 0.0_f64.to_bits()
2958 );
2959 assert_eq!(
2960 transform_contact_support_detector_extension_time_warp_v1(
2961 &extension,
2962 &ContactTransformOperationV1::resample(),
2963 ),
2964 Err(FootCycleParameterizationError::UnsupportedContactExtension)
2965 );
2966 let opaque_lookalike = detector_extension_with_payload(json!({
2967 "algorithm": "stance-support-v1",
2968 "sampling": "metric-grid-longest-authored-channel",
2969 "max_frames": 1_000_000,
2970 "contact_height_m": 0.03,
2971 "roles": {"left": "left_foot", "right": "right_foot"},
2972 "opaque": true,
2973 }));
2974 assert_eq!(
2975 transform_contact_support_detector_extension_time_warp_v1(
2976 &opaque_lookalike,
2977 &operation,
2978 ),
2979 Err(FootCycleParameterizationError::InvalidDetectorProvenance)
2980 );
2981 }
2982
2983 #[test]
2984 fn control_point_cap_accepts_exact_and_rejects_first_excess() {
2985 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS, 4_096);
2986 fn boundaries(count: usize) -> Vec<Boundary> {
2987 (0..count)
2988 .map(|index| Boundary {
2989 kind: BoundaryKind {
2990 side: if index % 2 == 0 {
2991 Side::Left
2992 } else {
2993 Side::Right
2994 },
2995 edge: if index % 2 == 0 {
2996 Edge::Onset
2997 } else {
2998 Edge::Release
2999 },
3000 },
3001 time: (index + 1) as f64 / (count + 1) as f64,
3002 })
3003 .collect()
3004 }
3005
3006 let exact = boundaries(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS - 2);
3007 assert_eq!(
3008 build_control_points(&exact, &exact, 1.0, 1.0)
3009 .unwrap()
3010 .len(),
3011 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS
3012 );
3013
3014 let excess = boundaries(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS - 1);
3015 assert_eq!(
3016 build_control_points(&excess, &excess, 1.0, 1.0),
3017 Err(FootCycleParameterizationError::TooManyControlPoints {
3018 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS + 1,
3019 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
3020 })
3021 );
3022 }
3023
3024 #[test]
3025 fn planner_enforces_control_point_cap_on_supplied_ring() {
3026 let windows = alternating_windows(2_048);
3027 let (manifest, manifest_input) = manifest();
3028 assert_eq!(
3029 plan_foot_cycle_parameterization_v1(
3030 &declaration(&manifest_input, 0.5, 2.0),
3031 InputIdentity::from_bytes(b"parameterization"),
3032 &manifest,
3033 manifest_input,
3034 &evidence(&windows, &windows),
3035 ),
3036 Err(FootCycleParameterizationError::TooManyControlPoints {
3037 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS + 2,
3038 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTROL_POINTS,
3039 })
3040 );
3041 }
3042
3043 #[test]
3044 fn aggregate_contact_event_budget_accepts_exact_and_stops_at_first_excess() {
3045 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS, 16_384);
3046 assert_eq!(
3047 validate_aggregate_contact_event_budget([
3048 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS - 1,
3049 1,
3050 ]),
3051 Ok(())
3052 );
3053 assert_eq!(
3054 validate_aggregate_contact_event_budget([
3055 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3056 usize::MAX,
3057 ]),
3058 Err(FootCycleParameterizationError::TooManyContactEvents {
3059 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1,
3060 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3061 })
3062 );
3063 }
3064
3065 #[test]
3066 fn aggregate_fragment_byte_budget_accepts_exact_and_stops_at_first_excess() {
3067 assert_eq!(
3068 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3069 32 * 1024 * 1024
3070 );
3071 assert_eq!(
3072 validate_aggregate_contact_fragment_byte_budget([
3073 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - 1,
3074 1,
3075 ]),
3076 Ok(())
3077 );
3078 assert_eq!(
3079 validate_aggregate_contact_fragment_byte_budget([
3080 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3081 u64::MAX,
3082 ]),
3083 Err(
3084 FootCycleParameterizationError::TooManyContactFragmentBytes {
3085 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1,
3086 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3087 }
3088 )
3089 );
3090 }
3091
3092 #[test]
3093 fn planner_preflights_fragment_byte_budget_before_canonicalization() {
3094 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
3095 let (manifest, manifest_input) = manifest();
3096 let declaration = declaration(&manifest_input, 0.5, 2.0);
3097 let mut source = evidence(&windows, &windows);
3098 let second_bytes = source[1].input.bytes();
3099 source[0].input = InputIdentity::from_sha256_digest(
3100 [0; 32],
3101 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - second_bytes,
3102 );
3103 assert_eq!(
3104 plan_foot_cycle_parameterization_v1(
3105 &declaration,
3106 InputIdentity::from_bytes(b"parameterization"),
3107 &manifest,
3108 manifest_input.clone(),
3109 &source,
3110 ),
3111 Err(FootCycleParameterizationError::NonCanonicalFragment)
3112 );
3113
3114 source[0].input = InputIdentity::from_sha256_digest(
3115 [0; 32],
3116 FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES - second_bytes + 1,
3117 );
3118 assert_eq!(
3119 plan_foot_cycle_parameterization_v1(
3120 &declaration,
3121 InputIdentity::from_bytes(b"parameterization"),
3122 &manifest,
3123 manifest_input,
3124 &source,
3125 ),
3126 Err(
3127 FootCycleParameterizationError::TooManyContactFragmentBytes {
3128 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES + 1,
3129 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_FRAGMENT_BYTES,
3130 }
3131 )
3132 );
3133 }
3134
3135 #[test]
3136 fn planner_preflights_aggregate_contact_events_before_topology_retention() {
3137 let names = ["reference", "member-1", "member-2", "member-3", "member-4"];
3138 let (manifest, manifest_input) =
3139 manifest_for_members(CollectionRuntimeSetKindV1::GaitGroup, &names, &names);
3140 let declaration = FootCycleParameterizationV1::new(
3141 FootCycleManifestBindingV1::new(
3142 CollectionIdV1::new("com.example").unwrap(),
3143 manifest_input.clone(),
3144 )
3145 .unwrap(),
3146 id("com.example/sets/walk"),
3147 id("com.example/reference"),
3148 path("generated/aligned"),
3149 0.5,
3150 2.0,
3151 proof_policy(),
3152 names
3153 .iter()
3154 .map(|name| {
3155 FootCycleParameterizationMemberV1::new(
3156 id(&format!("com.example/{name}")),
3157 path(&format!("contacts/{name}.json")),
3158 )
3159 })
3160 .collect(),
3161 )
3162 .unwrap();
3163 let windows = alternating_windows(2_048);
3164 let oversized_fragment = fragment("reference", 0, &windows);
3165 let fragment_input = oversized_fragment.canonical_identity().unwrap();
3166 let evidence = names
3167 .iter()
3168 .map(|name| {
3169 FootCycleMemberEvidenceV1::new(
3170 id(&format!("com.example/{name}")),
3171 path(&format!("contacts/{name}.json")),
3172 fragment_input.clone(),
3173 oversized_fragment.clone(),
3174 measured_root(&oversized_fragment, 0.0, 0.0),
3175 )
3176 })
3177 .collect::<Vec<_>>();
3178 assert_eq!(
3179 plan_foot_cycle_parameterization_v1(
3180 &declaration,
3181 InputIdentity::from_bytes(b"parameterization"),
3182 &manifest,
3183 manifest_input,
3184 &evidence,
3185 ),
3186 Err(FootCycleParameterizationError::TooManyContactEvents {
3187 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS + 1,
3188 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_CONTACT_EVENTS,
3189 })
3190 );
3191 }
3192
3193 #[test]
3194 fn proof_policy_requires_complete_finite_values_at_exact_inclusive_boundaries() {
3195 let lower = FootCycleProofPolicyV1::new(0.0, 0.0, 0.0).unwrap();
3196 assert_eq!(lower.max_gait_phase_spread(), 0.0);
3197 assert_eq!(lower.min_lr_amplitude_m(), 0.0);
3198 assert_eq!(lower.max_contact_boundary_phase_error(), 0.0);
3199
3200 let upper = FootCycleProofPolicyV1::new(0.5, 0.05, 0.5).unwrap();
3201 assert_eq!(upper.max_gait_phase_spread(), 0.5);
3202 assert_eq!(upper.min_lr_amplitude_m(), 0.05);
3203 assert_eq!(upper.max_contact_boundary_phase_error(), 0.5);
3204 assert_eq!(
3205 serde_json::to_value(&upper).unwrap(),
3206 json!({
3207 "max_gait_phase_spread": 0.5,
3208 "min_lr_amplitude_m": 0.05,
3209 "max_contact_boundary_phase_error": 0.5,
3210 })
3211 );
3212
3213 let above_half = f64::from_bits(0.5_f64.to_bits() + 1);
3214 for result in [
3215 FootCycleProofPolicyV1::new(above_half, 0.0, 0.0),
3216 FootCycleProofPolicyV1::new(0.0, -f64::MIN_POSITIVE, 0.0),
3217 FootCycleProofPolicyV1::new(0.0, 0.0, above_half),
3218 FootCycleProofPolicyV1::new(f64::NAN, 0.0, 0.0),
3219 FootCycleProofPolicyV1::new(0.0, f64::INFINITY, 0.0),
3220 FootCycleProofPolicyV1::new(0.0, 0.0, f64::NEG_INFINITY),
3221 ] {
3222 assert_eq!(
3223 result,
3224 Err(FootCycleParameterizationError::InvalidProofPolicy)
3225 );
3226 }
3227 }
3228
3229 #[test]
3230 fn plan_retains_identity_bound_proof_policy() {
3231 let (manifest, manifest_input) = manifest();
3232 let distinct = FootCycleProofPolicyV1::new(0.321, 0.123, 0.234).unwrap();
3233 let declaration = declaration_with_proof(&manifest_input, 0.5, 2.0, distinct.clone());
3234 let windows = [(Side::Left, 0.1, 0.2), (Side::Right, 0.6, 0.7)];
3235 let parameterization_input = InputIdentity::from_bytes(b"parameterization-with-proof");
3236 let plan = plan_foot_cycle_parameterization_v1(
3237 &declaration,
3238 parameterization_input.clone(),
3239 &manifest,
3240 manifest_input,
3241 &evidence(&windows, &windows),
3242 )
3243 .unwrap();
3244 assert_eq!(plan.parameterization_input(), ¶meterization_input);
3245 assert_eq!(plan.proof(), &distinct);
3246 assert_eq!(plan.proof(), declaration.proof());
3247 }
3248
3249 #[test]
3250 fn declaration_rejects_invalid_slopes_duplicates_and_path_collisions() {
3251 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE, 1_000_000.0);
3252 let (_, manifest_input) = manifest();
3253 assert!(
3254 FootCycleManifestBindingV1::new(
3255 CollectionIdV1::new("com.example").unwrap(),
3256 InputIdentity::from_sha256_digest(
3257 [0; 32],
3258 COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
3259 ),
3260 )
3261 .is_ok()
3262 );
3263 assert_eq!(
3264 FootCycleManifestBindingV1::new(
3265 CollectionIdV1::new("com.example").unwrap(),
3266 InputIdentity::from_sha256_digest(
3267 [0; 32],
3268 COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES + 1,
3269 ),
3270 ),
3271 Err(FootCycleParameterizationError::ManifestTooLarge)
3272 );
3273 for (minimum, maximum) in [
3274 (0.0, 1.0),
3275 (-1.0, 1.0),
3276 (f64::NAN, 1.0),
3277 (0.5, f64::NAN),
3278 (f64::NEG_INFINITY, 1.0),
3279 (0.5, f64::INFINITY),
3280 (1.0 + f64::EPSILON, 2.0),
3281 (0.5, 1.0 - f64::EPSILON),
3282 (2.0, 1.0),
3283 (1.0, FOOT_CYCLE_PARAMETERIZATION_V1_MAX_SLOPE * 2.0),
3284 ] {
3285 assert_eq!(
3286 FootCycleParameterizationV1::new(
3287 FootCycleManifestBindingV1::new(
3288 CollectionIdV1::new("com.example").unwrap(),
3289 manifest_input.clone(),
3290 )
3291 .unwrap(),
3292 id("com.example/sets/walk"),
3293 id("com.example/reference"),
3294 path("generated/aligned"),
3295 minimum,
3296 maximum,
3297 proof_policy(),
3298 vec![
3299 FootCycleParameterizationMemberV1::new(
3300 id("com.example/reference"),
3301 path("contacts/reference.json"),
3302 ),
3303 FootCycleParameterizationMemberV1::new(
3304 id("com.example/member"),
3305 path("contacts/member.json"),
3306 ),
3307 ],
3308 ),
3309 Err(FootCycleParameterizationError::InvalidSlopeBounds)
3310 );
3311 }
3312
3313 let binding = || {
3314 FootCycleManifestBindingV1::new(
3315 CollectionIdV1::new("com.example").unwrap(),
3316 manifest_input.clone(),
3317 )
3318 .unwrap()
3319 };
3320 for members in [
3321 Vec::new(),
3322 vec![FootCycleParameterizationMemberV1::new(
3323 id("com.example/reference"),
3324 path("contacts/reference.json"),
3325 )],
3326 ] {
3327 let found = members.len();
3328 assert_eq!(
3329 FootCycleParameterizationV1::new(
3330 binding(),
3331 id("com.example/sets/walk"),
3332 id("com.example/reference"),
3333 path("generated/aligned"),
3334 0.5,
3335 2.0,
3336 proof_policy(),
3337 members,
3338 ),
3339 Err(FootCycleParameterizationError::TooFewMembers { found })
3340 );
3341 }
3342 assert!(matches!(
3343 FootCycleParameterizationV1::new(
3344 binding(),
3345 id("com.example/sets/walk"),
3346 id("com.example/reference"),
3347 path("generated/aligned"),
3348 0.5,
3349 2.0,
3350 proof_policy(),
3351 vec![
3352 FootCycleParameterizationMemberV1::new(
3353 id("com.example/reference"),
3354 path("contacts/reference.json"),
3355 ),
3356 FootCycleParameterizationMemberV1::new(
3357 id("com.example/reference"),
3358 path("contacts/other.json"),
3359 ),
3360 ],
3361 ),
3362 Err(FootCycleParameterizationError::DuplicateMember { .. })
3363 ));
3364 assert_eq!(
3365 FootCycleParameterizationV1::new(
3366 binding(),
3367 id("com.example/sets/walk"),
3368 id("com.example/reference"),
3369 path("generated/aligned"),
3370 0.5,
3371 2.0,
3372 proof_policy(),
3373 vec![
3374 FootCycleParameterizationMemberV1::new(
3375 id("com.example/reference"),
3376 path("contacts/shared.json"),
3377 ),
3378 FootCycleParameterizationMemberV1::new(
3379 id("com.example/member"),
3380 path("contacts/shared.json"),
3381 ),
3382 ],
3383 ),
3384 Err(FootCycleParameterizationError::DuplicateFragmentPath)
3385 );
3386 assert_eq!(
3387 FootCycleParameterizationV1::new(
3388 binding(),
3389 id("com.example/sets/walk"),
3390 id("com.example/reference"),
3391 path("contacts/member.json"),
3392 0.5,
3393 2.0,
3394 proof_policy(),
3395 vec![
3396 FootCycleParameterizationMemberV1::new(
3397 id("com.example/reference"),
3398 path("contacts/reference.json"),
3399 ),
3400 FootCycleParameterizationMemberV1::new(
3401 id("com.example/member"),
3402 path("contacts/member.json"),
3403 ),
3404 ],
3405 ),
3406 Err(FootCycleParameterizationError::OutputPathCollision)
3407 );
3408 assert_eq!(
3409 FootCycleParameterizationV1::new(
3410 binding(),
3411 id("com.example/sets/walk"),
3412 id("com.example/missing"),
3413 path("generated/aligned"),
3414 0.5,
3415 2.0,
3416 proof_policy(),
3417 vec![
3418 FootCycleParameterizationMemberV1::new(
3419 id("com.example/reference"),
3420 path("contacts/reference.json"),
3421 ),
3422 FootCycleParameterizationMemberV1::new(
3423 id("com.example/member"),
3424 path("contacts/member.json"),
3425 ),
3426 ],
3427 ),
3428 Err(FootCycleParameterizationError::MissingReferenceMember)
3429 );
3430 }
3431
3432 #[test]
3433 fn declaration_member_cap_accepts_exact_and_rejects_n_plus_one() {
3434 assert_eq!(FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS, 4_096);
3435 let mut members = (0..FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS)
3436 .map(|index| {
3437 FootCycleParameterizationMemberV1::new(
3438 id(&format!("com.example/member-{index}")),
3439 path(&format!("contacts/member-{index}.json")),
3440 )
3441 })
3442 .collect::<Vec<_>>();
3443 let binding = || {
3444 FootCycleManifestBindingV1::new(
3445 CollectionIdV1::new("com.example").unwrap(),
3446 InputIdentity::from_bytes(b"manifest"),
3447 )
3448 .unwrap()
3449 };
3450 let construct = |members| {
3451 FootCycleParameterizationV1::new(
3452 binding(),
3453 id("com.example/sets/walk"),
3454 id("com.example/member-0"),
3455 path("generated/aligned"),
3456 0.5,
3457 2.0,
3458 proof_policy(),
3459 members,
3460 )
3461 };
3462
3463 assert!(construct(members.clone()).is_ok());
3464 let index = FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS;
3465 members.push(FootCycleParameterizationMemberV1::new(
3466 id(&format!("com.example/member-{index}")),
3467 path(&format!("contacts/member-{index}.json")),
3468 ));
3469 assert_eq!(
3470 construct(members),
3471 Err(FootCycleParameterizationError::TooManyMembers {
3472 found: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS + 1,
3473 max: FOOT_CYCLE_PARAMETERIZATION_V1_MAX_MEMBERS,
3474 })
3475 );
3476 }
3477}