|
6 | 6 | //! *is* the residue. `encode` is the compute path (`&self`, read-only); `observe` |
7 | 7 | //! / `roll` are the calibration paths (`&mut self`) — honouring "no `&mut self` |
8 | 8 | //! during computation". |
9 | | -use crate::constants::{EULER_GAMMA, LN_17, MODULUS, STRIDE}; |
| 9 | +use crate::constants::{EULER_GAMMA, GOLDEN_RATIO, LN_17, MODULUS, STRIDE}; |
10 | 10 | use crate::curve_ruler::CurveRuler; |
11 | 11 | use crate::distance::DistanceLut; |
12 | 12 | use crate::fisher_z::Similarity; |
13 | | -use crate::placement::HemispherePoint; |
| 13 | +use crate::placement::{HemispherePoint, Sign}; |
14 | 14 | use crate::quantize::RollingFloor; |
15 | 15 |
|
16 | 16 | /// A residue edge: the `(start, end)` endpoint pair on the φ-spiral curve-ruler, |
@@ -60,6 +60,58 @@ impl ResidueEdge { |
60 | 60 | } |
61 | 61 | } |
62 | 62 |
|
| 63 | +/// Signed full-sphere residue — the 24-bit hemisphere [`ResidueEdge`] **doubled |
| 64 | +/// to 48 bit (6 bytes)**. Maps a signed magnitude to the FULL sphere: the |
| 65 | +/// unsigned hemisphere `rim` edge (rim radius + place anchor via the existing |
| 66 | +/// pipeline), the signed `polar` byte (the equal-area lift `y = sign·√(1 − u)` |
| 67 | +/// quantised, centred at 128 — `> 128` upper hemisphere, `< 128` lower, so the |
| 68 | +/// hemisphere sign is recoverable), and the 16-bit `azimuth` (`n·φ` wrapped to |
| 69 | +/// `[0, 2π)` over the full **360°**). Wire layout (LE): |
| 70 | +/// `[rim.start, rim.end, rim.floor_version, polar, azimuth_lo, azimuth_hi]`. |
| 71 | +/// |
| 72 | +/// This is the codec the contract `HelixResidue` value-tenant reserves 6 bytes |
| 73 | +/// for; the producer writes [`to_bytes`](Signed360::to_bytes). The contract |
| 74 | +/// itself is zero-dep and only reserves the bytes. |
| 75 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 76 | +pub struct Signed360 { |
| 77 | + /// Unsigned hemisphere edge (rim radius + place anchor). 3 bytes. |
| 78 | + pub rim: ResidueEdge, |
| 79 | + /// Signed equal-area lift `y` quantised, centred at 128 (128 = equator, |
| 80 | + /// `> 128` = upper hemisphere, `< 128` = lower). 1 byte. |
| 81 | + pub polar: u8, |
| 82 | + /// Golden azimuth `n·φ mod 2π` mapped to `[0, 65536)` over the full 360°. 2 bytes. |
| 83 | + pub azimuth: u16, |
| 84 | +} |
| 85 | + |
| 86 | +impl Signed360 { |
| 87 | + /// Serialise to 6 bytes (LE): |
| 88 | + /// `[rim.start, rim.end, rim.floor_version, polar, azimuth_lo, azimuth_hi]`. |
| 89 | + pub fn to_bytes(self) -> [u8; 6] { |
| 90 | + let r = self.rim.to_bytes(); |
| 91 | + let a = self.azimuth.to_le_bytes(); |
| 92 | + [r[0], r[1], r[2], self.polar, a[0], a[1]] |
| 93 | + } |
| 94 | + |
| 95 | + /// Deserialise from 6 bytes. |
| 96 | + pub fn from_bytes(b: [u8; 6]) -> Self { |
| 97 | + Self { |
| 98 | + rim: ResidueEdge::from_bytes([b[0], b[1], b[2]]), |
| 99 | + polar: b[3], |
| 100 | + azimuth: u16::from_le_bytes([b[4], b[5]]), |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + /// Which hemisphere this residue sits in — recovered from the `polar` byte |
| 105 | + /// (`>= 128` ⇒ upper [`Sign::Pos`], `< 128` ⇒ lower [`Sign::Neg`]). |
| 106 | + pub fn sign(&self) -> Sign { |
| 107 | + if self.polar >= 128 { |
| 108 | + Sign::Pos |
| 109 | + } else { |
| 110 | + Sign::Neg |
| 111 | + } |
| 112 | + } |
| 113 | +} |
| 114 | + |
63 | 115 | /// The four-stage residue encoder: total residue count `N` + the rolling |
64 | 116 | /// 256-palette floor. |
65 | 117 | #[derive(Debug, Clone)] |
@@ -117,6 +169,28 @@ impl ResidueEncoder { |
117 | 169 | } |
118 | 170 | } |
119 | 171 |
|
| 172 | + /// Encode `(place, n, sign)` into a 6-byte [`Signed360`] — the signed |
| 173 | + /// full-sphere residue (the doubled-hemisphere companion to |
| 174 | + /// [`encode`](Self::encode)). The `rim` reuses the unsigned hemisphere |
| 175 | + /// pipeline; `polar` carries the signed equal-area lift `y = sign·√(1 − u)` |
| 176 | + /// (centred at 128, so the hemisphere sign is recoverable via |
| 177 | + /// [`Signed360::sign`]); `azimuth` is the golden angle `n·φ` over the full 360°. |
| 178 | + pub fn encode_signed(&self, place: u64, n: usize, sign: Sign) -> Signed360 { |
| 179 | + let n = n.min(self.total - 1); |
| 180 | + let rim = self.encode(place, n); |
| 181 | + // Signed equal-area lift y ∈ [−1, 1] → byte centred at 128. |
| 182 | + let p = HemispherePoint::signed_lift(n, self.total, sign); |
| 183 | + let polar = (128.0 + p.y * 127.0).round().clamp(0.0, 255.0) as u8; |
| 184 | + // Golden azimuth n·φ wrapped to [0, 2π) → u16 over the full 360°. |
| 185 | + let az = (n as f64 * GOLDEN_RATIO).rem_euclid(core::f64::consts::TAU); |
| 186 | + let azimuth = ((az / core::f64::consts::TAU) * 65536.0) as u16; |
| 187 | + Signed360 { |
| 188 | + rim, |
| 189 | + polar, |
| 190 | + azimuth, |
| 191 | + } |
| 192 | + } |
| 193 | + |
120 | 194 | /// Calibration: feed an observation through the floor's occupancy monitor. |
121 | 195 | pub fn observe(&mut self, place: u64, n: usize) { |
122 | 196 | let n = n.min(self.total - 1); |
@@ -213,4 +287,64 @@ mod tests { |
213 | 287 | let (_d, _below) = a.distance_heuristic(&a); |
214 | 288 | assert_eq!(a.distance_heuristic(&a).0, 0); |
215 | 289 | } |
| 290 | + |
| 291 | + // ── Signed360 (signed full-sphere, 48-bit) ─────────────────────────────── |
| 292 | + |
| 293 | + #[test] |
| 294 | + fn signed360_byte_roundtrip_is_6_bytes() { |
| 295 | + let enc = ResidueEncoder::new(4096); |
| 296 | + let s = enc.encode_signed(0x1234, 1700, Sign::Neg); |
| 297 | + assert_eq!(Signed360::from_bytes(s.to_bytes()), s); |
| 298 | + assert_eq!( |
| 299 | + s.to_bytes().len(), |
| 300 | + 6, |
| 301 | + "Signed360 is exactly 6 bytes (48 bit)" |
| 302 | + ); |
| 303 | + } |
| 304 | + |
| 305 | + #[test] |
| 306 | + fn signed360_rim_matches_unsigned_encode() { |
| 307 | + let enc = ResidueEncoder::new(4096); |
| 308 | + // The rim edge is the existing unsigned hemisphere encode (sign-independent). |
| 309 | + let rim = enc.encode(0x1234, 1700); |
| 310 | + assert_eq!(enc.encode_signed(0x1234, 1700, Sign::Pos).rim, rim); |
| 311 | + assert_eq!(enc.encode_signed(0x1234, 1700, Sign::Neg).rim, rim); |
| 312 | + } |
| 313 | + |
| 314 | + #[test] |
| 315 | + fn signed360_sign_recoverable_from_polar() { |
| 316 | + let enc = ResidueEncoder::new(4096); |
| 317 | + for n in [1usize, 100, 1700, 4000] { |
| 318 | + let pos = enc.encode_signed(7, n, Sign::Pos); |
| 319 | + let neg = enc.encode_signed(7, n, Sign::Neg); |
| 320 | + assert_eq!( |
| 321 | + pos.sign(), |
| 322 | + Sign::Pos, |
| 323 | + "Pos ⇒ upper hemisphere (polar ≥ 128)" |
| 324 | + ); |
| 325 | + assert_eq!( |
| 326 | + neg.sign(), |
| 327 | + Sign::Neg, |
| 328 | + "Neg ⇒ lower hemisphere (polar < 128)" |
| 329 | + ); |
| 330 | + assert!(pos.polar >= 128 && neg.polar < 128); |
| 331 | + } |
| 332 | + } |
| 333 | + |
| 334 | + #[test] |
| 335 | + fn signed360_azimuth_varies_with_n() { |
| 336 | + let enc = ResidueEncoder::new(4096); |
| 337 | + let a = enc.encode_signed(7, 100, Sign::Pos).azimuth; |
| 338 | + let b = enc.encode_signed(7, 101, Sign::Pos).azimuth; |
| 339 | + assert_ne!(a, b, "consecutive residues get distinct golden azimuths"); |
| 340 | + } |
| 341 | + |
| 342 | + #[test] |
| 343 | + fn signed360_is_deterministic() { |
| 344 | + let enc = ResidueEncoder::new(4096); |
| 345 | + assert_eq!( |
| 346 | + enc.encode_signed(0x99, 2000, Sign::Neg), |
| 347 | + enc.encode_signed(0x99, 2000, Sign::Neg) |
| 348 | + ); |
| 349 | + } |
216 | 350 | } |
0 commit comments