diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec07d6..3bd9583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # changelog +## 0.12.1 (2026-05-22) + +### added +- `GeoCollection::wave_sum` — interfering vector sum of a collection (the superposition), the counterpart to `total_magnitude`; `wave_sum().mag <= total_magnitude()`, the gap is angular cancellation +- `Angle::boost(k)` — celestial-sphere (relativistic aberration) boost: scales the stored half-tangent by the Bondi factor `k` via the grade-keyed Cayley maps, rational, no trig +- `Geonum::boost(axis, k)` — Lorentz event boost: projection onto the two light-cone nulls (a quarter turn apart) scaled by `k` and `1/k`, interval-preserving, for any boost axis +- `chemistry` feature: `Chemistry` extension trait on `Geonum` deriving the periodic table from blade arithmetic — `madelung_order`, `electron_shell`/`electron_wave`, `valence_shell`/`relativistic_valence_shell`, `ionization_projection`, and the observables `ionization_energy` (IE1, IE2, successive), `electron_affinity`, `electronegativity`; configured by the `Lattice` enum (`Canonical`/`Custom`), validated against NIST +- spacetime_test.rs: metric signature as a π rotation, causal structure (timelike/spacelike/lightlike by grade), Lorentz boosts, the dual as the metric involution (`t → −1/t`, fixed points the isotropic vectors), the three conics in one half-tangent +- chem_constants_test.rs: the three lattice constants (π/2, π/3, π/4) proven forced as distinct rotation closures, and the 1/n radial law as inv(winding count) + +### changed +- metric signature test relocated from tensor_test.rs into a dedicated spacetime_test.rs +- chemistry model moved into the `chemistry` library trait; the chemistry test suites thinned to validate it against NIST + ## 0.12.0 (2026-03-31) ### fixed diff --git a/Cargo.lock b/Cargo.lock index f9ea663..5ca47f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "geonum" -version = "0.12.0" +version = "0.12.1" dependencies = [ "criterion", "geonum", diff --git a/Cargo.toml b/Cargo.toml index 8610ab7..d433535 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "geonum" -version = "0.12.0" +version = "0.12.1" edition = "2021" repository = "https://github.com/mxfactorial/geonum" description = "geometric number library supporting unlimited dimensions with O(1) complexity" @@ -18,7 +18,8 @@ ml = [] em = [] waves = [] affine = [] -all = ["optics", "projection", "ml", "em", "waves", "affine"] +chemistry = [] +all = ["optics", "projection", "ml", "em", "waves", "affine", "chemistry"] [dependencies] diff --git a/benches/geonum_benchmarks.rs b/benches/geonum_benchmarks.rs index b74952e..81a53aa 100644 --- a/benches/geonum_benchmarks.rs +++ b/benches/geonum_benchmarks.rs @@ -1161,8 +1161,8 @@ fn bench_rendering(c: &mut Criterion) { let cos_r = (PI / 6.0).cos(); let sin_r = (PI / 6.0).sin(); let mut mat = [[0.0_f64; 10]; 10]; - for i in 0..10 { - mat[i][i] = 1.0; + for (i, row) in mat.iter_mut().enumerate() { + row[i] = 1.0; } mat[0][0] = cos_r; mat[0][1] = -sin_r; @@ -1190,8 +1190,8 @@ fn bench_rendering(c: &mut Criterion) { let cos_r = (PI / 6.0).cos(); let sin_r = (PI / 6.0).sin(); let mut mat = [[0.0_f64; 100]; 100]; - for i in 0..100 { - mat[i][i] = 1.0; + for (i, row) in mat.iter_mut().enumerate() { + row[i] = 1.0; } mat[0][0] = cos_r; mat[0][1] = -sin_r; diff --git a/src/angle.rs b/src/angle.rs index 53cc795..a827bf4 100644 --- a/src/angle.rs +++ b/src/angle.rs @@ -557,6 +557,64 @@ impl Angle { let (cos_val, _) = diff.cos_sin(); cos_val } + + /// boosts this direction on the celestial sphere by the Bondi factor `k` + /// + /// a unit direction at polar angle θ from the boost axis has stereographic + /// coordinate tan(θ/2). a lorentz boost along the axis is the Möbius dilation + /// that scales it by 1/k, where k = √((1+β)/(1−β)) = e^φ is the Bondi + /// (Doppler) factor for velocity β, rapidity φ. this is relativistic + /// aberration — k > 1 crowds directions toward the forward axis (the + /// headlight effect) + /// + /// the stereographic coordinate is rational in the stored (grade, t) — the + /// Cayley maps of the half-tangent — so the boost is one rational scale, no + /// trig, for any blade: + /// * grade 0: tan(θ/2) = t grade 1: (1+t)/(1−t) + /// * grade 2: −1/t grade 3: (t−1)/(t+1) + /// + /// the forward pole (θ=0) and backward pole (θ=π) are the fixed points + /// + /// # arguments + /// * `k` - the Bondi / Doppler factor (k > 0); k > 1 boosts toward the axis + /// + /// # examples + /// ``` + /// use geonum::Angle; + /// let ray = Angle::new(1.0, 3.0); // θ = π/3, forward hemisphere + /// let aberrated = ray.boost(2.0); // boost by Bondi factor k = 2 + /// // a forward ray's stereographic coordinate (its stored t) scales by 1/k + /// assert!((aberrated.t() - ray.t() / 2.0).abs() < 1e-10); + /// ``` + pub fn boost(&self, k: f64) -> Angle { + const EPSILON: f64 = 1e-10; + + // the backward pole θ=π (stereographic coordinate ∞) is a fixed point + if self.grade() == 2 && self.t.abs() < EPSILON { + return *self; + } + + // stereographic coordinate tan(θ/2), rational in (grade, t) + let t = self.t; + let s = match self.grade() { + 0 => t, + 1 => (1.0 + t) / (1.0 - t), + 2 => -1.0 / t, + _ => (t - 1.0) / (t + 1.0), + }; + + // the Möbius dilation, then rebuild into the quadrant it landed in + let s = s / k; + if (0.0..=1.0).contains(&s) { + Angle::from_parts(0, s) + } else if s > 1.0 { + Angle::from_parts(1, (s - 1.0) / (s + 1.0)) + } else if s < -1.0 { + Angle::from_parts(2, -1.0 / s) + } else { + Angle::from_parts(3, (1.0 + s) / (1.0 - s)) + } + } } /// normalize negative blade to positive by adding full rotations @@ -1860,4 +1918,30 @@ mod tests { let c = Angle::new(1.0, 2.0); // π/2 assert!(c.near_rem(0.0)); } + + #[test] + fn it_boosts_the_half_tangent_by_the_bondi_factor() { + // forward hemisphere (grade 0): the boost scales the stored half-tangent + // by 1/k — one rational division, no trig + let ray = Angle::new(1.0, 3.0); // π/3 + assert!((ray.boost(2.0).t() - ray.t() / 2.0).abs() < EPSILON); + + // k = 1 is the identity + assert!(ray.boost(1.0).near(&ray)); + + // both poles are fixed points: the forward axis θ=0 (t=0) and the + // backward pole θ=π (grade 2, t=0) + assert!(Angle::new(0.0, 1.0).boost(2.0).t().abs() < EPSILON); + let back = Angle::new(1.0, 1.0); // π + assert!(back.boost(2.0).near(&back)); + + // boosts compose: the Bondi dilations multiply (rapidity adds) + assert!((ray.boost(1.5).boost(2.0).t() - ray.boost(3.0).t()).abs() < EPSILON); + + // a backward-hemisphere ray (grade 1, past π/2) boosts across the blade + // boundary into the forward hemisphere under a strong enough boost + let rear = Angle::new(2.0, 3.0); // 2π/3, grade 1 + assert_eq!(rear.blade(), 1); + assert_eq!(rear.boost(0.6_f64.exp()).grade(), 0); + } } diff --git a/src/geocollection.rs b/src/geocollection.rs index 910e59e..ac80542 100644 --- a/src/geocollection.rs +++ b/src/geocollection.rs @@ -145,6 +145,17 @@ impl GeoCollection { self.objects.iter().map(|g| g.mag).sum() } + /// the interfering vector sum of all objects — the superposition. complements + /// [`total_magnitude`](Self::total_magnitude) (the scalar sum): because `+` is + /// cosine interference, `wave_sum().mag <= total_magnitude()`, the gap being + /// angular cancellation. this is the wave the electron shell, the attention + /// output, and summed decay products are each a vector sum of + pub fn wave_sum(&self) -> Geonum { + self.objects + .iter() + .fold(Geonum::scalar(0.0), |acc, g| acc + *g) + } + /// finds the dominant object (largest magnitude) in the collection /// /// useful for identifying primary contributions, maximum forces, @@ -188,6 +199,31 @@ mod tests { use crate::{Angle, Geonum}; use std::f64::consts::PI; + #[test] + fn it_superposes_with_interference_below_the_scalar_sum() { + // two equal magnitudes a half turn apart cancel: the vector sum is zero + // while the scalar sum is 2 — interference, not addition of lengths + let opposed = GeoCollection::from(vec![ + Geonum::new(1.0, 0.0, 1.0), // [1, 0] + Geonum::new(1.0, 1.0, 1.0), // [1, π] + ]); + assert!(opposed.wave_sum().mag < 1e-10); + assert_eq!(opposed.total_magnitude(), 2.0); + + // aligned objects reinforce: the vector sum reaches the scalar sum + let aligned = + GeoCollection::from(vec![Geonum::new(1.0, 1.0, 4.0), Geonum::new(1.0, 1.0, 4.0)]); + assert!(aligned.wave_sum().near_mag(aligned.total_magnitude())); + + // in general the wave sum never exceeds the scalar sum + let mixed = GeoCollection::from(vec![ + Geonum::new(2.0, 1.0, 6.0), + Geonum::new(3.0, 1.0, 4.0), + Geonum::new(1.0, 7.0, 6.0), + ]); + assert!(mixed.wave_sum().mag <= mixed.total_magnitude() + 1e-10); + } + #[test] fn it_creates_empty_collection() { let collection = GeoCollection::new(); diff --git a/src/geonum_mod.rs b/src/geonum_mod.rs index f3701f4..ce87332 100644 --- a/src/geonum_mod.rs +++ b/src/geonum_mod.rs @@ -666,6 +666,46 @@ impl Geonum { } } + /// boosts this event by the Bondi factor `k` along the spatial direction `axis` + /// + /// a lorentz boost is a squeeze of the (space, time) plane that fixes the + /// light cone. the cone's two null rays sit a quarter turn off the boost axis + /// (the forward null) and three quarters off (the backward null). the boost + /// projects the event onto each null, stretches the forward one by k = e^φ + /// and compresses the backward one by 1/k, then sums — projection and scale, + /// no (t±x) component arithmetic. the interval is preserved because the two + /// scalings cancel, k·(1/k) = 1 + /// + /// this is the boost on a spacetime VECTOR — it keeps magnitude, a squeeze. + /// [`Angle::boost`] is the companion action on a celestial DIRECTION, where + /// dropping the magnitude turns the same null-pair scaling into aberration + /// + /// # arguments + /// * `axis` - the spatial boost direction; the event's angle is read from it, + /// so `Angle::new(0.0, 1.0)` boosts along x (nulls at π/4 and 3π/4) + /// * `k` - the Bondi / Doppler factor e^φ for rapidity φ (k > 0); k > 1 boosts + /// toward the axis + /// + /// # examples + /// ``` + /// use geonum::{Geonum, Angle}; + /// let event = Geonum::new_from_cartesian(0.5, 2.0); // (x, t) = (0.5, 2.0) + /// let boosted = event.boost(Angle::new(0.0, 1.0), 0.6_f64.exp()); // along x + /// // the interval t²−x² is invariant under the boost + /// let (cos, sin) = boosted.angle.cos_sin(); + /// let (xb, tb) = (boosted.mag * cos, boosted.mag * sin); + /// assert!((tb * tb - xb * xb - (2.0 * 2.0 - 0.5 * 0.5)).abs() < 1e-9); + /// ``` + pub fn boost(&self, axis: Angle, k: f64) -> Geonum { + // the two light-cone nulls: a forward ray a quarter turn off the axis, + // a backward ray three quarters off — bisecting the axis and time + let forward = Geonum::new_with_angle(1.0, axis + Angle::new(1.0, 4.0)); // axis + π/4 + let backward = Geonum::new_with_angle(1.0, axis + Angle::new(3.0, 4.0)); // axis + 3π/4 + + // stretch the forward null by k, compress the backward by 1/k, sum + self.project(&forward).scale(k) + self.project(&backward).scale(1.0 / k) + } + /// computes distance between two points using law of cosines /// returns a scalar geonum representing the distance pub fn distance_to(&self, other: &Geonum) -> Geonum { @@ -3224,4 +3264,42 @@ mod tests { assert!(a.near_mag(5.0 + 1e-12)); // within tolerance assert!(!a.near_mag(5.0 + 1e-8)); // outside tolerance } + + #[test] + fn it_boosts_an_event_preserving_the_interval() { + let x_axis = Angle::new(0.0, 1.0); + let k = 0.6_f64.exp(); + + // boost an event (x, t) along the x-axis; the interval t²−x² is invariant + let event = Geonum::new_from_cartesian(0.5, 2.0); // (x, t) + let b = event.boost(x_axis, k); + let (cos, sin) = b.angle.cos_sin(); + let (xb, tb) = (b.mag * cos, b.mag * sin); + assert!((tb * tb - xb * xb - (2.0 * 2.0 - 0.5 * 0.5)).abs() < 1e-9); + + // boosts compose: the Bondi factors multiply + let twice = event.boost(x_axis, 1.5).boost(x_axis, 2.0); + let once = event.boost(x_axis, 3.0); + assert!(twice.near(&once)); + + // the light cone is invariant: a null event (t = x) stays null + let null = Geonum::new_from_cartesian(1.0, 1.0); + let nb = null.boost(x_axis, k); + let (c2, s2) = nb.angle.cos_sin(); + let (xn, tn) = (nb.mag * c2, nb.mag * s2); + assert!((tn * tn - xn * xn).abs() < 1e-9); + + // the axis is a free parameter: boosting along a tilted direction still + // preserves the interval measured in that axis's frame + let tilt = Angle::new(1.0, 5.0); // π/5 + let n = Geonum::new_with_angle(1.0, tilt); + let along = event.mag * event.angle.project(tilt); + let perp = event.reject(&n).mag; + let bt = event.boost(tilt, k); + let along_b = bt.mag * bt.angle.project(tilt); + let perp_b = bt.reject(&n).mag; + assert!( + ((perp_b * perp_b - along_b * along_b) - (perp * perp - along * along)).abs() < 1e-9 + ); + } } diff --git a/src/lib.rs b/src/lib.rs index c75037d..d88dacb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,3 +37,5 @@ pub use traits::Projection; pub use traits::Waves; #[cfg(feature = "ml")] pub use traits::{Activation, MachineLearning}; +#[cfg(feature = "chemistry")] +pub use traits::{Chemistry, Lattice}; diff --git a/src/traits/chemistry.rs b/src/traits/chemistry.rs new file mode 100644 index 0000000..88b141c --- /dev/null +++ b/src/traits/chemistry.rs @@ -0,0 +1,241 @@ +//! chemistry trait implementation +//! +//! the periodic table from blade arithmetic. the madelung filling order is a +//! blade-chain walk (tier T = n+l is a blade); the electron shell is the +//! [`wave_sum`](crate::GeoCollection::wave_sum) of electron geonums placed at the +//! lattice angles; ionization energy, electron affinity, and electronegativity +//! are projections of the marginal electron over the valence shell. validated +//! against NIST in the chemistry test suites. + +use crate::{Angle, GeoCollection, Geonum}; + +/// rydberg energy (eV) — the hydrogenic binding scale +const RYDBERG: f64 = 13.6; + +/// fine-structure constant — fixed by nature, drives the relativistic contraction +const ALPHA: f64 = 1.0 / 137.035_999_084; + +/// the 1/n radial law (Bohr momentum ∝ 1/n) +fn bohr(n: usize) -> f64 { + 1.0 / n as f64 +} + +/// the constants the model runs on. `Canonical` is the assignment the suite proves +/// forced: spin = π/3 (the pairing closure), radial = 1/n, q = π/4 (the phase +/// coefficient). `Custom` varies them to probe why the canonical one is forced +/// (tests/chem_constants_test.rs). spread = π/2 is the lattice itself and is +/// never varied, so it is not part of the configuration. +#[derive(Clone, Copy)] +pub enum Lattice { + Canonical, + Custom { + spin: Angle, + radial: fn(usize) -> f64, + q: Angle, + }, +} + +impl Lattice { + fn constants(self) -> (Angle, fn(usize) -> f64, Angle) { + match self { + Lattice::Canonical => (Angle::new(1.0, 3.0), bohr, Angle::new(1.0, 4.0)), + Lattice::Custom { spin, radial, q } => (spin, radial, q), + } + } +} + +/// the orbital positions of a subshell at grade l: 2l+1 orbitals stepping across +/// the π/2 quadrant from `base`, each with its spin pair one `spin` offset away +fn grade_positions(base: Angle, l: usize, spread: Angle, spin: Angle) -> Vec { + let n_orb = 2 * l + 1; + let orbital_step = spread / n_orb as f64; + let mut pos = Vec::new(); + let mut angle = base; + for _ in 0..n_orb { + pos.push(angle); + pos.push(angle + spin); + angle = angle + orbital_step; + } + pos +} + +/// the last-filled shell — the naive outer-shell rule the d-block madelung +/// inversion breaks (4s fills before 3d). internal: feeds the relativistic +/// contraction's overshoot term. the spatial rule consumers want is `valence_shell` +fn last_filled(z: usize) -> usize { + let mut placed = 0; + let mut n = 1; + for (nn, l) in Geonum::madelung_order(6) { + if placed >= z { + break; + } + n = nn; + placed += (2 * (2 * l + 1)).min(z - placed); + } + n +} + +/// the unsigned binding of the (z+1)th electron stepping on — a screened (+1) +/// nucleus, projected over the anion's valence shell. shared by `electron_affinity` +/// (signed) and `electronegativity` +fn affinity_binding(z: usize, lattice: Lattice) -> f64 { + let screened = Geonum::new(1.0, 0.0, 1.0); + let marginal = Geonum::electron_wave(z + 1, lattice) - Geonum::electron_wave(z, lattice); + marginal.ionization_projection(screened, Geonum::valence_shell(z + 1) as f64, lattice) +} + +/// the periodic table as blade arithmetic — an extension trait on [`Geonum`] +pub trait Chemistry: Sized { + /// the madelung filling order as (n, l) pairs up to principal shell `max_n`, + /// walked as a blade chain: tier T = n+l is a blade, each tier's diagonal + /// trades l for n + fn madelung_order(max_n: usize) -> Vec<(usize, usize)>; + + /// the `z` electron geonums, placed in madelung order at the lattice angles + /// with the radial law — the shell as a collection + fn electron_shell(z: usize, lattice: Lattice) -> GeoCollection; + + /// the electron shell summed into one wave — `electron_shell(z).wave_sum()` + fn electron_wave(z: usize, lattice: Lattice) -> Self; + + /// the spatial valence shell — the largest n across filled subshells + fn valence_shell(z: usize) -> usize; + + /// the relativistic effective valence shell: `valence_shell` contracted toward + /// the inner d by (Zα)² × periods-since-the-d-inversion × the overshoot. for + /// the heavy 5d row where the 6s contraction reverses the max(n) rule + fn relativistic_valence_shell(z: usize) -> f64; + + /// the low-level projection: `self` (a marginal electron) scaled by `nuclear`, + /// projected over `n²` through the phase coefficient — the building block of + /// every ionization observable + fn ionization_projection(&self, nuclear: Self, n: f64, lattice: Lattice) -> f64; + + /// ionization energy in eV of the species with `z` protons and `electrons` + /// electrons. IE1 = `ionization_energy(z, z)`, IE2 = `(z, z-1)`, and successive + /// ionization follows. the nuclear factor carries the exposed core charge, so + /// deep stripping recovers the hydrogenic Z² limit + fn ionization_energy(z: usize, electrons: usize, lattice: Lattice) -> f64; + + /// signed electron affinity in eV: the next electron stepping on. bound + /// (positive) for open shells, repulsive (negative) where it would open a new + /// shell — a closed-shell marginal lands at grade 2 and the sign flips + fn electron_affinity(z: usize, lattice: Lattice) -> f64; + + /// Mulliken electronegativity, (IE1 + EA binding)/2 + fn electronegativity(z: usize, lattice: Lattice) -> f64; +} + +impl Chemistry for Geonum { + fn madelung_order(max_n: usize) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + let mut tier = Geonum::new(1.0, 1.0, 2.0); // π/2, blade 1 — the 1s tier + while tier.angle.blade() < 2 * max_n { + let t = tier.angle.blade(); // tier T = n+l, the rotation count + let l_start = (t - 1) / 2; // diagonal start: the largest l with l < n + let mut n = t - l_start; + let mut l = l_start; + loop { + if n <= max_n { + out.push((n, l)); + } + if l == 0 { + break; + } + n += 1; // the diagonal step: trade one l for one n + l -= 1; + } + tier = tier.increment_blade(); // π/2 rotation to the next tier + } + out + } + + fn electron_shell(z: usize, lattice: Lattice) -> GeoCollection { + let (spin, radial, _) = lattice.constants(); + let spread = Angle::new(1.0, 2.0); // π/2 — the lattice + let mut electrons = Vec::new(); + let mut placed = 0; + for (n, l) in Geonum::madelung_order(6) { + if placed >= z { + break; + } + let mut base = Angle::new(1.0, 1.0); // π + for _ in 0..l { + base = base + spread; + } + let positions = grade_positions(base, l, spread, spin); + let to_fill = positions.len().min(z - placed); + let mag = radial(n); + for &p in positions.iter().take(to_fill) { + electrons.push(Geonum::new_with_angle(mag, p)); + } + placed += to_fill; + } + GeoCollection::from(electrons) + } + + fn electron_wave(z: usize, lattice: Lattice) -> Geonum { + Geonum::electron_shell(z, lattice).wave_sum() + } + + fn valence_shell(z: usize) -> usize { + let mut placed = 0; + let mut n = 1; + for (nn, l) in Geonum::madelung_order(6) { + if placed >= z { + break; + } + if nn > n { + n = nn; // running max — the spatial valence shell + } + placed += (2 * (2 * l + 1)).min(z - placed); + } + n + } + + fn relativistic_valence_shell(z: usize) -> f64 { + let n_max = Geonum::valence_shell(z) as f64; + let n_last = last_filled(z) as f64; + let lorentz = (z as f64 * ALPHA).powi(2); + let periods_since_inversion = (n_max - 4.0).max(0.0); + n_max - lorentz * periods_since_inversion * (n_max - n_last) + } + + fn ionization_projection(&self, nuclear: Self, n: f64, lattice: Lattice) -> f64 { + let (_, _, q) = lattice.constants(); + let p = nuclear * *self; // self = the marginal electron + let ref0 = Geonum::new(1.0, 0.0, 1.0); + let ref_q = Geonum::new_with_angle(1.0, Angle::new(1.0, 2.0)); + let adj = p.project(&ref0); + let opp = p.project(&ref_q); + RYDBERG * (adj.mag + q.grade_angle() * opp.mag) / (n * n) + } + + fn ionization_energy(z: usize, electrons: usize, lattice: Lattice) -> f64 { + // the exposed core charge: the electrons-1 inner electrons screen + // electrons-1 protons, leaving z-(electrons-1) exposed to the marginal + let nucleus = Geonum::new(z as f64, 0.0, 1.0); + let exposed = Geonum::new((z - (electrons - 1)) as f64, 0.0, 1.0); + let marginal = Geonum::electron_wave(electrons, lattice) + - Geonum::electron_wave(electrons - 1, lattice); + marginal.ionization_projection( + nucleus.geo(&exposed), + Geonum::valence_shell(electrons) as f64, + lattice, + ) + } + + fn electron_affinity(z: usize, lattice: Lattice) -> f64 { + let marginal = Geonum::electron_wave(z + 1, lattice) - Geonum::electron_wave(z, lattice); + let bind = affinity_binding(z, lattice); + if marginal.angle.grade() == 2 { + -bind // a closed shell refuses the electron — repulsive + } else { + bind + } + } + + fn electronegativity(z: usize, lattice: Lattice) -> f64 { + (Geonum::ionization_energy(z, z, lattice) + affinity_binding(z, lattice)) / 2.0 + } +} diff --git a/src/traits/mod.rs b/src/traits/mod.rs index adbf3b3..d2379a9 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -33,3 +33,8 @@ pub use waves::Waves; pub mod affine; #[cfg(feature = "affine")] pub use affine::Affine; + +#[cfg(feature = "chemistry")] +pub mod chemistry; +#[cfg(feature = "chemistry")] +pub use chemistry::{Chemistry, Lattice}; diff --git a/tests/chem_constants_test.rs b/tests/chem_constants_test.rs new file mode 100644 index 0000000..56b8e2f --- /dev/null +++ b/tests/chem_constants_test.rs @@ -0,0 +1,588 @@ +// why the three lattice constants are π/2, π/3, π/4 — and why all three +// +// the chemistry IE model (tests/chemistry_test.rs, acts VII-XII) runs on three +// constants with denominators 2, 3, 4 and calls them "zero fitted parameters". +// this file proves that claim: the denominators are not chosen small, they are +// the three smallest each first to fill a distinct rotation-closure role on the +// π/2 lattice. and the two non-lattice roles cannot collapse into one. +// +// the lattice is quadrature: sin(θ+π/2) = cos(θ) forces a 4-cycle on grades +// (0→1→2→3→0 under π/2 rotation). π/2 is not a peer of π/3 and π/4 — it is the +// ambient geometry the other two live inside. so the real question is why +// pairing lands at π/3 and the phase shift at π/4. +// +// 2 — the Q-period: the quadrature quarter-turn, the lattice itself +// 4 — the Q-bisector: the unique angle equidistant from the two poles, where +// projection onto the 0-axis equals projection onto the π/2-axis — a clean +// lattice landmark at 45°. (the IE uses π/4's radian as a band-confined +// phase coefficient, NOT this bisector projection — see it_bisects) +// 3 — the D-closure: the smallest fraction whose rotation cycle first lands on +// a pure blade at π (the dual, a π rotation, blade+2) WITHOUT ever hitting +// π/2. the smallest non-Q closure +// +// 5, 6, 7 fill no new role: 5 and 7 are D-closures larger than 3, and 6 = 2·3 +// closes Q like 4 but composite. so the ANGLE denominators 2, 3, 4 are forced, +// not fitted. +// +// the magnitude 1/n is the other half, and it is derived too — by a different +// geometric operation. rotation preserves magnitude, so the angular closure +// cannot reach 1/n. division can: shell n is n windings of the four-blade 2π +// cycle (act IV blade_count_is_shell), and 1/n is the inverse of that winding +// count — the rotational extent read as a temporal rate, the way tan = sin/cos +// forms a rate in trigonometry_test. the final tests show 1/n = inv(n) and that +// it is load-bearing. the model runs on two geometric operations: rotation for +// the angular lattice (2/3/4), inversion for the radial rate (1/n). +// +// run: cargo test --test chem_constants_test -- --show-output + +use geonum::*; + +const EPSILON: f64 = 1e-10; +const RYDBERG: f64 = 13.6; +const ROOT_HALF: f64 = std::f64::consts::FRAC_1_SQRT_2; // √2/2 = cos(π/4) = sin(π/4) + +// NIST first ionization energies, Z=1..=18 (eV) +const EXP: [f64; 18] = [ + 13.598, 24.587, 5.392, 9.323, 8.298, 11.260, 14.534, 13.618, 17.423, 21.565, 5.139, 7.646, + 5.986, 8.152, 10.487, 10.360, 12.968, 15.760, +]; + +// the first step where a π/k rotation cycle lands on a pure blade (t = 0), and +// the blade it lands on. blade 1 is the π/2 quarter (a Q closure); blade 2 is π, +// the dual (a D closure). rotating from 0 by π/k, count steps to the first t=0 +fn first_pure_blade(k: f64) -> (usize, usize) { + let step = Angle::new(1.0, k); + let mut a = Angle::new(0.0, 1.0); // blade 0, t 0 + for s in 1..=(2.0 * k).ceil() as usize { + a = a + step; + if a.t().abs() < EPSILON { + return (s, a.blade()); + } + } + unreachable!("a π/{k} cycle reaches a pure blade by 2k steps") +} + +#[test] +fn it_closes_the_quadrature_lattice_with_pi_over_2() { + // π/2 is the lattice: one π/2 rotation advances the grade by one, and four + // of them return to the start — the 4-cycle quadrature forces. this is not a + // constant chosen beside the others, it is the geometry they sit in + let quarter = Angle::new(1.0, 2.0); // π/2 + let mut a = Angle::new(0.0, 1.0); // grade 0 + for expected in [1usize, 2, 3, 0] { + a = a + quarter; + assert_eq!( + a.grade(), + expected, + "π/2 rotation advances the grade by one" + ); + } + // four quarter-turns close the cycle: blade advanced by exactly 4 + assert_eq!( + a.blade(), + 4, + "four π/2 turns close the lattice (full 2π cycle)" + ); + + // the closure IS quadrature: differentiation is a +π/2 rotation, so it + // cycles the grades f→f'→f''→f'''→f. sin(θ+π/2) = cos(θ) + let f = Geonum::new(1.0, 0.0, 1.0); // grade 0 + let mut d = f; + for expected in [1usize, 2, 3, 0] { + d = d.differentiate(); + assert_eq!(d.angle.grade(), expected, "differentiate is a π/2 rotation"); + } +} + +#[test] +fn it_bisects_the_quadrature_with_pi_over_4() { + // π/4 is the bisector of the lattice quarter: the unique angle in (0, π/2) + // equidistant from the 0-axis and the π/2-axis — projection onto adjacent + // (cos) equals projection onto opposite (sin), both √2/2. that fixes π/4 as a + // clean lattice landmark at 45°, which is why a clean rational coefficient is + // available there for the IE to use. + // + // the IE formula does NOT use this bisector projection: it keeps adj at full + // weight (the hydrogenic backbone) and weights opp by π/4's radian value + // ≈ 0.785 — a radial-dominant ray at atan(π/4) ≈ 38°. the symmetric 45° ray + // would need coefficient tan(π/4) = 1 and would break the hydrogenic series. + // so π/4 enters the IE as a band-confined coefficient (proven in + // it_projects_the_opp_combiner_onto_the_atan_ray and + // it_proves_the_opp_coefficient_is_load_bearing), distinct from the bisector + // proven below + let bisector = Angle::new(1.0, 4.0); // π/4 + let pole_0 = Angle::new(0.0, 1.0); // the 0-axis (adj) + let pole_q = Angle::new(1.0, 2.0); // the π/2-axis (opp) + + let onto_0 = bisector.project(pole_0); // cos(π/4) + let onto_q = bisector.project(pole_q); // cos(π/4 − π/2) = cos(π/4) + assert!( + (onto_0 - onto_q).abs() < EPSILON, + "π/4 projects equally onto both poles: {onto_0:.6} vs {onto_q:.6}" + ); + assert!( + (onto_0 - ROOT_HALF).abs() < EPSILON, + "the equidistant value is √2/2" + ); + + // cos = sin only at the bisector. the other small fractions tilt toward one + // pole — π/3 and π/6 are reflections of each other across π/4, neither equal + for k in [3.0, 6.0] { + let a = Angle::new(1.0, k); + let gap = (a.project(pole_0) - a.project(pole_q)).abs(); + assert!( + gap > 0.3, + "π/{k} is not equidistant from the poles (gap {gap:.3})" + ); + } + + // π/4 = (π/2)/2 — literally the midpoint of the lattice quarter + let midpoint = pole_q.project(bisector); // cos(π/2 − π/4) = cos(π/4) + assert!( + (midpoint - ROOT_HALF).abs() < EPSILON, + "π/4 bisects the quarter" + ); +} + +#[test] +fn it_closes_the_dual_with_pi_over_3() { + // π/3 is the D-closure: three π/3 rotations sum to π — a dual, the π rotation + // that adds 2 blades and maps grade 0↔2. the cycle first lands on a pure + // blade at π (blade 2), SKIPPING the π/2 quarter (blade 1) entirely + let third = Angle::new(1.0, 3.0); // π/3 + let mut a = Angle::new(0.0, 1.0); + + // step 1: π/3 carries a remainder — not on the lattice + a = a + third; + assert!(a.t().abs() > EPSILON, "π/3 is off the pure-blade lattice"); + // step 2: 2π/3 still carries a remainder — the quarter is skipped + a = a + third; + assert!(a.t().abs() > EPSILON, "2π/3 skips the π/2 quarter"); + // step 3: π — a pure blade at last, blade 2, grade 2: the dual + a = a + third; + assert!(a.t().abs() < EPSILON, "3·π/3 = π lands on a pure blade"); + assert_eq!(a.blade(), 2, "the closure is at π — blade 2"); + + // and that pure blade IS the dual: a π rotation, what .dual() applies + let dual_of_zero = Geonum::new(1.0, 0.0, 1.0).dual(); + assert_eq!( + a.blade(), + dual_of_zero.angle.blade(), + "three π/3 turns equal one dual (π, blade+2)" + ); + + // π/3 is the SMALLEST such closure: the first pure blade it reaches is the + // dual (blade 2) at 3 steps. π/2 and π/4 instead close on the quarter + assert_eq!( + first_pure_blade(3.0), + (3, 2), + "π/3 first closes at the dual" + ); + assert_eq!(first_pure_blade(2.0), (1, 1), "π/2 closes on the quarter"); + assert_eq!(first_pure_blade(4.0), (2, 1), "π/4 closes on the quarter"); +} + +#[test] +fn it_assigns_2_3_4_to_three_distinct_closures() { + // sweep k = 2..=7 and read which closure each π/k first reaches. blade 1 is a + // Q closure (the quarter); blade 2 is a D closure (the dual). the three + // smallest each first to fill a distinct role are 2, 3, 4 + let landings: Vec<(usize, (usize, usize))> = + (2..=7).map(|k| (k, first_pure_blade(k as f64))).collect(); + + // 2 → Q at 1 step (the lattice), 4 → Q at 2 steps (the bisector), + // 3 → D at 3 steps (first dual closure). the rest replicate: + // 5 → D at 5 (a slower 3), 6 → Q at 3 (2·3, composite), 7 → D at 7 + let expected = [ + (2usize, (1usize, 1usize)), // Q-lattice + (3, (3, 2)), // D-closure (first) + (4, (2, 1)), // Q-bisector (first even > 2) + (5, (5, 2)), // D, slower than 3 + (6, (3, 1)), // Q, but 6 = 2·3 + (7, (7, 2)), // D, slower than 3 + ]; + assert_eq!(landings, expected, "the closure each π/k first reaches"); + + // the distinct roles, each held by the smallest k that fills it: + // Q-lattice : the unique 1-step quarter closure → k = 2 + // Q-bisector: the smallest quarter closure past the lattice → k = 4 + // D-closure : the smallest dual closure → k = 3 + let q_lattice = landings.iter().find(|(_, l)| *l == (1, 1)).unwrap().0; + let dual_closures: Vec = landings + .iter() + .filter(|(_, (_, b))| *b == 2) + .map(|(k, _)| *k) + .collect(); + let bisectors: Vec = landings + .iter() + .filter(|(_, (s, b))| *b == 1 && *s > 1) + .map(|(k, _)| *k) + .collect(); + + assert_eq!( + q_lattice, 2, + "the lattice is the unique 1-step quarter closure" + ); + assert_eq!( + *dual_closures.iter().min().unwrap(), + 3, + "the smallest dual closure is π/3" + ); + assert_eq!( + *bisectors.iter().min().unwrap(), + 4, + "the smallest bisector past the lattice is π/4" + ); + + // 5, 6, 7 add nothing: 5 and 7 are dual closures larger than 3, and 6 is a + // quarter closure larger than 4 (and composite, 6 = 2·3). every role is + // already held by a smaller denominator + for &k in &[5usize, 6, 7] { + let (_, b) = first_pure_blade(k as f64); + let role_holder = if b == 2 { 3 } else { 4 }; + assert!( + k > role_holder, + "π/{k} replicates the role of π/{role_holder}" + ); + } + + eprintln!("\n═══ the denominators are forced, not fitted ═══\n"); + eprintln!(" k first pure blade closure role"); + for (k, (steps, blade)) in &landings { + let closure = if *blade == 1 { + "quarter (Q)" + } else { + "dual (D) " + }; + let role = match (k, blade) { + (2, _) => "Q-lattice (the geometry)", + (4, _) => "Q-bisector (phase coeff)", + (3, _) => "D-closure (pairing)", + (_, 1) => "→ replicates π/4", + _ => "→ replicates π/3", + }; + eprintln!(" {k} step {steps} → blade {blade} {closure} {role}"); + } +} + +#[test] +fn it_needs_both_a_dual_closure_and_a_bisector() { + // why does IE use TWO distinct constants for spin and phase, not one repeated? + // because π/3 and π/4 are geometrically disjoint lattice closures — π/3 closes + // the dual, π/4 bisects the quarter — so neither can stand in for the other. + // the IE uses π/3 as the spin/pairing offset and π/4's radian as the phase + // coefficient; this proves the two are not interchangeable lattice constants + let third = Angle::new(1.0, 3.0); // π/3, the D-closure (spin) + let quarter = Angle::new(1.0, 4.0); // π/4, the bisector + let pole_0 = Angle::new(0.0, 1.0); + let pole_q = Angle::new(1.0, 2.0); + + // one distinguishing property is the quarter-bisector: the angle equidistant + // from both poles (cos = sin). π/4 has it; π/3 tilts toward the 0-axis. this is + // what separates them geometrically — NOT a claim that the IE combiner needs + // balance (it does not; it weights adj over opp — see it_bisects, audit #1) + let q_balance = (quarter.project(pole_0) - quarter.project(pole_q)).abs(); + let third_balance = (third.project(pole_0) - third.project(pole_q)).abs(); + assert!( + q_balance < EPSILON, + "π/4 balances the two axes — the bisector" + ); + assert!( + third_balance > 0.3, + "π/3 tilts ({third_balance:.3}) — distinct from the bisector" + ); + + // the pairing job needs the triplet to close on the dual: three steps must + // land on π (blade 2), the antipode. π/3 does — three π/3 turns reach the + // dual exactly. three π/4 turns land on 3π/4 (blade 1, still carrying a + // remainder), so a bisector cannot pair electrons antipodally in a triplet + let three_thirds = Angle::new(0.0, 1.0) + third + third + third; + let three_quarters = Angle::new(0.0, 1.0) + quarter + quarter + quarter; + assert_eq!( + three_thirds.blade(), + 2, + "three π/3 turns reach the dual (blade 2)" + ); + assert!( + three_thirds.t().abs() < EPSILON, + "the dual is a pure blade at π" + ); + assert_eq!( + three_quarters.blade(), + 1, + "three π/4 turns land on 3π/4 — blade 1, short of the dual" + ); + assert!( + three_quarters.t().abs() > EPSILON, + "3π/4 still carries a remainder — not even a pure blade" + ); + + // the two roles are disjoint: π/4 bisects but does not close the dual, π/3 + // closes the dual but does not bisect. neither absorbs the other, so the two + // lattice constants are not interchangeable — which it_works_only confirms + // empirically (swapping spin and phase loses an anomaly) + let bisector_closes_dual = three_quarters.blade() == 2 && three_quarters.t().abs() < EPSILON; + let dual_bisects = third_balance < EPSILON; + assert!( + !bisector_closes_dual, + "the bisector does not reach the dual in a triplet" + ); + assert!(!dual_bisects, "the dual closure does not bisect the axes"); +} + +// ───────────────────────────────────────────────────────────────────────── +// the empirical confirmation: the minimal IE model, run with the two constants +// swapped. spread = π/2 is held fixed (it is the lattice). only spin and Q vary +// ───────────────────────────────────────────────────────────────────────── + +// the 1/n radial law (Bohr momentum ∝ 1/n), and a flat foil that ignores the shell +fn bohr(n: usize) -> f64 { + 1.0 / n as f64 +} +fn flat(_n: usize) -> f64 { + 1.0 +} + +// the nucleus-scaled marginal p of electron z under the canonical lattice, with +// its projections onto the 0-axis (adj) and the π/2-axis (opp) +fn marginal_projections(z: usize) -> (Geonum, f64, f64) { + let nucleus = Geonum::new(z as f64, 0.0, 1.0); + let marginal = Geonum::electron_wave(z, Lattice::Canonical) + - Geonum::electron_wave(z - 1, Lattice::Canonical); + let p = nucleus * marginal; + let adj = p.project(&Geonum::new(1.0, 0.0, 1.0)).mag; + let opp = p + .project(&Geonum::new_with_angle(1.0, Angle::new(1.0, 2.0))) + .mag; + (p, adj, opp) +} + +// (RMSE over Z=1-18, Be>B holds, N>O holds) for a (spin, q, radial) assignment — +// the IE1 series swapped through Geonum::ionization_energy under Lattice::Custom +fn score(spin: Angle, q: Angle, radial: fn(usize) -> f64) -> (f64, bool, bool) { + let lattice = Lattice::Custom { spin, radial, q }; + let ie = |z: usize| Geonum::ionization_energy(z, z, lattice); + let mut sse = 0.0; + for z in 1..=18usize { + sse += (ie(z) - EXP[z - 1]).powi(2); + } + let rmse = (sse / 18.0).sqrt(); + (rmse, ie(4) > ie(5), ie(7) > ie(8)) +} + +#[test] +fn it_works_only_with_the_forced_spin_q_assignment() { + // run the model with the two free constants assigned every way the eligible + // denominators allow. only spin = π/3 (D-closure), Q = π/4 (bisector) + // reproduces both anomalies inside the 3 eV band. the roles are not + // interchangeable in the model, exactly as the geometry forces + let third = Angle::new(1.0, 3.0); // D-closure + let quarter = Angle::new(1.0, 4.0); // Q-bisector + + let configs = [ + ("spin π/3, Q π/4 (forced)", third, quarter), + ("spin π/4, Q π/4 (two bisectors)", quarter, quarter), + ("spin π/3, Q π/3 (two D-closures)", third, third), + ("spin π/4, Q π/3 (swapped roles)", quarter, third), + ]; + + eprintln!("\n═══ the (spin, Q) assignment, every eligible way ═══\n"); + eprintln!(" assignment RMSE Be>B N>O"); + let mut results = Vec::new(); + for (label, spin, q) in configs { + let (rmse, be, no) = score(spin, q, bohr); + eprintln!(" {label:34} {rmse:5.2} {be:5} {no:5}"); + results.push((rmse, be, no)); + } + + let (base_rmse, base_be, base_no) = results[0]; + // the forced assignment reproduces both anomalies under the 3 eV band + assert!( + base_be && base_no, + "the forced assignment keeps both anomalies" + ); + assert!( + base_rmse < 3.0, + "the forced assignment RMSE {base_rmse:.2} < 3 eV" + ); + + // no other assignment matches it: each swap loses an anomaly or leaves the + // band. the model confirms the roles are non-interchangeable + for (label, (rmse, be, no)) in configs.iter().skip(1).zip(results.iter().skip(1)) { + let matches_baseline = *be && *no && *rmse < 3.0; + assert!( + !matches_baseline, + "{}: a swap reproduced the baseline — roles would be interchangeable", + label.0 + ); + } +} + +// ───────────────────────────────────────────────────────────────────────── +// what π/4 actually does in the IE: a scaled projection, not the bisector +// +// it_bisects proves π/4 is the bisector DIRECTION (cos = sin). these two pin its +// actual IE role. q.grade_angle() reads π/4 out as the coefficient (≈0.785), and +// adj + 0.785·opp is the scaled projection of the marginal onto the ray +// atan(π/4) ≈ 38° — a radial-dominant ray below the 45° bisector. the coefficient +// is load-bearing: no parameter-free combiner reproduces it. +// ───────────────────────────────────────────────────────────────────────── + +#[test] +fn it_projects_the_opp_combiner_onto_the_atan_ray() { + // adj + c·opp is p · (1, c): the scaled projection of the marginal (folded + // into the first quadrant by the |·| that .mag takes) onto the ray ψ = + // atan(c), scaled by √(1+c²) = sec ψ. for the IE's c = π/4 the ray is + // atan(π/4) ≈ 38°, below the 45° bisector — adj's full weight pulls it down + let c = std::f64::consts::FRAC_PI_4; // the IE coefficient = π/4's radian + let psi = c.atan(); // the projecting ray + let scale = (1.0 + c * c).sqrt(); // sec ψ + + for z in 1..=18usize { + let (p, adj, opp) = marginal_projections(z); + let phi = p.angle.grade_angle(); + let alpha = phi.sin().abs().atan2(phi.cos().abs()); // p folded into Q1 + assert!( + (adj + c * opp - scale * p.mag * (alpha - psi).cos()).abs() < 1e-9, + "z={z}: adj + c·opp = sec ψ · p.mag · cos(foldedφ − ψ)" + ); + } + + // the ray sits below the 45° bisector: a bisector projection would need + // coefficient tan(π/4) = 1 (and would break the hydrogenic series) + assert!( + psi < std::f64::consts::FRAC_PI_4, + "the IE ray atan(π/4) ≈ {:.1}° is below the 45° bisector", + psi.to_degrees() + ); +} + +#[test] +fn it_proves_the_opp_coefficient_is_load_bearing() { + // the π/4 coefficient earns its place: it gets both anomalies AND keeps + // hydrogenic. the two parameter-free combiners the projection geometry allows + // each fail — the hypotenuse √(adj²+opp²) discards p's angle and loses both + // anomalies; the bisector (adj+opp)/√2 breaks hydrogenic + let q = Angle::new(1.0, 4.0); + + // (RMSE, Be>B, N>O) over Z=1-18 for a combiner (adj, opp) -> scalar + let eval = |combine: &dyn Fn(f64, f64) -> f64| -> (f64, bool, bool) { + let ie = |z: usize| { + let (_, a, o) = marginal_projections(z); + let n = Geonum::valence_shell(z); + RYDBERG * combine(a, o) / (n * n) as f64 + }; + let mut sse = 0.0; + for z in 1..=18usize { + sse += (ie(z) - EXP[z - 1]).powi(2); + } + ((sse / 18.0).sqrt(), ie(4) > ie(5), ie(7) > ie(8)) + }; + + // the π/4 coefficient: both anomalies, RMSE in band + let (rmse_q, be_q, no_q) = eval(&|a, o| a + q.grade_angle() * o); + assert!(be_q && no_q, "π/4 reproduces Be>B and N>O"); + assert!(rmse_q < 3.0, "π/4 RMSE {rmse_q:.2} sits in the band"); + + // the hypotenuse keeps hydrogenic but loses both anomalies + let (_, be_h, no_h) = eval(&|a, o| (a * a + o * o).sqrt()); + assert!(!be_h && !no_h, "the hypotenuse loses both anomalies"); + + // the bisector breaks hydrogenic: H (Z=1, opp=0) lands 13.6/√2, not 13.6 + let (_, a1, o1) = marginal_projections(1); + let n1 = Geonum::valence_shell(1); + let h_bisector = RYDBERG * (a1 + o1) / 2.0_f64.sqrt() / (n1 * n1) as f64; + assert!( + (h_bisector - RYDBERG / 2.0_f64.sqrt()).abs() < 1e-9, + "the bisector sends hydrogenic H to 13.6/√2, not 13.6" + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// the radial axis: 1/n is the inverse of the winding count +// +// the closure forces the ANGLES (2, 3, 4) through rotation. the per-electron +// magnitude 1/n comes from a different geometric operation — division. rotation +// preserves magnitude, so the angular closure cannot reach 1/n; inversion does. +// +// shell n is reached after n windings of the fundamental four-blade (2π) cycle +// (act IV blade_count_is_shell reads shell = blade/4 + 1). the winding count is a +// rotational extent — a geonum of magnitude n. its inverse is the per-winding +// rate, magnitude 1/n. division is geometry becoming temporal: the same +// operation that forms tan = sin/cos in trigonometry_test, a rate of one +// quadrature component against another. so 1/n is derived (inv of the winding +// count), not fitted. the model carries two geometric operations — rotation for +// the angular lattice, inversion for the radial rate. +// ───────────────────────────────────────────────────────────────────────── + +#[test] +fn it_separates_the_radial_law_from_the_angular_closure() { + // rotation by any lattice angle preserves magnitude — it only adds to the + // angle. so the angular closure (rotation) cannot reach 1/n: the radial law + // lives in a different operation, division, not in the 2/3/4 argument + let e = Geonum::new(0.5, 1.0, 1.0); // an electron of magnitude 0.5 + for k in [2.0, 3.0, 4.0] { + let rotated = e.rotate(Angle::new(1.0, k)); + assert!( + (rotated.mag - e.mag).abs() < EPSILON, + "rotation by π/{k} preserves magnitude — rotation fixes angle, not radius" + ); + } + + // two electrons at the same angle but different shells differ only in + // magnitude — the radial axis is orthogonal to the angular one + let pos = Angle::new(1.0, 1.0); // π + let shell_1 = Geonum::new_with_angle(bohr(1), pos); + let shell_3 = Geonum::new_with_angle(bohr(3), pos); + assert!( + shell_1.angle.near(&shell_3.angle), + "same angle, different shell" + ); + assert!( + (shell_1.mag - shell_3.mag).abs() > EPSILON, + "the shell distinction lives in the radial magnitude, not the angle" + ); +} + +#[test] +fn it_derives_the_radial_law_by_inverting_the_winding_count() { + // 1/n is not supplied — it is the geonum inverse of the shell's winding + // count. division turns the rotational extent (n windings) into the temporal + // rate (1/n per winding), the operation trigonometry_test forms tan with + for n in 1..=5usize { + // shell n sits at blades 4·(n-1)..4·n — n full four-blade (2π) windings. + // the winding count reads back out of the blade structure (act IV) + let shell_blade = 4 * (n - 1); + let winding_count = shell_blade / 4 + 1; + assert_eq!(winding_count, n, "shell n is n windings of the 2π cycle"); + + // the winding count as a rotational extent, and its rate by inversion + let windings = Geonum::new(winding_count as f64, 0.0, 1.0); + let rate = windings.inv(); + assert!( + rate.near_mag(bohr(n)), + "the radial law 1/n is inv(winding count): {} vs {}", + rate.mag, + bohr(n) + ); + } +} + +#[test] +fn it_proves_the_radial_law_is_load_bearing() { + // 1/n is not a removable scale: replacing it with a flat magnitude changes + // the predictions and worsens the fit. the model genuinely depends on the + // radial input, so the angular closure does not tell the whole story + let spin = Angle::new(1.0, 3.0); + let q = Angle::new(1.0, 4.0); + + let (rmse_bohr, _, _) = score(spin, q, bohr); + let (rmse_flat, _, _) = score(spin, q, flat); + + eprintln!("\n radial law: 1/n RMSE {rmse_bohr:.2} eV vs flat RMSE {rmse_flat:.2} eV"); + + assert!( + rmse_bohr < rmse_flat, + "the 1/n radial law ({rmse_bohr:.2}) fits better than a flat magnitude ({rmse_flat:.2})" + ); +} diff --git a/tests/chemistry_test.rs b/tests/chemistry_test.rs index ba604e3..d1d9d58 100644 --- a/tests/chemistry_test.rs +++ b/tests/chemistry_test.rs @@ -56,6 +56,29 @@ // act VII: ionization energy from three lattice constants — // spread = π/2, spin = π/3, Q = π/4 — denominators 2, 3, 4 // zero fitted parameters, both anomalies (Be > B, N > O) +// +// act VIII: the outer shell is max(n) across filled subshells — +// madelung fills 4s before 3d, so the spatial outer shell is the largest n +// present. the IE formula divides by n² of the outer shell. taking max(n) +// extends the model through both transition rows (Z=1-54, up to Xe) +// +// act IX: the same three constants predict second ionization energy — +// one formula iek(z, electrons) covers neutral atom and cation. the nuclear +// factor carries the exposed core charge z·(z-electrons+1), recovering the +// hydrogenic Z² limit for deep stripping while staying identity at neutral +// +// act X: a third observable — electron affinity. the next electron stepping ON +// (wave[z+1]-wave[z]) separates halogens (bound) from noble gases (a shell jump, +// repulsive), and Mulliken EN = (IE1+EA)/2 puts F on top. the intra-period +// gradient stays flat — the model's honest limit +// +// act XI: the relativistic edge — the max(n) d-rescue reverses at the 5d row. +// one parameter-free term, n_eff = max(n) − (Zα)²·(n_max−4)·(max(n)−last), the +// fine-structure constant fixed by nature, threads all three d rows +// +// act XII: the np-shortfall wall — the boundary nothing closes. landing the np +// closed shells on NIST needs a quadratic opp term, but opp ≤ p.mag grows +// linearly, so no frame rotation reaches it and the deficit widens each period use geonum::*; use std::f64::consts::PI; @@ -63,12 +86,28 @@ use std::f64::consts::PI; const EPSILON: f64 = 1e-10; const RYDBERG: f64 = 13.6; +// NIST first ionization energies, Z=1..=80 (eV). Z=55-80 (Cs..Hg) carry act XI +const EXP: [f64; 80] = [ + 13.598, 24.587, 5.392, 9.323, 8.298, 11.260, 14.534, 13.618, 17.423, 21.565, 5.139, 7.646, + 5.986, 8.152, 10.487, 10.360, 12.968, 15.760, 4.341, 6.113, 6.561, 6.828, 6.746, 6.767, 7.434, + 7.902, 7.881, 7.640, 7.726, 9.394, 5.999, 7.900, 9.815, 9.752, 11.814, 13.999, 4.177, 5.695, + 6.217, 6.634, 6.759, 7.092, 7.280, 7.361, 7.459, 8.337, 7.576, 8.994, 5.786, 7.344, 8.608, + 9.010, 10.451, 12.130, // Z=55..80: period 6 (Cs..Hg) + 3.894, 5.212, 5.577, 5.539, 5.473, 5.525, 5.582, 5.644, 5.670, 6.150, 5.864, 5.939, 6.022, + 6.108, 6.184, 6.254, 5.426, 6.825, 7.550, 7.864, 7.834, 8.438, 8.967, 8.959, 9.226, 10.438, +]; + +const ELEMENT: [&str; 80] = [ + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", + "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", + "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", + "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", + "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", +]; + fn spread() -> Angle { Angle::new(1.0, 2.0) // π/2 — one grade step } -fn spin() -> Angle { - Angle::new(1.0, 3.0) // π/3 — pairing angle -} // act I: build the conventional abstractions @@ -138,26 +177,17 @@ fn it_computes_shell_capacity() { } #[test] -fn it_derives_aufbau_from_angle_ordering() { - // (n+l) * pi/2 gives total angle per subshell (madelung rule) - // sorting subshells by total angle produces the filling order - // no lookup table needed — its ascending angle order - // eliminates: 19-entry aufbau filling order +fn it_walks_aufbau_through_the_blade_lattice() { + // the filling order is a walk through the blade lattice, no sorted table. + // a subshell's tier T = n+l is the total angle (n+l)·π/2, which is blade + // n+l. madelung_walk rotates one tier at a time (increment_blade) and trades + // l for n down each tier's diagonal — the (n,l) pairs fall out of the walk + // eliminates: the 19-entry aufbau order AND the (n+l, n) sort comparator - // generate subshells as (n, l) pairs with their total angle - let mut subshells: Vec<(usize, usize, f64)> = Vec::new(); - for n in 1..=5usize { - for l in 0..n { - let total_angle = (n + l) as f64 * PI / 2.0; - subshells.push((n, l, total_angle)); - } - } - - // sort by total angle, break ties by n (lower n first) - subshells.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap().then(a.0.cmp(&b.0))); + let order = Geonum::madelung_order(6); - // the conventional 19-entry aufbau table - let aufbau_table: Vec<(usize, usize)> = vec![ + // the walk reproduces the memorized aufbau sequence with no comparator + let aufbau = [ (1, 0), // 1s (2, 0), // 2s (2, 1), // 2p @@ -170,12 +200,40 @@ fn it_derives_aufbau_from_angle_ordering() { (4, 2), // 4d (5, 1), // 5p ]; + for (i, &(n, l)) in aufbau.iter().enumerate() { + assert_eq!(order[i], (n, l), "subshell {i} diverges from aufbau"); + } + + // each subshell's tier is a blade: the total angle (n+l)·π/2 lands on blade + // n+l with zero remainder. the madelung tier is a π/2 rotation count + for &(n, l) in &order { + let tier = Geonum::new(1.0, (n + l) as f64, 2.0); // (n+l)·π/2 + assert_eq!(tier.angle.blade(), n + l, "tier blade carries n+l"); + assert!( + tier.angle.t().abs() < EPSILON, + "tier angle lands on a clean π/2 multiple" + ); + } - // sorted angle order matches the memorized aufbau sequence - for (i, &(n, l)) in aufbau_table.iter().enumerate() { - assert_eq!(subshells[i].0, n); - assert_eq!(subshells[i].1, l); + // within a shared tier the diagonal trades l for n: consecutive same-tier + // entries climb n by one and drop l by one + for w in order.windows(2) { + let (n0, l0) = w[0]; + let (n1, l1) = w[1]; + if n0 + l0 == n1 + l1 { + assert_eq!(n1, n0 + 1, "diagonal step climbs n by one"); + assert_eq!(l0, l1 + 1, "diagonal step drops l by one"); + } } + + // successive tiers sit one π/2 rotation apart — the outer walk is + // increment_blade, the same quarter turn that separates grades + let tier_2p = Geonum::new(1.0, 3.0, 2.0); // 3·π/2, blade 3, the 2p tier + assert_eq!( + tier_2p.rotate(spread()).angle.blade(), + 4, + "the next tier is one π/2 rotation past" + ); } // act II: watch the abstractions dissolve @@ -220,15 +278,19 @@ fn it_proves_spin_pairing_from_dual() { #[test] fn it_dissolves_aufbau_exceptions() { - // chromium Z=24: conventional says [Ar] 4s2 3d4, measured is [Ar] 4s1 3d5 - // half-filled d shell (5 electrons) creates symmetric angle distribution - // the "exception" is the expected minimum-interference filling - // eliminates: ~20 exception element patches - - // 4s (n+l=4) and 3d (n+l=5) are adjacent angle tiers - let s_angle = 4.0 * PI / 2.0; // 4s total angle - let d_angle = 5.0 * PI / 2.0; // 3d total angle - assert!((d_angle - s_angle - PI / 2.0).abs() < EPSILON); // adjacent tiers + // chromium Z=24: conventional [Ar] 4s2 3d4, measured [Ar] 4s1 3d5. a + // half-filled d shell is five evenly-spaced angles, and their lower average + // pairwise overlap is the geometric reason that filling is favored (the + // madelung walk eliminates the ~20 exception patches; this is the intuition) + + // 4s (n+l=4) and 3d (n+l=5) are adjacent tiers — one π/2, one blade, apart + let s_tier = Geonum::new(1.0, 4.0, 2.0); // 4·π/2, the 4s tier + let d_tier = Geonum::new(1.0, 5.0, 2.0); // 5·π/2, the 3d tier + assert_eq!( + d_tier.angle.blade(), + s_tier.angle.blade() + 1, + "3d sits one tier (one blade) past 4s" + ); // half-filled d shell: 5 evenly-spaced angles in a pi/2 quadrant // step = pi/10, so m-th position = m * pi/10 @@ -528,104 +590,33 @@ fn it_replaces_element_class_with_angle_count() { // energy is projection back to origin. // ═══════════════════════════════════════════════════════════ -fn subshell_order(max_n: usize) -> Vec<(usize, usize)> { - let mut subs = Vec::new(); - for n in 1..=max_n { - for l in 0..n { - subs.push((n, l)); - } - } - subs.sort_by_key(|&(n, l)| (n + l, n)); - subs -} +// the model itself — the madelung walk, the electron wave, the valence shell, +// the ionization projection — lives in the library: `Geonum`'s `Chemistry` trait +// (src/traits/chemistry.rs). these tests build their geonums with that trait and +// validate the outputs against NIST. two small helpers stay test-side: -fn grade_positions(base: Angle, l: usize, spread: Angle, spin: Angle) -> Vec { - let n_orb = 2 * l + 1; - let orbital_step = spread / n_orb as f64; - let mut pos = Vec::new(); - for orb in 0..n_orb { - let mut angle = base; - for _ in 0..orb { - angle = angle + orbital_step; - } - pos.push(angle); - pos.push(angle + spin); - } - pos -} - -fn wave_sum(z: usize, spread: Angle, spin: Angle) -> Geonum { - if z == 0 { - return Geonum::new(0.0, 0.0, 1.0); - } - let order = subshell_order(5); - let mut wave = Geonum::new(0.0, 0.0, 1.0); - let mut placed = 0; - - for &(n, l) in &order { - if placed >= z { - break; - } - let mut base = Angle::new(1.0, 1.0); // π - for _ in 0..l { - base = base + spread; - } - let positions = grade_positions(base, l, spread, spin); - let to_fill = positions.len().min(z - placed); - let mag = 1.0 / n as f64; - - for &pos in positions.iter().take(to_fill) { - wave = wave + Geonum::new_with_angle(mag, pos); - } - placed += to_fill; - } - wave -} - -fn collect(z: usize, spread: Angle, spin: Angle) -> Vec { - let order = subshell_order(5); - let mut particles = Vec::new(); - let mut placed = 0; - for &(n, l) in &order { - if placed >= z { - break; - } - let mut base = Angle::new(1.0, 1.0); // π - for _ in 0..l { - base = base + spread; - } - let positions = grade_positions(base, l, spread, spin); - let to_fill = positions.len().min(z - placed); - let mag = 1.0 / n as f64; - for &pos in positions.iter().take(to_fill) { - particles.push(Geonum::new_with_angle(mag, pos)); - } - placed += to_fill; - } - particles -} - -fn n_outer(z: usize) -> usize { - let order = subshell_order(5); +// the last-filled shell (the naive rule) — the foil act VIII measures the spatial +// valence shell against. a consumer wants `Geonum::valence_shell`; this is only +// the comparison baseline (and the relativistic shell's overshoot reference) +fn last_filled(z: usize) -> usize { let mut placed = 0; let mut n = 1; - for &(nn, l) in &order { + for (nn, l) in Geonum::madelung_order(6) { if placed >= z { break; } - n = nn; + n = nn; // overwrite — last-filled wins placed += (2 * (2 * l + 1)).min(z - placed); } n } -/// scaffolding: compute Σ(count_at_shell / n²) from z. -/// deterministic from z and the derived ordering. +// Σ(count_at_shell / n²) over the filled subshells — the diagonal sum the wave +// amplitude decomposes into (it_contains_all_pairs_in_the_wave_amplitude) fn individual_sq(z: usize) -> f64 { - let order = subshell_order(5); let mut sum = 0.0; let mut rem = z; - for &(n, l) in &order { + for (n, l) in Geonum::madelung_order(6) { if rem == 0 { break; } @@ -639,7 +630,7 @@ fn individual_sq(z: usize) -> f64 { // act IV: the blade chain #[test] -fn blade_chain_is_the_particle_zoo() { +fn it_chains_the_particle_zoo_with_increment_blade() { let proton = Geonum::new(1.0, 0.0, 1.0); let neutron = proton.increment_blade(); let electron = neutron.increment_blade(); @@ -655,14 +646,21 @@ fn blade_chain_is_the_particle_zoo() { } #[test] -fn blade_count_is_shell() { +fn it_decomposes_the_blade_into_shell_and_subshell() { + // one blade integer carries both quantum numbers: shell = blade/4 + 1 (which + // 2π winding) and subshell = grade = blade % 4. they recombine to the blade, + // so the two are one count, not two separate stores. the physical (n,l) + // anchoring lives in it_walks_aufbau_through_the_blade_lattice let mut g = Geonum::new(1.0, 0.0, 1.0); for _ in 0..12 { - let shell = g.angle.blade() / 4 + 1; + let blade = g.angle.blade(); + let shell = blade / 4 + 1; let sub = g.angle.grade(); - // blade 0..3 → shell 1, blade 4..7 → shell 2, blade 8..11 → shell 3 - assert_eq!(shell, g.angle.blade() / 4 + 1); - assert_eq!(sub, g.angle.blade() % 4); + assert_eq!( + 4 * (shell - 1) + sub, + blade, + "shell and subshell recombine to the one blade" + ); g = g.increment_blade(); } } @@ -670,7 +668,7 @@ fn blade_count_is_shell() { // act V: grades tell you everything #[test] -fn binding_is_grade_2() { +fn it_binds_at_grade_2() { let nucleus = Geonum::new(RYDBERG, 0.0, 1.0); for n in 1..=4usize { let e = Geonum::new(1.0 / n as f64, 1.0, 1.0); @@ -681,7 +679,7 @@ fn binding_is_grade_2() { } #[test] -fn electron_electron_is_grade_0() { +fn it_dots_electron_electron_to_grade_0() { let e1 = Geonum::new(1.0, 1.0, 1.0); let e2 = Geonum::new(1.0, 1.0, 1.0); let d = e1.dot(&e2); @@ -689,34 +687,37 @@ fn electron_electron_is_grade_0() { } #[test] -fn grade_offset_weakens_projection() { - let spread = spread(); +fn it_weakens_projection_with_grade_offset() { let nucleus = Geonum::new(RYDBERG, 0.0, 1.0); - let s = Geonum::new(0.5, 1.0, 1.0); // at π - let p_angle = Angle::new(1.0, 1.0) + spread; // π + π/2 - let p = Geonum::new_with_angle(0.5, p_angle); - - let sb = nucleus.dot(&s); - let pb = nucleus.dot(&p); - - assert_eq!(sb.angle.grade(), 2); - // p-orbital at 3π/2 is orthogonal to nucleus at 0: cos(3π/2) = 0 - // so dot product magnitude is zero and grade is 0 (non-negative zero → grade 0) - assert_eq!(pb.angle.grade(), 0); - // p-electron offset by spread has zero binding projection (orthogonal) - assert!(sb.mag > pb.mag); + // binding is the electron's projection onto the nucleus axis; offsetting it + // off-axis weakens that projection monotonically, reaching zero at the + // orthogonal quarter turn. bind at π, then offset by π/4, then by π/2 + let aligned = Geonum::new_with_angle(0.5, Angle::new(1.0, 1.0)); // π + let off_q = Geonum::new_with_angle(0.5, Angle::new(1.0, 1.0) + Angle::new(1.0, 4.0)); // π+π/4 + let off_h = Geonum::new_with_angle(0.5, Angle::new(1.0, 1.0) + Angle::new(1.0, 2.0)); // π+π/2 + + let b_aligned = nucleus.dot(&aligned).mag; + let b_off_q = nucleus.dot(&off_q).mag; + let b_off_h = nucleus.dot(&off_h).mag; + + // monotone weakening: full at π, less at a quarter-grade offset, zero at the + // orthogonal half-grade offset + assert!( + b_aligned > b_off_q, + "a quarter-grade offset weakens the binding" + ); + assert!(b_off_q > b_off_h, "a half-grade offset weakens it further"); + assert!(b_off_h < 1e-9, "the orthogonal offset zeroes the binding"); } // act VI: wave interference #[test] -fn wave_self_dot_is_grade_0() { +fn it_self_dots_the_wave_to_grade_0() { // wave.dot(wave): grade 2 + grade 2 = 4 ≡ 0 - let spread = spread(); - let spin = spin(); for z in 1..=10 { - let wave = wave_sum(z, spread, spin); + let wave = Geonum::electron_wave(z, Lattice::Canonical); let sd = wave.dot(&wave); assert_eq!(sd.angle.grade(), 0, "Z={}: self-dot is grade 0", z); assert!((sd.mag - wave.mag * wave.mag).abs() < 1e-6); @@ -724,14 +725,11 @@ fn wave_self_dot_is_grade_0() { } #[test] -fn wave_sum_and_collect_are_the_same_chain() { - let spread = spread(); - let spin = spin(); - +fn it_proves_wave_sum_and_collect_are_one_chain() { for z in 1..=10usize { - let wave = wave_sum(z, spread, spin); + let wave = Geonum::electron_wave(z, Lattice::Canonical); - let particles = collect(z, spread, spin); + let particles = Geonum::electron_shell(z, Lattice::Canonical).objects; let reconstructed = particles .iter() .fold(Geonum::new(0.0, 0.0, 1.0), |acc, &g| acc + g); @@ -743,12 +741,10 @@ fn wave_sum_and_collect_are_the_same_chain() { } #[test] -fn every_wave_sum_cancels() { - let spread = spread(); - let spin = spin(); +fn it_cancels_every_wave_sum() { for z in 2..=18 { - let wave = wave_sum(z, spread, spin); - let particles = collect(z, spread, spin); + let wave = Geonum::electron_wave(z, Lattice::Canonical); + let particles = Geonum::electron_shell(z, Lattice::Canonical).objects; let scalar_sum: f64 = particles.iter().map(|g| g.mag).sum(); assert!( wave.mag < scalar_sum, @@ -761,14 +757,11 @@ fn every_wave_sum_cancels() { } #[test] -fn wave_amplitude_contains_all_pairs() { - let spread = spread(); - let spin = spin(); - +fn it_contains_all_pairs_in_the_wave_amplitude() { for z in 2..=10usize { - let wave = wave_sum(z, spread, spin); + let wave = Geonum::electron_wave(z, Lattice::Canonical); - let particles = collect(z, spread, spin); + let particles = Geonum::electron_shell(z, Lattice::Canonical).objects; // |wave|² = Σ|eᵢ|² + 2Σ|eᵢ||eⱼ|cos(θᵢ-θⱼ) // pairwise dot gives signed contribution via cos of angle diff @@ -798,52 +791,76 @@ fn wave_amplitude_contains_all_pairs() { // // spread = π/2 = Angle::new(1.0, 2.0) — one grade step // spin = π/3 = Angle::new(1.0, 3.0) — pairing angle -// Q = π/4 = Angle::new(1.0, 4.0) — phase shift between projection axes +// Q = π/4 = Angle::new(1.0, 4.0) — the opp coefficient: π/4's radian (≈0.785) +// weights the π/2-axis projection (the 38° ray, not the bisector — see +// chem_constants_test::it_projects_the_opp_combiner_onto_the_atan_ray) // -// denominators 2, 3, 4 — the smallest rational π fractions after 1. -// zero fitted parameters. +// denominators 2, 3, 4 — the smallest rational π fractions after 1, each a clean +// lattice landmark; Q's radian rides as a band-confined coefficient (finding #1) -fn ie_model(z: usize, waves: &[Geonum]) -> f64 { - let q = Angle::new(1.0, 4.0); - let n = n_outer(z); - let nucleus = Geonum::new(z as f64, 0.0, 1.0); - let marginal = waves[z] - waves[z - 1]; - let p = nucleus * marginal; - let ref0 = Geonum::new(1.0, 0.0, 1.0); - let ref_q = Geonum::new_with_angle(1.0, Angle::new(1.0, 2.0)); - let adj = p.project(&ref0); - let opp = p.project(&ref_q); - RYDBERG * (adj.mag + q.grade_angle() * opp.mag) / (n * n) as f64 +// the IE model lives in the library — Geonum's Chemistry trait +// (src/traits/chemistry.rs). these are thin views on it, so the tests below +// validate Geonum::ionization_energy / electron_affinity against NIST. + +// IE1 (the z-th electron stepping off) and IE_k (z protons, `electrons` electrons) +fn ie_model(z: usize) -> f64 { + Geonum::ionization_energy(z, z, Lattice::Canonical) +} +fn iek(z: usize, electrons: usize) -> f64 { + Geonum::ionization_energy(z, electrons, Lattice::Canonical) +} + +// IE1 projected over a CHOSEN outer shell — used to compare the spatial +// valence_shell against the naive last_filled foil (act VIII) +fn ie_at(z: usize, n: usize) -> f64 { + let marginal = Geonum::electron_wave(z, Lattice::Canonical) + - Geonum::electron_wave(z - 1, Lattice::Canonical); + marginal.ionization_projection( + Geonum::new(z as f64, 0.0, 1.0), + n as f64, + Lattice::Canonical, + ) +} + +// electron affinity: signed, and the unsigned binding magnitude +fn ea(z: usize) -> f64 { + Geonum::electron_affinity(z, Lattice::Canonical) +} +fn ea_bind(z: usize) -> f64 { + Geonum::electron_affinity(z, Lattice::Canonical).abs() +} + +// the relativistic IE (act XI): the marginal projected over the contracted shell +fn ie_rel(z: usize) -> f64 { + let marginal = Geonum::electron_wave(z, Lattice::Canonical) + - Geonum::electron_wave(z - 1, Lattice::Canonical); + marginal.ionization_projection( + Geonum::new(z as f64, 0.0, 1.0), + Geonum::relativistic_valence_shell(z), + Lattice::Canonical, + ) } #[test] -fn ionization_energy_from_geometry() { +fn it_computes_ionization_energy_from_geometry() { // three lattice constants, zero fitted parameters - let spread = spread(); - let spin = spin(); - let exp: [f64; 18] = [ - 13.598, 24.587, 5.392, 9.323, 8.298, 11.260, 14.534, 13.618, 17.423, 21.565, 5.139, 7.646, - 5.986, 8.152, 10.487, 10.360, 12.968, 15.760, - ]; - - let waves: Vec = (0..=18).map(|z| wave_sum(z, spread, spin)).collect(); let mut sse = 0.0; for z in 1..=18usize { - let ie = ie_model(z, &waves); + let ie = ie_model(z); assert!(ie > 0.0, "Z={}: IE must be positive", z); - sse += (ie - exp[z - 1]).powi(2); + sse += (ie - EXP[z - 1]).powi(2); } let rmse = (sse / 18.0).sqrt(); // Be > B anomaly (Z=4 > Z=5) - let ie_be = ie_model(4, &waves); - let ie_b = ie_model(5, &waves); + let ie_be = ie_model(4); + let ie_b = ie_model(5); assert!(ie_be > ie_b, "Be ({:.2}) > B ({:.2})", ie_be, ie_b); // N > O anomaly (Z=7 > Z=8) - let ie_n = ie_model(7, &waves); - let ie_o = ie_model(8, &waves); + let ie_n = ie_model(7); + let ie_o = ie_model(8); assert!(ie_n > ie_o, "N ({:.2}) > O ({:.2})", ie_n, ie_o); // RMSE < 3.0 with zero free parameters @@ -853,15 +870,764 @@ fn ionization_energy_from_geometry() { eprintln!(" spread = π/2, spin = π/3, Q = π/4"); eprintln!(" denominators: 2, 3, 4 — zero fitted parameters\n"); for z in 1..=18 { - let ie = ie_model(z, &waves); - let err = (ie - exp[z - 1]) / exp[z - 1] * 100.0; + let ie = ie_model(z); + let err = (ie - EXP[z - 1]) / EXP[z - 1] * 100.0; eprintln!( " Z={:2} IE={:6.2} exp={:6.2} err={:+5.1}%", z, ie, - exp[z - 1], + EXP[z - 1], err ); } eprintln!("\n RMSE={:.2} anomalies=2/2\n", rmse); } + +// act VIII: the outer shell is max(n) +// +// madelung fills 4s before 3d, 5s before 4d, 6s before 4f before 5d. across +// these the largest n present is the spatial outer shell — the electron the +// IE formula ionizes. taking max(n) for the n² divisor extends act VII from +// the s/p block (Z=1-18) through both transition rows — the 3d block (Z=19-36) +// and the 4d block (Z=37-54, up to Xe) — with zero new parameters. the same +// fix rescues both d rows. last_filled (last-filled n) stands as the foil. + +// root mean square error of the IE model over Z=start..=end for a given outer +// shell rule +fn rmse(start: usize, end: usize, outer: fn(usize) -> usize) -> f64 { + let mut sse = 0.0; + for z in start..=end { + let marginal = Geonum::electron_wave(z, Lattice::Canonical) + - Geonum::electron_wave(z - 1, Lattice::Canonical); + let pred = marginal.ionization_projection( + Geonum::new(z as f64, 0.0, 1.0), + outer(z) as f64, + Lattice::Canonical, + ); + sse += (pred - EXP[z - 1]).powi(2); + } + (sse / (end - start + 1) as f64).sqrt() +} + +#[test] +fn it_holds_max_n_as_identity_in_sample() { + // Z=1-18: madelung order is monotonic in n, so max(n) equals last-filled n. + // the running max leaves the in-sample RMSE untouched + + let r_orig = rmse(1, 18, last_filled); + let r_max = rmse(1, 18, Geonum::valence_shell); + + assert!( + (r_orig - r_max).abs() < 1e-10, + "max(n) is identity for Z=1-18" + ); + assert!(r_max < 3.0, "in-sample RMSE preserved: {:.3}", r_max); +} + +#[test] +fn it_tames_both_d_blocks_with_max_n() { + // 3d fills after 4s, 4d after 5s. last-filled n drops to the inner d and + // over-predicts each row; max(n) holds the outer s and lands both d blocks + // in the s/p error band. the fix that rescued the 3d row rescues the 4d row + + for (block, lo, hi) in [("3d", 21, 30), ("4d", 39, 48)] { + let r_orig = rmse(lo, hi, last_filled); + let r_max = rmse(lo, hi, Geonum::valence_shell); + + eprintln!(" {block} block: last-filled {r_orig:.3} eV -> max(n) {r_max:.3} eV"); + + assert!( + r_max < r_orig / 2.0, + "{block}: max(n) at least halves the d-block RMSE (last-filled {r_orig:.3}, max {r_max:.3})" + ); + assert!( + r_max < 1.5, + "{block}: max(n) RMSE sits in the s/p band ({r_max:.3} eV)" + ); + } +} + +#[test] +fn it_lands_transition_metals_in_physical_range() { + // last-filled n predicts the d rows at 11-19 eV (measured 6-9 eV), +50-115% + // error. max(n) lands both Sc-Zn and Y-Cd in single digits within 30% + + for z in (21..=30).chain(39..=48) { + let pred = ie_at(z, Geonum::valence_shell(z)); + let measured = EXP[z - 1]; + let err_pct = (pred - measured).abs() / measured * 100.0; + + assert!( + pred < 12.0, + "{} (Z={}): predicted {:.2} eV outside physical range", + ELEMENT[z - 1], + z, + pred + ); + assert!( + err_pct < 30.0, + "{} (Z={}): {:.1}% error exceeds 30% bound", + ELEMENT[z - 1], + z, + err_pct + ); + } +} + +#[test] +fn it_prints_the_full_comparison() { + eprintln!("\nZ=1-54 ionization energies, last-filled n vs max(n) (zero new parameters)\n"); + eprintln!(" Z elem lastfill max(n) exp last_err max_err"); + for z in 1..=54 { + let p_orig = ie_at(z, last_filled(z)); + let p_max = ie_at(z, Geonum::valence_shell(z)); + let e = EXP[z - 1]; + let e_orig = (p_orig - e) / e * 100.0; + let e_max = (p_max - e) / e * 100.0; + let mark = match z { + 1..=18 => "", + 19..=36 => " (3d row)", + _ => " (4d row)", + }; + eprintln!( + " {:2} {:3} {:6.2} {:6.2} {:6.2} {:+6.1}% {:+6.1}%{}", + z, + ELEMENT[z - 1], + p_orig, + p_max, + e, + e_orig, + e_max, + mark + ); + } + + let periods: [(&str, usize, usize); 3] = [ + ("Z=1-18 (s/p)", 1, 18), + ("Z=19-36 (3d) ", 19, 36), + ("Z=37-54 (4d) ", 37, 54), + ]; + eprintln!("\n RMSE by period (last-filled -> max(n)):"); + for (label, lo, hi) in periods { + eprintln!( + " {label} {:.3} -> {:.3} eV", + rmse(lo, hi, last_filled), + rmse(lo, hi, Geonum::valence_shell) + ); + } +} + +// act IX: the same constants predict second ionization energy +// +// a model with real geometric content predicts more than the one observable it +// was read off. the cation has z protons but fewer electrons. the marginal is +// the electron stepping off (from electrons-1 to electrons), the nucleus keeps +// its full z protons, and the n² divisor reads the outer shell that remains. +// +// the nuclear factor carries the EXPOSED CORE CHARGE: the electrons-1 inner +// electrons screen electrons-1 protons, leaving z-(electrons-1) exposed to the +// marginal. the geometric product nucleus.geo(exposed) lands magnitude +// z·(z-electrons+1) at grade 0 — one factor of z from the nucleus, the second +// from the exposed core. at neutral exposed=1, so the factor is the bare z and +// acts VII-VIII are untouched. at a bare ion (1 electron) exposed=z, so the +// factor is z² and the model recovers the hydrogenic IE = RYDBERG·Z²/n² exactly. + +// NIST second ionization energies, (Z, eV) for Z=3..=20. IE2 starts at Z=3 +// because He+ to He²⁺ is the bare hydrogenic limit, outside the screened model +const IE2: [(usize, f64); 18] = [ + (3, 75.640), + (4, 18.211), + (5, 25.155), + (6, 24.383), + (7, 29.601), + (8, 35.121), + (9, 34.971), + (10, 40.963), + (11, 47.286), + (12, 15.035), + (13, 18.829), + (14, 16.346), + (15, 19.769), + (16, 23.338), + (17, 23.814), + (18, 27.630), + (19, 31.625), + (20, 11.872), +]; + +#[test] +fn it_reduces_ie2_to_ie1_at_full_electron_count() { + // the bridge: iek(z, z) is the neutral atom — exposed = z-(z-1) = 1, so the + // nuclear factor collapses to the bare z and iek recovers act VII's ie_model + // exactly. IE2 rides the same machinery, the electron count drops by one + + for z in 1..=20usize { + let general = iek(z, z); + let neutral = ie_model(z); + assert!( + (general - neutral).abs() < EPSILON, + "Z={z}: iek(z,z) {general:.6} != ie_model {neutral:.6}" + ); + } +} + +#[test] +fn it_reproduces_the_hydrogenic_series_exactly() { + // one electron of charge Z binds at exactly RYDBERG·Z². the single-electron + // marginal is [1, grade 2] (no opp component), so the bare-z numerator would + // give RYDBERG·Z, short one factor of z. the exposed core (electrons=1 → + // exposed=z) supplies the second z and the form lands 13.6·Z² to the bit + + let names = ["H", "He+", "Li2+", "Be3+"]; + for z in 1..=4usize { + let pred = iek(z, 1); + let exact = RYDBERG * (z * z) as f64; + assert!( + (pred - exact).abs() < EPSILON, + "{}: {pred:.4} != exact 13.6·Z² {exact:.4}", + names[z - 1] + ); + } +} + +#[test] +fn it_tracks_the_ie2_trend_against_nist() { + // the same three constants trace the IE2 series across Z=3-20. the exposed + // core Z² lands the magnitude near absolute — the global least-squares scale + // sits near 1, where the bare-z numerator needed ~1.9. pearson r measures the + // shape: every group rise and the resets at Be/Mg/Ca where a fresh valence + // shell opens + + let preds: Vec = IE2.iter().map(|&(z, _)| iek(z, z - 1)).collect(); + let measured: Vec = IE2.iter().map(|&(_, m)| m).collect(); + let n = preds.len() as f64; + + let mp = preds.iter().sum::() / n; + let mm = measured.iter().sum::() / n; + let cov: f64 = preds + .iter() + .zip(&measured) + .map(|(p, m)| (p - mp) * (m - mm)) + .sum(); + let sp = preds.iter().map(|p| (p - mp).powi(2)).sum::().sqrt(); + let sm = measured + .iter() + .map(|m| (m - mm).powi(2)) + .sum::() + .sqrt(); + let r = cov / (sp * sm); + + // least-squares global scale through the origin + let scale = preds.iter().zip(&measured).map(|(p, m)| p * m).sum::() + / preds.iter().map(|p| p * p).sum::(); + + eprintln!("\nIE2 trend, Z=3-20 (exposed-core Z², zero new parameters)\n"); + eprintln!(" el pred nist err"); + for (i, &(z, m)) in IE2.iter().enumerate() { + eprintln!( + " {:3} {:7.2} {:7.2} {:+5.1}%", + ELEMENT[z - 1], + preds[i], + m, + (preds[i] - m) / m * 100.0 + ); + } + // the scale's offset from 1 is the one-for-one screening residual: exposed = + // z-(electrons-1) charges each inner electron with exactly one proton of + // screening, but real screening isnt exactly 1.0, so the model reports the + // leak as a global scale near but not at 1 + let screening_residual = (1.0 - scale).abs(); + eprintln!( + "\n pearson r = {r:.4} global scale = {scale:.3} one-for-one screening residual = {:.1}%", + screening_residual * 100.0 + ); + + // r near 1 means the geometry orders the whole series + assert!( + r > 0.95, + "IE2 trend tracks NIST: pearson r {r:.4} below 0.95" + ); + // the exposed-core Z² lands the magnitude near absolute — the residual is + // the one-for-one screening assumption leaking, a few percent rather than + // the ~2x scale the bare-z numerator needed + assert!( + screening_residual < 0.15, + "one-for-one screening leaks {:.1}% — past the 15% band", + screening_residual * 100.0 + ); +} + +#[test] +fn it_separates_the_closed_core_cliff_from_valence_removal() { + // ionizing into a noble-gas core costs many times the neutral IE1, while + // pulling a remaining valence electron costs about the same again. the + // geometry draws the line through the valence shell: Li+/Na+/K+ collapse to a smaller + // inner shell so the n² divisor shrinks and IE2/IE1 jumps; Be/Mg/Ca keep + // their outer s shell so the ratio sits lower + + let closed_core = [3usize, 11, 19]; // Li, Na, K — ionize into He/Ne/Ar cores + let valence = [4usize, 12, 20]; // Be, Mg, Ca — still hold a valence s + + let ratio = |z: usize| iek(z, z - 1) / iek(z, z); + + let closed_ratios: Vec = closed_core.iter().map(|&z| ratio(z)).collect(); + let valence_ratios: Vec = valence.iter().map(|&z| ratio(z)).collect(); + + let min_closed = closed_ratios.iter().cloned().fold(f64::INFINITY, f64::min); + let max_valence = valence_ratios + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + + eprintln!("\n min closed-core IE2/IE1 {min_closed:.2} > max valence {max_valence:.2}"); + + // every closed-core jump clears every valence step by a wide margin — the + // geometry never reads a noble-core break as a valence removal + assert!( + min_closed > 2.0 * max_valence, + "cliff: smallest closed-core jump {min_closed:.2} more than doubles the largest valence ratio {max_valence:.2}" + ); + + // Li+ is the sharpest cliff in the table — a two-electron He core + assert!( + closed_ratios[0] > 8.0, + "Li IE2/IE1 jump {:.2} below 8 — the He-core cliff flattened", + closed_ratios[0] + ); +} + +#[test] +fn it_drops_a_shell_into_the_core() { + // the mechanism behind the cliff stated directly: ionizing Li+, Na+, K+ + // breaks into the core so the valence shell falls a full shell, while Be/Mg hold their + // valence shell. the n² divisor change IS the cliff + for (z, expect_drop) in [ + (3usize, true), + (11, true), + (19, true), + (4, false), + (12, false), + ] { + let neutral_n = Geonum::valence_shell(z); + let cation_n = Geonum::valence_shell(z - 1); + let dropped = cation_n < neutral_n; + assert_eq!( + dropped, + expect_drop, + "{}: cation shell drop {dropped} differs from physics {expect_drop}", + ELEMENT[z - 1] + ); + } +} + +#[test] +fn it_tracks_the_na_deep_stripping_staircase() { + // the Na successive-ionization staircase IE1..IE5. the exposed core climbs + // 1, 2, 3, 4, 5 as electrons strip away, carrying the nuclear factor from z + // toward z² and tracking the hydrogenic Z² rise NIST follows. the bare-z + // numerator plateaus near 18-24 eV here; the exposed-core form lands every + // deep step within 1.5x of measured + let z = 11; + let nist = [5.139, 47.286, 71.620, 98.910, 138.400]; + + eprintln!("\nNa successive ionization (Z=11), exposed-core Z²\n"); + eprintln!(" k electrons exposed n_out pred nist"); + let mut preds = Vec::new(); + for k in 1..=5usize { + let electrons = z - (k - 1); + let pred = iek(z, electrons); + preds.push(pred); + eprintln!( + " {k} {electrons:2} {} {} {pred:6.2} {:6.2}", + z - (electrons - 1), + Geonum::valence_shell(electrons), + nist[k - 1] + ); + } + + // the first cliff is real and large + assert!( + preds[1] / preds[0] > 3.0, + "Na IE2/IE1 {:.2} below 3 — first cliff flattened", + preds[1] / preds[0] + ); + // every deep step lands within 1.5x of NIST — the Z² rise is recovered + for k in 3..=5usize { + let gap = nist[k - 1] / preds[k - 1]; + assert!( + gap < 1.5, + "Na IE{k}: gap {gap:.2}x above 1.5x — deep stripping not recovered" + ); + } +} + +// act X: a third observable — electron affinity +// +// IE1 reads the electron stepping OFF (wave[z]-wave[z-1]); electron affinity +// reads the next electron stepping ON (wave[z+1]-wave[z]) as a neutral atom +// gains an electron to an anion. the added electron sits outside the z already +// present, so it feels a nucleus screened to +1, and the divisor reads the +// anion's valence shell. same projection, the marginal index shifts. +// +// the geometry separates closed from open shells: a noble gas refuses the +// electron — the valence shell jumps a shell and the marginal lands at grade 2 / angle π +// (anti-aligned, repulsive) — while a halogen binds it at grade 0 near the +// origin, and F comes out the most bound. the limit is the intra-period +// gradient: the flat 1/n weight cant climb B Geonum::valence_shell(z) — flags exactly He, Ne, Ar across Z=1-18 with + // zero false positives. the same valence-shell closure the IE2 cliff rides, + // read the other direction: a closed shell refuses the next electron + + let nobles = [2usize, 10, 18]; // He, Ne, Ar + let mut flagged = Vec::new(); + for z in 1..=18usize { + if Geonum::valence_shell(z + 1) > Geonum::valence_shell(z) { + flagged.push(z); + } + } + assert_eq!( + flagged, nobles, + "the shell-jump flags exactly the noble gases" + ); + + // every flagged element measures EA ≤ 0 — the geometry never marks a bound + // element as opening a new shell + for &z in &nobles { + let nist = EA_NIST.iter().find(|&&(zz, _)| zz == z).unwrap().1; + assert!( + nist <= 0.0, + "{}: flag matches unbound EA {nist:.2}", + ELEMENT[z - 1] + ); + } +} + +#[test] +fn it_separates_halogens_from_noble_gases() { + // the headline: the geometry separates halogens (large positive EA) from + // noble gases (near zero or negative). the signed projection reads the + // noble-gas marginal at grade 2 / angle π as negative and the halogen + // marginal at grade 0 as the most bound. F is the most bound in period 2 + let waves: Vec = (0..=20) + .map(|z| Geonum::electron_wave(z, Lattice::Canonical)) + .collect(); + + let halogens = [9usize, 17]; // F, Cl + let nobles = [2usize, 10, 18]; // He, Ne, Ar + + let halogen_min = halogens + .iter() + .map(|&z| ea(z)) + .fold(f64::INFINITY, f64::min); + let noble_max = nobles + .iter() + .map(|&z| ea(z)) + .fold(f64::NEG_INFINITY, f64::max); + + for &z in &nobles { + let v = ea(z); + assert!( + v < 0.0, + "{}: noble-gas EA {v:.3} is unbound", + ELEMENT[z - 1] + ); + } + assert!( + halogen_min > noble_max, + "halogen floor {halogen_min:.3} clears noble ceiling {noble_max:.3}" + ); + + // F is the most bound element in period 2, compared against B, C, O. nitrogen + // (Z=7) is left out of the comparison: its half-filled 2p³ is a degenerate + // special case the flat 1/n weight treats as a peer, so it is not a fair + // comparand (act X's stated limit) + let ea_f = ea(9); + for z in [5usize, 6, 8] { + assert!( + ea_f >= ea(z), + "F ({ea_f:.3}) is at least as bound as {} ({:.3})", + ELEMENT[z - 1], + ea(z) + ); + } + + eprintln!("\n═══ act X: electron affinity from the same three constants ═══\n"); + eprintln!(" el Z ea_pred nist shell-jump marg-grade"); + for &(z, nist) in EA_NIST.iter() { + let jump = Geonum::valence_shell(z + 1) > Geonum::valence_shell(z); + let marg = waves[z + 1] - waves[z]; + eprintln!( + " {:3} {:2} {:8.4} {:+7.3} {:9} {}", + ELEMENT[z - 1], + z, + ea(z), + nist, + if jump { "NEW SHELL" } else { "-" }, + marg.angle.grade(), + ); + } + eprintln!("\n halogen floor {halogen_min:.3} eV > noble ceiling {noble_max:.3} eV"); +} + +#[test] +fn it_holds_the_halogen_separation_into_period_4() { + // the split is not a small-Z accident: Br (Z=35, halogen) binds while Kr + // (Z=36, noble) opens shell 5 and goes unbound, same as F/Ne and Cl/Ar + + let ea_br = ea(35); // Br, NIST 3.36 eV + let ea_kr = ea(36); // Kr, unbound + + assert!(ea_br > 0.0, "Br EA {ea_br:.3} is bound"); + assert!(ea_kr < 0.0, "Kr EA {ea_kr:.3} is unbound (new shell)"); + assert!( + Geonum::valence_shell(36) < Geonum::valence_shell(37), + "Kr->anion opens shell 5, the geometric signal for unbound" + ); +} + +#[test] +fn it_leaves_the_intra_period_ea_gradient_flat() { + // the honest limit: the screened binding magnitude is nearly flat across a + // p-block, so the model does not reproduce the rising EA gradient B = (5..=9).map(ea_bind).collect(); + let span_pred = block.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + - block.iter().cloned().fold(f64::INFINITY, f64::min); + let span_nist = 3.401 - 0.280; // NIST B..F spans over 3 eV + + assert!( + span_pred < 0.5 * span_nist, + "model p-block span {span_pred:.3} eV far below NIST {span_nist:.3} eV — the sawtooth is missing" + ); + + // the alkalis are mislabeled: Li, Na carry a grade-2 marginal (read unbound) + // yet measure bound + for &z in &[3usize, 11] { + let pred = ea(z); + let nist = EA_NIST.iter().find(|&&(zz, _)| zz == z).unwrap().1; + assert!( + pred < 0.0 && nist > 0.0, + "{}: model {pred:.3} disagrees in sign with NIST {nist:.3}", + ELEMENT[z - 1] + ); + } +} + +#[test] +fn it_puts_fluorine_on_top_by_mulliken_electronegativity() { + // mulliken EN = (IE1 + EA)/2, both from marginals. the IE1 component carries + // the period trend and the EA binding tips F over the rest. test against the + // pauling scale by pearson correlation — rising across a period, F on top + + let pauling: [(usize, f64); 10] = [ + (3, 0.98), + (5, 2.04), + (6, 2.55), + (7, 3.04), + (8, 3.44), + (9, 3.98), + (11, 0.93), + (15, 2.19), + (16, 2.58), + (17, 3.16), + ]; + + let en = |z: usize| (ie_model(z) + ea_bind(z)) / 2.0; + let preds: Vec = pauling.iter().map(|&(z, _)| en(z)).collect(); + let refs: Vec = pauling.iter().map(|&(_, p)| p).collect(); + + // F carries the largest predicted EN — the top of the scale + let en_f = en(9); + let en_max = preds.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + assert!( + (en_f - en_max).abs() < EPSILON, + "F EN {en_f:.3} is the maximum {en_max:.3}" + ); + + let n = preds.len() as f64; + let mp = preds.iter().sum::() / n; + let mm = refs.iter().sum::() / n; + let cov: f64 = preds + .iter() + .zip(&refs) + .map(|(p, m)| (p - mp) * (m - mm)) + .sum(); + let sp = preds.iter().map(|p| (p - mp).powi(2)).sum::().sqrt(); + let sm = refs.iter().map(|m| (m - mm).powi(2)).sum::().sqrt(); + let r = cov / (sp * sm); + + eprintln!("\n mulliken EN vs pauling: F on top, pearson r = {r:.4}"); + assert!(r > 0.85, "mulliken EN tracks pauling: r {r:.4} below 0.85"); +} + +// act XI: the relativistic edge — the 5d row +// +// max(n) rescued the 3d row (4s before 3d) and the 4d row (5s before 4d) by +// holding the outer s shell. the third try reverses: at the 5d row (Hf-Hg, +// Z=72-80) max(n) holds the outer 6s and under-predicts (Os −39.7%), while +// last-filled drops to the inner 5d and lands closer. relativistic 6s +// contraction pulls the measured 5d IEs up toward the inner-d magnitude — the +// heavy 6s feels a smaller effective n than its principal quantum number. +// +// the correction is one parameter-free term. n_eff contracts max(n) toward the +// inner d by the product of two factors read from nature and the walk: +// (Zα)² — the lorentz weight of the s electron at velocity Zα·c +// (n_max − 4) — periods since the d-inversion onset at the 3d row (n_max=4) +// at the 3d row (n_max−4)=0 kills the term, so n_eff = max(n) exactly and the +// first rescue stands untouched. at 5d the factor is 2 and (Zα)² ≈ 0.3, pulling +// n_eff most of the way to the inner 5d and rescuing the reversed row. +// +// the FORM is an ansatz: the product structure — linear in (n_max−4), linear in +// (n_max−n_last), quadratic in Zα — is chosen to thread the three d rows, with α +// the only input fixed by nature. it is a parameter-free fit of a chosen shape +// across three rows, the physical reading being the relativistic 6s contraction. + +#[test] +fn it_threads_three_d_rows_with_relativistic_contraction() { + // RMSE of the relativistic rule over a row + let r_rel = |lo: usize, hi: usize| -> f64 { + let mut sse = 0.0; + for z in lo..=hi { + sse += (ie_rel(z) - EXP[z - 1]).powi(2); + } + (sse / (hi - lo + 1) as f64).sqrt() + }; + + eprintln!("\n═══ act XI: relativistic correction across three d rows ═══\n"); + eprintln!(" row last-filled max(n) correction"); + let rows = [("3d", 21usize, 30usize), ("4d", 39, 48), ("5d", 72, 80)]; + let mut last = [0.0; 3]; + let mut max = [0.0; 3]; + let mut rel = [0.0; 3]; + for (i, (row, lo, hi)) in rows.iter().enumerate() { + last[i] = rmse(*lo, *hi, last_filled); + max[i] = rmse(*lo, *hi, Geonum::valence_shell); + rel[i] = r_rel(*lo, *hi); + eprintln!( + " {:3} {:8.3} {:8.3} {:8.3} eV", + row, last[i], max[i], rel[i] + ); + } + + // 3d: (n_max−4)=0 kills the term, so n_eff = max(n) bit-for-bit + assert!( + (rel[0] - max[0]).abs() < EPSILON, + "3d: (n_max−4)=0 leaves the max(n) rescue exact" + ); + // 4d: a light contraction holds the rescue, staying in the s/p band + assert!( + rel[1] <= max[1] + EPSILON, + "4d: correction holds the max(n) rescue" + ); + assert!( + rel[1] < 1.5, + "4d: relativistic RMSE in the s/p band ({:.3})", + rel[1] + ); + // 5d: the rescue reverses (max loses to last-filled), and the correction + // beats both, at least halving the max(n) error + assert!( + max[2] > last[2], + "5d: max(n) ({:.3}) loses to last-filled ({:.3}) — the rescue reverses", + max[2], + last[2] + ); + assert!( + rel[2] < last[2], + "5d: correction ({:.3}) beats last-filled ({:.3})", + rel[2], + last[2] + ); + assert!( + rel[2] < max[2] / 2.0, + "5d: correction ({:.3}) at least halves the max(n) RMSE ({:.3})", + rel[2], + max[2] + ); +} + +// act XII: the np-shortfall wall +// +// every act so far closed a gap with parameter-free geometry. the np closed +// shells are the boundary that resists. the model under-predicts Ar, Kr, Xe and +// the shortfall deepens each period. this proves WHY: to land on NIST the +// projection needs its opp term to grow quadratically with the period, but opp +// is capped by the marginal magnitude (opp ≤ p.mag), so the q·opp term grows +// only linearly. no frame rotation manufactures the missing magnitude — the +// deficit between what NIST needs and the geometric ceiling widens each period. + +#[test] +fn it_proves_the_np_shortfall_is_a_quadratic_wall() { + let waves: Vec = (0..=54) + .map(|z| Geonum::electron_wave(z, Lattice::Canonical)) + .collect(); + let q = Angle::new(1.0, 4.0); + + eprintln!("\n═══ act XII: the np-shortfall wall ═══\n"); + eprintln!(" np need q·opp ceiling π/4·p.mag deficit"); + let mut deficits = Vec::new(); + for &z in &[18usize, 36, 54] { + let marginal = waves[z] - waves[z - 1]; + let nucleus = Geonum::new(z as f64, 0.0, 1.0); + let p = nucleus * marginal; + let ref0 = Geonum::new(1.0, 0.0, 1.0); + let adj = p.project(&ref0); + let n = Geonum::valence_shell(z); + + // the q·opp the projection needs to land on NIST, holding adj fixed + let need = EXP[z - 1] * (n * n) as f64 / RYDBERG - adj.mag; + // the geometric ceiling: opp ≤ p.mag, so q·opp ≤ (π/4)·p.mag + let ceiling = q.grade_angle() * p.mag; + deficits.push(need - ceiling); + + eprintln!( + " {:3} {:9.2} {:14.2} {:7.2}", + ELEMENT[z - 1], + need, + ceiling, + need - ceiling + ); + + // no frame rotation reaches NIST: the needed q·opp exceeds the ceiling + assert!( + need > ceiling, + "{}: NIST needs q·opp {need:.2} above the π/4·p.mag ceiling {ceiling:.2}", + ELEMENT[z - 1] + ); + } + + // the wall rises: the deficit widens each period as the linear opp term + // falls further behind the quadratic target Ar -> Kr -> Xe + assert!(deficits[1] > deficits[0], "Kr deficit deepens past Ar"); + assert!(deficits[2] > deficits[1], "Xe deficit deepens past Kr"); +} diff --git a/tests/spacetime_test.rs b/tests/spacetime_test.rs new file mode 100644 index 0000000..73d622e --- /dev/null +++ b/tests/spacetime_test.rs @@ -0,0 +1,828 @@ +// the minkowski minus sign is a π rotation, not a scalar +// +// algebra_test.rs shows the geonum lattice is z⁴ = 1: grades 0, 1, 2, 3 ARE the +// four fourth-roots of unity, the Q lattice. so −1 is [1, π], grade 2, the dual +// — a π rotation, a geometric position. the metric signature s² = −t²+x²+y²+z² +// rests entirely on that one minus, and time is space rotated a quarter turn +// (the Wick rotation t → it), so the minus is the quarter turn squared: +// (i·t)² = [t², π] = −t². +// +// the tensor formalism buries this. a metric tensor g_μν is an n×n grid of +// scalar inner products, and "choosing a signature" gets dressed up as a deep +// decision about the nature of spacetime. it is none of that — it is which angle +// each basis squares to: 0 squares to +, π/2 squares to −. the matrix +// bookkeeping (components, raised and lowered indices, scalar combinations) +// obscures that one fact. the signature proof below is relocated here out of the +// tensor suite, where it sat under the scalar-combination machinery. +// +// the causal structure of spacetime (https://en.wikipedia.org/wiki/Causal_structure) +// — whether two events are timelike, lightlike, or spacelike separated — then +// reads directly off the grade of the assembled interval: +// timelike s² < 0 → grade 2 (causally connected, a sub-light worldline) +// lightlike s² = 0 → null (the light cone, a light ray) +// spacelike s² > 0 → grade 0 (causally disconnected, no signal connects them) +// no metric tensor, no index gymnastics — the trichotomy is one geonum's grade. +// +// run: cargo test --test spacetime_test -- --show-output + +use geonum::*; +use std::f64::consts::FRAC_PI_2; + +const EPSILON: f64 = 1e-10; + +#[test] +fn its_a_metric_signature() { + // relocated from tensor_test.rs. the tensor framing — g_μν matrices, a + // "choice" of signature — is bookkeeping over the one geometric fact this + // proves: a basis squares to + or − by the angle it sits at, no choice + + // traditional physics: "we must carefully choose our metric tensor signature" + // euclidean: (+,+,+,+) with g_μν = diag(1,1,1,1) + // minkowski: (-,+,+,+) with g_μν = diag(-1,1,1,1) + // this seems like a deep choice about the nature of spacetime + + // geonum: metric signature is just "what happens when angles add during squaring" + // no choice needed - it mechanically emerges from angle arithmetic + + // test 1: euclidean signature emerges from 0° basis vectors + // traditional: "we choose positive signature (+,+,+)" + // geonum: basis vectors at 0° naturally square to positive + + let e1_euclidean = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° basis + let e1_squared = e1_euclidean * e1_euclidean; + + // 0 + 0 = 0, cos(0) = +1 + assert_eq!(e1_squared.angle.blade(), 0); + assert!(e1_squared.angle.grade_angle().cos() > 0.0); // positive signature + assert_eq!(e1_squared.mag, 1.0); + + // test 2: minkowski signature emerges from timelike at π/2 + // traditional: "time has negative signature in the metric" + // geonum: time at π/2 naturally squares to negative + + let time_basis = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 (perpendicular to space) + let time_squared = time_basis * time_basis; + + // π/2 + π/2 = π, cos(π) = -1 + assert_eq!(time_squared.angle.blade(), 2); // blade 1 + 1 = 2 (which is π) + assert!(time_squared.angle.grade_angle().cos() < 0.0); // negative signature! + + // test 3: the "choice" of signature is just choosing initial angles + // traditional: "lets use signature (+,-,-,+)" + // geonum: "lets point basis vectors at 0, π/2, π/2, 0" + + let custom_e0 = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° → squares to + + let custom_e1 = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 → squares to - + let custom_e2 = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 → squares to - + let custom_e3 = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° → squares to + + + // verify the signature (+,-,-,+) + assert!((custom_e0 * custom_e0).angle.grade_angle().cos() > 0.0); // + + assert!((custom_e1 * custom_e1).angle.grade_angle().cos() < 0.0); // - + assert!((custom_e2 * custom_e2).angle.grade_angle().cos() < 0.0); // - + assert!((custom_e3 * custom_e3).angle.grade_angle().cos() > 0.0); // + + + // test 4: "negative" vectors squaring to positive + // traditional: "in clifford algebras, some negative elements square to positive" + // geonum: π + π = 2π ≡ 0, so negative times negative = positive + + let negative_vector = Geonum::new(1.0, 2.0, 2.0); // [1, π] = -1 + let squared = negative_vector * negative_vector; + + // π + π = 2π, and 2π ≡ 0 (mod 2π) + assert!(squared.angle.grade_angle().abs() < 1e-10); // back to 0 + assert!(squared.angle.grade_angle().cos() > 0.0); // positive result + assert_eq!(squared.mag, 1.0); + + // this is why (-1) × (-1) = +1: its just π + π = 2π ≡ 0 + + // test 5: the metric tensor is just tracking angle relationships + // traditional: "the metric tensor g_μν encodes the geometry of spacetime" + // geonum: the "metric" is just how basis angles relate to each other + + let spatial = Geonum::new_with_blade(2.0, 0, 0.3, 1.0); // spatial vector at blade 0 + let temporal = Geonum::new_with_blade(2.0, 1, 0.3, 1.0); // temporal vector at blade 1 + + // square both vectors through multiplication to reveal signature + let spatial_squared = spatial * spatial; // blade arithmetic with boundary crossing + let temporal_squared = temporal * temporal; // blade arithmetic with boundary crossing + + // prove exact blade accumulation shows signature + assert_eq!(spatial_squared.angle.blade(), 1); // spatial squares to blade 1 + assert_eq!(temporal_squared.angle.blade(), 3); // temporal squares to blade 3 + let blade_diff = temporal_squared.angle.blade() - spatial_squared.angle.blade(); + assert_eq!(blade_diff, 2); // 3 - 1 = 2, encodes dual positive/negative spacetime signature (π angle as -,+) + + // prove signature through cosine values - measured from actual blade arithmetic + assert!(spatial_squared.angle.grade_angle().cos() < 0.0); // spatial blade 1 gives negative cosine + assert!(temporal_squared.angle.grade_angle().cos() > 0.0); // temporal blade 3 gives positive cosine + + // minkowski metric signature emerges: 2 blade difference maintains space/time distinction + + // test 6: signature "flips" are just π rotations + // traditional: "changing signature requires careful metric tensor manipulation" + // geonum: just rotate your basis by π + + let positive_signature = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // cos(0) = +1 + let flipped_signature = Geonum::new_with_blade(1.0, 2, 0.0, 1.0); // cos(π) = -1 + + // same basis vector, just rotated by π + assert_eq!(positive_signature.mag, flipped_signature.mag); + assert_eq!( + (positive_signature.angle.blade() + 2) % 4, + flipped_signature.angle.blade() % 4 + ); + + // test 7: complex metric signatures are just angle patterns + // traditional: "some exotic spacetimes have signature (--++--++)" + // geonum: "some bases have angles at π/2, π/2, 0, 0, π/2, π/2, 0, 0" + + let exotic_signature: Vec = vec![ + Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - + Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - + Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + + Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + + Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - + Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - + Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + + Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + + ]; + + // prove the exotic signature pattern + for (i, basis) in exotic_signature.iter().enumerate() { + let squared = *basis * *basis; + let expected_negative = i % 4 < 2; // first two of each group are negative + + if expected_negative { + assert!( + squared.angle.grade_angle().cos() < 0.0, + "index {} negative", + i + ); + } else { + assert!( + squared.angle.grade_angle().cos() > 0.0, + "index {} positive", + i + ); + } + } + + // test 8: the pseudoscalar signature property I² = ±1 + // traditional: "the pseudoscalar squares to ±1 depending on metric signature" + // geonum: different dimension counts create different angle sums + + // in 3D euclidean: 3 spatial dimensions at 0° + let i_3d_euclidean = Geonum::new_with_blade(1.0, 3, 0.0, 1.0); // 3 × π/2 + let i_squared_euclidean = i_3d_euclidean * i_3d_euclidean; + + // 3π/2 + 3π/2 = 3π ≡ π (mod 2π), cos(π) = -1 + assert_eq!(i_squared_euclidean.angle.grade_angle().cos(), -1.0); // I² = -1 for euclidean + + // in 4D minkowski: 1 time (π/2) + 3 space (0°) + let i_4d_minkowski = Geonum::new_with_blade(1.0, 4, 0.0, 1.0); // 4 × π/2 = 2π + let i_squared_minkowski = i_4d_minkowski * i_4d_minkowski; + + // 2π + 2π = 4π ≡ 0 (mod 2π), cos(0) = +1 + assert_eq!(i_squared_minkowski.angle.grade_angle().cos(), 1.0); // I² = +1 for minkowski + + // the ±1 "mystery" is just whether your total angle is odd or even multiples of π + + // conclusion: metric signatures arent choices or conventions + // theyre mechanical consequences of angle arithmetic: + // - angles add when multiplying + // - 2π wraps to 0 + // - cos(0) = +1, cos(π) = -1 + // the entire formalism of metric tensors is just bookkeeping for "what angle is this?" +} + +#[test] +fn it_replaces_the_squared_zero_with_rotation_and_cancellation() { + // the conventional eye carries two "squares to zero" devices — the dual unit + // ε² = 0 and the null vector v·v = 0, each its own algebra. geonum replaces + // both with angle arithmetic: products rotate, and the single zero is additive + // + // a square rotates: [r, θ]² = [r², 2θ] doubles the angle and SQUARES the + // magnitude. so a product can never vanish for r ≠ 0 — squaring is a rotation + // with a growing magnitude, not an annihilation. this replaces the dual-number + // ε² = 0: squaring [1, π] rotates it to [1, 2π] = +1, there is no nilpotent + let sq = Geonum::new(1.0, 2.0, 2.0).pow(2.0); // [1, π]² = [1, 2π] = +1 + assert!( + sq.near_mag(1.0), + "squaring rotates — the magnitude survives, never zero" + ); + assert_eq!(sq.angle.grade(), 0, "[1,π]² lands back at +1, not at 0"); + + // the single zero is additive: a quantity against its own dual, [r,θ] + [r,θ+π], + // two equal magnitudes a π rotation apart. this replaces the null vector — no + // indefinite metric, the lightcone null is just a sum of opposites cancelling + let cancel = Geonum::new(1.0, 0.0, 1.0) + Geonum::new(1.0, 1.0, 1.0); // [1,0] + [1,π] + assert!( + cancel.mag < EPSILON, + "cancellation is additive — a sum of opposites" + ); + + // so the lightcone null comes from the additive branch (summing the grade-0 + // space square against the grade-2 time square), never from squaring. two + // conventional squared-zero algebras replaced by one rotation and one sum +} + +#[test] +fn it_reads_the_metric_as_the_dual_inverting_the_half_tangent() { + // the metric is not a grid of squared inner products — it is the dual: a π + // rotation, blade + 2 (src/angle.rs::dual). on the half-tangent S = tan(θ/2) + // the dual is the inversion S → −1/S, since dualizing sends θ → θ+π and + // tan((θ+π)/2) = −cot(θ/2) = −1/S. the same involution carries the metric + // signature (it_replaces_the_squared_zero_with_rotation_and_cancellation), and + // its fixed point S = ±i is the isotropic vector e₁ ± i·e₂ — the light cone + + let half_tangent = |a: Angle| (a.grade_angle() / 2.0).tan(); + + for (p, q) in [(1.0, 3.0), (1.0, 4.0), (2.0, 5.0), (1.0, 6.0)] { + let a = Angle::new(p, q); + let s = half_tangent(a); + + // the dual inverts the half-tangent: S → −1/S + assert!( + (half_tangent(a.dual()) - (-1.0 / s)).abs() < 1e-9, + "the dual inverts the half-tangent: tan((θ+π)/2) = −1/tan(θ/2)" + ); + + // and it always moves the grade by two, so no real direction is its own + // dual — the inversion has no fixed point on the real lattice + assert_ne!( + a.dual().grade(), + a.grade(), + "no real direction is self-dual" + ); + + // the rational metric reads cos θ off that same half-tangent: + // cos θ = (1−S²)/(1+S²). its denominator 1 + S² is the very polynomial the + // dual's fixed point solves — S = −1/S ⟺ S² + 1 = 0 ⟺ S = ±i. so the + // isotropic vector is the dual's fixed point AND the metric's pole: one + // imaginary balance, which 1 + S² ≥ 1 holds off the real line + let cos_from_s = (1.0 - s * s) / (1.0 + s * s); + assert!( + (cos_from_s - a.grade_angle().cos()).abs() < 1e-9, + "cos θ = (1−S²)/(1+S²) — the metric read from the half-tangent" + ); + } + + // so the light cone never appears as a real self-dual ray, only as the real + // shadow of S = ±i: a direction and its dual, a π apart, cancelling additively + // (the null of it_replaces_the_squared_zero...). the metric is the involution; + // the isotropic vector is where it would hold still +} + +#[test] +fn it_spans_the_three_conics_with_one_half_tangent() { + // the three generalized complex units are the three conics, and one rational + // half-tangent t parametrizes all of them through a single curvature κ: + // cos_κ(t) = (1 − κt²)/(1 + κt²) sin_κ(t) = 2t/(1 + κt²) + // the unit conic is cos_κ² + κ·sin_κ² = 1, and the generalized unit squares + // to −κ: + // κ = +1 elliptic i² = −1 the circle cos²+sin²=1 + // κ = 0 parabolic ε² = 0 the light cone the dual number, s²=0 + // κ = −1 hyperbolic j² = +1 the boost cosh²−sinh²=1 + // geonum carries one (blade, t); t says which conic. the same κ is the unit's + // square, the conic's curvature, and the denominator sign — and the light cone + // is the κ=0 seam between an imaginary null (t=±i) and a real one (t=±1) + + let cos_k = |kappa: f64, t: f64| (1.0 - kappa * t * t) / (1.0 + kappa * t * t); + let sin_k = |kappa: f64, t: f64| 2.0 * t / (1.0 + kappa * t * t); + + // the unit-conic identity holds for every curvature, one rational form + for t in [0.2_f64, 0.5, 0.9] { + for kappa in [1.0_f64, 0.0, -1.0] { + let (c, s) = (cos_k(kappa, t), sin_k(kappa, t)); + assert!( + (c * c + kappa * s * s - 1.0).abs() < 1e-12, + "cos_κ² + κ·sin_κ² = 1 at κ = {kappa}" + ); + } + } + + // κ = +1 is not a foil — it IS geonum's circle: the rational cos/sin geonum + // recovers from the stored half-tangent (src/angle.rs) is exactly cos_{+1}/sin_{+1} + for (p, q) in [(1.0, 3.0), (1.0, 4.0), (2.0, 5.0)] { + let a = Angle::new(p, q); + let t = (a.grade_angle() / 2.0).tan(); + let (cos, sin) = a.cos_sin(); + assert!((cos_k(1.0, t) - cos).abs() < 1e-9, "κ=+1 is geonum's cos"); + assert!((sin_k(1.0, t) - sin).abs() < 1e-9, "κ=+1 is geonum's sin"); + } + + // κ = −1 is the boost: one sign flip from the circle (1+t² → 1−t²) yields the + // rational hyperbola, and the rapidity reads back as s = tanh(φ/2) + let s = 0.5_f64; + let (cosh, sinh) = (cos_k(-1.0, s), sin_k(-1.0, s)); + assert!( + (cosh * cosh - sinh * sinh - 1.0).abs() < 1e-12, + "cosh² − sinh² = 1" + ); + let phi = (sinh / cosh).atanh(); + assert!((s - (phi / 2.0).tanh()).abs() < 1e-12, "s = tanh(φ/2)"); + + // κ = 0 is the parabolic seam — the light cone, the dual number ε²=0. cos_0=1, + // sin_0=2t: the norm carries no contribution from sin, so length is blind to + // the t direction — exactly s²=0, the nilpotent + for t in [0.2_f64, 0.5, 0.9] { + assert!((cos_k(0.0, t) - 1.0).abs() < 1e-12, "cos_0 = 1"); + assert!((sin_k(0.0, t) - 2.0 * t).abs() < 1e-12, "sin_0 = 2t"); + } + + // the null is the pole of cos_κ where 1 + κt² = 0: real for the hyperbola + // (s → 1, the asymptote, the light cone head-on) so cos_{−1} blows up, but + // imaginary for the circle (t = ±i) so cos_{+1} stays bounded. the κ=0 dual + // number is the seam between them. geonum never squares to zero + // (it_replaces_the_squared_zero) — the light cone is this boundary, reached + // additively, the fixed-point shadow of the dual (it_reads_the_metric...) + assert!( + cos_k(-1.0, 0.99).abs() > 50.0, + "hyperbolic cos blows up at the real null s → 1 — the light cone asymptote" + ); + assert!( + cos_k(1.0, 0.99).abs() <= 1.0, + "elliptic cos stays bounded — its null t = ±i is off the real line" + ); +} + +// the spacetime interval s² = (space)² + (time)² as a geonum vector sum. space +// sits on the real axis (grade 0), time on the i-axis (grade 1), so the time +// square lands at grade 2 (−t²) and subtracts. the causal character is the grade +// of the result — no metric tensor needed to assemble it +fn interval(space: f64, time: f64) -> Geonum { + let space_sq = Geonum::new(space, 0.0, 1.0).pow(2.0); // [x², 0] + let time_sq = Geonum::new(time, 1.0, 2.0).pow(2.0); // [t², π] + space_sq + time_sq +} + +#[test] +fn its_timelike() { + // timelike separation: more time than space, |Δt| > |Δx|. the grade-2 time + // square outweighs the grade-0 space square, so the interval lands grade 2 + // (s² < 0). the events are causally connected — a massive worldline slower + // than light passes through both, and every frame agrees on their order + let s = interval(3.0, 5.0); // Δx = 3, Δt = 5 + assert_eq!(s.angle.grade(), 2, "timelike interval is grade 2 (s² < 0)"); + assert!(s.near_mag(25.0 - 9.0), "|s²| = |9 − 25| = 16"); + // Δx/Δt = 3/5 < 1: a sub-light worldline connects timelike events + + // a 3+1 timelike interval: x²+y²+z² − t² with t dominating (1+4+4 − 16 = −7) + let s4 = Geonum::new(1.0, 0.0, 1.0).pow(2.0) + + Geonum::new(2.0, 0.0, 1.0).pow(2.0) + + Geonum::new(2.0, 0.0, 1.0).pow(2.0) + + Geonum::new(4.0, 1.0, 2.0).pow(2.0); + assert_eq!(s4.angle.grade(), 2, "3+1 timelike at grade 2"); + assert!(s4.near_mag(7.0), "|s²| = |9 − 16| = 7"); +} + +#[test] +fn its_spacelike() { + // spacelike separation: more space than time, |Δx| > |Δt|. the grade-0 space + // square outweighs the grade-2 time square, so the interval stays grade 0 + // (s² > 0). the events are causally disconnected — no signal at or below light + // speed connects them, and their time order is frame-dependent + let s = interval(5.0, 3.0); // Δx = 5, Δt = 3 + assert_eq!(s.angle.grade(), 0, "spacelike interval is grade 0 (s² > 0)"); + assert!(s.near_mag(25.0 - 9.0), "|s²| = 25 − 9 = 16"); + // Δx/Δt = 5/3 > 1: connecting them would need a faster-than-light signal + + // a 3+1 spacelike interval: x²+y²+z² − t² with space dominating (4+4+1 − 4 = +5) + let s4 = Geonum::new(2.0, 0.0, 1.0).pow(2.0) + + Geonum::new(2.0, 0.0, 1.0).pow(2.0) + + Geonum::new(1.0, 0.0, 1.0).pow(2.0) + + Geonum::new(2.0, 1.0, 2.0).pow(2.0); + assert_eq!(s4.angle.grade(), 0, "3+1 spacelike at grade 0"); + assert!(s4.near_mag(5.0), "|s²| = 9 − 4 = 5"); +} + +#[test] +fn its_lightlike() { + // lightlike (null) separation: space equals time, |Δx| = |Δt|. the grade-2 + // time square exactly cancels the grade-0 space square — destructive + // interference of a quantity against its own dual, [r,0] + [r,π] = 0. the + // interval is null, the light cone itself, reachable only by a light ray + let s = interval(4.0, 4.0); // Δx = Δt = 4 + assert!(s.mag < EPSILON, "lightlike interval is null — s² = 0"); + // Δx/Δt = 4/4 = 1: a light ray connects null-separated events + + // a 3+1 null interval: 3² + 4² + 0² = 5², a photon in the xy-plane on the cone + let s4 = Geonum::new(3.0, 0.0, 1.0).pow(2.0) + + Geonum::new(4.0, 0.0, 1.0).pow(2.0) + + Geonum::new(0.0, 0.0, 1.0).pow(2.0) + + Geonum::new(5.0, 1.0, 2.0).pow(2.0); + assert!(s4.mag < EPSILON, "3+1 null: 3²+4² = 5², on the light cone"); +} + +#[test] +fn it_finds_the_light_cone_where_the_dual_cancels() { + // the light cone is the boundary between the causal regions, and the grade + // flips across it. exactly on the cone (x = t) the grade-2 time square cancels + // the grade-0 space square — a quantity against its own dual, [r,0]+[r,π] = 0 — + // so the interval is null. step off the cone and the grade reappears: more + // space lands grade 0 (spacelike exterior), more time lands grade 2 (timelike + // interior). the null cone separates the two + let on_cone = interval(4.0, 4.0); + assert!( + on_cone.mag < EPSILON, + "on the cone the dual cancels — s² = 0" + ); + + // step out toward space: the interval reappears spacelike, grade 0 + let exterior = interval(5.0, 4.0); + assert!( + exterior.mag > EPSILON, + "off the cone the interval is nonzero" + ); + assert_eq!( + exterior.angle.grade(), + 0, + "more space than time is spacelike" + ); + + // step in toward time: the interval reappears timelike, grade 2 + let interior = interval(4.0, 5.0); + assert!( + interior.mag > EPSILON, + "off the cone the interval is nonzero" + ); + assert_eq!( + interior.angle.grade(), + 2, + "more time than space is timelike" + ); +} + +#[test] +fn it_shows_a_scalar_interval_discards_causal_structure() { + // the lesson algebra_test draws for winding numbers, drawn here for causality: + // a scalar |s²| cant tell timelike from spacelike. two events with s² = +9 + // and s² = −9 share the same scalar magnitude — the causal character is the + // grade, an angle a scalar metric discards and then re-smuggles as a sign + let spacelike = interval(5.0, 4.0); // 25 − 16 = +9 + let timelike = interval(4.0, 5.0); // 16 − 25 = −9 + + // the scalar a metric reports is identical — it has thrown the angle away + assert!(spacelike.near_mag(timelike.mag), "same scalar |s²| = 9"); + + // the causal structure survives only in the grade: grade 0 vs grade 2 + assert_eq!(spacelike.angle.grade(), 0, "spacelike lives at grade 0"); + assert_eq!(timelike.angle.grade(), 2, "timelike lives at grade 2"); + + eprintln!("\n scalar |s²| = {:.0} for both events", spacelike.mag); + eprintln!(" causality is the grade: spacelike grade 0, timelike grade 2"); + eprintln!(" a scalar metric discards it, then re-smuggles it as the −+++ sign"); +} + +// the scalar-coordinate callers below boost an event (t, x) along the x-axis and +// read the pair back. this thin adapter wraps Geonum::boost so the suite has ONE +// boost implementation. a boost is the SCALE half of scale_rotate: the method +// projects onto the two light-cone nulls and scales them reciprocally — the +// forward null t+x stretches by the Bondi factor k = e^α, the backward t−x +// shrinks by 1/k, zero rotation. magnitude = boost, angle = rotation +fn boost_xt(t: f64, x: f64, rapidity: f64) -> (f64, f64) { + let boosted = Geonum::new_from_cartesian(x, t).boost(Angle::new(0.0, 1.0), rapidity.exp()); + let (cos, sin) = boosted.angle.cos_sin(); + (boosted.mag * sin, boosted.mag * cos) // (t', x') +} + +#[test] +fn it_boosts_an_event_by_scaling_the_null_cone() { + // Geonum::boost reproduces the standard hyperbolic boost: scaling the null + // rays by e^±α gives back t' = t cosh α + x sinh α exactly, no cosh/sinh in + // the method — just the reciprocal scaling of the two null projections + let (t, x) = (5.0, 3.0); // a timelike event + let alpha = 0.5; + let (tp, xp) = boost_xt(t, x, alpha); + + assert!( + (tp - (t * alpha.cosh() + x * alpha.sinh())).abs() < EPSILON, + "t' = t cosh α + x sinh α" + ); + assert!( + (xp - (t * alpha.sinh() + x * alpha.cosh())).abs() < EPSILON, + "x' = t sinh α + x cosh α" + ); + + // boosts compose by multiplying the scale factors, so rapidity is ADDITIVE: + // e^α · e^β = e^(α+β). the geometric product turns boost composition into + // magnitude multiplication, the way angle addition composes rotations + let compose = Geonum::new(0.5_f64.exp(), 0.0, 1.0) * Geonum::new(0.9_f64.exp(), 0.0, 1.0); + assert!( + compose.near_mag((0.5_f64 + 0.9).exp()), + "two boosts compose to one of summed rapidity" + ); +} + +#[test] +fn it_boosts_an_event_by_projecting_onto_the_asymptotes_a_quarter_turn_apart() { + // Geonum::boost is the conjugate-hyperbola picture: t²−x²=1 and its conjugate + // x²−t²=1 share the asymptotes t = ±x — the light cone — one quarter turn + // apart (π/4 and 3π/4). a boost is not a separate squeeze: the method projects + // the event onto the two asymptotes and scales them oppositely by the Bondi + // factor. projection + the quarter turn + a scale, no (t±x) touched by hand + let alpha = 0.6_f64; + let k = alpha.exp(); // the Bondi / Doppler factor + + // the event as one geonum in the (x, t) plane, boosted along x (axis = 0) + let (t, x) = (2.0, 0.5); + let event = Geonum::new_from_cartesian(x, t); + let boosted = event.boost(Angle::new(0.0, 1.0), k); + let (cos, sin) = boosted.angle.cos_sin(); + let (xb, tb) = (boosted.mag * cos, boosted.mag * sin); + + // it preserves the interval — it IS the lorentz boost, built from projections + assert!( + (tb * tb - xb * xb - (t * t - x * x)).abs() < 1e-9, + "the asymptote-projection boost preserves t²−x²" + ); + + // the axis is a free parameter: boosting along a tilted spatial direction + // moves the nulls with it (axis ± π/4) and still preserves the interval — the + // squeeze is the same geometry pointed any way, not hard-coded to x + let tilt = Angle::new(1.0, 5.0); // π/5 + let tilted = event.boost(tilt, k); + let n = Geonum::new_with_angle(1.0, tilt); + let along = event.mag * event.angle.project(tilt); // signed component on the axis + let perp = event.reject(&n).mag; // perpendicular (the tilted-frame "time") + let along_b = tilted.mag * tilted.angle.project(tilt); + let perp_b = tilted.reject(&n).mag; + assert!( + ((perp_b * perp_b - along_b * along_b) - (perp * perp - along * along)).abs() < 1e-9, + "a boost along any axis preserves the interval in that axis's frame" + ); + + // the conjugate hyperbola is the timelike sector turned that same quarter: a + // point on t²−x²=1, rotated π/2, lands on the spacelike conjugate x²−t²=1. + // the grade-0/grade-2 causal split is this quarter turn seen on the curves + let on_timelike = Geonum::new_from_cartesian(0.5, (1.0 + 0.25_f64).sqrt()); + let turned = on_timelike.rotate(Angle::new(1.0, 2.0)); + let cx = turned.mag * turned.angle.grade_angle().cos(); + let ct = turned.mag * turned.angle.grade_angle().sin(); + assert!( + (cx * cx - ct * ct - 1.0).abs() < EPSILON, + "a quarter turn carries the timelike hyperbola onto its spacelike conjugate" + ); +} + +#[test] +fn it_holds_the_causal_grade_invariant_under_a_boost() { + // the trichotomy extends to boosts: a boost preserves the interval, so it + // preserves the grade. timelike stays grade 2, spacelike stays grade 0, and a + // null event stays on the cone. the causal class is the lorentz invariant + let alpha = 0.7; + + for (t, x, grade, label) in [(5.0, 3.0, 2usize, "timelike"), (3.0, 5.0, 0, "spacelike")] { + let before = interval(x, t); + let (tp, xp) = boost_xt(t, x, alpha); + let after = interval(xp.abs(), tp.abs()); // interval squares its inputs + + assert_eq!( + after.angle.grade(), + grade, + "{label} stays {label} (grade {grade})" + ); + assert!( + after.near_mag(before.mag), + "{label}: |s²| invariant under the boost" + ); + } + + // a null event stays null — scaling fixes zero, so the light cone is + // boost-invariant. this is the geonum statement of light-speed invariance + let (tp, xp) = boost_xt(4.0, 4.0, alpha); + assert!( + interval(xp.abs(), tp.abs()).mag < EPSILON, + "the light cone is invariant — a null event stays null under any boost" + ); +} + +// an event as (time, space): time the magnitude on the boost-orthogonal axis, +// space a geonum [ρ, ψ] carrying its DIRECTION in the angle. a boost keeps the +// directional work in angle space — project the spatial geonum onto the boost +// line n (the cos of the angle difference), apply the one hyperbolic step to the +// (time, parallel) magnitudes, then reassemble by adding the boosted parallel +// back along n to the untouched perpendicular. the spatial direction rotates as +// a result, and that rotation is read straight off the geonum angle. no (x,y) +// component arithmetic — the boost is magnitude, the direction is angle +fn boost_event(time: f64, space: Geonum, n: Angle, rapidity: f64) -> (f64, Geonum) { + let along = Geonum::new_with_angle(1.0, n); + let perp = space.reject(&along); // perpendicular part, untouched by the boost + let par = space.mag * space.angle.project(n); // signed length along n (cos of the diff) + + // the one hyperbolic step — scaling the (time ± parallel) null pair, magnitude + let (t2, par2) = boost_xt(time, par, rapidity); + + // reassemble in angle space: boosted parallel back along n, plus the perp + let pi = Angle::new(1.0, 1.0); + let par_vec = Geonum::new_with_angle(par2.abs(), if par2 >= 0.0 { n } else { n + pi }); + (t2, par_vec + perp) +} + +#[test] +fn it_keeps_the_wigner_rotation_in_angle_space() { + // the same non-commutative case, done geometrically: the spatial direction is + // a geonum angle throughout, decomposition is projection (cos of the angle + // difference), the boost is the magnitude step, and the wigner rotation comes + // out as a geonum ANGLE — no (x,y) components, no atan2 + let (alpha, beta) = (0.9, 0.7); + let x_axis = Angle::new(0.0, 1.0); // 0 + let y_axis = Angle::new(1.0, 2.0); // π/2 + let at_rest = Geonum::new(0.0, 0.0, 1.0); // no spatial part + + // the net boost reached by x then y + let (t1, s1) = boost_event(1.0, at_rest, x_axis, alpha); + let (t2, s2) = boost_event(t1, s1, y_axis, beta); + let net_dir = s2.angle; + let net_rapidity = (s2.mag / t2).atanh(); + + // R = undo the net boost ∘ the two boosts — it fixes the rest frame + let r = |time: f64, space: Geonum| { + let (a, b) = boost_event(time, space, x_axis, alpha); + let (c, d) = boost_event(a, b, y_axis, beta); + boost_event(c, d, net_dir, -net_rapidity) + }; + let (tf, sf) = r(1.0, at_rest); + assert!( + (tf - 1.0).abs() < 1e-9 && sf.mag < 1e-9, + "R returns rest to rest" + ); + + // R on a spatial probe: it fixes time and turns in the spatial plane. the + // wigner angle is the geonum angle of the turned x-axis + let (tx, sx) = r(0.0, Geonum::new(1.0, 0.0, 1.0)); // x-axis probe + assert!( + tx.abs() < 1e-9, + "the residual fixes time — a rotation, not a boost" + ); + assert!(sx.near_mag(1.0), "and preserves spatial length"); + let omega = sx.angle.grade_angle(); + assert!( + (1e-2..1.0).contains(&omega), + "two non-collinear boosts leave a geonum-angle rotation: Ω = {omega:.4}" + ); + + // R acts on the probe exactly as scale_rotate(1, Ω) — geonum's spiral with + // the boost knob at unity. the boosts were scale_rotate(k, no turn) (pure + // scale); the residual is scale_rotate(1, Ω) (pure rotation). one primitive, + // two knobs: scale = boost, angle = rotation + let spiral = Geonum::new(1.0, 0.0, 1.0).scale_rotate(1.0, sx.angle); + assert!( + spiral.near(&sx), + "R = scale_rotate(1, Ω) on space — the boost undone, the wigner rotation left" + ); + + // the y-axis probe turns by the SAME geonum angle — a rigid rotation, read + // entirely in angle space (the y-axis at π/2 lands at π/2 + Ω) + let (_, sy) = r(0.0, Geonum::new(1.0, 1.0, 2.0)); // y-axis probe (π/2) + assert!( + (sy.angle.grade_angle() - (omega + FRAC_PI_2)).abs() < 1e-9, + "both axes turn by Ω — a rotation, not a shear" + ); + + eprintln!("\n wigner rotation as a geonum angle: Ω = {omega:.4} rad"); + + // collinear control: a single boost leaves no rotation — the x-probe returns + // unturned, Ω = 0. the rotation above is born of non-collinearity + let solo = |time: f64, space: Geonum| { + let (a, b) = boost_event(time, space, x_axis, alpha); + let (sg, ss) = boost_event(1.0, at_rest, x_axis, alpha); + boost_event(a, b, ss.angle, -(ss.mag / sg).atanh()) + }; + let (_, sx0) = solo(0.0, Geonum::new(1.0, 0.0, 1.0)); + assert!( + sx0.angle.grade_angle() < 1e-9, + "a single boost leaves no rotation — Ω = 0 collinear" + ); + + // so the [magnitude, angle] split survives the non-commutative case WITHOUT + // leaving angle space: the boost is the magnitude step, the spatial direction + // and the wigner rotation it leaves behind are geonum angles throughout +} + +// the boosts above act on spacetime POINTS (t, x). the Angle::boost method acts +// on a DIRECTION on the celestial sphere — a light ray's polar angle θ from the +// boost axis. a unit direction has stereographic coordinate tan(θ/2), and a +// lorentz boost is the Möbius dilation that scales it by 1/k, the Bondi factor. +// geonum stores tan(θ/2) as its own half-tangent t, rational in (grade, t) +// across all four quadrants, so the boost is one rational scale — relativistic +// aberration with no cosh/sinh, the Penrose celestial-sphere picture + +#[test] +fn it_aberrates_a_light_ray_by_scaling_the_half_tangent() { + let ray = Angle::new(1.0, 3.0); // θ = π/3, 60° off the boost axis + let k = 0.6_f64.exp(); // Bondi factor for rapidity 0.6 + + // in the forward hemisphere the boost is just t → t/k: one division of the + // stored half-tangent, no transcendentals + let aberrated = ray.boost(k); + assert!( + (aberrated.t() - ray.t() / k).abs() < EPSILON, + "forward hemisphere: t' = t/k" + ); + + // the boosted direction obeys the relativistic aberration formula + // cos θ' = (cos θ + β)/(1 + β cos θ) — scaling the half-tangent IS stellar + // aberration, recovered rationally from the stored ratio + let beta = (k * k - 1.0) / (k * k + 1.0); // β = tanh φ, from the Bondi factor + let (cos, _) = ray.cos_sin(); + let (cos_prime, _) = aberrated.cos_sin(); + assert!( + (cos_prime - (cos + beta) / (1.0 + beta * cos)).abs() < EPSILON, + "cos θ' = (cos θ + β)/(1 + β cos θ) — relativistic aberration" + ); + + // the forward axis is a fixed point: boosting θ = 0 (t = 0) leaves it on the + // axis. (the backward pole θ = π is the other fixed point) + let fixed = Angle::new(0.0, 1.0).boost(k); + assert!( + fixed.t().abs() < EPSILON, + "the forward axis is fixed — t = 0 stays 0" + ); + + // boosts compose: boost by k1 then k2 = boost by k1·k2. the dilations multiply + // (rapidity adds), the same composition law as the event boost + let (k1, k2) = (0.4_f64.exp(), 0.5_f64.exp()); + let twice = ray.boost(k1).boost(k2); + let once = ray.boost(k1 * k2); + assert!( + (twice.t() - once.t()).abs() < EPSILON, + "boosts compose — dilations multiply, rapidity adds" + ); + + // headlight effect: a stronger boost crowds the ray toward the forward axis + // (smaller t') — relativistic beaming, as a smaller stored ratio + let strong = ray.boost(3.0_f64.exp()); + assert!( + strong.t() < aberrated.t(), + "a stronger boost pulls the ray toward the forward axis — the headlight effect" + ); +} + +#[test] +fn it_boosts_a_backward_ray_across_the_blade_boundary() { + let k = 0.6_f64.exp(); + let beta = (k * k - 1.0) / (k * k + 1.0); + + // a backward-hemisphere ray, θ = 2π/3 — past the π/2 boundary, blade 1, where + // the stereographic coordinate is (1+t)/(1−t), still rational + let ray = Angle::new(2.0, 3.0); + assert_eq!( + ray.blade(), + 1, + "θ = 2π/3 sits in the backward hemisphere, blade 1" + ); + + let aberrated = ray.boost(k); + let (cos, _) = ray.cos_sin(); + let (cos_prime, _) = aberrated.cos_sin(); + assert!( + (cos_prime - (cos + beta) / (1.0 + beta * cos)).abs() < EPSILON, + "cos θ' = (cos θ + β)/(1 + β cos θ) holds across the blade boundary" + ); + + // and the boost crosses the boundary: a strong enough boost swings the + // backward ray (blade 1) into the forward hemisphere (blade 0) — relativistic + // beaming pulling a rear ray to the front + assert_eq!( + aberrated.blade(), + 0, + "the backward ray boosts forward, crossing into blade 0" + ); +} + +#[test] +fn it_boosts_any_blade_via_the_grade() { + let k = 0.6_f64.exp(); + let beta = (k * k - 1.0) / (k * k + 1.0); + + // a grade-2 direction, θ = 5π/4 — into the third quadrant, where the + // stereographic coordinate is S = −1/t + let ray = Angle::new(5.0, 4.0); + assert_eq!(ray.grade(), 2, "θ = 5π/4 is grade 2"); + let aberrated = ray.boost(k); + let (cos, _) = ray.cos_sin(); + let (cos_prime, _) = aberrated.cos_sin(); + assert!( + (cos_prime - (cos + beta) / (1.0 + beta * cos)).abs() < EPSILON, + "the aberration formula holds at grade 2 too" + ); + + // keyed on grade, not the literal blade: a direction one full turn on + // (blade +4, same grade 0) boosts to the same place. any accumulated blade works + let wound = Angle::new_with_blade(4, 1.0, 3.0); // θ = π/3 + 2π, grade 0 + let plain = Angle::new(1.0, 3.0); // θ = π/3 + assert!( + (wound.boost(k).t() - plain.boost(k).t()).abs() < EPSILON, + "a full turn of accumulated blade boosts identically — keyed on grade" + ); + + // the backward pole θ = π (grade 2, t = 0, the stereographic point at ∞) is a + // fixed point — like the forward pole, the boost leaves it put + let back_pole = Angle::new(1.0, 1.0); // π + assert!( + back_pole.boost(k).near(&back_pole), + "the backward pole is fixed" + ); +} diff --git a/tests/taylor_series_test.rs b/tests/taylor_series_test.rs index db03051..29bfc1f 100644 --- a/tests/taylor_series_test.rs +++ b/tests/taylor_series_test.rs @@ -50,8 +50,8 @@ fn it_produces_taylor_coefficients_from_angle_descent() { } // angle ratios at each level - for i in 1..=7 { - let ratio = powers[i].angle.grade_angle() / x_angle; + for (i, p) in powers.iter().enumerate().skip(1) { + let ratio = p.angle.grade_angle() / x_angle; assert!( (ratio - i as f64).abs() < EPSILON, "x^{} angle ratio = {}", @@ -62,8 +62,8 @@ fn it_produces_taylor_coefficients_from_angle_descent() { // factorials from cumulative products of angle ratios let mut factorial = 1.0; - for n in 1..=7 { - let ratio = powers[n].angle.grade_angle() / x_angle; + for (n, p) in powers.iter().enumerate().skip(1) { + let ratio = p.angle.grade_angle() / x_angle; factorial *= ratio; // the taylor coefficient for the nth term is 1/n! diff --git a/tests/tensor_test.rs b/tests/tensor_test.rs index 6064b09..4bf06d7 100644 --- a/tests/tensor_test.rs +++ b/tests/tensor_test.rs @@ -2010,165 +2010,3 @@ fn its_a_tensor_comparison() { println!(" - enables previously impossible calculations"); println!(" - eliminates complexity bottlenecks"); } - -#[test] -fn its_a_metric_signature() { - // traditional physics: "we must carefully choose our metric tensor signature" - // euclidean: (+,+,+,+) with g_μν = diag(1,1,1,1) - // minkowski: (-,+,+,+) with g_μν = diag(-1,1,1,1) - // this seems like a deep choice about the nature of spacetime - - // geonum: metric signature is just "what happens when angles add during squaring" - // no choice needed - it mechanically emerges from angle arithmetic - - // test 1: euclidean signature emerges from 0° basis vectors - // traditional: "we choose positive signature (+,+,+)" - // geonum: basis vectors at 0° naturally square to positive - - let e1_euclidean = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° basis - let e1_squared = e1_euclidean * e1_euclidean; - - // 0 + 0 = 0, cos(0) = +1 - assert_eq!(e1_squared.angle.blade(), 0); - assert!(e1_squared.angle.grade_angle().cos() > 0.0); // positive signature - assert_eq!(e1_squared.mag, 1.0); - - // test 2: minkowski signature emerges from timelike at π/2 - // traditional: "time has negative signature in the metric" - // geonum: time at π/2 naturally squares to negative - - let time_basis = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 (perpendicular to space) - let time_squared = time_basis * time_basis; - - // π/2 + π/2 = π, cos(π) = -1 - assert_eq!(time_squared.angle.blade(), 2); // blade 1 + 1 = 2 (which is π) - assert!(time_squared.angle.grade_angle().cos() < 0.0); // negative signature! - - // test 3: the "choice" of signature is just choosing initial angles - // traditional: "lets use signature (+,-,-,+)" - // geonum: "lets point basis vectors at 0, π/2, π/2, 0" - - let custom_e0 = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° → squares to + - let custom_e1 = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 → squares to - - let custom_e2 = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // π/2 → squares to - - let custom_e3 = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // 0° → squares to + - - // verify the signature (+,-,-,+) - assert!((custom_e0 * custom_e0).angle.grade_angle().cos() > 0.0); // + - assert!((custom_e1 * custom_e1).angle.grade_angle().cos() < 0.0); // - - assert!((custom_e2 * custom_e2).angle.grade_angle().cos() < 0.0); // - - assert!((custom_e3 * custom_e3).angle.grade_angle().cos() > 0.0); // + - - // test 4: "negative" vectors squaring to positive - // traditional: "in clifford algebras, some negative elements square to positive" - // geonum: π + π = 2π ≡ 0, so negative times negative = positive - - let negative_vector = Geonum::new(1.0, 2.0, 2.0); // [1, π] = -1 - let squared = negative_vector * negative_vector; - - // π + π = 2π, and 2π ≡ 0 (mod 2π) - assert!(squared.angle.grade_angle().abs() < 1e-10); // back to 0 - assert!(squared.angle.grade_angle().cos() > 0.0); // positive result - assert_eq!(squared.mag, 1.0); - - // this is why (-1) × (-1) = +1: its just π + π = 2π ≡ 0 - - // test 5: the metric tensor is just tracking angle relationships - // traditional: "the metric tensor g_μν encodes the geometry of spacetime" - // geonum: the "metric" is just how basis angles relate to each other - - let spatial = Geonum::new_with_blade(2.0, 0, 0.3, 1.0); // spatial vector at blade 0 - let temporal = Geonum::new_with_blade(2.0, 1, 0.3, 1.0); // temporal vector at blade 1 - - // square both vectors through multiplication to reveal signature - let spatial_squared = spatial * spatial; // blade arithmetic with boundary crossing - let temporal_squared = temporal * temporal; // blade arithmetic with boundary crossing - - // prove exact blade accumulation shows signature - assert_eq!(spatial_squared.angle.blade(), 1); // spatial squares to blade 1 - assert_eq!(temporal_squared.angle.blade(), 3); // temporal squares to blade 3 - let blade_diff = temporal_squared.angle.blade() - spatial_squared.angle.blade(); - assert_eq!(blade_diff, 2); // 3 - 1 = 2, encodes dual positive/negative spacetime signature (π angle as -,+) - - // prove signature through cosine values - measured from actual blade arithmetic - assert!(spatial_squared.angle.grade_angle().cos() < 0.0); // spatial blade 1 gives negative cosine - assert!(temporal_squared.angle.grade_angle().cos() > 0.0); // temporal blade 3 gives positive cosine - - // minkowski metric signature emerges: 2 blade difference maintains space/time distinction - - // test 6: signature "flips" are just π rotations - // traditional: "changing signature requires careful metric tensor manipulation" - // geonum: just rotate your basis by π - - let positive_signature = Geonum::new_with_blade(1.0, 0, 0.0, 1.0); // cos(0) = +1 - let flipped_signature = Geonum::new_with_blade(1.0, 2, 0.0, 1.0); // cos(π) = -1 - - // same basis vector, just rotated by π - assert_eq!(positive_signature.mag, flipped_signature.mag); - assert_eq!( - (positive_signature.angle.blade() + 2) % 4, - flipped_signature.angle.blade() % 4 - ); - - // test 7: complex metric signatures are just angle patterns - // traditional: "some exotic spacetimes have signature (--++--++)" - // geonum: "some bases have angles at π/2, π/2, 0, 0, π/2, π/2, 0, 0" - - let exotic_signature: Vec = vec![ - Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - - Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - - Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + - Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + - Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - - Geonum::new_with_blade(1.0, 1, 0.0, 1.0), // π/2 → - - Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + - Geonum::new_with_blade(1.0, 0, 0.0, 1.0), // 0 → + - ]; - - // prove the exotic signature pattern - for (i, basis) in exotic_signature.iter().enumerate() { - let squared = *basis * *basis; - let expected_negative = i % 4 < 2; // first two of each group are negative - - if expected_negative { - assert!( - squared.angle.grade_angle().cos() < 0.0, - "index {} negative", - i - ); - } else { - assert!( - squared.angle.grade_angle().cos() > 0.0, - "index {} positive", - i - ); - } - } - - // test 8: the pseudoscalar signature property I² = ±1 - // traditional: "the pseudoscalar squares to ±1 depending on metric signature" - // geonum: different dimension counts create different angle sums - - // in 3D euclidean: 3 spatial dimensions at 0° - let i_3d_euclidean = Geonum::new_with_blade(1.0, 3, 0.0, 1.0); // 3 × π/2 - let i_squared_euclidean = i_3d_euclidean * i_3d_euclidean; - - // 3π/2 + 3π/2 = 3π ≡ π (mod 2π), cos(π) = -1 - assert_eq!(i_squared_euclidean.angle.grade_angle().cos(), -1.0); // I² = -1 for euclidean - - // in 4D minkowski: 1 time (π/2) + 3 space (0°) - let i_4d_minkowski = Geonum::new_with_blade(1.0, 4, 0.0, 1.0); // 4 × π/2 = 2π - let i_squared_minkowski = i_4d_minkowski * i_4d_minkowski; - - // 2π + 2π = 4π ≡ 0 (mod 2π), cos(0) = +1 - assert_eq!(i_squared_minkowski.angle.grade_angle().cos(), 1.0); // I² = +1 for minkowski - - // the ±1 "mystery" is just whether your total angle is odd or even multiples of π - - // conclusion: metric signatures arent choices or conventions - // theyre mechanical consequences of angle arithmetic: - // - angles add when multiplying - // - 2π wraps to 0 - // - cos(0) = +1, cos(π) = -1 - // the entire formalism of metric tensors is just bookkeeping for "what angle is this?" -}