Skip to main content

dodecet_encoder/
eisenstein.rs

1//! Eisenstein Constraint Module for Dodecet Encoder
2//!
3//! Integrates the Weyl group folding (S₃) with dodecet 12-bit constraint state encoding.
4//!
5//! ## Layout
6//!
7//! ```text
8//! ┌─────────────────────────────────────────────┐
9//! │              DODECET (12 bits)               │
10//! ├──────────────┬──────────────┬───────────────┤
11//! │  Nibble 2    │  Nibble 1    │  Nibble 0     │
12//! │  Bits 11-8   │  Bits 7-4    │  Bits 3-0     │
13//! │              │              │               │
14//! │  CONSTRAINT  │  DIRECTION   │  CHIRALITY    │
15//! │  LEVEL       │  IN CELL     │  + SAFETY     │
16//! │              │              │               │
17//! │  0 = on snap │  0-15 = 22.5°│  bits 0-2:    │
18//! │  15 = at ρ   │  azimuth     │   chamber 0-5 │
19//! │              │              │  bit 3:       │
20//! │  Right-skewed│  Uniform     │   safe/crit   │
21//! │  (70% ≥ 8)  │  (all equal) │  (0.70 = crit)│
22//! └──────────────┴──────────────┴───────────────┘
23//! ```
24//!
25//! ## Usage
26//!
27//! ```rust,ignore
28//! use dodecet_encoder::eisenstein::{EisensteinConstraint, SnapResult};
29//!
30//! let constraint = EisensteinConstraint::new();
31//! let result = constraint.snap(1.5, 2.3);
32//! println!("Dodecet: 0x{:03X}", result.dodecet);
33//! println!("Error: {:.4} / ρ = {:.4}", result.error, result.error_normalized);
34//! println!("Chamber: {} ({})", result.chamber, result.parity_str());
35//! println!("Safe: {}", result.is_safe());
36//! ```
37
38const SQRT_3: f64 = 1.7320508075688772;
39
40/// Covering radius of A₂ lattice: ρ = 1/√3
41pub const COVERING_RADIUS: f64 = 1.0 / SQRT_3;
42
43/// Voronoi cell area of A₂: A = √3/2
44pub const CELL_AREA: f64 = SQRT_3 / 2.0;
45
46/// ω = e^{2πi/3} = -1/2 + i√3/2
47const OMEGA_RE: f64 = -0.5;
48const OMEGA_IM: f64 = SQRT_3 / 2.0;
49
50/// |W(A₂)| = |S₃| = 6
51pub const WEYL_ORDER: usize = 6;
52
53/// Square-root funnel threshold: ρ/2 (safe if below)
54pub const SAFE_THRESHOLD: f64 = COVERING_RADIUS / 2.0;
55
56/// The 6 Weyl chambers of S₃ (sorted descending permutations of (0,1,2))
57const WEYL_PERMS: [(usize, usize, usize); 6] = [
58    (0, 1, 2), // chamber 0: even
59    (0, 2, 1), // chamber 1: odd
60    (1, 0, 2), // chamber 2: even
61    (1, 2, 0), // chamber 3: odd
62    (2, 0, 1), // chamber 4: even
63    (2, 1, 0), // chamber 5: even
64];
65
66/// Even chambers have positive parity (reached by rotations)
67const EVEN_CHAMBERS: [usize; 3] = [0, 2, 5];
68/// Odd chambers have negative parity (reached by reflections)
69const ODD_CHAMBERS: [usize; 3] = [1, 3, 4];
70
71/// Result of Eisenstein constraint snap
72#[derive(Debug, Clone, Copy)]
73pub struct SnapResult {
74    /// The 12-bit dodecet encoding the full constraint state
75    pub dodecet: u16,
76    /// Nearest Eisenstein integer (a, b) where a + bω is the snap point
77    pub snap_a: i32,
78    pub snap_b: i32,
79    /// Snap error (distance from input to snap point)
80    pub error: f64,
81    /// Error normalized to covering radius [0, 1]
82    pub error_normalized: f64,
83    /// Error quantized to 16 levels (nibble 2)
84    pub error_level: u8,
85    /// Azimuthal angle quantized to 16 levels (nibble 1)
86    pub angle_level: u8,
87    /// Weyl chamber index 0-5
88    pub chamber: u8,
89    /// Parity: +1 for even chambers, -1 for odd
90    pub parity: i8,
91    /// Whether the point is within the safe zone (error < ρ/2)
92    pub is_safe: bool,
93}
94
95impl SnapResult {
96    /// Format dodecet as hex string (3 chars)
97    pub fn to_hex(&self) -> String {
98        format!("{:03X}", self.dodecet)
99    }
100
101    /// Get parity as string
102    pub fn parity_str(&self) -> &'static str {
103        if self.parity > 0 { "even (+)" } else { "odd (-)" }
104    }
105
106    /// Deadband funnel position: √(error/ρ)
107    /// 0.0 = on snap, 1.0 = at boundary
108    /// Square-root because CDF = πr²/A → r = √(A·P/π)
109    pub fn funnel_position(&self) -> f64 {
110        (self.error / COVERING_RADIUS).sqrt()
111    }
112
113    /// Precision feeling Φ = 1/δ where δ is the deadband
114    /// Higher Φ = more constrained
115    pub fn precision_feeling(&self) -> f64 {
116        if self.error > 0.0 {
117            1.0 / self.error
118        } else {
119            f64::INFINITY
120        }
121    }
122
123    /// Decode dodecet back to components
124    pub fn decode_dodecet(dodecet: u16) -> (u8, u8, u8, bool) {
125        let err_level = ((dodecet >> 8) & 0xF) as u8;
126        let angle_level = ((dodecet >> 4) & 0xF) as u8;
127        let chamber_byte = (dodecet & 0xF) as u8;
128        let chamber = chamber_byte & 0x7;
129        let safe = (chamber_byte >> 3) & 1 == 0;
130        (err_level, angle_level, chamber, safe)
131    }
132
133    /// CDF prediction: P(d < r) = πr²/A
134    /// Given this error level, what fraction of points have LESS error?
135    pub fn cdf_below(&self) -> f64 {
136        std::f64::consts::PI * self.error * self.error / CELL_AREA
137    }
138}
139
140/// Eisenstein constraint checker
141pub struct EisensteinConstraint {
142    /// Deadband funnel width (0 to 1.0). Controls how tight the constraint is.
143    /// Square-root funnel: δ(t) = ρ·√(1-t)
144    pub funnel_width: f64,
145}
146
147impl Default for EisensteinConstraint {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl EisensteinConstraint {
154    pub fn new() -> Self {
155        EisensteinConstraint { funnel_width: 1.0 }
156    }
157
158    pub fn with_funnel(mut self, width: f64) -> Self {
159        self.funnel_width = width.clamp(0.0, 1.0);
160        self
161    }
162
163    /// Snap a point to the nearest Eisenstein integer using 9-candidate Voronoi search.
164    ///
165    /// This is the core constraint check: map ℝ² → A₂ → dodecet.
166    pub fn snap(&self, x: f64, y: f64) -> SnapResult {
167        // Convert to Eisenstein coordinates
168        let a_f = x - y * OMEGA_RE / OMEGA_IM;
169        let b_f = y / OMEGA_IM;
170
171        let a0 = a_f.round() as i32;
172        let b0 = b_f.round() as i32;
173
174        // 9-candidate Voronoi search (guaranteed covering radius)
175        let mut best_a = a0;
176        let mut best_b = b0;
177        let mut best_err = f64::MAX;
178
179        for da in -1..=1i32 {
180            for db in -1..=1i32 {
181                let ca = a0 + da;
182                let cb = b0 + db;
183                let cx = ca as f64 + cb as f64 * OMEGA_RE;
184                let cy = cb as f64 * OMEGA_IM;
185                let err = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
186                if err < best_err {
187                    best_a = ca;
188                    best_b = cb;
189                    best_err = err;
190                }
191            }
192        }
193
194        // Classify into Weyl chamber
195        let chamber = Self::classify_chamber(x, y);
196        let parity = if EVEN_CHAMBERS.contains(&(chamber as usize)) { 1 } else { -1 };
197
198        // Quantize error to 16 levels (nibble 2)
199        let err_norm = (best_err / COVERING_RADIUS).min(1.0);
200        let err_level = (err_norm * 15.0).round() as u8;
201
202        // Quantize angle to 16 levels (nibble 1)
203        let dx = x - (best_a as f64 + best_b as f64 * OMEGA_RE);
204        let dy = y - (best_b as f64 * OMEGA_IM);
205        let angle_level = if dx != 0.0 || dy != 0.0 {
206            let angle = dy.atan2(dx);
207            let norm = ((angle + std::f64::consts::PI) / (2.0 * std::f64::consts::PI));
208            (norm * 16.0).floor() as u8 % 16
209        } else {
210            0
211        };
212
213        // Safety flag: safe if error < ρ/2
214        let is_safe = best_err < SAFE_THRESHOLD;
215        let safe_bit: u8 = if is_safe { 0 } else { 1 };
216        let chamber_byte = (safe_bit << 3) | (chamber as u8 & 0x7);
217
218        // Pack dodecet
219        let dodecet = ((err_level as u16) << 8)
220            | ((angle_level as u16) << 4)
221            | (chamber_byte as u16);
222
223        SnapResult {
224            dodecet,
225            snap_a: best_a,
226            snap_b: best_b,
227            error: best_err,
228            error_normalized: err_norm,
229            error_level: err_level,
230            angle_level,
231            chamber: chamber as u8,
232            parity,
233            is_safe,
234        }
235    }
236
237    /// Classify a point into one of 6 Weyl chambers by sorting barycentric coords.
238    fn classify_chamber(x: f64, y: f64) -> usize {
239        let b1 = x - y * OMEGA_RE / OMEGA_IM;
240        let b2 = y / OMEGA_IM;
241        let b3 = -(b1 + b2);
242
243        let vals = [b1, b2, b3];
244        let indices = [0usize, 1, 2];
245        let mut sorted = indices;
246        sorted.sort_by(|&a, &b| vals[b].partial_cmp(&vals[a]).unwrap_or(std::cmp::Ordering::Equal));
247
248        let perm = (sorted[0], sorted[1], sorted[2]);
249        WEYL_PERMS.iter().position(|&p| p == perm).unwrap_or(0)
250    }
251
252    /// Merge multiple constraint states (fleet consensus).
253    ///
254    /// Pessimistic on error, majority vote on chirality.
255    pub fn merge(&self, results: &[SnapResult]) -> SnapResult {
256        if results.is_empty() {
257            return self.snap(0.0, 0.0);
258        }
259        if results.len() == 1 {
260            return results[0];
261        }
262
263        // Error: take max (pessimistic)
264        let max_err = results.iter().map(|r| r.error).fold(f64::NEG_INFINITY, f64::max);
265        let err_level = (max_err / COVERING_RADIUS * 15.0).round() as u8;
266
267        // Angle: majority vote on angle level
268        let mut angle_votes = [0u16; 16];
269        for r in results {
270            angle_votes[r.angle_level as usize] += 1;
271        }
272        let angle_level = angle_votes.iter().enumerate()
273            .max_by_key(|(_, &v)| v)
274            .map(|(i, _)| i as u8)
275            .unwrap_or(0);
276
277        // Chirality: majority vote
278        let mut chamber_votes = [0u16; 6];
279        for r in results {
280            chamber_votes[r.chamber as usize] += 1;
281        }
282        let chamber = chamber_votes.iter().enumerate()
283            .max_by_key(|(_, &v)| v)
284            .map(|(i, _)| i as u8)
285            .unwrap_or(0) as u8;
286
287        // Safety: all must be safe for merged to be safe
288        let all_safe = results.iter().all(|r| r.is_safe);
289        let safe_bit: u8 = if all_safe { 0 } else { 1 };
290        let chamber_byte = (safe_bit << 3) | (chamber & 0x7);
291
292        let dodecet = ((err_level as u16) << 8)
293            | ((angle_level as u16) << 4)
294            | (chamber_byte as u16);
295
296        let parity = if EVEN_CHAMBERS.contains(&(chamber as usize)) { 1 } else { -1 };
297
298        SnapResult {
299            dodecet,
300            snap_a: 0, // merged result doesn't have a single snap point
301            snap_b: 0,
302            error: max_err,
303            error_normalized: max_err / COVERING_RADIUS,
304            error_level: err_level,
305            angle_level,
306            chamber,
307            parity,
308            is_safe: all_safe,
309        }
310    }
311
312    /// Square-root deadband funnel: δ(t) = ρ·√(1-t)
313    ///
314    /// At t=0: δ=ρ (wide, starting)
315    /// At t=0.5: δ=ρ/√2 ≈ 0.707ρ
316    /// At t=1.0: δ=0 (narrow, snapped)
317    pub fn deadband(&self, t: f64) -> f64 {
318        COVERING_RADIUS * (1.0 - t).sqrt().max(0.0)
319    }
320
321    /// Check if a point satisfies the constraint at the current funnel width.
322    pub fn check(&self, x: f64, y: f64) -> ConstraintVerdict {
323        let result = self.snap(x, y);
324        let threshold = self.deadband(self.funnel_width);
325
326        if result.error <= threshold {
327            ConstraintVerdict::Satisfied(result)
328        } else {
329            ConstraintVerdict::Violated(result)
330        }
331    }
332}
333
334/// Result of constraint check
335#[derive(Debug)]
336pub enum ConstraintVerdict {
337    Satisfied(SnapResult),
338    Violated(SnapResult),
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_covering_radius() {
347        assert!((COVERING_RADIUS - 1.0 / SQRT_3).abs() < 1e-10);
348        assert!((COVERING_RADIUS - 0.577350269).abs() < 1e-6);
349    }
350
351    #[test]
352    fn test_snap_origin() {
353        let ec = EisensteinConstraint::new();
354        let result = ec.snap(0.0, 0.0);
355        assert_eq!(result.snap_a, 0);
356        assert_eq!(result.snap_b, 0);
357        assert!(result.error < 1e-10);
358        assert!(result.is_safe);
359        assert_eq!(result.error_level, 0);
360    }
361
362    #[test]
363    fn test_snap_near_lattice_point() {
364        let ec = EisensteinConstraint::new();
365        // Snap (1, 0) should snap to (1, 0) in Eisenstein
366        let result = ec.snap(1.0, 0.0);
367        assert_eq!(result.snap_a, 1);
368        assert_eq!(result.snap_b, 0);
369        assert!(result.error < 0.01);
370    }
371
372    #[test]
373    fn test_covering_radius_never_exceeded() {
374        let ec = EisensteinConstraint::new();
375        for _ in 0..10000 {
376            let x = rand_float(-10.0, 10.0);
377            let y = rand_float(-10.0, 10.0);
378            let result = ec.snap(x, y);
379            assert!(result.error <= COVERING_RADIUS + 1e-6,
380                "Error {:.6} exceeds ρ {:.6} at ({:.2}, {:.2})",
381                result.error, COVERING_RADIUS, x, y);
382        }
383    }
384
385    #[test]
386    fn test_dodecet_roundtrip() {
387        let ec = EisensteinConstraint::new();
388        let result = ec.snap(1.5, 2.3);
389        let (err, angle, ch, safe) = SnapResult::decode_dodecet(result.dodecet);
390        assert_eq!(err, result.error_level);
391        assert_eq!(angle, result.angle_level);
392        assert_eq!(ch, result.chamber);
393        assert_eq!(safe, result.is_safe);
394    }
395
396    #[test]
397    fn test_weyl_invariance() {
398        let ec = EisensteinConstraint::new();
399        let mut errors_by_chamber: [Vec<f64>; 6] = Default::default();
400        for _ in 0..10000 {
401            let x = rand_float(-5.0, 5.0);
402            let y = rand_float(-5.0, 5.0);
403            let result = ec.snap(x, y);
404            errors_by_chamber[result.chamber as usize].push(result.error);
405        }
406        let means: Vec<f64> = errors_by_chamber.iter()
407            .filter(|v| !v.is_empty())
408            .map(|v| v.iter().sum::<f64>() / v.len() as f64)
409            .collect();
410        let max_spread = means.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
411            - means.iter().cloned().fold(f64::INFINITY, f64::min);
412        // Snap error should be Weyl-invariant (spread < 5%)
413        assert!(max_spread / means[0] < 0.05,
414            "Chamber means too spread: {:?}", means);
415    }
416
417    #[test]
418    fn test_merge_pessimistic() {
419        let ec = EisensteinConstraint::new();
420        let r1 = ec.snap(0.1, 0.1); // small error
421        let r2 = ec.snap(0.5, 0.3); // larger error
422        let merged = ec.merge(&[r1, r2]);
423        // Merged error should be the maximum
424        assert!(merged.error >= r1.error - 1e-6);
425        assert!(merged.error >= r2.error - 1e-6);
426    }
427
428    #[test]
429    fn test_deadband_funnel() {
430        let ec = EisensteinConstraint::new();
431        assert!((ec.deadband(0.0) - COVERING_RADIUS).abs() < 1e-10);
432        assert!(ec.deadband(0.5) < COVERING_RADIUS);
433        assert!(ec.deadband(0.5) > 0.0);
434        assert!((ec.deadband(1.0)).abs() < 1e-10);
435    }
436
437    #[test]
438    fn test_right_skew() {
439        let ec = EisensteinConstraint::new();
440        let mut high_count = 0;
441        let total = 10000;
442        for _ in 0..total {
443            let x = rand_float(-5.0, 5.0);
444            let y = rand_float(-5.0, 5.0);
445            let result = ec.snap(x, y);
446            if result.error_level >= 8 {
447                high_count += 1;
448            }
449        }
450        // Right-skew: majority should be at high error levels
451        assert!(high_count as f64 / total as f64 > 0.60,
452            "Expected >60% at levels 8-15, got {:.1}%",
453            high_count as f64 / total as f64 * 100.0);
454    }
455
456    fn rand_float(min: f64, max: f64) -> f64 {
457        use std::time::{SystemTime, UNIX_EPOCH};
458        static mut SEED: u64 = 0;
459        unsafe {
460            if SEED == 0 {
461                SEED = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
462            }
463            SEED = SEED.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
464            let x = SEED;
465            min + (max - min) * ((x >> 33) as f64 / (1u64 << 31) as f64)
466        }
467    }
468}