Skip to main content

animsmith_core/
foot_cycle.rs

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