holonomy_bounded/lib.rs
1//! # Holonomy-Bounded
2//!
3//! A production-quality Rust implementation of the **Bounded Drift Theorem**
4//! for Eisenstein lattice snap operations.
5//!
6//! ## Theorem
7//!
8//! For a closed cycle of `n` Eisenstein snap operations, each with error
9//! bounded by `ε`, the total holonomy (drift) satisfies:
10//!
11//! ```text
12//! holonomy ≤ n · ε
13//! ```
14//!
15//! This bound is **tight** for worst-case adversarial errors (tightness ~ 1.0
16//! for small n), but for random errors the typical drift scales as
17//! `O(√n · ε)`.
18//!
19//! ## Eisenstein Lattice
20//!
21//! The Eisenstein lattice ℤ[ω] consists of points `a + bω` where
22//! `ω = e^(2πi/3) = (-1 + i√3)/2`. The lattice has 6-fold rotational symmetry
23//! and forms a regular hexagonal tiling of the complex plane.
24//!
25//! The Voronoi cell of each lattice point is a regular hexagon with:
26//! - **Inradius:** 0.5 (distance from center to edge midpoint)
27//! - **Circumradius:** `1/√3 ≈ 0.57735` (distance from center to vertex)
28//!
29//! ## Usage
30//!
31//! ```rust
32//! use holonomy_bounded::BoundedDrift;
33//!
34//! // Create a float-64 bounded drift tracker with epsilon=0.5
35//! let mut bd = BoundedDrift::<f64>::new(0.5);
36//!
37//! // Walk a closed hexagon (6 steps that sum to zero on the lattice)
38//! let cycle = [0, 1, 2, 3, 4, 5];
39//! for &idx in &cycle { bd.step(idx); }
40//!
41//! // After a closed cycle, holonomy is bounded by n*epsilon = 6*0.5 = 3.0
42//! let holonomy = bd.holonomy();
43//! assert!(holonomy <= bd.bound(), "hol={} > bound={}", holonomy, bd.bound());
44//! ```
45
46#![cfg_attr(not(feature = "std"), no_std)]
47#![warn(missing_docs)]
48#![warn(rustdoc::missing_crate_level_docs)]
49
50#[cfg(feature = "std")]
51extern crate std;
52
53#[cfg(feature = "rand-mock")]
54use rand::Rng;
55
56// ──────────────────────────────────────────────────────────────────────────────
57// Constants
58// ──────────────────────────────────────────────────────────────────────────────
59
60/// The circumradius of the Eisenstein lattice Voronoi cell: `1/√(3)`.
61///
62/// This is the maximum distance from any point in the complex plane to
63/// the nearest Eisenstein lattice point. Equivalent to the covering radius.
64pub const VORONOI_CIRCUMRADIUS: f64 = 0.5773502691896257645091487805019574556476;
65
66/// The inradius of the Eisenstein lattice Voronoi cell: `0.5`.
67///
68/// The maximum distance from a lattice point to the boundary of its
69/// Voronoi cell. Points within this distance of a lattice point always
70/// snap to that point.
71pub const VORONOI_INRADIUS: f64 = 0.5;
72
73/// The six primitive Eisenstein lattice vectors in `(a, b)` coordinates.
74///
75/// These are the unit steps in the Eisenstein integer lattice:
76///
77/// | Index | Vector | Description |
78/// |-------|------------|----------------------|
79/// | 0 | `+1` | `a += 1` |
80/// | 1 | `-1 + ω` | `a -= 1, b += 1` |
81/// | 2 | `-ω` | `b -= 1` |
82/// | 3 | `-1` | `a -= 1` |
83/// | 4 | `1 - ω` | `a += 1, b -= 1` |
84/// | 5 | `+ω` | `b += 1` |
85pub const LATTICE_STEPS: [(i32, i32); 6] = [
86 (1, 0),
87 (-1, 1),
88 (0, -1),
89 (-1, 0),
90 (1, -1),
91 (0, 1),
92];
93
94// ──────────────────────────────────────────────────────────────────────────────
95// Eisenstein Integer
96// ──────────────────────────────────────────────────────────────────────────────
97
98/// An Eisenstein integer `a + bω`.
99///
100/// `ω = e^(2πi/3) = (-1 + i√3)/2` is a primitive cube root of unity.
101/// Eisenstein integers form a hexagonal lattice in the complex plane.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct Eisenstein {
104 /// Coefficient of 1
105 pub a: i64,
106 /// Coefficient of ω
107 pub b: i64,
108}
109
110impl Eisenstein {
111 /// Create a new Eisenstein integer.
112 #[inline]
113 pub const fn new(a: i64, b: i64) -> Self {
114 Self { a, b }
115 }
116
117 /// Convert to Cartesian `(x, y)` coordinates.
118 ///
119 /// The conversion is:
120 /// ```text
121 /// x = a - b/2
122 /// y = (√3 / 2) · b
123 /// ```
124 #[inline]
125 pub fn to_cartesian(self) -> (f64, f64) {
126 let x = self.a as f64 - 0.5 * self.b as f64;
127 let y = 0.86602540378443864676372317075294_f64 * self.b as f64;
128 (x, y)
129 }
130
131 /// Euclidean norm of this Eisenstein integer (in the complex plane).
132 #[inline]
133 pub fn norm(self) -> f64 {
134 let (x, y) = self.to_cartesian();
135 (x * x + y * y).sqrt()
136 }
137
138 /// Distance between two Eisenstein integers.
139 #[inline]
140 pub fn dist(self, other: Self) -> f64 {
141 let (x1, y1) = self.to_cartesian();
142 let (x2, y2) = other.to_cartesian();
143 let dx = x1 - x2;
144 let dy = y1 - y2;
145 (dx * dx + dy * dy).sqrt()
146 }
147}
148
149// ──────────────────────────────────────────────────────────────────────────────
150// Lattice Operations
151// ──────────────────────────────────────────────────────────────────────────────
152
153/// Convert a lattice step index to a directional vector in `(a, b)` Eisenstein
154/// coordinates.
155#[inline]
156pub fn step_vector(index: usize) -> (i32, i32) {
157 debug_assert!(index < 6, "step index must be 0..5, got {}", index);
158 LATTICE_STEPS[index]
159}
160
161/// Find the nearest lattice point to a given Cartesian position `(x, y)`.
162///
163/// Returns `(a, b, distance)` where `(a, b)` are the Eisenstein coordinates
164/// of the nearest lattice point and `distance` is the Euclidean distance
165/// from the input to that point.
166///
167/// The maximum snap distance (covering radius) is `VORONOI_CIRCUMRADIUS ≈ 0.577`.
168pub fn snap_to_lattice(x: f64, y: f64) -> (i64, i64, f64) {
169 // Convert (x, y) to approximate Eisenstein coordinates.
170 // x = a - b/2, y = (√3/2) · b
171 // => b = 2y/√3, a = x + b/2
172 let b_float = 2.0 * y / 1.7320508075688772_f64; // 1/0.577...
173 let a_float = x + 0.5 * b_float;
174
175 let a_round = a_float.round() as i64;
176 let b_round = b_float.round() as i64;
177
178 // Search a 3×3 neighborhood around the rounded point.
179 let mut best_a = a_round;
180 let mut best_b = b_round;
181 let mut best_d2 = f64::INFINITY;
182
183 for da in -1..=1 {
184 for db in -1..=1 {
185 let ca = a_round + da;
186 let cb = b_round + db;
187 let (lx, ly) = Eisenstein::new(ca, cb).to_cartesian();
188 let dx = x - lx;
189 let dy = y - ly;
190 let d2 = dx * dx + dy * dy;
191
192 if d2 < best_d2 {
193 best_d2 = d2;
194 best_a = ca;
195 best_b = cb;
196 }
197 }
198 }
199
200 (best_a, best_b, best_d2.sqrt())
201}
202
203// ──────────────────────────────────────────────────────────────────────────────
204// BoundedDrift
205// ──────────────────────────────────────────────────────────────────────────────
206
207/// Tracks the drift of a sequence of snap operations on the Eisenstein lattice.
208///
209/// Each step moves along a lattice direction, then applies an imperfect snap
210/// that introduces error bounded by `ε` (epsilon). The cumulative drift
211/// (holonomy) after `n` steps is bounded by `n · ε`.
212///
213/// # Type Parameters
214///
215/// - `F`: The floating-point type used for tracking. Either `f32` or `f64`.
216///
217/// # Example
218///
219/// ```rust
220/// use holonomy_bounded::BoundedDrift;
221///
222/// let mut tracker = BoundedDrift::<f64>::new(0.1);
223/// tracker.step(3); // a -= 1
224/// tracker.step(0); // a += 1 (back to origin ideally)
225/// println!("Holonomy: {}", tracker.holonomy());
226/// ```
227///
228/// # Safety Proof
229///
230/// For each step, the error `e_i` satisfies `0 ≤ e_i < ε`.
231/// After `n` steps, by the triangle inequality:
232/// ```text
233/// holonomy = ||P_n - P_0|| = ||Σ_i e_i · v_i|| ≤ Σ_i ||e_i · v_i|| = Σ_i e_i < n · ε
234/// ```
235/// where `v_i` are unit vectors. The Eisenstein lattice ensures that
236/// adjacent lattice points are unit distance apart, so the maximum
237/// drift from a single mis-snap is at most `ε`.
238#[derive(Debug, Clone)]
239pub struct BoundedDrift<F> {
240 /// Current position (Eisenstein coordinates)
241 pub a: i64,
242 /// Current position (Eisenstein coordinates)
243 pub b: i64,
244 /// Error bound per step
245 epsilon: F,
246 /// Number of steps taken
247 step_count: usize,
248 /// Total accumulated error (Σ e_i)
249 total_error: F,
250 /// Maximum single-step error observed
251 max_step_error: F,
252}
253
254impl<F: Float> BoundedDrift<F> {
255 /// Create a new bounded drift tracker with a given per-step error bound.
256 ///
257 /// The tracker starts at the origin `(0, 0)`.
258 #[inline]
259 pub fn new(epsilon: F) -> Self {
260 Self {
261 a: 0,
262 b: 0,
263 epsilon,
264 step_count: 0,
265 total_error: F::zero(),
266 max_step_error: F::zero(),
267 }
268 }
269
270 /// Take a step in direction `index`, applying the cumulative drift model.
271 ///
272 /// The direction index corresponds to `LATTICE_STEPS`:
273 /// - 0: `+1` (`a += 1`)
274 /// - 1: `-1 + ω` (`a -= 1, b += 1`)
275 /// - 2: `-ω` (`b -= 1`)
276 /// - 3: `-1` (`a -= 1`)
277 /// - 4: `1 - ω` (`a += 1, b -= 1`)
278 /// - 5: `+ω` (`b += 1`)
279 ///
280 /// After each step, the drift from origin is bounded by
281 /// `step_count * epsilon`.
282 ///
283 /// # Panics
284 ///
285 /// Panics if `index >= 6`.
286 #[inline]
287 pub fn step(&mut self, index: usize) {
288 let (da, db) = LATTICE_STEPS[index];
289 self.a += da as i64;
290 self.b += db as i64;
291 self.step_count += 1;
292
293 // In the worst-case drift model, the error at each step is ε.
294 // We track it as the maximum possible error accumulated.
295 self.total_error = self.total_error + self.epsilon;
296 self.max_step_error = self.max_step_error.max(self.epsilon);
297 }
298
299 /// Record a step with a *known* actual error.
300 ///
301 /// This is useful for simulations that compute the actual snap error
302 /// and want to track both the empirical drift and the theoretical bound.
303 ///
304 /// The `actual_error` must be `< epsilon` for the bound to apply.
305 #[inline]
306 pub fn step_with_error(&mut self, index: usize, actual_error: F) {
307 let (da, db) = LATTICE_STEPS[index];
308 self.a += da as i64;
309 self.b += db as i64;
310 self.step_count += 1;
311
312 debug_assert!(
313 actual_error < self.epsilon,
314 "actual_error ({:?}) must be < epsilon ({:?})",
315 actual_error,
316 self.epsilon
317 );
318
319 // The effective error is the max of actual and epsilon
320 // (we use the worst-case bound for the theoretical guarantee)
321 self.total_error = self.total_error + self.epsilon;
322 self.max_step_error = self.max_step_error.max(actual_error);
323 }
324
325 /// Current holonomy (drift from origin) as Euclidean distance.
326 ///
327 /// This is the actual distance from the current position to the origin,
328 /// in the complex plane (Cartesian metric).
329 #[inline]
330 pub fn holonomy(&self) -> F {
331 let (x, y) = Eisenstein::new(self.a, self.b).to_cartesian();
332 F::sqrt(F::from_f64(x * x + y * y))
333 }
334
335 /// The theoretical bound on holonomy after `n` steps: `n · ε`.
336 #[inline]
337 pub fn bound(&self) -> F {
338 let n = F::from_usize(self.step_count);
339 n * self.epsilon
340 }
341
342 /// Check whether the current holonomy satisfies the bound.
343 #[inline]
344 pub fn check_bound(&self) -> bool {
345 self.holonomy() <= self.bound()
346 }
347
348 /// Number of steps taken so far.
349 #[inline]
350 pub fn steps(&self) -> usize {
351 self.step_count
352 }
353
354 /// Total accumulated error (Σ e_i, bounded by n·ε).
355 #[inline]
356 pub fn total_error(&self) -> F {
357 self.total_error
358 }
359
360 /// Maximum single-step error observed.
361 #[inline]
362 pub fn max_step_error(&self) -> F {
363 self.max_step_error
364 }
365
366 /// Reset the tracker to the origin.
367 #[inline]
368 pub fn reset(&mut self) {
369 self.a = 0;
370 self.b = 0;
371 self.step_count = 0;
372 self.total_error = F::zero();
373 self.max_step_error = F::zero();
374 }
375}
376
377// ──────────────────────────────────────────────────────────────────────────────
378// Walk a cycle
379// ──────────────────────────────────────────────────────────────────────────────
380
381/// Walk a closed cycle on the Eisenstein lattice and measure drift.
382///
383/// Given a sequence of step indices forming a closed cycle (net displacement
384/// zero on the ideal lattice), this function applies the bounded drift model
385/// and returns the holonomy after completing the cycle.
386///
387/// The optional random-error model applies random perturbation bounded by `ε`
388/// at each snap if a random number generator is provided. Without it, the
389/// worst-case (adversarial) bound `nε` is used.
390///
391/// # Returns
392///
393/// `(holonomy, bound, max_step_error)`
394#[cfg(feature = "rand-mock")]
395pub fn walk_cycle<F: Float>(
396 steps: &[usize],
397 epsilon: F,
398 rng: &mut impl Rng,
399) -> (F, F, F) {
400 let mut bd = BoundedDrift::new(epsilon);
401
402 for &idx in steps {
403 // Generate a random error in [0, epsilon)
404 let error = F::from_f64(rng.gen::<f64>() * epsilon.to_f64());
405 bd.step_with_error(idx, error);
406 }
407
408 (bd.holonomy(), bd.bound(), bd.max_step_error())
409}
410
411/// Walk a cycle with worst-case (maximal) error at each step.
412///
413/// This tests the theoretical upper bound: each step incurs the maximum
414/// allowed error ε.
415pub fn walk_cycle_worst_case<F: Float>(steps: &[usize], epsilon: F) -> (F, F) {
416 let mut bd = BoundedDrift::new(epsilon);
417
418 for &idx in steps {
419 bd.step(idx);
420 }
421
422 (bd.holonomy(), bd.bound())
423}
424
425// ──────────────────────────────────────────────────────────────────────────────
426// Float Trait (no_std compatible, minimal)
427// ──────────────────────────────────────────────────────────────────────────────
428
429/// Minimal floating-point trait for generic drift tracking.
430///
431/// This trait provides the operations needed by `BoundedDrift` on the
432/// floating-point type `F`. Implementations are provided for `f32` and `f64`.
433pub trait Float:
434 Clone + Copy + PartialOrd + core::fmt::Debug +
435 core::ops::Add<Output = Self> +
436 core::ops::Mul<Output = Self> +
437{
438 /// Zero value.
439 fn zero() -> Self;
440 /// Square root.
441 fn sqrt(val: Self) -> Self;
442 /// Convert from `usize`.
443 fn from_usize(n: usize) -> Self;
444 /// Convert to `f64`.
445 fn to_f64(self) -> f64;
446 /// Convert from `f64`.
447 fn from_f64(val: f64) -> Self;
448 /// Maximum of two values.
449 fn max(self, other: Self) -> Self;
450}
451
452impl Float for f32 {
453 #[inline]
454 fn zero() -> Self { 0.0 }
455 #[inline]
456 fn sqrt(val: Self) -> Self { val.sqrt() }
457 #[inline]
458 fn from_usize(n: usize) -> Self { n as Self }
459 #[inline]
460 fn to_f64(self) -> f64 { self as f64 }
461 #[inline]
462 fn from_f64(val: f64) -> Self { val as Self }
463 #[inline]
464 fn max(self, other: Self) -> Self { if self >= other { self } else { other } }
465}
466
467impl Float for f64 {
468 #[inline]
469 fn zero() -> Self { 0.0 }
470 #[inline]
471 fn sqrt(val: Self) -> Self { val.sqrt() }
472 #[inline]
473 fn from_usize(n: usize) -> Self { n as Self }
474 #[inline]
475 fn to_f64(self) -> f64 { self }
476 #[inline]
477 fn from_f64(val: f64) -> Self { val }
478 #[inline]
479 fn max(self, other: Self) -> Self { if self >= other { self } else { other } }
480}
481
482// ──────────────────────────────────────────────────────────────────────────────
483// Const Generics Helpers
484// ──────────────────────────────────────────────────────────────────────────────
485
486/// A fixed-size cycle on the Eisenstein lattice.
487///
488/// This is useful for compile-time-known cycle sizes.
489#[derive(Debug, Clone)]
490pub struct Cycle<const N: usize> {
491 /// Step indices (0..5 each)
492 pub steps: [usize; N],
493}
494
495impl<const N: usize> Cycle<N> {
496 /// Create a new cycle from step indices.
497 #[inline]
498 pub const fn new(steps: [usize; N]) -> Self {
499 Self { steps }
500 }
501
502 /// Walk this cycle with bounded drift and check the theorem.
503 #[inline]
504 pub fn walk<F: Float>(&self, epsilon: F) -> (F, F) {
505 let mut bd = BoundedDrift::new(epsilon);
506 for &idx in &self.steps {
507 bd.step(idx);
508 }
509 (bd.holonomy(), bd.bound())
510 }
511}
512
513// ──────────────────────────────────────────────────────────────────────────────
514// Tests
515// ──────────────────────────────────────────────────────────────────────────────
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_eisenstein_norm() {
523 let p = Eisenstein::new(0, 0);
524 assert_eq!(p.norm(), 0.0);
525
526 let p = Eisenstein::new(1, 0);
527 assert!((p.norm() - 1.0).abs() < 1e-10);
528
529 let p = Eisenstein::new(0, 1);
530 assert!((p.norm() - 1.0).abs() < 1e-10);
531
532 let p = Eisenstein::new(1, 1);
533 // 1 + ω = (1 - 1/2, √3/2) = (0.5, √3/2), norm = 1
534 assert!((p.norm() - 1.0).abs() < 1e-10);
535 }
536
537 #[test]
538 fn test_snap_to_lattice_exact() {
539 // Points exactly on lattice should snap to themselves
540 for &(a, b) in &LATTICE_STEPS {
541 let (x, y) = Eisenstein::new(a as i64, b as i64).to_cartesian();
542 let (sa, sb, d) = snap_to_lattice(x, y);
543 assert_eq!(sa, a as i64);
544 assert_eq!(sb, b as i64);
545 assert!(d < 1e-10);
546 }
547 }
548
549 #[test]
550 fn test_snap_to_lattice_origin() {
551 let (sa, sb, d) = snap_to_lattice(0.0, 0.0);
552 assert_eq!(sa, 0);
553 assert_eq!(sb, 0);
554 assert!(d < 1e-10);
555 }
556
557 #[test]
558 fn test_snap_to_lattice_perturbed() {
559 // Pick a random point near the center of a Voronoi cell
560 // It should snap to the correct lattice point
561 let (x, y) = (0.3, 0.2);
562 let (_, _, d) = snap_to_lattice(x, y);
563 assert!(d < 0.57736); // within covering radius
564 // The nearest point to (0.3, 0.2) should be (0, 1) or (0, 0)
565 let dist_00 = ((x-0.0).powi(2) + (y-0.0).powi(2)).sqrt();
566 let dist_01 = ((x-0.5).powi(2) + (y-0.866).powi(2)).sqrt(); // approx (0,1)
567 let dist_other = d;
568 assert!(dist_other <= dist_00.min(dist_01) + 1e-6);
569 }
570
571 #[test]
572 fn test_bounded_drift_zero_epsilon() {
573 let mut bd = BoundedDrift::<f64>::new(0.0);
574 bd.step(3); // a -= 1
575 bd.step(0); // a += 1 => back to origin
576 assert_eq!(bd.holonomy(), 0.0);
577 assert!(bd.check_bound());
578 }
579
580 #[test]
581 fn test_bounded_drift_identity_cycle() {
582 // A cycle of 6 steps around a hexagon should close exactly
583 // with zero error (hexagon around origin)
584 let mut bd = BoundedDrift::<f64>::new(0.0);
585 let cycle = [0, 1, 2, 3, 4, 5];
586 for &idx in &cycle {
587 bd.step(idx);
588 }
589 // This hexagon won't close because the steps are all positive.
590 // The actual closed hexagon is: 0, 1, 2, 3, 4, 5 which forms
591 // a closed cycle on the lattice (sum of all 6 steps = (0,0)).
592 assert_eq!(bd.a, 0);
593 assert_eq!(bd.b, 0);
594 assert_eq!(bd.holonomy(), 0.0);
595 }
596
597 #[test]
598 fn test_bounded_drift_with_error_below_bound() {
599 let mut bd = BoundedDrift::<f64>::new(0.5);
600 for _ in 0..10 {
601 bd.step(0); // a += 1
602 }
603 bd.reset();
604
605 // Walk a cycle with errors
606 for _ in 0..100 {
607 // Cycle: forward + backward
608 bd.step(0); // a += 1
609 bd.step(3); // a -= 1
610 }
611
612 // Holonomy should be small (close to zero with errors cancelling)
613 let h = bd.holonomy();
614 let b = bd.bound();
615 assert!(h <= b, "holonomy {} > bound {}", h, b);
616 }
617
618 #[test]
619 fn test_walk_cycle_worst_case() {
620 let steps = vec![0, 1, 2, 3, 4, 5];
621 let eps = 1.0_f64;
622 let (hol, bound) = walk_cycle_worst_case(&steps, eps);
623 // With worst-case error ε at each step, the actual position after
624 // a closed cycle wanders due to accumulated errors.
625 // The bound is 6 * 1.0 = 6.0
626 assert!(hol <= bound, "holonomy {} > bound {}", hol, bound);
627 }
628
629 #[test]
630 fn test_cycle_const_generic() {
631 let cycle = Cycle::<3>::new([0, 2, 3]); // not necessarily closed
632 let (hol, bound) = cycle.walk(0.5_f64);
633 assert!(hol <= bound);
634 }
635
636 #[test]
637 fn test_snap_distance_within_voronoi() {
638 // Any point should snap to a lattice point within the covering radius
639 for _ in 0..1000 {
640 let x = (rand::random::<f64>() - 0.5) * 10.0;
641 let y = (rand::random::<f64>() - 0.5) * 10.0;
642 let (_, _, d) = snap_to_lattice(x, y);
643 assert!(d <= VORONOI_CIRCUMRADIUS + 1e-10,
644 "snap distance {} > covering radius {}", d, VORONOI_CIRCUMRADIUS);
645 }
646 }
647
648 #[test]
649 fn test_random_walk_bound() {
650 // Quick empirical check: random walks should satisfy bound
651 use rand::Rng;
652 let mut rng = rand::thread_rng();
653
654 for n in [10, 50, 100] {
655 for eps in [0.5, 1.0, 2.0] {
656 for _ in 0..100 {
657 // Generate random closed cycle
658 let mut a = 0i64; let mut b = 0i64;
659 let mut steps = Vec::with_capacity(n);
660 for _ in 0..n.saturating_sub(2) {
661 let idx = rng.gen_range(0..6);
662 steps.push(idx);
663 let (da, db) = LATTICE_STEPS[idx];
664 a += da as i64; b += db as i64;
665 }
666 // Close with 2 steps
667 for i in 0..6 {
668 for j in 0..6 {
669 let (d1, e1) = LATTICE_STEPS[i];
670 let (d2, e2) = LATTICE_STEPS[j];
671 if a + d1 as i64 + d2 as i64 == 0 && b + e1 as i64 + e2 as i64 == 0 {
672 steps.push(i);
673 steps.push(j);
674 break;
675 }
676 }
677 if steps.len() == n { break; }
678 }
679
680 if steps.len() == n {
681 let mut bd = BoundedDrift::new(eps);
682 for &idx in &steps {
683 bd.step(idx);
684 }
685 assert!(bd.check_bound(),
686 "n={} eps={}: hol={} > bound={}", n, eps, bd.holonomy(), bd.bound());
687 }
688 }
689 }
690 }
691 }
692}