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