diff --git a/.agents/onboard.md b/.agents/onboard.md index 7ef6fbf..bfda6f7 100644 --- a/.agents/onboard.md +++ b/.agents/onboard.md @@ -57,15 +57,15 @@ learn how angle subtraction borrows blades rationally in src/angle.rs:383~445 learn how geonum implements the dual in src/angle.rs:463~478 -learn how angle impls PartialEq and Eq in src/angle.rs:635~653 +learn how angle impls PartialEq and Eq in src/angle.rs:640~658 -learn how angle overloads arithmetic operators in src/angle.rs:655~844 +learn how angle overloads arithmetic operators in src/angle.rs:660~849 learn how to construct geonum with new, new_with_angle from src/geonum_mod.rs:23~49 -learn how geonum overloads arithmetic operators in src/geonum_mod.rs:814~1080 +learn how geonum overloads arithmetic operators in src/geonum_mod.rs:814~1081 -learn how geonum can express any number type from the its_a_scalar:8-36, its_a_vector:39-72, its_a_real_number:75-108, its_an_imaginary_number:111-139, its_a_complex_number:142-174, its_a_dual_number:177-295, its_an_octonion:298-318 tests in tests/numbers_test.rs +learn how geonum can express any number type from the its_a_scalar:8-36, its_a_vector:39-72, its_a_real_number:75-103, its_an_imaginary_number:106-134, its_a_complex_number:137-169, its_a_dual_number:172-218, its_an_octonion:221-241 tests in tests/numbers_test.rs learn how geonum eliminates angle slack created by decomposing angles into scalar coefficients by reading the it_proves_decomposing_angles_with_linearly_combined_basis_vectors_loses_angle_addition:13-84, it_proves_decomposition_distributes_one_angle_across_multiple_scalars:87-160, it_proves_quaternion_tables_add_back_what_decomposition_subtracts:519-660, it_proves_anticommutativity_exists_because_decomposition_subtracts_different_amounts:663-726 tests in tests/linear_algebra_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e2541..f05e319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # changelog +## 0.16.0 (2026-07-11) + +### changed +- `Geonum` addition previously erased blade history at complete cancellation — opposing angles summing to zero magnitude snapped the result to a bare lattice point (`t = 0`), a recorded history the operands never had. the branch now preserves it: the zero-magnitude result carries the summed blade count AND the operands' shared projection ratio, `Angle::from_parts(blade_a + blade_b, t)`, the same history preservation the dominance branches always had + +### added +- a bunch of domain integration tests +- `Angle::boost` doc names the orbital-anomaly identity: kepler's eccentric ↔ true conversion is the boost at `k = √((1−e)/(1+e))` — aberration at `β = −e`, perihelion and aphelion the fixed poles + ## 0.15.0 (2026-06-23) ### added diff --git a/Cargo.lock b/Cargo.lock index 32160b4..1f6d1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "geonum" -version = "0.15.0" +version = "0.16.0" dependencies = [ "criterion", "geonum", diff --git a/Cargo.toml b/Cargo.toml index 4d95b97..301cfcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "geonum" -version = "0.15.0" +version = "0.16.0" edition = "2021" repository = "https://github.com/mxfactorial/geonum" description = "geometric number library supporting unlimited dimensions with O(1) complexity" diff --git a/README.md b/README.md index 2e4718a..6d43176 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![docs](https://docs.rs/geonum/badge.svg)](https://docs.rs/geonum) [![dependency status](https://deps.rs/repo/github/mxfactorial/geonum/status.svg)](https://deps.rs/repo/github/mxfactorial/geonum) [![crates.io](https://img.shields.io/crates/v/geonum.svg)](https://crates.io/crates/geonum) -[![Discord](https://img.shields.io/discord/868565277955203122.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/KQdC65bG) +[![Discord](https://img.shields.io/discord/1519537430414495845.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/rPyRAbwK3) [![contribute](https://img.shields.io/badge/contribute-paypal-brightgreen.svg)](https://www.paypal.com/paypalme/mxfactorial) @@ -28,7 +28,7 @@ with most requiring "sparsity" to conceal how many explicit zeros appear declari the omission of geometry is so extreme at this point its suspicious -now your number must hobble through a prison of complicated "matrix" and "tensor" operations computing expensive dot & cross products in a scalar-dimension chain gang with other "linearly independent" scalars—only to reconstruct the simple detail of the direction its facing +now your number hobbles through a prison of complicated "matrix" and "tensor" operations computing expensive dot & cross products in a scalar-dimension chain gang with other "linearly independent" scalars—only to reconstruct the simple detail of the direction its facing and if you want to change its rate of motion, it must freeze all other scalar dimensions in a "partial derivative" with even more zeros @@ -143,17 +143,23 @@ affine_test.rs algebra_test.rs algorithms_test.rs angle_arithmetic_test.rs +anomaly_test.rs arithmetic_test.rs astrophysics_test.rs +atomic_clock_test.rs calculus_test.rs category_theory_test.rs cga_test.rs chem_constants_test.rs chemistry_test.rs computer_vision_test.rs +control_test.rs +convolution_test.rs cosmology_test.rs +crystallography_test.rs curve_test.rs dimension_test.rs +directional_stats_test.rs economics_test.rs einstein_test.rs em_field_theory_test.rs @@ -161,11 +167,15 @@ exponential_test.rs fem_test.rs field_test.rs finance_test.rs +fluid_test.rs +gauss_bonnet_test.rs geocollection_test.rs geometry_test.rs grade_test.rs gravitational_wave_test.rs +holonomy_test.rs integral_test.rs +interferometer_test.rs linear_algebra_test.rs machine_learning_test.rs mechanics_test.rs @@ -173,25 +183,36 @@ ml_attention_test.rs ml_training_test.rs monetary_policy_test.rs motion_laws_test.rs +multivalued_test.rs multivector_test.rs +music_test.rs numbers_test.rs optics_test.rs optimization_test.rs orbit_test.rs pga_test.rs +phasor_test.rs projection_test.rs pseudoscalar_test.rs qm_test.rs +quantum_computing_test.rs quaternion_test.rs rendering_test.rs +renormalization_test.rs robotics_test.rs +sampling_test.rs schwarzschild_test.rs set_theory_test.rs +smith_chart_test.rs spacetime_test.rs +spinor_test.rs sr_gr_collapse_test.rs +stability_test.rs taylor_series_test.rs tensor_test.rs +thermo_test.rs trigonometry_test.rs +whisper_test.rs ``` ### benches @@ -323,15 +344,12 @@ so: - a 1d number or scalar: `[4, 0]` - 4 units long facing 0 radians -- a 2d number or vector: `[[4, 0], [4, pi/2]]` - - one component 4 units at 0 radians - - one component 4 units at pi/2 radians -- a 3d number: `[[4, 0], [4, pi/2], [4, pi]]` - - one component 4 units at 0 radians - - one component 4 units at pi/2 radians - - one component 4 units at pi radians +- a 2d number or vector: `[4, pi/2]` + - the same 4 units, one quarter turn on +- a 3d number: `[4, pi]` + - two quarter turns — still two components, not three -higher dimensions just keep adding components rotated by +pi/2 each time +higher dimensions just keep rotating by +pi/2 — the millionth dimension costs the same two components as the first dimensions are created by rotations and not stacking coordinates @@ -402,15 +420,15 @@ geometric numbers build dimensions by rotating—not stacking - tests/numbers_test.rs - its_a_scalar:8-36 - its_a_vector:39-72 - - its_a_real_number:75-108 - - its_an_imaginary_number:111-139 - - its_a_complex_number:142-174 - - its_a_dual_number:177-295 - - its_an_octonion:298-318 - - its_a_matrix:321-375 - - its_a_tensor:378-572 - - it_dualizes_log2_geometric_algebra_components:624-657 - - its_a_clifford_number:917-997 + - its_a_real_number:75-103 + - its_an_imaginary_number:106-134 + - its_a_complex_number:137-169 + - its_a_dual_number:172-218 + - its_an_octonion:221-241 + - its_a_matrix:244-298 + - its_a_tensor:301-479 + - it_dualizes_log2_geometric_algebra_components:531-564 + - its_a_clifford_number:736-816 - tests/pseudoscalar_test.rs - it_solves_the_exponential_complexity_explosion:18-79 @@ -433,20 +451,20 @@ geometric numbers build dimensions by rotating—not stacking - it_shows_factorial_emerges_from_angle_descent:501-542 - it_proves_differentiation_cycles_grades:586-664 - it_proves_fundamental_theorem_is_accumulation_equals_interference:704-743 - - its_a_gradient:806-853 - - its_a_laplacian:856-902 - - its_a_line_integral:909-931 - - its_a_surface_integral:934-948 + - its_a_gradient:803-850 + - its_a_laplacian:853-899 + - its_a_line_integral:906-928 + - its_a_surface_integral:931-945 - tests/mechanics_test.rs - - it_changes_kinematic_level_by_cycling_grade:46-193 - - it_encodes_velocity:268-321 - - it_encodes_acceleration:324-362 - - it_encodes_jerk:365-412 - - it_encodes_kinetic_energy:959-1044 - - it_handles_energy_conservation:1783-1939 - - it_handles_momentum_conservation:1942-2050 - - it_handles_angular_momentum_conservation:2053-2157 + - it_cycles_the_kinematic_hierarchy_through_grades:30-53 + - it_carries_its_own_motion_in_the_quarter_turn:56-105 + - it_grows_displacement_quadratically_from_derived_acceleration:108-145 + - it_reads_kinematic_level_off_grade_not_blade:148-166 + - it_dots_velocity_into_kinetic_energy:266-289 + - it_conserves_energy_as_the_phase_magnitude:556-597 + - it_conserves_momentum_because_the_interaction_forces_are_pi_apart:437-464 + - it_conserves_angular_momentum_because_the_wedge_is_antisymmetric:467-503 create tests/my_test.rs with use geonum::*; ``` diff --git a/src/angle.rs b/src/angle.rs index b513ae6..af39e8b 100644 --- a/src/angle.rs +++ b/src/angle.rs @@ -575,6 +575,11 @@ impl Angle { /// /// the forward pole (θ=0) and backward pole (θ=π) are the fixed points /// + /// the same dilation converts orbital anomalies: k = √((1−e)/(1+e)) maps + /// eccentric to true anomaly, tan(ν/2) = tan(E/2)/k — kepler's conversion + /// is aberration at β = −e, with perihelion and aphelion the fixed poles + /// (tests/anomaly_test.rs) + /// /// # arguments /// * `k` - the Bondi / Doppler factor (k > 0); k > 1 boosts toward the axis /// @@ -873,28 +878,6 @@ mod tests { const EPSILON: f64 = 1e-10; - #[test] - fn it_sums_less_than_a_quarter_turn() { - let angle1 = Angle::new(1.0, 8.0); // π/8 - let angle2 = Angle::new(1.0, 6.0); // π/6 - - let sum = angle1 + angle2; // π/8 + π/6 = 7π/24 < π/2 - - assert_eq!(sum.blade(), 0); // no boundary crossing - assert!((sum.rem() - (7.0 * PI / 24.0)).abs() < EPSILON); - } - - #[test] - fn it_sums_greater_than_a_quarter_turn() { - let angle1 = Angle::new(1.0, 3.0); // π/3 - let angle2 = Angle::new(1.0, 4.0); // π/4 - - let sum = angle1 + angle2; // π/3 + π/4 = 7π/12 > π/2 - - assert_eq!(sum.blade(), 1); // crosses π/2 boundary, increments blade - assert!((sum.rem() - (7.0 * PI / 12.0 - PI / 2.0)).abs() < EPSILON); - } - #[test] fn it_sums_rotations_to_multiple_blades() { let angle1 = Angle::new(3.0, 4.0); // 3π/4, blade 1, rem π/4 @@ -907,42 +890,6 @@ mod tests { assert!((sum.rem()).abs() < EPSILON); // π/2 boundary crossing leaves no remainder } - #[test] - fn it_constructs_blade_0_from_large_angles() { - let angle = Angle::new(4.0, 2.0); // 4*(π/2) = 2π - - assert_eq!(angle.blade(), 4); // preserves original blade count - assert_eq!(angle.grade(), 0); // 4 % 4 = 0 (scalar grade) - assert!((angle.rem()).abs() < EPSILON); // exact multiple of π/2 leaves no remainder - } - - #[test] - fn it_constructs_blade_1_from_large_angles() { - let angle = Angle::new(5.0, 2.0); // 5*(π/2) - - assert_eq!(angle.blade(), 5); // preserves original blade count - assert_eq!(angle.grade(), 1); // 5 % 4 = 1 (vector grade) - assert!((angle.rem()).abs() < EPSILON); // exact multiple of π/2 leaves no remainder - } - - #[test] - fn it_constructs_blade_2_from_large_angles() { - let angle = Angle::new(6.0, 2.0); // 6*(π/2) - - assert_eq!(angle.blade(), 6); // preserves original blade count - assert_eq!(angle.grade(), 2); // 6 % 4 = 2 (bivector grade) - assert!((angle.rem()).abs() < EPSILON); // exact multiple of π/2 leaves no remainder - } - - #[test] - fn it_constructs_blade_3_from_large_angles() { - let angle = Angle::new(7.0, 2.0); // 7*(π/2) - - assert_eq!(angle.blade(), 7); // preserves original blade count - assert_eq!(angle.grade(), 3); // 7 % 4 = 3 (trivector grade) - assert!((angle.rem()).abs() < EPSILON); // exact multiple of π/2 leaves no remainder - } - #[test] fn it_preserves_blade_1000() { let angle = Angle::new(1000.0, 2.0); // 1000*(π/2) @@ -965,17 +912,6 @@ mod tests { assert!(sum.is_scalar()); // grade test works } - #[test] - fn it_subtracts_pi_over_6_from_pi_over_3() { - let angle1 = Angle::new(1.0, 3.0); // π/3 - let angle2 = Angle::new(1.0, 6.0); // π/6 - - let diff = angle1 - angle2; // π/3 - π/6 = π/6 - - assert_eq!(diff.blade(), 0); // π/6 is less than π/2, no blade increment - assert!((diff.rem() - PI / 6.0).abs() < EPSILON); // remainder is π/6 - } - #[test] fn it_subtracts_pi_over_3_from_4pi_over_3() { let angle1 = Angle::new(4.0, 3.0); // 4π/3 @@ -1000,50 +936,6 @@ mod tests { assert!((diff.rem() - PI / 3.0).abs() < EPSILON); // remainder is π/3 } - #[test] - fn it_multiplies_angles_as_addition() { - let angle1 = Angle::new(1.0, 8.0); // π/8 - let angle2 = Angle::new(1.0, 6.0); // π/6 - - let product = angle1 * angle2; // π/8 * π/6 = π/8 + π/6 = 7π/24 - - assert_eq!(product.blade(), 0); // 7π/24 < π/2, no blade increment - assert!((product.rem() - (7.0 * PI / 24.0)).abs() < EPSILON); - } - - #[test] - fn it_computes_cos_sin_of_1000_blade() { - let angle = Angle::new(1000.0, 2.0); // 1000*(π/2) - let (cos_result, sin_result) = angle.cos_sin(); - - // 1000 % 4 = 0, so grade is 0 (scalar) - // cos(0) = 1, sin(0) = 0 - assert!((cos_result - 1.0).abs() < EPSILON); - assert!((sin_result - 0.0).abs() < EPSILON); - } - - #[test] - fn it_computes_cos_sin_with_1001_blade() { - let angle = Angle::new(1001.0, 2.0); // 1001*(π/2) - let (cos_result, sin_result) = angle.cos_sin(); - - // 1001 % 4 = 1, so grade is 1 (vector) - // cos(π/2) = 0, sin(π/2) = 1 - assert!((cos_result - 0.0).abs() < EPSILON); - assert!((sin_result - 1.0).abs() < EPSILON); - } - - #[test] - fn it_computes_cos_sin_with_1003_blade() { - let angle = Angle::new(1003.0, 2.0); // 1003*(π/2) - let (cos_result, sin_result) = angle.cos_sin(); - - // 1003 % 4 = 3, so grade is 3 (trivector) - // cos(3π/2) = 0, sin(3π/2) = -1 - assert!((cos_result - 0.0).abs() < EPSILON); - assert!((sin_result - (-1.0)).abs() < EPSILON); - } - #[test] fn it_creates_angle_with_additional_blade() { // test basic functionality @@ -1225,39 +1117,6 @@ mod tests { assert_eq!(result.rem(), 0.0); } - #[test] - fn it_handles_negative_angle_on_one_blade() { - // test subtracting π from blade=1 (angle π/2) - let one_blade = Angle::new(1.0, 2.0); // π/2 (blade=1) - let pi = Angle::new(1.0, 1.0); // π - let result = one_blade - pi; - - // π/2 - π = -π/2 normalizes to 3π/2 - // 3π/2 = 3 * (π/2) + 0 - assert_eq!(result.blade(), 3); - assert_eq!(result.rem(), 0.0); - } - - #[test] - fn it_creates_negative_pi_over_2() { - // test what Angle::new(-1.0, 2.0) actually creates - let neg_half_pi = Angle::new(-1.0, 2.0); - println!( - "Angle::new(-1.0, 2.0) gives blade={}, rem={}", - neg_half_pi.blade(), - neg_half_pi.rem() - ); - - // test adding it to zero - let zero = Angle::new(0.0, 1.0); - let result = zero + neg_half_pi; - println!( - "0 + Angle::new(-1.0, 2.0) gives blade={}, rem={}", - result.blade(), - result.rem() - ); - } - #[test] fn it_computes_dual_angle() { // test dual operation using π-rotation (adds 2 blades) @@ -1416,20 +1275,6 @@ mod tests { assert!((angle9.grade_angle() - PI / 2.0).abs() < EPSILON); } - #[test] - fn it_adds_two_blades_when_dualizing_bivector() { - // π-rotation dual adds 2 blades - - let bivector_angle = Angle::new_with_blade(2, 0.0, 1.0); // blade 2, grade 2 - let dual_angle = bivector_angle.dual(); - - // bivector → scalar (grade 2 → grade 0) - assert_eq!(dual_angle.grade(), 0); - - // blade 2 + 2 = blade 4 - assert_eq!(dual_angle.blade(), 4); - } - #[test] fn it_negates_angle() { // test angle negation (complex conjugation) @@ -1768,15 +1613,6 @@ mod tests { assert!((sum.t() - expected_t).abs() < EPSILON); } - #[test] - fn it_adds_at_exact_boundary() { - // π/4 + π/4 = π/2 → exact boundary - let a = Angle::new(1.0, 4.0); - let sum = a + a; - assert_eq!(sum.blade(), 1); - assert!(sum.t() < EPSILON); - } - #[test] fn it_adds_across_boundary_with_rational_correction() { // π/3 + π/4 = 7π/12 → crosses π/2 @@ -1840,15 +1676,6 @@ mod tests { } } - #[test] - fn it_dualizes_and_negates_with_blade_only() { - let a = Angle::new(1.0, 4.0); - assert_eq!(a.dual().t(), a.t()); - assert_eq!(a.dual().blade() - a.blade(), 2); - assert_eq!(a.negate().t(), a.t()); - assert_eq!(a.negate().blade() - a.blade(), 2); - } - #[test] fn it_multiplies_via_tangent_sum() { let a = Angle::new(1.0, 4.0); diff --git a/src/geonum_mod.rs b/src/geonum_mod.rs index cdf3f00..cc3515a 100644 --- a/src/geonum_mod.rs +++ b/src/geonum_mod.rs @@ -832,11 +832,12 @@ impl Add for Geonum { let diff = self.mag - other.mag; if diff.abs() < EPSILON { - // complete cancellation - preserve blade history + // complete cancellation: self.t == other.t (the opposite-branch + // precondition), so the length-0 result takes that shared t, not 0 let combined_blade_count = self.angle.blade() + other.angle.blade(); return Self { mag: 0.0, - angle: Angle::new_with_blade(combined_blade_count, 0.0, 1.0), + angle: Angle::from_parts(combined_blade_count, self.angle.t()), }; } else if diff > 0.0 { // first dominates - preserve its blade history @@ -1105,15 +1106,6 @@ mod tests { use super::*; use std::f64::consts::PI; - #[test] - fn geonum_constructor_sets_components() { - let g = Geonum::new(1.0, 0.5, 2.0); - - assert!((g.mag - 1.0).abs() < EPSILON); - assert!((g.angle.rem() - PI / 4.0).abs() < EPSILON); - assert_eq!(g.angle.blade(), 0); - } - #[test] fn it_spreads_over_a_boundary() { // spread divides the magnitude by the boundary measure and composes the angle @@ -1719,32 +1711,6 @@ mod tests { assert_eq!(result.angle.blade(), 1); // blade preservation: 1 + 0 = 1 } - #[test] - fn it_handles_mixed_blade_addition() { - // test addition that results in large angle to avoid negative correction - let scalar = Geonum::new(1.0, 0.0, 1.0); // 1 unit at 0, blade=0 - let vector = Geonum::new(1.0, 5.0, 4.0); // 1 unit at 5π/4, blade=2 - - // scalar + vector: (1,0) + (-√2/2, -√2/2) results in angle > π/2 - let result1 = scalar + vector; - // combined blade count: 0 + 2 = 2, with minimal wrapping gives blade=3 - assert_eq!(result1.angle.blade(), 3); // blade preservation with minimal wrapping - - // test same-angle addition for comparison - let scalar2 = Geonum::new(2.0, 0.0, 2.0); // [2, 0] blade=0 - let scalar3 = Geonum::new(3.0, 0.0, 2.0); // [3, 0] blade=0 - let result2 = scalar2 + scalar3; - assert_eq!(result2.mag, 5.0); // magnitudes add directly - assert_eq!(result2.angle.blade(), 0); // blade preserved - - // test opposite angles - let pos = Geonum::new(4.0, 0.0, 1.0); // [4, 0] blade=0 - let neg = Geonum::new(2.0, 1.0, 1.0); // [2, π] blade=2 - let result3 = pos + neg; - assert_eq!(result3.mag, 2.0); // 4 - 2 = 2 - assert_eq!(result3.angle.blade(), 0); // result points right - } - #[test] fn it_projects_to_arbitrary_dimensions() { // test the new project_to_dimension method @@ -2009,24 +1975,6 @@ mod tests { } } - #[test] - fn it_computes_meet_between_different_grades() { - // line (vector) meets plane (bivector) at point (scalar) - - let vector = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // grade 1 (vector) with 0 angle value - let bivector = Geonum::new_with_blade(1.0, 2, 0.0, 1.0); // grade 2 (bivector) with 0 angle value - - let intersection = vector.meet(&bivector); - - // with π-rotation dual: - // grade 1 → dual → grade 3 - // grade 2 → dual → grade 0 - // wedge(grade 3, grade 0) → depends on angle sum - // final dual produces grade 2 - assert_eq!(intersection.angle.grade(), 2); - assert_eq!(intersection.mag, 1.0); - } - #[test] fn it_proves_meet_is_anticommutative() { // meet is anticommutative: meet(A, B) encoded as angle difference @@ -2051,39 +1999,6 @@ mod tests { ); } - #[test] - fn it_computes_self_meet_for_same_grade_objects() { - // object meeting itself with even-odd dual - - let scalar = Geonum::new(2.0, 1.0, 4.0); // grade 0 scalar - let self_meet = scalar.meet(&scalar); - - println!( - "scalar self-meet: magnitude {}, grade {}, blade {}", - self_meet.mag, - self_meet.angle.grade(), - self_meet.angle.blade() - ); - - // wedge of parallel objects (same angle) produces zero - // this is geometrically consistent - an object doesn't intersect with itself - assert_eq!(self_meet.mag, 0.0); - - // test with actual vector (grade 1) - let vector = Geonum::new_with_blade(3.0, 1, 1.0, 4.0); // grade 1 vector - let vector_self_meet = vector.meet(&vector); - - println!( - "vector self-meet: magnitude {}, grade {}, blade {}", - vector_self_meet.mag, - vector_self_meet.angle.grade(), - vector_self_meet.angle.blade() - ); - - // parallel vectors have zero wedge product - assert_eq!(vector_self_meet.mag, 0.0); - } - #[test] fn it_computes_meet_with_high_blade_counts() { // meet operations maintain O(1) complexity regardless of dimension @@ -2135,37 +2050,6 @@ mod tests { ); } - #[test] - fn it_proves_meet_uses_duality_relationship() { - // meet(A, B) = dual(wedge(dual(A), dual(B))) - - // test with same-grade objects - let a = Geonum::new(2.0, 1.0, 4.0); // grade 0, angle π/4 - let b = Geonum::new(3.0, 1.0, 3.0); // grade 0, angle π/3 - - let direct_meet = a.meet(&b); - - // manually compute using duality formula - let dual_a = a.dual(); - let dual_b = b.dual(); - let wedge_duals = dual_a.wedge(&dual_b); - let manual_meet = wedge_duals.dual(); - - // with even-odd dual, the formula works for all cases - assert!((direct_meet.mag - manual_meet.mag).abs() < EPSILON); - assert_eq!(direct_meet.angle, manual_meet.angle); - - // test with different-grade objects - let c = Geonum::new_with_blade(1.5, 1, 0.0, 1.0); // grade 1 - let d = Geonum::new_with_blade(2.5, 2, 0.0, 1.0); // grade 2 - - let direct_meet_cd = c.meet(&d); - let manual_meet_cd = c.dual().wedge(&d.dual()).dual(); - - assert!((direct_meet_cd.mag - manual_meet_cd.mag).abs() < EPSILON); - assert_eq!(direct_meet_cd.angle, manual_meet_cd.angle); - } - #[test] fn it_shows_geonum_meet_incidence_structure() { // test meet operations produce expected grades @@ -2205,33 +2089,6 @@ mod tests { assert_eq!(line1.dual().dual().angle.grade(), line1.angle.grade()); } - #[test] - fn it_maintains_constant_time_meet_operations() { - // meet operations maintain O(1) complexity regardless of dimension - - use std::time::Instant; - - let small1 = Geonum::new_with_blade(1.0, 3, 1.0, 4.0); - let small2 = Geonum::new_with_blade(1.0, 4, 1.0, 3.0); - - let start = Instant::now(); - let _result_small = small1.meet(&small2); - let time_small = start.elapsed(); - - let large1 = Geonum::new_with_blade(1.0, 1_000_000, 1.0, 4.0); - let large2 = Geonum::new_with_blade(1.0, 2_000_000, 1.0, 3.0); - - let start = Instant::now(); - let _result_large = large1.meet(&large2); - let time_large = start.elapsed(); - - let time_ratio = time_large.as_nanos() as f64 / time_small.as_nanos().max(1) as f64; - - assert!(time_ratio < 100.0); - assert!(_result_small.mag.is_finite()); - assert!(_result_large.mag.is_finite()); - } - #[test] fn it_meets_lines_at_intersection() { // test intersection of geometric objects using geonum's π-rotation dual @@ -2266,38 +2123,6 @@ mod tests { // scalar↔bivector and vector↔trivector pairings } - #[test] - fn it_meets_scalars_at_different_locations() { - // scalars at different angles represent different directions - // their meet should reflect their geometric relationship - let scalar1 = Geonum::new(3.0, 0.0, 1.0); // grade 0 at angle 0 - let scalar2 = Geonum::new(3.0, 1.0, 4.0); // grade 0 at angle π/4 - - let meet = scalar1.meet(&scalar2); - - // scalars at different angles dual to different bivectors - // their wedge product has non-zero area (sin of angle difference) - // so the meet produces a non-zero result - assert!(meet.mag > 0.0, "non-parallel scalars have non-zero meet"); - assert!(meet.mag.is_finite(), "meet has finite magnitude"); - } - - #[test] - fn it_meets_parallel_vectors() { - // parallel vectors have the same angle within their grade - let vector1 = Geonum::new_with_blade(2.0, 1, 1.0, 4.0); // grade 1, π/4 angle - let vector2 = Geonum::new_with_blade(3.0, 1, 1.0, 4.0); // grade 1, same π/4 angle - - let meet = vector1.meet(&vector2); - - // parallel vectors (same angle) dual to parallel trivectors - // their wedge product is zero (no area between parallel objects) - assert_eq!(meet.mag, 0.0, "parallel vectors have zero meet"); - - // the meet of parallel objects produces zero magnitude - // representing no intersection in finite space - } - #[test] fn it_meets_antiparallel_vectors() { // antiparallel vectors point in opposite directions (π radians apart) @@ -2314,85 +2139,6 @@ mod tests { // geonum represents this as zero magnitude rather than special infinity blade } - #[test] - fn it_meets_intersecting_vectors_at_point() { - // intersecting vectors at different angles meet at their intersection point - let vector1 = Geonum::new(2.0, 3.0, 4.0); // 3π/4 = grade 1 vector - let vector2 = Geonum::new(3.0, 5.0, 4.0); // 5π/4 = grade 2 bivector - - let meet = vector1.meet(&vector2); - - // grade 1 vector meets grade 2 bivector - // this represents line-plane intersection in projective geometry - assert!(meet.mag > 0.0, "non-parallel objects have non-zero meet"); - assert_eq!(meet.mag, 6.0, "meet magnitude = 2.0 * 3.0"); - - // geonum represents intersection at grade 3 due to π-rotation dual - assert_eq!(meet.angle.grade(), 3); - } - - #[test] - fn it_meets_bivectors_in_same_plane() { - // coplanar bivectors (same angle) represent parallel planes - let bivector1 = Geonum::new(4.0, 5.0, 4.0); // 5π/4 = grade 2 bivector - let bivector2 = Geonum::new(6.0, 5.0, 4.0); // 5π/4 = same angle, grade 2 - - let meet = bivector1.meet(&bivector2); - - // parallel planes (same angle bivectors) have zero meet - // they don't intersect in finite projective space - assert!(meet.mag < EPSILON, "parallel planes have zero meet"); - } - - #[test] - fn it_meets_bivectors_in_different_planes() { - // non-parallel planes intersect along a line - let plane1 = Geonum::new(4.0, 5.0, 4.0); // 5π/4 = grade 2 bivector - let plane2 = Geonum::new(9.0, 7.0, 4.0); // 7π/4 = grade 3 trivector - - let meet = plane1.meet(&plane2); - - // grade 2 bivector meets grade 3 trivector - // represents plane-volume intersection in projective geometry - assert!(meet.mag > 0.0, "non-parallel planes have non-zero meet"); - - // the intersection produces a geometric object encoding the line of intersection - assert!(meet.mag.is_finite()); - } - - #[test] - fn it_meets_trivectors_at_plane() { - // trivectors represent 3D volumes in projective geometry - let volume1 = Geonum::new(8.0, 7.0, 4.0); // 7π/4 = grade 3 trivector - let volume2 = Geonum::new(2.0, 15.0, 8.0); // 15π/8 = different angle grade 3 - - let meet = volume1.meet(&volume2); - - // non-parallel volumes intersect - // the meet encodes their common 2D subspace (plane) - assert!(meet.mag > 0.0, "non-parallel volumes have non-zero meet"); - - // geonum's π-rotation dual produces specific grade for volume-volume meet - assert!(meet.mag.is_finite()); - } - - #[test] - fn it_meets_higher_grades_cycling_pattern() { - // grades cycle modulo 4 in geonum's framework - let high_blade1 = Geonum::new_with_blade(3.0, 7, 1.0, 6.0); // blade 7, grade 3 - let high_blade2 = Geonum::new_with_blade(12.0, 11, 1.0, 4.0); // blade 11, grade 3 - - let meet = high_blade1.meet(&high_blade2); - - // high blade numbers still follow grade cycling - // blade 7 % 4 = 3, blade 11 % 4 = 3 (both grade 3) - assert!(meet.mag > 0.0, "non-parallel high-blade objects meet"); - - // the meet operation works consistently regardless of blade magnitude - // demonstrating O(1) complexity even for high-dimensional spaces - assert!(meet.mag.is_finite()); - } - #[test] fn it_maintains_grade_consistency_in_meet() { // prove that meet produces specific grades based on input grade combinations @@ -2456,48 +2202,6 @@ mod tests { // the π-rotation dual and wedge product angle addition } - #[test] - fn it_reflects_using_angle_arithmetic() { - // reflection is primitively a π rotation (2 blades) in absolute angle space - // the operation accumulates blades while computing the reflected position - - // test that reflection adds 2 blades (π rotation) - let point = Geonum::new_from_cartesian(3.0, 2.0); - let axis_45 = Geonum::new(1.0, 1.0, 4.0); // 45° axis - - // use the reflect method - let reflected = point.reflect(&axis_45); - - // reflection preserves magnitude and accumulates blades forward - assert_eq!(reflected.mag, point.mag); - - // forward-only reflection adds ~4 blades per reflection - let blade_added = reflected.angle.blade() as i32 - point.angle.blade() as i32; - assert!(blade_added >= 0, "blade only increases in forward-only"); - - // test that double reflection accumulates blade - let reflected_twice = reflected.reflect(&axis_45); - - // double reflection accumulates ~8 blades total - let total_blade_added = reflected_twice.angle.blade() as i32 - point.angle.blade() as i32; - assert!( - total_blade_added >= 7, - "double reflection accumulates significant blade" - ); - - // with base_angle(), returns to original position - let px = point.mag * point.angle.grade_angle().cos(); - let py = point.mag * point.angle.grade_angle().sin(); - let rx = reflected_twice.base_angle().mag - * reflected_twice.base_angle().angle.grade_angle().cos(); - let ry = reflected_twice.base_angle().mag - * reflected_twice.base_angle().angle.grade_angle().sin(); - assert!( - (px - rx).abs() < 1e-10 && (py - ry).abs() < 1e-10, - "double reflection with base_angle returns to original position" - ); - } - #[test] fn it_multiplies_angle_by_geonum() { // test Angle * Geonum (owned version) @@ -2514,180 +2218,6 @@ mod tests { assert_eq!(result.angle, expected_angle); } - #[test] - fn it_multiplies_angle_by_geonum_ref() { - // test Angle * &Geonum (borrow version) - let angle = Angle::new(1.0, 2.0); // π/2 - let geonum = Geonum::new(3.0, 1.0, 4.0); // magnitude 3, angle π/4 - - let result = angle * geonum; - - // test magnitude preserved - assert_eq!(result.mag, 3.0); - - // test angles add: π/2 + π/4 = 3π/4 - let expected_angle = Angle::new(3.0, 4.0); - assert_eq!(result.angle, expected_angle); - - // test original geonum still usable after borrow - assert_eq!(geonum.mag, 3.0); - assert_eq!(geonum.angle, Angle::new(1.0, 4.0)); - } - - #[test] - fn it_preserves_blade_in_angle_mul_geonum() { - // test that blade counts accumulate through angle multiplication - let angle = Angle::new(3.0, 2.0); // 3π/2 = blade 3 - let geonum = Geonum::new_with_blade(2.0, 5, 1.0, 4.0); // blade 5, angle π/4 - - // owned version - let result1 = angle * geonum; - assert_eq!(result1.mag, 2.0); - assert_eq!(result1.angle.blade(), 8); // 3 + 5 = 8 - - // borrow version - let result2 = angle * geonum; - assert_eq!(result2.mag, 2.0); - assert_eq!(result2.angle.blade(), 8); // 3 + 5 = 8 - } - - #[test] - fn it_handles_zero_angle_multiplication() { - // test multiplication with zero angle - let zero_angle = Angle::new(0.0, 1.0); // 0 radians - let geonum = Geonum::new(5.0, 2.0, 3.0); // magnitude 5, angle 2π/3 - - // owned version - let result1 = zero_angle * geonum; - assert_eq!(result1.mag, 5.0); - assert_eq!(result1.angle, geonum.angle); // angle unchanged - - // borrow version - let result2 = zero_angle * geonum; - assert_eq!(result2.mag, 5.0); - assert_eq!(result2.angle, geonum.angle); // angle unchanged - } - - #[test] - fn it_handles_full_rotation_multiplication() { - // test multiplication with full rotation (2π) - let full_rotation = Angle::new(2.0, 1.0); // 2π - let geonum = Geonum::new(1.0, 1.0, 6.0); // magnitude 1, angle π/6 - - // owned version - let result1 = full_rotation * geonum; - assert_eq!(result1.mag, 1.0); - // 2π + π/6 = blade 4 + fractional part π/6 - assert_eq!(result1.angle.blade(), 4); - - // borrow version - let result2 = full_rotation * geonum; - assert_eq!(result2.mag, 1.0); - assert_eq!(result2.angle.blade(), 4); - } - - #[test] - fn it_meets_vector_trivector_based_on_angles() { - // meet finds intersection based on angle relationships, not magnitude comparisons - // parallel objects (same angle) have zero meet, perpendicular have non-zero - - // create vector and trivector with different angle relationships - let vector_0 = Geonum::new_with_blade(3.0, 1, 0.0, 1.0); // blade 1, angle 0 - let vector_45 = Geonum::new_with_blade(3.0, 1, 1.0, 4.0); // blade 1, angle π/4 - - let trivector_0 = Geonum::new_with_blade(5.0, 3, 0.0, 1.0); // blade 3, angle 0 - let trivector_90 = Geonum::new_with_blade(5.0, 3, 1.0, 2.0); // blade 3, angle π/2 - - // parallel case: vector and trivector at same fractional angle - // after dual: blade 1→3, blade 3→5 - // wedge of two blade 3s with angle 0 gives sin(0) ≈ 0 - let meet_parallel = vector_0.meet(&trivector_0); - assert!( - meet_parallel.mag < EPSILON, - "parallel objects have zero meet" - ); - - // perpendicular case: vector at 0, trivector at π/2 - // after dual: blade 1→3 angle 0, blade 3→5 angle π/2 - // wedge gives non-zero result from sin(angle_diff) - let meet_perpendicular = vector_0.meet(&trivector_90); - assert!( - meet_perpendicular.mag > EPSILON, - "perpendicular objects have non-zero meet" - ); - - // angled case: vector at π/4, trivector at 0 - // wedge gives sin(π/4) = √2/2 - let meet_angled = vector_45.meet(&trivector_0); - assert!( - meet_angled.mag > EPSILON, - "angled objects have non-zero meet" - ); - } - - #[test] - fn it_reflects_point_across_x_axis_with_blade_accumulation() { - // reflection primitively adds π rotation (2 blades) - // reflecting (1, 1) across x-axis changes position AND adds 2 blades - let point = Geonum::new_from_cartesian(1.0, 1.0); - let x_axis = Geonum::new_from_cartesian(1.0, 0.0); - - let reflected = point.reflect(&x_axis); - - // forward-only formula: 2*axis + (2π - base_angle(point)) - // point at π/4 with base_angle π/4, so adds 7π/4 = 7 blades - let blade_accumulation = reflected.angle.blade() - point.angle.blade(); - assert_eq!( - blade_accumulation, 7, - "reflection across x-axis from π/4 adds 7 blades" - ); - - // reflection changes the grade structure - // π/4 (blade 0) → 7π/4 (blade 3) - assert_eq!( - reflected.angle.grade(), - 3, - "reflected point has trivector grade" - ); - - // test cartesian coordinates after reflection - let ox = point.mag * point.angle.grade_angle().cos(); - let oy = point.mag * point.angle.grade_angle().sin(); - let rx = reflected.mag * reflected.angle.grade_angle().cos(); - let ry = reflected.mag * reflected.angle.grade_angle().sin(); - - println!("Original: ({ox}, {oy})"); - println!("Reflected: ({rx}, {ry})"); - println!("Expected: ({}, {})", ox, -oy); - - // with forward-only geometry, reflection may not produce traditional result - // the blade accumulation changes the geometric interpretation - } - - #[test] - fn it_gets_mag() { - let g = Geonum::new(3.5, 1.0, 4.0); // [3.5, π/4] - assert_eq!(g.mag(), 3.5); - - let g2 = Geonum::new(0.0, 0.0, 1.0); // [0, 0] - assert_eq!(g2.mag(), 0.0); - - let g3 = Geonum::new(10.0, 3.0, 2.0); // [10, 3π/2] - assert_eq!(g3.mag(), 10.0); - } - - #[test] - fn it_gets_angle() { - let g = Geonum::new(2.0, 1.0, 4.0); // [2, π/4] - assert_eq!(g.angle(), Angle::new(1.0, 4.0)); - - let g2 = Geonum::new(1.0, 0.0, 1.0); // [1, 0] - assert_eq!(g2.angle(), Angle::new(0.0, 1.0)); - - let g3 = Geonum::new(5.0, 3.0, 2.0); // [5, 3π/2] - assert_eq!(g3.angle(), Angle::new(3.0, 2.0)); - } - #[test] fn it_scales_by_factor() { let g = Geonum::new(2.0, 1.0, 4.0); // [2, π/4] @@ -2771,32 +2301,6 @@ mod tests { assert_eq!(angle_after.blade(), angle_before.blade()); } - #[test] - fn it_inverts_unit_circle_conjugates_angle() { - // inversion through unit circle at origin is complex inversion 1/z - // this conjugates the angle: z = re^(iθ) → 1/z = (1/r)e^(-iθ) - - let origin = Geonum::scalar(0.0); - let unit_radius = 1.0; - - // point at distance 2, angle π/3 - let z = Geonum::new(2.0, 1.0, 3.0); - - // invert through unit circle at origin - let inverted = z.invert_circle(&origin, unit_radius); - - // distance becomes 1/2 - assert!((inverted.mag - 0.5).abs() < 1e-10); - - // circle inversion preserves angle value and grade, adds 4 transformation blades - let transformation_blades = Angle::new_with_blade(4, 0.0, 1.0); - let expected_angle = z.angle + transformation_blades; - assert_eq!( - inverted.angle, expected_angle, - "circle inversion adds 4 transformation blades" - ); - } - #[test] fn test_scale_rotate() { // test spiral similarity transformation @@ -2979,33 +2483,6 @@ mod tests { ); } - #[test] - fn it_makes_double_inversion_involutive_with_blade_reset() { - // test circular inversion with blade management - let center = Geonum::new_from_cartesian(0.0, 0.0); - let radius = 2.0; - let point = Geonum::new(3.0, 1.0, 6.0); - - let inverted_once = point.invert_circle(¢er, radius); - let inverted_twice = inverted_once.invert_circle(¢er, radius); - - // circle inversion preserves angle remainder/grade, adds 4 blades per operation - // double inversion adds 8 total transformation blades (2 × 4) - let double_transformation_blades = Angle::new_with_blade(8, 0.0, 1.0); - let expected_angle = point.angle + double_transformation_blades; - // avoid stricter equality in PartialEq by comparing blade and remainder separately - assert_eq!(inverted_twice.angle.blade(), expected_angle.blade()); - assert!((inverted_twice.angle.rem() - expected_angle.rem()).abs() < 1e-12); - assert!( - (inverted_twice.mag - point.mag).abs() < 1e-12, - "double inversion returns to original magnitude" - ); - - // blade accumulation comparison: - // reflection adds 2 blades (π rotation) per operation - // circle inversion adds 4 blades per operation (subtraction + addition) - } - #[test] fn it_demonstrates_forward_only_reflection_pattern() { // forward-only reflection has a single consistent pattern: @@ -3084,70 +2561,6 @@ mod tests { assert_eq!(result.angle, expected_angle); } - #[test] - fn it_adds_angle_to_ref() { - // test Angle + &Geonum (borrow version) - let angle = Angle::new(1.0, 2.0); // π/2 - let geonum = Geonum::new(3.0, 1.0, 4.0); // magnitude 3, angle π/4 - - let result = angle + geonum; - - // test magnitude preserved - assert_eq!(result.mag, 3.0); - - // test angles add: π/2 + π/4 = 3π/4 - let expected_angle = Angle::new(3.0, 4.0); - assert_eq!(result.angle, expected_angle); - - // test original geonum still usable after borrow - assert_eq!(geonum.mag, 3.0); - assert_eq!(geonum.angle, Angle::new(1.0, 4.0)); - } - - #[test] - fn it_scales_with_angle_addition() { - // test scale transformation using Angle + Geonum - let image = Geonum::new(1.0, 0.0, 2.0); // base image - let scale_shift = Angle::new(2.0, 1.0); // 2π = 4 blades - - let scaled_image = scale_shift + image; - - // test magnitude preserved - assert_eq!(scaled_image.mag, 1.0); - - // test blade shifted by 4 (2π = 4 × π/2) - assert_eq!(scaled_image.angle.blade(), 4); - - // test angle value preserved - assert_eq!(scaled_image.angle.rem(), 0.0); - } - - #[test] - fn it_preserves_blade_structure_in_circle_inversion() { - let center = Geonum::new(0.0, 0.0, 1.0); - let high_blade_point = Geonum::new_with_blade(2.0, 1000, 1.0, 6.0); // blade=1000, value=π/6 - - let inverted = high_blade_point.invert_circle(¢er, 1.0); - - // circle inversion accumulates blade through addition: 1000 + 0 + boundary crossings = 1004 - assert_eq!(inverted.angle.blade(), 1004); // blade accumulated through forward-only addition - assert!((inverted.angle.rem() - high_blade_point.angle.rem()).abs() < 1e-10); - } - - #[test] - fn it_preserves_blade_history_in_opposite_angle_addition() { - // create geonums with opposite angles (0 and π) but different blade histories - // opposite angles require blade difference of 2 with same value - let forward = Geonum::new_with_blade(4.0, 100, 0.0, 1.0); // blade=100, grade 0, pointing at 0° - let backward = Geonum::new_with_blade(4.0, 102, 0.0, 1.0); // blade=102, grade 2, pointing at π - - let oppose_sum = forward + backward; - - // opposite angle addition preserves transformation history through blade accumulation - assert_eq!(oppose_sum.angle.blade(), 202); // blade accumulated: 100 + 102 = 202 - assert!(oppose_sum.mag < 1e-10); // magnitude cancels - } - #[test] fn it_computes_distance_to() { // test distance between two points using law of cosines @@ -3263,21 +2676,6 @@ mod tests { assert!(matches!(t.angle.grade(), 1 | 3)); } - #[test] - fn it_computes_adj_and_opp_at_quadrature() { - let g = Geonum::new(5.0, 1.0, 4.0); // [5, π/4] - - let adj = g.adj(); - let opp = g.opp(); - - let expected = 5.0 * (2.0_f64).sqrt() / 2.0; - assert!((adj.mag - expected).abs() < EPSILON); - assert_eq!(adj.angle, Angle::new(0.0, 1.0)); - - assert!((opp.mag - expected).abs() < EPSILON); - assert_eq!(opp.angle, Angle::new(1.0, 2.0)); - } - #[test] fn it_encodes_sign_in_angle_for_adj_opp() { // angle = π → adj = [r, π], opp = [0, π/2] @@ -3379,4 +2777,32 @@ mod tests { ((perp_b * perp_b - along_b * along_b) - (perp * perp - along * along)).abs() < 1e-9 ); } + + #[test] + fn it_writes_the_shared_remainder_on_complete_cancellation() { + // two opposite rays of equal magnitude cancel to length 0. self.t == other.t in + // the opposite branch, so the result's t is that shared sub-segment value, not 0 — + // cancellations along different lines in one quadrant stay distinct + let pi = Angle::new(1.0, 1.0); + + // lines at π/8 and 3π/8: same quadrant (both blade 0), different t + let a = Geonum::new(1.0, 1.0, 8.0); + let a_opp = Geonum::new_with_angle(1.0, a.angle + pi); + let b = Geonum::new(1.0, 3.0, 8.0); + let b_opp = Geonum::new_with_angle(1.0, b.angle + pi); + + let zero_a = a + a_opp; + let zero_b = b + b_opp; + + // both cancel to length 0; each result's t equals its rays' shared remainder + assert!(zero_a.near_mag(0.0)); + assert!(zero_b.near_mag(0.0)); + assert_eq!(zero_a.angle.t(), a.angle.t()); + assert_eq!(zero_b.angle.t(), b.angle.t()); + + // so the two length-0 results are distinct, not one collapsed t=0 zero; the blade + // is the two rays' combined winding + assert_ne!(zero_a.angle, zero_b.angle); + assert_eq!(zero_a.angle.blade(), a.angle.blade() + a_opp.angle.blade()); + } } diff --git a/tests/algebra_test.rs b/tests/algebra_test.rs index 7d231da..7a9371a 100644 --- a/tests/algebra_test.rs +++ b/tests/algebra_test.rs @@ -23,9 +23,6 @@ // it straight off, no atan2, no cartesian round-trip. use geonum::*; -use std::f64::consts::PI; - -const TAU: f64 = 2.0 * PI; // ═══════════════════════════════════════════════════════════════════════════════ // PART 1 — THE MONOMIAL: counting, no winding integral needed @@ -81,32 +78,27 @@ fn it_multiplies_angles() { #[test] fn it_sweeps_n_times_as_theta_sweeps_once() { - // as θ goes 0 → 2π, nθ goes 0 → n×2π: the output angle wraps past 0 exactly n times + // as θ goes 0 → 2π, nθ goes 0 → n×2π: the output's blade climbs four + // quarter-turns per wrap, monotonically, and lands at exactly 4n — the + // wrap count read off storage, no crossing watched for let samples = 3600; - for n in 1..=8 { - let mut crossings = 0; - let mut prev: Option = None; - + for n in 1..=8usize { + let mut prev_blade = 0; for i in 0..=samples { // θ = (2i/samples)·π — swept once around the circle - let angle = unit(2.0 * i as f64 / samples as f64, 1.0) + let blade = unit(2.0 * i as f64 / samples as f64, 1.0) .pow(n as f64) .angle - .grade_angle(); - - if let Some(p) = prev { - // a crossing: the output angle wraps past 0 - if p > TAU * 0.9 && angle < TAU * 0.1 { - crossings += 1; - } - } - prev = Some(angle); + .blade(); + assert!(blade >= prev_blade, "degree {n}: the winding only climbs"); + prev_blade = blade; } assert_eq!( - crossings, n, - "degree {n}: output wrapped {crossings} times (expected {n})" + prev_blade, + 4 * n, + "degree {n}: the sweep stored 4n quarter-turns — n full wraps" ); } } @@ -254,10 +246,10 @@ fn it_shows_i_squared_is_minus_one_as_angle_addition() { // PART 2 — THE GENERAL POLYNOMIAL: the winding number // // p(z) is a sum of monomials, so its angle is nθ only to leading order. the winding -// number recovers the count anyway: sweep z around a circle, accumulate the change in -// the OUTPUT angle, divide by 2π. on a large circle it reads the degree; shrink past a -// root and it drops by one. the output direction is read straight off grade_angle — -// the same angle the monomial section counts, now summed around a path. +// number recovers the count anyway: sweep z around a circle and watch the output's +// grade — its quadrant address — walk the four-step cycle; the net walks are the +// winding. on a large circle it reads the degree; shrink past a root and it drops by +// one — the count the monomial section reads off the blade, now counted around a path. // ═══════════════════════════════════════════════════════════════════════════════ /// a real coefficient: positive sits at angle 0, negative at π — sign IS the angle @@ -282,33 +274,33 @@ fn eval_poly(coeffs: &[Geonum], z: Geonum) -> Geonum { } /// winding number of p(z) around the origin on a circle of given radius: sweep z = [r, θ] -/// once around, accumulate the signed change in the output's direction (grade_angle, read -/// straight off the angle), and divide the total by 2π +/// once around and watch the output's GRADE — its quadrant address — walk the four-step +/// cycle. forward steps count +1 quarter, backward −1, and the winding is the net walks +/// around the cycle: an integer by counting, no angle unwrapped, no rounding fn winding_number(coeffs: &[Geonum], radius: f64) -> i32 { let num_points = 10000; - let mut total = 0.0; - let mut prev: Option = None; + let mut quarters: i32 = 0; + let mut prev: Option = None; for i in 0..=num_points { let z = Geonum::new(radius, 2.0 * i as f64 / num_points as f64, 1.0); // [r, θ] let p_z = eval_poly(coeffs, z); - let current = p_z.angle.grade_angle(); // the output direction, no atan2 + let grade = p_z.angle.grade(); // the output's quadrant address if let Some(p) = prev { - // the signed step, unwrapped onto (−π, π] - let mut delta = current - p; - while delta > PI { - delta -= TAU; - } - while delta < -PI { - delta += TAU; + match (grade + 4 - p) % 4 { + 1 => quarters += 1, // the output stepped forward a quadrant + 3 => quarters -= 1, // and here it stepped back + 2 => unreachable!("quarter-step ambiguity — sample denser"), + _ => {} } - total += delta; } - prev = Some(current); + prev = Some(grade); } - (total / TAU).round() as i32 + // z closes its circle, so p(z) closes too: the walk lands whole turns + assert_eq!(quarters % 4, 0, "the closed sweep lands whole turns"); + quarters / 4 } // the polynomial root checks evaluate p(z) at a candidate angle; the horner chain of diff --git a/tests/anomaly_test.rs b/tests/anomaly_test.rs new file mode 100644 index 0000000..a9b5b07 --- /dev/null +++ b/tests/anomaly_test.rs @@ -0,0 +1,202 @@ +// kepler's anomaly conversion is a boost +// +// three centuries of series expansions — the "equation of the center", laplace +// coefficients, bessel-function inversions — to convert between orbital ANGLES. +// the eccentric-to-true anomaly map tan(ν/2) = √((1+e)/(1−e))·tan(E/2) is a +// möbius dilation of the half-tangent, and geonum ships that operation as +// Angle::boost: one rational scale of the stored t, with bondi factor +// k = √((1−e)/(1+e)) +// +// the identification runs deeper than a shared formula. the boost's velocity +// parameter is β = (k²−1)/(k²+1) = −e: relativistic stellar aberration and +// orbital anomaly conversion are ONE operation — kepler 1609 and einstein 1905 +// compute the same möbius dilation, three centuries apart, and the half-tangent +// the formula wants is the coordinate geonum stores +// +// perihelion and aphelion are the dilation's two fixed points — the same poles +// the celestial boost fixes. the transcendental leg of orbit propagation +// (mean ↔ eccentric anomaly, area ↔ angle) stays transcendental; the leg the +// series ceremony multiplied on (eccentric ↔ true) is one boost +// +// run: cargo test --test anomaly_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +const MERCURY_E: f64 = 0.205_630; +const HALLEY_E: f64 = 0.967_14; + +// the bondi factor of an orbit: k = √((1−e)/(1+e)) +fn bondi(e: f64) -> f64 { + ((1.0 - e) / (1.0 + e)).sqrt() +} + +// the closed-form conversion the textbooks derive by half-angle identities: +// cos ν = (cos E − e)/(1 − e·cos E), sin ν = √(1−e²)·sin E/(1 − e·cos E) +fn true_anomaly_foil(e: f64, big_e: f64) -> (f64, f64) { + let d = 1.0 - e * big_e.cos(); + ( + (big_e.cos() - e) / d, + ((1.0 - e * e).sqrt() * big_e.sin()) / d, + ) +} + +#[test] +fn it_converts_eccentric_to_true_anomaly_with_one_boost() { + // eccentric anomalies across all four quadrants of the orbit + let anomalies = [ + Angle::new(1.0, 6.0), // π/6 + Angle::new(1.0, 3.0), // π/3 + Angle::new(2.0, 3.0), // 2π/3 — blade 1 + Angle::new(7.0, 6.0), // 7π/6 — blade 2, the return half + Angle::new(11.0, 6.0), // 11π/6 — blade 3, inbound to perihelion + ]; + + for e in [MERCURY_E, HALLEY_E] { + for big_e in anomalies { + let nu = big_e.boost(bondi(e)); + let (cos_nu, sin_nu) = nu.cos_sin(); + let (foil_cos, foil_sin) = true_anomaly_foil(e, big_e.grade_angle()); + + assert!( + (cos_nu - foil_cos).abs() < 1e-12, + "e={e}: cos ν from one boost matches the closed form" + ); + assert!( + (sin_nu - foil_sin).abs() < 1e-12, + "e={e}: sin ν too — the quadrant rides the grade, no atan2 patching" + ); + } + } +} + +#[test] +fn it_fixes_perihelion_and_aphelion_as_the_boost_poles() { + // the möbius dilation has two fixed points, the forward and backward poles. + // on the orbit they are perihelion (E = ν = 0) and aphelion (E = ν = π) — + // the two places eccentric and true anomaly agree, because the dilation + // holds them still + for e in [MERCURY_E, HALLEY_E] { + let perihelion = Angle::new(0.0, 1.0); + let aphelion = Angle::new(1.0, 1.0); + + assert!( + perihelion.boost(bondi(e)).near(&perihelion), + "perihelion is the forward pole" + ); + assert!( + aphelion.boost(bondi(e)).near(&aphelion), + "aphelion is the backward pole" + ); + } +} + +#[test] +fn it_inverts_the_conversion_by_the_reciprocal_boost() { + // ν → E is the same dilation at 1/k — boosts compose by multiplying, so + // k·(1/k) = 1 returns the angle. no series inversion, no newton iteration + let k = bondi(MERCURY_E); + let anomalies = [ + Angle::new(1.0, 5.0), + Angle::new(3.0, 4.0), + Angle::new(6.0, 5.0), + Angle::new(9.0, 5.0), + ]; + + for big_e in anomalies { + let round_trip = big_e.boost(k).boost(1.0 / k); + assert!( + round_trip.near(&big_e), + "boost then reciprocal boost returns the eccentric anomaly" + ); + } +} + +#[test] +fn it_identifies_the_anomaly_map_as_aberration_at_beta_minus_e() { + // the boost's velocity parameter computes to β = −e: the aberration formula + // cos θ' = (cos θ + β)/(1 + β·cos θ) IS the anomaly conversion. starlight + // aberration and orbit geometry are one dilation of the half-tangent + for e in [MERCURY_E, HALLEY_E] { + let k = bondi(e); + let beta = (k * k - 1.0) / (k * k + 1.0); + assert!( + (beta + e).abs() < 1e-12, + "the orbit's eccentricity is the boost's −β" + ); + + // one angle, three routes: the geonum boost, the aberration formula at + // β = −e, the anomaly closed form — all land the same cos ν + let big_e = Angle::new(2.0, 5.0); + let (cos_e, _) = big_e.cos_sin(); + let aberration = (cos_e + beta) / (1.0 + beta * cos_e); + let (anomaly, _) = true_anomaly_foil(e, big_e.grade_angle()); + let (boosted, _) = big_e.boost(k).cos_sin(); + + assert!( + (aberration - anomaly).abs() < 1e-12, + "aberration at β = −e is the anomaly conversion" + ); + assert!( + (boosted - aberration).abs() < 1e-12, + "and the boost computes both" + ); + } +} + +#[test] +fn it_agrees_with_the_conic_radius_at_both_anomalies() { + // the ellipse itself is the anchor: r = a(1 − e·cos E) from the eccentric + // anomaly and r = a(1−e²)/(1 + e·cos ν) from the true anomaly describe the + // same point. run the boost between them and the two radii agree — the + // conversion lands on the orbit the test never parametrized + let a = 1.0; // semi-major axis + let anomalies = [ + Angle::new(1.0, 6.0), + Angle::new(1.0, 2.0), + Angle::new(5.0, 6.0), + Angle::new(3.0, 2.0), + ]; + + for e in [MERCURY_E, HALLEY_E] { + for big_e in anomalies { + let (cos_e, _) = big_e.cos_sin(); + let r_eccentric = a * (1.0 - e * cos_e); + + let (cos_nu, _) = big_e.boost(bondi(e)).cos_sin(); + let r_true = a * (1.0 - e * e) / (1.0 + e * cos_nu); + + assert!( + (r_eccentric - r_true).abs() < 1e-12, + "e={e}: both anomalies read the same radius through the boost" + ); + } + } +} + +#[test] +fn it_crosses_the_blade_boundary_at_comet_eccentricity() { + // halley at E = π/6: thirty degrees of eccentric anomaly is already 128° of + // true anomaly — the comet spends its life near aphelion and whips through + // perihelion. the whip IS the boost crossing the blade boundary: E sits at + // blade 0, ν lands at blade 1, one rational scale of t carries it over + let big_e = Angle::new(1.0, 6.0); + assert_eq!(big_e.blade(), 0, "E = π/6 sits in the first quadrant"); + + let nu = big_e.boost(bondi(HALLEY_E)); + assert_eq!( + nu.blade(), + 1, + "ν crosses into the second quadrant — the perihelion whip" + ); + + // the crossing is measured, not asserted by fiat: the closed form puts ν + // there too. tan(π/12)/k = 2.073, ν = 2·atan(2.073) ≈ 2.243 rad ≈ 128° + let (foil_cos, foil_sin) = true_anomaly_foil(HALLEY_E, PI / 6.0); + let (cos_nu, sin_nu) = nu.cos_sin(); + assert!((cos_nu - foil_cos).abs() < 1e-12, "cos ν lands the whip"); + assert!( + (sin_nu - foil_sin).abs() < 1e-12, + "sin ν stays positive — outbound" + ); +} diff --git a/tests/atomic_clock_test.rs b/tests/atomic_clock_test.rs new file mode 100644 index 0000000..cd5b1d1 --- /dev/null +++ b/tests/atomic_clock_test.rs @@ -0,0 +1,143 @@ +// timekeeping is winding counting +// +// the SI second is DEFINED as 9,192,631,770 turns of cesium's hyperfine +// phase — time's base unit is a winding count. no float phase register can +// hold what that definition demands: a year of atomic time is 2.9×10^17 +// turns, a count past f64's exact-integer range before the year even starts, +// and at 1.8×10^18 radians the float's next representable instant sits ~40 +// cycles away — four and a half nanoseconds that cannot be expressed, 1.3 +// meters of GPS, a whole tick vanishing into rounding bit-identically. the +// accumulated float clock ends the year ~87 μs adrift — ~60× the telecom +// PTP sync budget — while the blade count is exact. the industry hand-rolls the +// fix: NTP eras, PTP's 96-bit second+fraction registers — an integer count +// beside a fraction, blade and t shipped as a protocol instead of arithmetic +// +// - a year added one minute at a time (525,600 additions) lands the blade +// on the exact integer 4 × 31,536,000 × 9,192,631,770 — every cycle of +// the year counted, none rounded +// - two clocks one tick apart: four blades apart as geonums, bit-identical +// as floats +// - after a 10^17-turn year the sub-cycle readout is as sharp as at t = 0: +// the angle's resolution never ages, while a float's degrades with every +// order of magnitude it climbs +// +// run: cargo test --test atomic_clock_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +const CESIUM_HZ: u64 = 9_192_631_770; // the SI second, by definition +const MINUTES_PER_YEAR: u64 = 525_600; // 365 days, measured in minutes +const SECONDS_PER_YEAR: u64 = 31_536_000; + +// the year's true cycle count, exact in integer arithmetic +fn year_cycles() -> u128 { + SECONDS_PER_YEAR as u128 * CESIUM_HZ as u128 +} + +#[test] +fn it_counts_every_cesium_cycle_of_a_year() { + // one minute of atomic time: 60 × 9,192,631,770 cycles, four blades each — + // an exact integer handed to the blade, no radians constructed + let one_minute = Angle::new_with_blade((4 * 60 * CESIUM_HZ) as usize, 0.0, 1.0); + + let mut clock = Angle::new(0.0, 1.0); + let mut float_phase = 0.0_f64; // the conventional register, run alongside + for _ in 0..MINUTES_PER_YEAR { + clock = clock + one_minute; + float_phase += 2.0 * PI * (60 * CESIUM_HZ) as f64; + } + + // the blade lands the year exactly: 1.16×10^18 quarter-turns, an integer + // the test verifies against u128 arithmetic — a count too large for f64 + // to even state (2.9×10^17 cycles > 2^53) + assert_eq!( + clock.blade() as u128, + 4 * year_cycles(), + "every cycle of the year counted, none rounded" + ); + assert!( + clock.near_rem(0.0), + "and nothing spilled into the fraction — whole cycles stay whole" + ); + + // the float register fogged: its next representable instant is dozens of + // cycles away, and its accumulated count has drifted off the true one + let fog_cycles = (float_phase.next_up() - float_phase) / (2.0 * PI); + assert!( + fog_cycles > 10.0, + "the float cannot resolve its own tick: next instant {fog_cycles:.0} cycles away" + ); + let drift_cycles = (float_phase / (2.0 * PI) - year_cycles() as f64).abs(); + assert!( + drift_cycles > 1.0, + "the accumulated float count lost whole cycles: {drift_cycles:.0} adrift" + ); + + // in the timing industry's units: the float clock ends the year tens of + // microseconds adrift — the telecom PTP budget is 1.5 μs, GPS wants + // nanoseconds — while the blade clock is exact + let drift_seconds = drift_cycles / CESIUM_HZ as f64; + assert!( + drift_seconds > 1e-5, + "the float clock broke microsecond sync: {:.1} μs adrift", + drift_seconds * 1e6 + ); + eprintln!( + "year end: blade exact; float fog {fog_cycles:.0} cycles ({:.1} ns), drift {drift_cycles:.0} cycles ({:.1} μs)", + fog_cycles / CESIUM_HZ as f64 * 1e9, + drift_seconds * 1e6 + ); +} + +#[test] +fn it_distinguishes_clocks_one_tick_apart() { + // two cesium clocks at year end, clock b one cycle ahead — the smallest + // disagreement two atomic clocks can have + let year_blade = (4 * year_cycles()) as usize; + let clock_a = Angle::new_with_blade(year_blade, 0.0, 1.0); + let clock_b = clock_a + Angle::new_with_blade(4, 0.0, 1.0); + + assert_eq!( + clock_b.blade() - clock_a.blade(), + 4, + "one tick resolved exactly — four quarter-turns of blade" + ); + + // the float registers absorb the tick: adding one full cycle to the + // year-end phase rounds back to the same bits. the two clocks read as + // one clock — the disagreement is unrepresentable + let phase_a = year_cycles() as f64 * 2.0 * PI; + let phase_b = phase_a + 2.0 * PI; + assert!( + phase_b == phase_a, + "the float clocks are bit-identical — the tick vanished into rounding" + ); +} + +#[test] +fn it_keeps_subcycle_resolution_after_a_petaturn_year() { + // advance the year-end clock by 3/8 of a cycle and read the sub-cycle + // position: it matches a newborn clock's reading exactly — same base + // angle, same t, bit for bit. the winding never taxed the fraction + let year_blade = (4 * year_cycles()) as usize; + let aged = Angle::new_with_blade(year_blade, 0.0, 1.0) + Angle::new(3.0, 4.0); + let newborn = Angle::new(3.0, 4.0); + + assert_eq!( + aged.base_angle(), + newborn.base_angle(), + "the sub-cycle readout after 10^17 turns matches the newborn clock exactly" + ); + + // the float's resolution aged eighteen orders of magnitude over the same + // year: its representable step grew from femtoradians to hundreds of + // radians. the angle's step never moved + let phase_old = year_cycles() as f64 * 2.0 * PI; + let phase_new = 0.75 * PI; + let aging = (phase_old.next_up() - phase_old) / (phase_new.next_up() - phase_new); + assert!( + aging > 1e15, + "float resolution degraded {aging:.1e}× over the year — the blade+t register did not age" + ); +} diff --git a/tests/calculus_test.rs b/tests/calculus_test.rs index cf4a5a3..d84f9e1 100644 --- a/tests/calculus_test.rs +++ b/tests/calculus_test.rs @@ -762,40 +762,37 @@ fn it_shows_subtraction_in_fundamental_theorem_is_interference() { #[test] fn it_encodes_definite_integrals_with_value_and_domain() { - // traditional: ∫₂⁵ x² dx = 39 (value only) - // angle space: [magnitude=39, angle=3π] — value AND domain in one geonum + // ∫₂⁵ x² dx = 39, but the scalar keeps only the value and drops the domain [2,5] it ran + // over. the geonum carries both: the value in the magnitude, the domain span b−a in the + // angle. and the value is not hand-subtracted — F(b) − F(a) is geonum subtraction, the + // "minus" a π rotation that interferes the endpoints, the way the FTC test does it above - let a: f64 = 2.0; - let b: f64 = 5.0; - let traditional = (b.powi(3) - a.powi(3)) / 3.0; // 39 + let (a, b): (f64, f64) = (2.0, 5.0); - // encode bounds as angles - let angle_a = Angle::new(a, 1.0); // 2π - let angle_b = Angle::new(b, 1.0); // 5π + // the antiderivative of x² is x³/3, read at the two endpoints on the scalar ray + let f_a = Geonum::new(a.powi(3) / 3.0, 0.0, 1.0); // F(2) = 8/3 + let f_b = Geonum::new(b.powi(3) / 3.0, 0.0, 1.0); // F(5) = 125/3 - // antiderivative values with angle encoding - let f_a = Geonum::new_with_angle(a.powi(3) / 3.0, angle_a); - let f_b = Geonum::new_with_angle(b.powi(3) / 3.0, angle_b); - - // magnitude encodes the integral value - let value = f_b.mag - f_a.mag; + // F(b) − F(a): subtraction negates F(a) to angle π and interferes — the value falls out + let value = f_b - f_a; assert!( - (value - traditional).abs() < EPSILON, - "magnitude = integral value = 39" + value.near_mag(39.0), + "∫₂⁵ x² dx = 39 by endpoint interference" ); + assert_eq!(value.angle.grade(), 0, "a positive value on the scalar ray"); - // angle encodes the integration domain - let domain = f_b.angle - f_a.angle; - let expected_domain = Angle::new(b - a, 1.0); // 3π - assert_eq!(domain, expected_domain, "angle encodes domain span 3π"); - - // the complete encoding - let integral = Geonum::new_with_angle(value, domain); - assert!( - (integral.mag - 39.0).abs() < EPSILON, - "magnitude: integral value" + // the domain the scalar forgot is the span b − a, carried as an angle — three half-turns + let domain = Angle::new(b, 1.0) - Angle::new(a, 1.0); // 5π − 2π = 3π + assert_eq!( + domain, + Angle::new(3.0, 1.0), + "the angle carries the domain span, 3π" ); - assert_eq!(integral.angle, Angle::new(3.0, 1.0), "angle: domain span"); + + // one geonum holds both — the value the scalar keeps and the domain it drops + let integral = Geonum::new_with_angle(value.mag, domain); + assert!(integral.near_mag(39.0), "value in the magnitude"); + assert_eq!(integral.angle, Angle::new(3.0, 1.0), "domain in the angle") } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/tests/control_test.rs b/tests/control_test.rs new file mode 100644 index 0000000..43f28cb --- /dev/null +++ b/tests/control_test.rs @@ -0,0 +1,270 @@ +// bode is one plot, nyquist is a winding +// +// classical control theory splits every transfer function into two charts — +// gain in decibels, phase in degrees — because the formalism split magnitude +// from angle at the start. G(jω) is one geonum per frequency: +// +// - gain margin and phase margin are the magnitude and angle of that one +// object measured against [1, π] — the two "margins" are the two +// components of a single distance to instability +// - the nyquist stability criterion is a winding count pointed at −1: as ω +// sweeps, the direction of G + 1 walks the grade cycle, and the net walks +// ARE the closed-loop unstable pole count — no angle unwrapped, no +// characteristic polynomial solved +// - a complex pole pair σ ± jω is a scale_rotate generator: the taxonomy of +// fixed points — node, center, spiral — is which of the two knobs is +// turned. the step-response spiral is the spiral +// +// run: cargo test --test control_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// the open-loop denominator of G(s) = 1/(s(s+1)(0.5s+1)) at s = jω, +// assembled from geonums: jω times two first-order factors +fn loop_denominator(omega: f64) -> Geonum { + let jw = Geonum::new_with_angle(omega, Angle::new(1.0, 2.0)); + let f1 = Geonum::new_from_cartesian(1.0, omega); + let f2 = Geonum::new_from_cartesian(1.0, 0.5 * omega); + jw * f1 * f2 +} + +// G(jω) as one geonum: reciprocal magnitude, angle read by Angle subtraction. +// Div answers a different question — what transformation carries the +// denominator to the numerator, inversion event included (numbers_test lands +// quotients at grade 2). the nyquist curve asks where the ratio POINTS, and +// angle subtraction is that question's operator +fn transfer(omega: f64) -> Geonum { + let d = loop_denominator(omega); + Geonum::new_with_angle(1.0 / d.mag, Angle::new(0.0, 1.0) - d.angle) +} + +#[test] +fn it_reads_gain_and_phase_margin_off_one_geonum() { + // phase crossover: ∠G = −π where atan(ω) + atan(ω/2) = π/2, i.e. ω = √2 + // (the product of the two tangents is 1 — the crossover is exact, not + // searched). the geonum lands grade 2 with nothing left over + let g_pc = transfer(2.0_f64.sqrt()); + assert_eq!( + g_pc.angle.grade(), + 2, + "at ω = √2 the loop points at −1's ray" + ); + assert!(g_pc.angle.near_rem(0.0), "exactly π — no residual phase"); + + // gain margin: |G| there is 1/3, so the loop tolerates a gain of 3 before + // the point reaches −1 — the textbook value for this plant + assert!(g_pc.near_mag(1.0 / 3.0), "|G(j√2)| = 1/3"); + let gain_margin = 1.0 / g_pc.mag; + assert!( + (gain_margin - 3.0).abs() < 1e-9, + "gain margin = 3 — the 9.5 dB the certification report states" + ); + + // gain crossover: bisect |G| = 1 (monotone in ω), then the phase margin is + // the angle distance from G to [1, π] — one subtraction + let (mut lo, mut hi) = (0.1, 2.0); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if transfer(mid).mag > 1.0 { + lo = mid; + } else { + hi = mid; + } + } + let omega_gc = 0.5 * (lo + hi); + let g_gc = transfer(omega_gc); + assert!(g_gc.near_mag(1.0), "unit gain at the crossover"); + + let phase_margin = Angle::new(1.0, 1.0) - loop_denominator(omega_gc).angle; + let foil = PI - (PI / 2.0 + omega_gc.atan() + (0.5 * omega_gc).atan()); + assert!( + phase_margin.near_rad(foil), + "phase margin = π − ∠denominator, one angle subtraction" + ); + assert!( + (foil - 0.5693).abs() < 1e-3, + "≈ 32.6° — the plant's known margin, read off one geonum" + ); +} + +// the nyquist example plant's denominator: (1 + jω)³ for G(s) = K/(s+1)³ +fn cubic_denominator(omega: f64) -> Geonum { + let d = Geonum::new_from_cartesian(1.0, omega); + d * d * d +} + +// where the ratio K/(1+jω)³ POINTS: reciprocal magnitude, angle subtraction — +// the oscilloscope reading of gain and phase +fn ratio_pointing(k: f64, omega: f64) -> Geonum { + let d3 = cubic_denominator(omega); + Geonum::new_with_angle(k / d3.mag, Angle::new(0.0, 1.0) - d3.angle) +} + +// winding number of g(ω) + 1 around the origin as ω sweeps the whole real +// line — the nyquist contour, sampled through ω = tan(u) so one linear grid +// covers (−∞, ∞). no scalar angle is unwrapped: each sample's direction has a +// quadrant address — its grade — and as the curve turns, the grade walks the +// four-step cycle. forward steps count +1 quarter, backward steps −1, and the +// winding is the net walks around the cycle. the G-builder comes in as a +// closure: the machinery counts whatever loop closure the operator inside it +// asks about +fn nyquist_winding(g: impl Fn(f64) -> Geonum) -> i32 { + let samples = 200_000; + let mut quarters: i32 = 0; + let mut prev: Option = None; + + for i in 0..=samples { + let u = -PI / 2.0 + 1e-4 + (PI - 2e-4) * i as f64 / samples as f64; + let omega = u.tan(); + + let w = g(omega) + Geonum::scalar(1.0); // the distance-to-(−1) vector + + let grade = w.angle.grade(); + if let Some(p) = prev { + match (grade + 4 - p) % 4 { + 1 => quarters += 1, // the direction stepped forward a quadrant + 3 => quarters -= 1, // and here it stepped back + 2 => unreachable!("quarter-step ambiguity — sample denser"), + _ => {} + } + } + prev = Some(grade); + } + + // the sweep starts and ends pointing at 1 + 0, so the walk closes on the + // grade cycle — whole turns only, the count intrinsically an integer + assert_eq!(quarters % 4, 0, "the contour closes on the grade cycle"); + quarters / 4 +} + +#[test] +fn it_counts_encirclements_of_minus_one_as_the_winding_number() { + // G(s) = K/(s+1)³ closes the loop at 1 + G = 0, i.e. (s+1)³ = −K. the + // closed-loop poles sit at s = −1 + K^(1/3)·(cube roots of −1): for K = 2 + // all three stay left of the axis; for K = 20 a conjugate pair crosses to + // 0.357 ± 2.35j — unstable. the winding number reads that count off the + // swept angle, no cubic solved + assert_eq!( + nyquist_winding(|w| ratio_pointing(2.0, w)), + 0, + "K = 2: no encirclement — the closed loop is stable" + ); + assert_eq!( + nyquist_winding(|w| ratio_pointing(20.0, w)), + -2, + "K = 20: two clockwise encirclements — the two unstable poles, counted by winding" + ); +} + +#[test] +fn it_counts_the_other_closure_when_div_builds_the_curve() { + // Div and angle subtraction at one frequency: same ratio magnitude, but + // Div's quotient is the pointing ratio's −conjugate — real part negated, + // imaginary part kept. not a different number for the same thing; a + // different thing + let omega = 0.7; + let pointing = ratio_pointing(2.0, omega); + let quotient = Geonum::scalar(2.0) / cubic_denominator(omega); + + let (c_p, s_p) = pointing.angle.cos_sin(); + let (c_q, s_q) = quotient.angle.cos_sin(); + assert!( + quotient.near_mag(pointing.mag), + "same magnitude — the ratio" + ); + assert!((c_q + c_p).abs() < 1e-12, "real part negated"); + assert!( + (s_q - s_p).abs() < 1e-12, + "imaginary part kept — Div hands over the −conj curve" + ); + + // hand each build to the same winding machinery. the pointing closure + // counts the negative-feedback closure 1 + G = 0. the Div closure counts + // the POSITIVE-feedback closure 1 − G = 0, whose characteristic + // (s+1)³ = +K keeps one RHP root (at K^(1/3) − 1) for every K > 1 + + // K = 2: negative feedback comfortably stable — positive feedback already broken + assert_eq!( + nyquist_winding(|w| ratio_pointing(2.0, w)), + 0, + "pointing closure: 1 + G stable at K = 2" + ); + assert_eq!( + nyquist_winding(|w| Geonum::scalar(2.0) / cubic_denominator(w)), + 1, + "Div closure: 1 − G already lost a pole at K = 2 — counted, mirror-oriented" + ); + + // K = 20: negative feedback loses its conjugate pair; the positive-feedback + // count is unmoved — still the one real crossing + assert_eq!( + nyquist_winding(|w| ratio_pointing(20.0, w)), + -2, + "pointing closure: two poles crossed at K = 20" + ); + assert_eq!( + nyquist_winding(|w| Geonum::scalar(20.0) / cubic_denominator(w)), + 1, + "Div closure: 1 − G holds its single RHP root at K = 20" + ); + + // K = 1/2: below unity gain both closures hold and the operators agree + assert_eq!( + nyquist_winding(|w| ratio_pointing(0.5, w)), + 0, + "pointing closure: stable below unity gain" + ); + assert_eq!( + nyquist_winding(|w| Geonum::scalar(0.5) / cubic_denominator(w)), + 0, + "Div closure: stable below unity gain — no question left to disagree on" + ); + + // Div never computed a wrong nyquist — it answered the question it always + // answers. pick the operator by the loop closure you are asking about +} + +#[test] +fn it_spirals_the_pole_pair_with_scale_rotate() { + // a pole pair σ ± jω generates e^(σt)·(rotation at ω): per timestep that + // is scale_rotate(e^(σΔt), ωΔt) — one knob for the envelope, one for the + // oscillation. the phase-portrait taxonomy is which knob is turned + let dt = 0.01_f64; + let steps = 300; + let x0 = Geonum::new(2.0, 1.0, 6.0); + + // spiral: σ = −0.5, ω = 3 — both knobs + let (sigma, omega) = (-0.5, 3.0); + let mut x = x0; + for _ in 0..steps { + x = x.scale_rotate((sigma * dt).exp(), Angle::new(omega * dt / PI, 1.0)); + } + let t = steps as f64 * dt; + assert!( + x.near_mag(x0.mag * (sigma * t).exp()), + "the envelope is e^(σt) — the scale knob" + ); + assert!( + (x.angle - x0.angle).near(&Angle::new(omega * t / PI, 1.0)), + "the oscillation is ωt of accumulated angle — the rotate knob, winding kept" + ); + + // center: σ = 0 — pure rotation, the orbit never decays + let mut c = x0; + for _ in 0..steps { + c = c.scale_rotate(1.0, Angle::new(omega * dt / PI, 1.0)); + } + assert!(c.near_mag(x0.mag), "center: the magnitude knob untouched"); + + // node: ω = 0 — pure scale, the trajectory never turns + let mut n = x0; + for _ in 0..steps { + n = n.scale_rotate((sigma * dt).exp(), Angle::new(0.0, 1.0)); + } + assert_eq!(n.angle, x0.angle, "node: the angle knob untouched"); + assert!( + n.near_mag(x0.mag * (sigma * t).exp()), + "node: all the motion is in the magnitude" + ); +} diff --git a/tests/convolution_test.rs b/tests/convolution_test.rs new file mode 100644 index 0000000..26a7ab5 --- /dev/null +++ b/tests/convolution_test.rs @@ -0,0 +1,323 @@ +// u(xᵢ) = Σⱼ K(xᵢ, yⱼ) ρⱼ is scalar summation — it adds magnitudes and throws away the +// influence direction from each yⱼ to xᵢ. the geometric version keeps the angle: +// +// U(xᵢ) = Σⱼ [K(rᵢⱼ) · ρⱼ, θᵢⱼ] +// +// where θᵢⱼ = angle from source yⱼ to target xᵢ. accumulating geonum instead of f64 +// lets the field carry both net strength AND net direction of influence. +// +// the scalar u(xᵢ) is then the grade-0 projection of U(xᵢ) — what you get when you +// collapse the direction back to a number. the geometric version is strictly richer + +use geonum::*; + +const EPSILON: f64 = 1e-10; + +#[test] +fn it_replaces_kernel_weighted_sum_with_directed_accumulation() { + // u(xᵢ) = Σⱼ K(rᵢⱼ) ρⱼ adds scalars — no direction, each term is K(r)·ρ + // U(xᵢ) = Σⱼ source_j.spread(boundary_j) accumulates directed quantities + // + // the kernel K is not a function here — boundary = [r, θᵢⱼ] is the kernel + // spread IS the kernel application: source.spread(boundary) = [ρ/r, φ + θᵢⱼ] + // each radial kernel expresses as a spread over a boundary, and the whole family is one + // op — only the boundary magnitude changes, r for 1/r and r² for 1/r²; the "formula" + // K(r) is just which magnitude sits in the boundary + + // grade 0 source, density ρ=2, kernel K(r) = 1/r + let rho = Geonum::new(2.0, 0.0, 1.0); + + // case 1: two sources at r=2, both from the east (θ=0) + let east = Geonum::new(2.0, 0.0, 1.0); // boundary [r=2, θ=0] + + let geo_collinear = rho.spread(east) + rho.spread(east); // [1,0] + [1,0] = [2,0] + let scalar_collinear = rho.mag / east.mag + rho.mag / east.mag; // 1 + 1 = 2 + + // collinear: geometric magnitude agrees with scalar sum + assert!(geo_collinear.near_mag(scalar_collinear)); + assert!(geo_collinear.angle.near_rad(0.0)); // net influence points east + + // case 2: one source east, one source west — same r=2, same ρ=2 + let west = Geonum::new(2.0, 1.0, 1.0); // boundary [r=2, θ=π] + + let geo_opposite = rho.spread(east) + rho.spread(west); // [1,0] + [1,π] → cancel + let scalar_opposite = rho.mag / east.mag + rho.mag / west.mag; // still 1 + 1 = 2 + + // scalar sum still 2 — no direction, no cancellation + assert!((scalar_opposite - scalar_collinear).abs() < EPSILON); + + // geometric sum cancels — equal sources from opposite directions annihilate + assert!(geo_opposite.near_mag(0.0)); + + // scalar sum cannot distinguish collinear from opposite; geometric sum can + assert!(geo_collinear.mag > geo_opposite.mag); +} + +#[test] +fn it_proves_scalar_sum_is_grade_0_projection_of_geometric_field() { + // u = Σⱼ K·ρⱼ sums contribution magnitudes — it adds legs, not vectors + // U = Σⱼ source_j.spread(boundary_j) accumulates with direction — it computes the hypotenuse + // + // geometry_test::it_overshoots_the_length_when_it_sums_the_legs: leg₁ + leg₂ > √(leg₁²+leg₂²) + // the scalar convolution has been computing that overshooting sum all along + + // source 1: density 3, kernel K(r)=1/r at r=1, from the east + let rho1 = Geonum::new(3.0, 0.0, 1.0); + let b1 = Geonum::new(1.0, 0.0, 1.0); // boundary [r=1, θ=0] + + // source 2: density 4, kernel K(r)=1/r at r=1, from the north + let rho2 = Geonum::new(4.0, 0.0, 1.0); + let b2 = Geonum::new(1.0, 1.0, 2.0); // boundary [r=1, θ=π/2] + + let field = rho1.spread(b1) + rho2.spread(b2); // [3,east] + [4,north] + let scalar_sum = rho1.mag / b1.mag + rho2.mag / b2.mag; // 3 + 4 = 7 + + // geometric field is the 3-4-5 hypotenuse + assert!(field.near_mag(5.0)); + + // scalar sum is the legs added: 3 + 4 = 7 > 5 + assert!((scalar_sum - 7.0).abs() < EPSILON); + assert!(scalar_sum > field.mag); + + // project_to_dimension recovers each source leg from the geometric field + let x_component = field.project_to_dimension(0); // east leg: 3 + let y_component = field.project_to_dimension(1); // north leg: 4 + + assert!((x_component - rho1.mag).abs() < EPSILON); + assert!((y_component - rho2.mag).abs() < EPSILON); + + // the legs reconstruct the hypotenuse via √(3²+4²) — not by summing them + let reconstructed = (x_component.powi(2) + y_component.powi(2)).sqrt(); + assert!((reconstructed - field.mag).abs() < EPSILON); +} + +#[test] +fn it_accumulates_sources_with_influence_direction_intact() { + // two equal influences a π turn apart hand off to length 0 — but a length-0 geonum is + // not the scalar's bare 0. the opposite-angle sum's blade is the combined winding of + // the two rays (geonum_mod.rs Add), so two opposed-pair cancellations land on two + // distinct length-0 positions. the scalar sum collapses every cancellation to the one + // bare number 0; the geometric sum lands each at its own position in angle space + + let rho = Geonum::new(2.0, 0.0, 1.0); // density ρ=2, kernel K(r)=1/r at r=2 + + // east + west: equal sources on the horizontal axis, influence from θ=0 and θ=π + let east = Geonum::new(2.0, 0.0, 1.0); // boundary [r=2, θ=0] + let west = Geonum::new(2.0, 1.0, 1.0); // boundary [r=2, θ=π] + let ew = rho.spread(east) + rho.spread(west); + + // north + south: equal sources on the vertical axis, influence from π/2 and 3π/2 + let north = Geonum::new(2.0, 1.0, 2.0); // [r=2, θ=π/2] + let south = Geonum::new(2.0, 3.0, 2.0); // [r=2, θ=3π/2] + let ns = rho.spread(north) + rho.spread(south); + + // both vanish in magnitude — the scalar sum's only outcome, and it reports the + // same 0 for both: K·ρ + K·ρ from opposite directions is 0 either way + assert!(ew.near_mag(0.0)); + assert!(ns.near_mag(0.0)); + + // but the two length-0 geonums are distinct: the horizontal pair's combined winding + // is 0 + 2 = 2, the vertical pair's is 1 + 3 = 4. the blade is winding — the + // quarter-turns the two rays accumulated (algebra_test) — not an axis the zero stores. + // two different cancellations, two different positions + assert_eq!(ew.angle.blade(), 2); + assert_eq!(ns.angle.blade(), 4); + + // the scalar collapses both to the one number 0; these land at two positions. winding + // 2 sits at bivector grade; winding 4 folds home to scalar grade — a full turn is four + // blades, winding home (algebra_test) + assert_eq!(ew.angle.grade(), 2); + assert_eq!(ns.angle.grade(), 0); +} + +#[test] +fn it_computes_convolution_integral_as_angle_quadrature() { + // the continuous convolution u(x) = ∫_B K(x,y) ρ(y) dy is a sum over a sweep of + // source directions. geonum integrates it as ANGLE QUADRATURE: each source angle + // contributes a directed [K(r)·ρ, θ], and the accumulation is exact — not a riemann + // limit (integral_test earns that refusal). a uniform ring of sources around the + // target makes the quadrature close on itself: the directed contributions sweep a + // full turn and cancel, so the field at the center is exactly zero. that is Newton's + // shell theorem — and it holds at EVERY sampling resolution, not in the limit. the + // scalar magnitude-sum, blind to direction, can never vanish; it only grows + + let rho = 2.0; // uniform source density ρ + let r = 2.0; // ring radius; kernel K(r) = 1/r, so the boundary magnitude is r + let source = Geonum::new(rho, 0.0, 1.0); // a source of density ρ at grade 0 + + // integrate the ring at several resolutions — exact at each one, never converging + for n in [2usize, 3, 6, 12] { + let mut field = Geonum::new(0.0, 0.0, 1.0); // directed accumulator + let mut scalar_sum = 0.0; // the scalar convolution, magnitudes only + + for j in 0..n { + // jth source sits a fraction j/n of a full turn around the ring; its kernel + // is the boundary [r, θ_j] and spread applies it: [ρ/r, θ_j] + let theta = Angle::new(2.0 * j as f64 / n as f64, 1.0); // 2πj/n + let boundary = Geonum::new_with_angle(r, theta); + field = field + source.spread(boundary); // directed quadrature term + scalar_sum += source.mag / boundary.mag; // |K·ρ|, direction discarded + } + + // the geometric quadrature vanishes — the ring's directed influence cancels and + // the field at the center is zero, the shell theorem, exact at this n + assert!( + field.near_mag(0.0), + "n={n}: directed ring quadrature cancels to zero field at the center" + ); + + // the scalar convolution sums magnitudes only: it grows as n·ρ/r, reporting + // "more sources, more signal" — it cannot represent the cancellation the field saw + assert!((scalar_sum - n as f64 * rho / r).abs() < EPSILON); + } +} + +#[test] +fn it_factors_kernel_into_magnitude_and_angle_components() { + // K(xᵢ, yⱼ) is conventionally a scalar function of two points, but it only ever + // reaches the displacement Δ = xᵢ − yⱼ through its length |Δ|. that collapse is the + // projection: K factors into a magnitude K(|Δ|) and an angle dir(Δ), and the scalar + // form keeps only the magnitude. the geometric kernel keeps both, so it is the + // faithful lift the scalar kernel is a shadow of + // + // K_geo(xᵢ, yⱼ) = [K(|xᵢ - yⱼ|), Angle::new_from_cartesian(xᵢ - yⱼ)] + + // radial kernel K(r) = 1/r — a function of the distance alone + let k = |r: f64| 1.0 / r; + + // three displacements Δ = xᵢ − yⱼ that share length 5 but point three different ways + let a = Geonum::new_from_cartesian(3.0, 4.0); // [5, atan2(4,3)] + let b = Geonum::new_from_cartesian(4.0, 3.0); // [5, atan2(3,4)] + let c = Geonum::new_from_cartesian(-3.0, -4.0); // [5, the opposite bearing] + + // same length → the scalar kernel hands back one number for all three, blind to + // direction. K(|Δ|) is a many-to-one map: it cannot tell these displacements apart + assert!(a.near_mag(5.0) && b.near_mag(5.0) && c.near_mag(5.0)); + let ks = k(a.mag); // 1/5 = 0.2 + assert!((ks - 0.2).abs() < EPSILON); + assert!((k(b.mag) - ks).abs() < EPSILON); + assert!((k(c.mag) - ks).abs() < EPSILON); + + // the geometric kernel factors K into [K(|Δ|), dir(Δ)] — applying K to the + // magnitude while carrying the displacement's angle through untouched + let ka = Geonum::new_with_angle(k(a.mag), a.angle); + let kb = Geonum::new_with_angle(k(b.mag), b.angle); + let kc = Geonum::new_with_angle(k(c.mag), c.angle); + + // the magnitude component IS the scalar kernel — nothing added there, the scalar + // form is exactly this projection onto grade-0 strength + assert!(ka.near_mag(ks) && kb.near_mag(ks) && kc.near_mag(ks)); + + // but the angle component separates what the scalar collapsed: three equal + // magnitudes, three distinct bearings — each the direction the scalar kernel dropped. + // the geometric kernel is one-to-one where the scalar kernel was many-to-one + assert!((ka.angle.grade_angle() - 4.0_f64.atan2(3.0)).abs() < EPSILON); // (3,4) → atan2(4,3) + assert!((kb.angle.grade_angle() - 3.0_f64.atan2(4.0)).abs() < EPSILON); // (4,3) → atan2(3,4) + + // (3,4) vs (−3,−4): identical scalar kernel, exactly opposite geometric kernel — + // the half-turn the scalar form folds onto the same number + assert!(ka.angle.is_opposite(&kc.angle)); +} + +#[test] +fn it_is_gauss_law_in_any_dimension_as_one_spread() { + // radially symmetric kernels — the ones constant on spheres — are the tractable class, + // and a sphere is why: it is a trivial object, a magnitude with the angle free, the + // orbit of one radius. its whole d-dimensional surface is the single number r^(d-1) + // (the unit-sphere constant folded into the source), so the inverse-power field is ONE + // spread and Gauss's law is that number cancelling itself. the dimension is an exponent, + // never a grid: the conventional method integrates over the d-ball; geonum divides by a + // number and multiplies it back. + + let flux = 7.0; // source strength Φ, a grade-0 scalar + let source = Geonum::new(flux, 0.0, 1.0); + let r = 1.5_f64; // sphere radius + + // sweep the dimension d — each field is one spread, the dimension only the exponent + let mut prev = f64::INFINITY; + for d in [2usize, 3, 4, 5, 7, 10, 15, 25, 40] { + let measure = r.powi((d - 1) as i32); // the sphere's whole surface, one number + let surface = Geonum::new(measure, 0.0, 1.0); + + // the d-dimensional inverse-power field, exact: Φ / r^(d-1) — the Laplacian + // Green's-function falloff in every dimension, no √(Σ d terms), no integral + let field = source.spread(surface); + assert!(field.near_mag(flux / measure)); + + // higher dimension falls faster at r > 1 — strictly, no plateau + assert!( + field.mag < prev, + "d={d}: the field falls faster as the dimension climbs" + ); + prev = field.mag; + + // Gauss's law: gather the field back through the same sphere and the source returns, + // because the surface's r^(d-1) annihilates the field's 1/r^(d-1) — in any dimension + assert!( + field.spread(surface.inv()).near_mag(flux), + "d={d}: flux conserved through the sphere, one op not an integral" + ); + } +} + +#[test] +fn it_keeps_the_sphere_a_single_number_a_million_dimensions_out() { + // push the dimension to a million. a coordinate kernel needs a 10^6-term √(Σ xᵢ²) for + // the distance and 10^6 components for the field. the sphere is still one number; the + // dimension lives in the blade (project_to_dimension is blade mod 4), read and projected + // in O(1) at the millionth axis exactly as at the lowest + + let source = Geonum::new(5.0, 0.0, 1.0); + + // a unit sphere whose surface direction is swept a million quarter-turns out + let surface = Geonum::new_with_angle(1.0, Angle::new_with_blade(1_000_000, 1.0, 4.0)); + let field = source.spread(surface); // one op, a million dimensions deep + + // a unit sphere dilutes nothing — the flux is unchanged, in any dimension + assert!(field.near_mag(5.0)); + + // and the field reads back at the millionth axis in O(1), the same as the lowest: + // 1_000_000 ≡ 0 (mod 4), so the millionth-dimension projection IS the 0th + let deep = field.project_to_dimension(1_000_000); + let shallow = field.project_to_dimension(0); + assert!( + (deep - shallow).abs() < EPSILON, + "the millionth dimension costs exactly what the lowest costs" + ); +} + +#[test] +fn it_steps_an_n_body_gravitational_acceleration() { + // a live application: one acceleration evaluation in an N-body gravitational step — the + // Newtonian / Poisson inverse-square field. each source pulls the body toward it with + // G·m/r²; spreading G·m over [r², direction] gives + // that pull as a directed geonum, and they accumulate into the force vector the + // integrator needs — one [magnitude, angle] per body, the distance read as the + // magnitude, in any dimension (a cartesian step carries d components and a d-term √Σ) + + let g = 1.0; // gravitational constant, natural units + let m = 3.0; // each source mass + let r = 2.0; // range to each source + let strength = Geonum::new(g * m, 0.0, 1.0); // the source charge G·m + + // the pull toward a source in a given direction: G·m spread over the 1/r² boundary + let pull = |dir: Angle| strength.spread(Geonum::new_with_angle(r * r, dir)); + + // a body flanked by equal masses due north and due south, with one due east + let accel = pull(Angle::new(1.0, 2.0)) // north + + pull(Angle::new(3.0, 2.0)) // south + + pull(Angle::new(0.0, 1.0)); // east + + // north and south annihilate; the body accelerates toward the east mass alone, at + // exactly G·m/r² due east — the closed form, not a value the test built + assert!(accel.near_mag(g * m / (r * r))); // 0.75 + assert!(accel.angle.near_rad(0.0)); // due east + + // the same three-body step a million dimensions out — the acceleration is the same one + // op, the dimension only a blade the accumulation never reads, no d-term distance + let out = Angle::new_with_blade(1_000_000, 0.0, 1.0); + let accel_hi = pull(Angle::new(1.0, 2.0) + out) + + pull(Angle::new(3.0, 2.0) + out) + + pull(Angle::new(0.0, 1.0) + out); + assert!(accel_hi.near_mag(g * m / (r * r))); // 0.75, a million dimensions deep +} diff --git a/tests/crystallography_test.rs b/tests/crystallography_test.rs new file mode 100644 index 0000000..d1519b2 --- /dev/null +++ b/tests/crystallography_test.rs @@ -0,0 +1,125 @@ +// the crystallographic restriction is an angle-lattice test +// +// only 1-, 2-, 3-, 4- and 6-fold rotations can be symmetries of a periodic +// lattice — the theorem that made shechtman's 1984 five-fold diffraction +// pattern a scandal. the standard proof rotates two neighboring lattice +// points toward each other and demands the result land back on the lattice: +// the produced vector has length |1 − 2cos θ| times the spacing, so the +// symmetry survives only when 2cos θ is an integer. that is an angle-lattice +// compatibility test, run here as geonum rotations: +// +// - n = 1, 2, 3, 4, 6 pass — the only angles whose doubled cosine is whole +// - every other n produces a lattice vector SHORTER than the minimal +// spacing: the contradiction, constructed +// - five-fold misses by golden-ratio amounts: 2cos 72° = 1/φ. penrose +// tilings and quasicrystals are golden because that is the residue the +// integer lattice refuses +// - closure is not tiling: the pentagon's turning closes at 2π like any +// polygon (curve_test) — the restriction is the extra gate translation +// symmetry adds +// +// run: cargo test --test crystallography_test -- --show-output + +use geonum::*; + +// the restriction construction for an n-fold candidate: take neighboring +// lattice points a and b one spacing apart, rotate a about b by θ and b about +// a by −θ. the images' separation is parallel to ab; symmetry demands it be a +// whole number of spacings +fn separation(n: usize) -> Geonum { + let theta = Angle::new(2.0, n as f64); // 2π/n + let a = Geonum::scalar(0.0); + let b = Geonum::new(1.0, 0.0, 1.0); + + let a_rotated = b + (a - b).rotate(theta); // a swung about b + let b_rotated = a + (b - a).rotate(Angle::new(-2.0, n as f64)); // b swung about a + + a_rotated - b_rotated +} + +#[test] +fn it_restricts_lattice_rotations_to_the_crystallographic_set() { + let mut allowed = Vec::new(); + + for n in 1..=12usize { + let s = separation(n); + + // the separation is parallel to the lattice row — its quadrature + // component cancels by the rotation symmetry itself + assert!( + s.opp().mag < 1e-9, + "{n}-fold: the construction stays on the row" + ); + + // lattice compatibility: the length is a whole number of spacings + if (s.mag - s.mag.round()).abs() < 1e-9 { + allowed.push(n); + } + } + + assert_eq!( + allowed, + vec![1, 2, 3, 4, 6], + "the crystallographic set — the only n with 2cos(2π/n) whole" + ); + + // the forbidden n produce a lattice vector strictly shorter than the + // minimal spacing — the contradiction that kills the symmetry + for n in [5usize, 7, 8, 9, 10, 12] { + let s = separation(n); + assert!( + s.mag > 1e-6 && s.mag < 1.0 - 1e-6, + "{n}-fold: a vector shorter than the shortest — impossible on a lattice" + ); + } +} + +#[test] +fn it_forbids_five_fold_by_the_golden_ratio() { + // 2cos 72° = φ − 1 = 1/φ: the five-fold angle's doubled cosine is the + // golden ratio's reciprocal — irrational, so the lattice test fails by + // exactly the amount that structures every quasicrystal + let phi = (1.0 + 5.0_f64.sqrt()) / 2.0; + let (cos_72, _) = Angle::new(2.0, 5.0).cos_sin(); + + assert!( + (2.0 * cos_72 - (phi - 1.0)).abs() < 1e-12, + "2cos 72° = 1/φ — the five-fold residue is golden" + ); + + // the construction's illegal vector has length 2 − φ = 1/φ² of a spacing + assert!( + (separation(5).mag - (2.0 - phi)).abs() < 1e-9, + "the five-fold contradiction is 1/φ² spacings long" + ); +} + +#[test] +fn it_closes_the_pentagon_but_cannot_tile_with_it() { + // closure is cheap: five exterior turns of 2π/5 close the pentagon the + // same way curve_test's hexagon closes — every polygon's turning totals + // one full turn + let mut turning = Angle::new(0.0, 1.0); + for _ in 0..5 { + turning = turning + Angle::new(2.0, 5.0); + } + assert!( + turning.near(&Angle::new(2.0, 1.0)), + "the pentagon closes — turning is no obstacle" + ); + + // tiling is the harder gate: closure needs the turning to total 2π, + // periodicity needs 2cos θ whole. the hexagon passes both; the pentagon + // passes one — which is why bathroom floors have hexagons and quasicrystals + // have penrose rhombs + let hexagon = separation(6); + assert!( + hexagon.near_mag(0.0), + "6-fold: the rotated neighbors coincide — the lattice absorbs the turn" + ); + let pentagon = separation(5); + assert!( + pentagon.mag > 0.38 && pentagon.mag < 0.39, + "5-fold: the golden residue survives — no lattice absorbs it" + ); +} diff --git a/tests/directional_stats_test.rs b/tests/directional_stats_test.rs new file mode 100644 index 0000000..be0975c --- /dev/null +++ b/tests/directional_stats_test.rs @@ -0,0 +1,119 @@ +// circular statistics is the patch, geonum is the fix +// +// statistics needed a special subfield — circular statistics, with its own +// journals, its own mean, its own variance — because scalar statistics breaks +// on angles: average the bearings 359° and 1° arithmetically and the answer +// points due south. the subfield exists to repair what dropping the angle +// broke. store the angle and nothing needs repair: +// +// - the circular mean is wave_sum normalized — interference does the +// averaging, and there is no wraparound to patch because there is no wrap +// - the mean resultant length R is wave_sum over total_magnitude — the +// interference gap 1 − R IS the dispersion, the same gap geocollection +// reads between coherent and incoherent superposition +// - the winding scalar records lose (the np.unwrap ceremony) never left: +// a vehicle that circled twice reads the same compass heading with blade 8 +// stored — the odometer is the blade +// +// run: cargo test --test directional_stats_test -- --show-output + +use geonum::*; + +#[test] +fn it_averages_359_and_1_to_north_not_south() { + // the textbook failure case: bearings 359° and 1°, both a degree off north + let bearings: GeoCollection = [359.0, 1.0] + .iter() + .map(|°| Geonum::new_with_angle(1.0, Angle::new(deg, 180.0))) + .collect(); + + let mean = bearings.wave_sum(); + + // the interference average points north, magnitude 2·cos(1°) — barely + // shy of full coherence + assert!( + mean.angle.base_angle().near(&Angle::new(0.0, 1.0)), + "the circular mean is north" + ); + assert!( + mean.near_mag(2.0 * Angle::new(1.0, 180.0).cos_sin().0), + "resultant = 2·cos(1°)" + ); + + // the scalar mean (359 + 1)/2 = 180 points due south — not slightly + // wrong, exactly backwards. the arithmetic mean of two angles a degree + // apart lands a half turn away + let scalar_mean = Angle::new((359.0 + 1.0) / 2.0, 180.0); + assert!( + scalar_mean.is_opposite(&mean.angle.base_angle()), + "the scalar method points exactly backwards" + ); +} + +#[test] +fn it_reads_dispersion_off_the_interference_gap() { + // the mean resultant length R = |wave_sum| / total_magnitude measures + // concentration: R = 1 fully coherent, R = 0 fully dispersed. the + // circular variance 1 − R is the interference gap — for a tight cluster + // of half-width scale δ it computes to δ² (the small-angle limit the + // von mises distribution linearizes to) + let delta = 0.02; + let heading = 1.0; // π/4 base heading — the cluster center + let cluster: GeoCollection = (-2..=2) + .map(|k| { + Geonum::new_with_angle( + 1.0, + Angle::new(heading / 4.0 + k as f64 * delta / std::f64::consts::PI, 1.0), + ) + }) + .collect(); + + let r = cluster.wave_sum().mag / cluster.total_magnitude(); + assert!( + (1.0 - r - delta * delta).abs() < 1e-7, + "circular variance 1 − R = δ² for the {{−2δ..2δ}} cluster: {:.2e}", + 1.0 - r + ); + + // full dispersion: the four cardinal directions interfere to nothing — + // R = 0, maximum circular variance, no mean direction exists + let dispersed: GeoCollection = (0..4) + .map(|k| Geonum::new_with_angle(1.0, Angle::new(k as f64, 2.0))) + .collect(); + assert!( + dispersed.wave_sum().near_mag(0.0), + "uniform directions cancel — R = 0, dispersion total" + ); +} + +#[test] +fn it_keeps_the_winding_the_compass_wraps_away() { + // a vehicle turns through two full laps in π/8 increments. every compass + // reading along the way lives in [0°, 360°) — the time series has to be + // "unwrapped" (the np.unwrap ceremony) to recover total rotation, and one + // missed sample aliases a whole lap away. the blade never wrapped, so + // there is nothing to unwrap + let start = Angle::new(1.0, 6.0); // heading π/6 + let step = Angle::new(1.0, 8.0); // π/8 per turn increment + + let mut heading = start; + for _ in 0..32 { + heading = heading + step; // 32 × π/8 = 4π — two laps + } + + assert_eq!( + heading.base_angle(), + start.base_angle(), + "the compass reads the same heading it started with" + ); + assert_eq!( + heading.blade() - start.blade(), + 8, + "while the blade stored both laps" + ); + assert_eq!( + (heading.blade() - start.blade()) / 4, + 2, + "the lap count is the winding — the odometer is the blade" + ); +} diff --git a/tests/fluid_test.rs b/tests/fluid_test.rs new file mode 100644 index 0000000..37fcf12 --- /dev/null +++ b/tests/fluid_test.rs @@ -0,0 +1,173 @@ +// lift is a circulation readout +// +// aerodynamics carries velocity potentials, stream functions and complex +// integrals to reach one result: a wing's lift per span is ρUΓ — density times +// speed times circulation, perpendicular to the stream. the machinery is +// bookkeeping for three geonum facts: +// +// - a free vortex is its circulation spread over the circle it crosses: the +// circumference is a grade-1 boundary, so the field falls off as 1/r and +// points along the tangent — spread's falloff exponent is the boundary's +// grade, not a coordinate count +// - the vortex is irrotational everywhere but its core: a loop that does +// not enclose the center reads zero circulation — the inner and outer +// arcs interfere to nothing and the radial legs are exact-zero dots. all +// the curl is a point of winding +// - kutta-joukowski: integrate the surface pressure around a cylinder with +// circulation and the whole sum collapses to one wedge, [ρU] ∧ [Γ] — with +// zero drag component, d'alembert's paradox included free +// +// run: cargo test --test fluid_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +const GAMMA: f64 = 5.0; // circulation strength + +// the vortex field at a position: the circulation spread over the circle it +// crosses — the circumference, a grade-1 boundary — pointed along the tangent +fn vortex_velocity(position: &Geonum) -> Geonum { + let circumference = Geonum::new_with_angle( + 2.0 * PI * position.mag, + position.angle + Angle::new(1.0, 2.0), + ); + Geonum::new(GAMMA, 0.0, 1.0).spread(circumference) +} + +#[test] +fn it_spreads_a_vortex_over_a_grade_1_boundary() { + let position = Geonum::new(2.0, 1.0, 6.0); // r = 2 at π/6 + + let v = vortex_velocity(&position); + assert!( + v.near_mag(GAMMA / (4.0 * PI)), + "|v| = Γ/(2πr) — the circulation spread over the circumference" + ); + assert_eq!( + v.angle, + position.angle + Angle::new(1.0, 2.0), + "the field points along the tangent — the boundary's direction" + ); + assert!( + position.dot(&v).near_mag(0.0), + "no radial component — the swirl carries nothing outward" + ); + + // the 1/r falloff is the grade-1 boundary's doing: double the radius, + // halve the speed — and the circulation ∮v·dl recovers Γ at every radius + let twice_out = Geonum::new(4.0, 1.0, 6.0); + assert!( + vortex_velocity(&twice_out).near_mag(v.mag / 2.0), + "grade-1 spreading falls off as 1/r" + ); + for r in [1.0, 2.5, 7.0] { + let sample = Geonum::new(r, 1.0, 5.0); + let circulation = vortex_velocity(&sample).mag * 2.0 * PI * r; + assert!( + (circulation - GAMMA).abs() < 1e-12, + "r = {r}: the loop reads Γ back — circulation is radius-free" + ); + } + + // contrast: spread the same source over a SPHERE — a grade-2 boundary, + // area 4πr² — and the falloff is inverse-square. the exponent is the + // boundary's grade, not a count of coordinates + let sphere = + |r: f64| Geonum::new(GAMMA, 0.0, 1.0).spread(Geonum::new(4.0 * PI * r * r, 0.0, 1.0)); + assert!( + sphere(4.0).near_mag(sphere(2.0).mag / 4.0), + "grade-2 spreading falls off as 1/r²" + ); +} + +#[test] +fn it_keeps_all_the_curl_at_the_winding_core() { + // circulate around an annular sector that does NOT enclose the center: + // inner arc forward, radial out, outer arc backward, radial in. the vortex + // is irrotational here — the loop reads zero + let (r_inner, r_outer) = (1.0, 2.0); + let arc = PI / 6.0; // the sector's angular width + + // the arc contributions: v·(r·dφ), forward on the inner arc, backward on + // the outer — and Γ/(2π)·dφ is radius-free, so they cancel exactly + let inner = Geonum::new( + vortex_velocity(&Geonum::new(r_inner, 1.0, 8.0)).mag * r_inner * arc, + 0.0, + 1.0, + ); + let outer = Geonum::new( + vortex_velocity(&Geonum::new(r_outer, 1.0, 8.0)).mag * r_outer * arc, + 1.0, + 1.0, + ); + let legs: GeoCollection = vec![inner, outer].into(); + assert!( + legs.wave_sum().near_mag(0.0), + "the arcs interfere to nothing — equal circulation shares, opposite senses" + ); + + // the radial legs contribute exact zeros: the field is a quarter turn off + // the path, and the rational cosine of a quarter turn is 0.0 dead + let radial_path = Geonum::new(1.0, 1.0, 8.0); // outward along the sector edge + let v_on_edge = vortex_velocity(&Geonum::new(1.5, 1.0, 8.0)); + assert!( + radial_path.dot(&v_on_edge).near_mag(0.0), + "the radial legs are silent — v ⊥ dl exactly" + ); + + // yet any loop AROUND the core reads Γ (the test above): the curl is not + // spread through the fluid — it is a point of winding at the center +} + +#[test] +fn it_computes_lift_from_the_circulation_wedge() { + // flow U past a unit cylinder carrying circulation Γ: surface speed is the + // superposition of the doublet's tangential flow and the vortex — geonum + // addition along the tangent. bernoulli prices each surface element and + // the pressure sum is one interference total + let (rho, u_inf, radius) = (1.2, 10.0, 1.0); + let samples = 360; + let d_phi = 2.0 * PI / samples as f64; + + let mut total = Geonum::scalar(0.0); + for k in 0..samples { + let phi = Angle::new(2.0 * (k as f64 + 0.5) / samples as f64, 1.0); + + // the doublet's surface flow 2U·sin φ along the tangent (sign in the + // angle) plus the vortex's Γ/(2πa) — same axis, geonum addition + let flow = Geonum::sin(phi).scale(2.0 * u_inf).rotate(phi); + let vortex = vortex_velocity(&Geonum::new_with_angle(radius, phi)); + let v_surface = flow + vortex; + + // bernoulli: p = ½ρ(U² − V²), V² read as the self-dot + let pressure = 0.5 * rho * (u_inf * u_inf - v_surface.dot(&v_surface).mag); + + // pressure pushes inward, suction pulls outward — the sign is a π turn + let direction = if pressure >= 0.0 { + phi + Angle::new(1.0, 1.0) + } else { + phi + }; + total = total + Geonum::new_with_angle(pressure.abs() * radius * d_phi, direction); + } + + // the whole surface integral collapses to the kutta-joukowski wedge + let kutta_joukowski = Geonum::new(rho * u_inf, 0.0, 1.0).wedge(&Geonum::new(GAMMA, 1.0, 2.0)); + let lift = total.project_to_dimension(1); + assert!( + (lift - kutta_joukowski.mag).abs() < 1e-8, + "lift = ρUΓ = {:.1} — the pressure sum IS the wedge", + kutta_joukowski.mag + ); + assert!( + (lift - rho * u_inf * GAMMA).abs() < 1e-8, + "the airplane flies on a circulation readout" + ); + + // and the streamwise component vanishes: no drag from the ideal flow — + // d'alembert's paradox, included free in the interference + assert!( + total.project_to_dimension(0).abs() < 1e-8, + "zero drag — the fore and aft pressures interfere away" + ); +} diff --git a/tests/gauss_bonnet_test.rs b/tests/gauss_bonnet_test.rs new file mode 100644 index 0000000..f100ebc --- /dev/null +++ b/tests/gauss_bonnet_test.rs @@ -0,0 +1,134 @@ +// total curvature is counted turns +// +// gauss-bonnet — ∫K dA + ∮k_g ds = 2πχ — is presented as a deep bridge between +// analysis and topology, proved with connections and pullbacks. the content is +// curve_test's closure argument gone global: a path closes when its turning +// completes full turns, and on a curved surface the interior takes a share of +// the turning. everything below is blade arithmetic: +// +// - descartes 1630: the angle deficits of a convex polyhedron total 4π — +// blade 8, exactly, for all five platonic solids. the euler characteristic +// is the winding count: total = 2πχ +// - a spherical triangle's area IS its angle excess — the sphere pays area +// for every radian the angles overshoot π +// - boundary turning plus enclosed curvature is one conserved 2π: the flat +// hexagon pays it all in turning, the spherical octant splits it 3π/2 +// turning + π/2 curvature +// - the scalar reading of 4π is 0 — grade_angle wraps the theorem away. the +// result lives in the blade +// +// run: cargo test --test gauss_bonnet_test -- --show-output + +use geonum::*; + +#[test] +fn it_sums_polyhedron_deficits_to_four_pi() { + // (vertices, edges, faces, faces meeting per vertex, face corner angle) + let platonic = [ + ( + "tetrahedron", + 4usize, + 6usize, + 4usize, + 3.0, + Angle::new(1.0, 3.0), + ), + ("cube", 8, 12, 6, 3.0, Angle::new(1.0, 2.0)), + ("octahedron", 6, 12, 8, 4.0, Angle::new(1.0, 3.0)), + ("dodecahedron", 20, 30, 12, 3.0, Angle::new(3.0, 5.0)), + ("icosahedron", 12, 30, 20, 5.0, Angle::new(1.0, 3.0)), + ]; + + for (name, v, e, f, meeting, corner) in platonic { + // one vertex's deficit: the full turn minus what the faces fill + let deficit = Angle::new(2.0, 1.0) - corner * meeting; + + // total over the solid — angle addition accumulating blade + let mut total = Angle::new(0.0, 1.0); + for _ in 0..v { + total = total + deficit; + } + + // descartes: 4π, always — blade 8 with nothing left over + assert!( + total.near(&Angle::new(4.0, 1.0)), + "{name}: deficits total 4π — blade {}, rem {:.2e}", + total.blade(), + total.rem() + ); + + // and 4π = 2πχ: the euler characteristic of the sphere, counted by + // the winding. the scalar reading is blind — grade_angle wraps 4π to 0 + let chi = (v + f) as i64 - e as i64; + assert_eq!(chi, 2, "{name}: V − E + F = 2"); + assert!( + total.near(&Angle::new(2.0 * chi as f64, 1.0)), + "{name}: total deficit = 2πχ" + ); + assert!( + total.grade_angle() < 1e-9, + "{name}: the scalar reads 0 — the theorem lives in the blade" + ); + } +} + +#[test] +fn it_measures_spherical_area_as_angle_excess() { + // the octant triangle — three right angles: excess = 3·π/2 − π = π/2, + // which is exactly one eighth of the unit sphere's 4π + let octant_excess = + Angle::new(1.0, 2.0) + Angle::new(1.0, 2.0) + Angle::new(1.0, 2.0) - Angle::new(1.0, 1.0); + assert!( + octant_excess.near(&Angle::new(1.0, 2.0)), + "excess π/2 — the octant's area, read off angle arithmetic" + ); + + // a birectangular triangle with apex α: two right angles pin it to a lune + // of angle α, and its area is α itself — the excess again + let apex = Angle::new(1.0, 3.0); + let excess = Angle::new(1.0, 2.0) + Angle::new(1.0, 2.0) + apex - Angle::new(1.0, 1.0); + assert!( + excess.near(&apex), + "the birectangular triangle's area IS its apex angle" + ); +} + +#[test] +fn it_splits_one_full_turn_between_turning_and_curvature() { + // flat closure: a hexagon pays the whole 2π in boundary turning — six + // exterior angles of π/3, no curvature to share with + let mut flat_turning = Angle::new(0.0, 1.0); + for _ in 0..6 { + flat_turning = flat_turning + Angle::new(1.0, 3.0); + } + assert!( + flat_turning.near(&Angle::new(2.0, 1.0)), + "the flat polygon closes on turning alone" + ); + + // spherical closure: the octant's boundary turns only 3·π/2 — the + // enclosed curvature (area π/2, K = 1) supplies the rest. same 2π, + // split between boundary and interior + let exterior = Angle::new(1.0, 2.0); // π − π/2 at each corner + let sphere_turning = exterior + exterior + exterior; + let enclosed_curvature = Angle::new(1.0, 2.0); // ∫K dA over the octant + assert!( + (sphere_turning + enclosed_curvature).near(&Angle::new(2.0, 1.0)), + "turning + curvature = 2π — the surface takes its share of the closure" + ); +} + +#[test] +fn it_zeroes_the_torus_deficits() { + // a flat square torus: four squares meet at every vertex, filling the full + // turn exactly — deficit zero, everywhere + let deficit = Angle::new(2.0, 1.0) - Angle::new(1.0, 2.0) * 4.0; + assert!( + deficit.near(&Angle::new(0.0, 1.0)), + "four right angles fill the vertex — no deficit" + ); + + // and 0 = 2πχ: the torus's euler characteristic, counted on a 3×3 grid + let (v, e, f) = (9i64, 18i64, 9i64); + assert_eq!(v - e + f, 0, "χ(torus) = 0 — the donut pays no curvature"); +} diff --git a/tests/holonomy_test.rs b/tests/holonomy_test.rs new file mode 100644 index 0000000..864e8e4 --- /dev/null +++ b/tests/holonomy_test.rs @@ -0,0 +1,114 @@ +// anholonomy is enclosed angle +// +// carry a vector around a closed loop on a curved surface and it comes back +// rotated. the conventional treatment builds connections, covariant +// derivatives and fiber bundles; the returned rotation is just the curvature +// the loop encloses — gauss_bonnet_test's split, read as physics: +// +// - the foucault pendulum precesses because parallel transport around a +// latitude circle picks up the polar cap's solid angle: the earth turns +// 2π per day, the surface keeps 2π(1 − sin λ) as transport, the pendulum +// shows the remainder 2π·sin λ. paris measures ~32 hours per cycle off +// that split +// - the berry phase is the same holonomy at spinor half-rate: a spin-1/2 +// transported around a loop of solid angle Ω picks up Ω/2 — and a great +// circle (Ω = 2π) lands π, grade 2: the −1 spinor_test reads at one turn, +// produced here by geometry alone +// +// no connection coefficients, no bundle: the transported rotation is one +// angle subtraction from a full turn +// +// run: cargo test --test holonomy_test -- --show-output + +use geonum::*; + +#[test] +fn it_precesses_the_foucault_pendulum_by_the_transport_deficit() { + // paris: latitude 48.8566° + let latitude = Angle::new(48.8566, 180.0); + let (_, sin_lat) = latitude.cos_sin(); + + // one day's walk around the latitude circle encloses the polar cap — + // solid angle 2π(1 − sin λ) — and that is the transport the surface keeps + let transport = Angle::new(2.0 * (1.0 - sin_lat), 1.0); + + // the pendulum precesses by what remains of the day's full turn + let precession = Angle::new(2.0, 1.0) - transport; + assert!( + precession.near_rad(2.0 * std::f64::consts::PI * sin_lat), + "daily precession = 2π·sin λ — the deficit of the enclosed cap" + ); + + // the split is exact: transport + precession = the whole day + assert!( + (transport + precession).near(&Angle::new(2.0, 1.0)), + "the earth's 2π divides between the surface and the pendulum" + ); + + // the number on the panthéon plaque: a full pendulum cycle takes + // 24h/sin λ ≈ 31.9 hours at paris + let hours = 24.0 / sin_lat; + assert!( + (hours - 31.9).abs() < 0.1, + "paris cycle ≈ 31.9 h, computed {hours:.2}" + ); + + // the limits: at the pole the loop encloses nothing and the pendulum + // shows the whole turn; at the equator the cap is the hemisphere and the + // pendulum stands still + let (_, sin_pole) = Angle::new(1.0, 2.0).cos_sin(); // λ = 90° + assert!( + (Angle::new(2.0, 1.0) - Angle::new(2.0 * (1.0 - sin_pole), 1.0)) + .near(&Angle::new(2.0, 1.0)), + "pole: one full precession per day" + ); + let (_, sin_equator) = Angle::new(0.0, 1.0).cos_sin(); // λ = 0 + assert!( + (Angle::new(2.0, 1.0) - Angle::new(2.0 * (1.0 - sin_equator), 1.0)).near_rad(0.0), + "equator: the plane never precesses" + ); +} + +#[test] +fn it_reads_the_berry_phase_as_half_the_solid_angle() { + // adiabatic transport of a spin-1/2 around a loop enclosing solid angle Ω + // acquires geometric phase Ω/2 — the holonomy at the spinor's half rate + // (spinor_test: spin halves every angle) + + // the octant loop: gauss_bonnet's triangle, solid angle π/2 — the berry + // phase is π/4 + let octant = Angle::new(1.0, 2.0); + let berry_octant = octant / 2.0; + assert!( + berry_octant.near(&Angle::new(1.0, 4.0)), + "Ω = π/2 → γ = π/4 — the octant's phase" + ); + + // a great-circle loop encloses a hemisphere: Ω = 2π, berry phase π — + // grade 2, the −1. the sign flip spinor_test produced by rotation appears + // here from pure geometry: no field turned the spin, the path did + let hemisphere = Angle::new(2.0, 1.0); + let berry_flip = hemisphere / 2.0; + assert_eq!( + berry_flip.grade(), + 2, + "Ω = 2π → γ = π: the spin returns negated — geometry alone flips it" + ); + + // interferometry reads it the same way spinor_test reads the 2π rotation: + // the transported arm cancels the reference arm + let reference = Geonum::new(1.0, 1.0, 8.0); + let transported = reference.rotate(berry_flip); + assert!( + (reference + transported).near_mag(0.0), + "the berry-phase arm interferes destructively — the measured −1" + ); + + // and the two famous phases are one formula: the pendulum's cap and the + // spin's hemisphere differ only in the loop, the half only in the carrier + let cap = Angle::new(2.0 * (1.0 - 0.75), 1.0); // a λ ≈ 48.6° cap + assert!( + (cap / 2.0 + cap / 2.0).near(&cap), + "holonomy halves recompose — foucault and berry share the geometry" + ); +} diff --git a/tests/interferometer_test.rs b/tests/interferometer_test.rs new file mode 100644 index 0000000..9840100 --- /dev/null +++ b/tests/interferometer_test.rs @@ -0,0 +1,90 @@ +// subtraction never cancels catastrophically +// +// an interferometer reads a difference of two enormous phases: LIGO recovers +// nanoradians of signal off ~10^11 turns of optical carrier. numerically that +// is the textbook catastrophic-cancellation setup — subtract two nearly-equal +// huge floats and the signal drowns in representation error — and physics +// already ships the workaround in hardware: the interferometer exists to +// compute the difference OPTICALLY, because no register that stores the total +// can afford the subtraction. numerics ships its own patches for the same +// wound: kahan summation, double-double arithmetic, hand-derived difference +// coordinates +// +// geonum's angle subtraction has no such failure mode: blades subtract as +// exact integers and t borrows rationally, so the difference of two +// hundred-gigaturn windings returns the nanoradian whole — and both totals +// survive, where the photodetector had to destroy them to read the beat +// +// fence, logged: geometric_sub reads t-differences below 1e-10 as equal — +// the position-comparison noise floor. signals above it subtract exactly; +// whispers below it need the storage route (whisper_test) +// +// run: cargo test --test interferometer_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +const CARRIER_TURNS: f64 = 280_000_000_000.0; // 1064 nm laser, ~1 ms of cavity storage + +#[test] +fn it_recovers_a_nanoradian_off_a_hundred_gigaturn_carrier() { + // the reference arm: 2.8×10^11 turns of carrier, exact in the blade + let carrier_blade = 4usize * 280_000_000_000; + let arm_a = Angle::new_with_blade(carrier_blade, 0.0, 1.0); + + // the signal arm carries 2 nanoradians more — a gravitational wave's + // worth of phase, sitting above the 1e-10 subtraction floor + let signal_t = 1e-9_f64; // t = θ/2 + let arm_b = arm_a + Angle::from_parts(0, signal_t); + + // the beat: blades cancel as integers, t returns whole + let beat = arm_b - arm_a; + assert_eq!( + beat.blade(), + 0, + "10^12 quarter-turns of carrier subtract to exactly zero" + ); + assert!( + (beat.t() - signal_t).abs() < 1e-24, + "the nanoradian returns whole — no precision spent on the carrier" + ); + assert!( + ((beat.rem() - 2.0 * signal_t) / (2.0 * signal_t)).abs() < 1e-9, + "the beat reads 2 nanoradians at full relative precision" + ); +} + +#[test] +fn it_subtracts_without_catastrophic_cancellation() { + // the float registers at the carrier's magnitude: 1.8×10^12 rad, where + // the next representable phase sits 2.4×10^-4 rad away — eleven orders + // coarser than the signal. the two arms are bit-identical + let phase_a = CARRIER_TURNS * 2.0 * PI; + let signal = 2e-9_f64; + let phase_b = phase_a + signal; + assert!( + phase_b == phase_a, + "the float arms read identical — the signal vanished into the carrier" + ); + assert!( + phase_b - phase_a == 0.0, + "and their difference is literally zero — cancellation's endpoint" + ); + + // geonum keeps what the hardware workaround destroys: the photodetector + // reads only the beat, the totals gone into the light. here the signal + // arm still carries its full winding AND the difference reads exact + let carrier_blade = 4usize * 280_000_000_000; + let arm_a = Angle::new_with_blade(carrier_blade, 0.0, 1.0); + let arm_b = arm_a + Angle::from_parts(0, signal / 2.0); + + assert_eq!( + arm_b.blade(), + carrier_blade, + "the signal arm keeps its hundred-gigaturn total" + ); + assert!( + ((arm_b - arm_a).rem() - signal).abs() < 1e-18, + "while the beat subtracts out exact — total and difference, one register" + ); +} diff --git a/tests/mechanics_test.rs b/tests/mechanics_test.rs index 6210b29..ed9f009 100644 --- a/tests/mechanics_test.rs +++ b/tests/mechanics_test.rs @@ -1,2157 +1,745 @@ +// mechanics in its native geometry: the kinematic hierarchy is grade cycling, +// the dynamic quantities are wedge and dot +// +// differentiation is a quarter turn, so position, velocity, acceleration, jerk are +// ONE object wound to grades 0, 1, 2, 3 — the derivative order is the blade, read +// mod 4. angular momentum, torque, and rotational velocity are wedges; kinetic +// energy, work, power, and moment of inertia are dots landing on grade 0. mass is a +// pure scalar — it scales the magnitude and touches no angle. +// +// the self-wedge a∧a = 0 is universal: sin(θ−θ) = 0 for every geonum, conserved or +// not. it witnesses that a state cannot repeat, not that a quantity is conserved. +// conservation is expressed by antisymmetry — the wedge — not searched for as a +// symmetry: dL/dt = v∧v + r∧a is a sum of wedges that vanish (self-wedge, parallel +// wedge), and internal forces cancel pairwise as F_ij = −F_ji. no Lagrangian, no +// symmetry hunt, cancellation read straight off the antisymmetric product. +// energy joins in act V: the conserved scalar is the phase point's magnitude, +// and dE/dt = 0 is the kinetic credit F·v cancelling the potential debit kx·ẋ. +// +// run: cargo test --test mechanics_test -- --show-output + use geonum::*; -use std::f64::consts::PI; const EPSILON: f64 = 1e-10; -// GEOMETRIC NUMBER REPRESENTATION OF MECHANICAL QUANTITIES -// -// geonum encoding eliminates vector mechanics complexity: -// -// 1. KINEMATIC HIERARCHY THROUGH BLADE COUNT: -// - position: blade=0 (fundamental spatial quantity) -// - velocity: blade=1 (position + π/2 rotation = first derivative) -// - acceleration: blade=2 (velocity + π/2 rotation = second derivative) -// - jerk: blade=3 (acceleration + π/2 rotation = third derivative) -// blade count tracks derivative order via differentiation = π/2 rotation -// traditional mechanics: separate vector equations for each quantity -// geonum mechanics: single geometric object at different blade levels -// -// 2. SAME GEOMETRIC OBJECT AT DIFFERENT RATES: -// position, velocity, acceleration are identical geometric objects -// at different blade levels representing different rates of change -// differentiation increments blade count while preserving geometric structure -// traditional mechanics: position r⃗, velocity v⃗, acceleration a⃗ as separate vectors -// geonum mechanics: r[blade=0] → r[blade=1] → r[blade=2] via π/2 rotation -// -// 3. CONSERVATION THROUGH GEOMETRIC NILPOTENCY: -// momentum.wedge(&momentum) = 0 IS momentum conservation -// energy.wedge(&energy) = 0 IS energy conservation -// no external conservation laws needed - built into geometric nilpotency -// traditional mechanics: impose conservation laws through lagrangian constraints -// geonum mechanics: conservation emerges from v∧v = 0 geometric relationship -// -// 4. FORCE AS MOMENTUM RATE: -// force = blade=2 quantity (acceleration level derivative of momentum) -// force application = momentum + force×dt via geometric angle addition -// traditional mechanics: F = ma through vector addition and scalar multiplication -// geonum mechanics: force integration through blade arithmetic -// -// 5. ENERGY THROUGH DOT PRODUCTS: -// kinetic energy = velocity.dot(&velocity) encodes ½mv² relationships -// potential energy = position in force field via geometric projections -// traditional mechanics: separate kinetic T = ½mv² and potential V energy formulas -// geonum mechanics: energy emerges from geometric product relationships +// ═══════════════════════════════════════════════════════════ +// act I: the kinematic hierarchy is grade cycling +// ═══════════════════════════════════════════════════════════ #[test] -fn it_changes_kinematic_level_by_cycling_grade() { - // fundamental principle: differentiation/integration cycles through grades - // grade (not blade) determines kinematic level: - // - differentiation: grade n → grade (n+1) % 4 - // - integration: grade n → grade (n-1) % 4 - // blade accumulates history, grade shows behavior - - // start with position at arbitrary blade count - let position = Geonum::new_with_blade(10.0, 5, 1.0, 7.0); // blade 5, grade 1 - assert_eq!(position.angle.grade(), 1, "position at grade 1"); - - // differentiate to get velocity - let velocity = position.differentiate(); - assert_eq!(velocity.angle.grade(), 2, "velocity at grade 2 (1+1 mod 4)"); - assert_eq!( - velocity.mag, position.mag, - "differentiation preserves magnitude" - ); +fn it_cycles_the_kinematic_hierarchy_through_grades() { + // conventional mechanics stacks a separate vector equation and a finite-difference + // scheme at each level — position, velocity, acceleration, jerk. here each is one + // differentiate(), a quarter turn, and grade cycles 0→1→2→3→0 while the magnitude + // rides through untouched - // differentiate velocity to get acceleration + let position = Geonum::new(10.0, 1.0, 3.0); // [10, π/3], grade 0 + let velocity = position.differentiate(); let acceleration = velocity.differentiate(); - assert_eq!( - acceleration.angle.grade(), - 3, - "acceleration at grade 3 (2+1 mod 4)" - ); - - // differentiate acceleration to get jerk let jerk = acceleration.differentiate(); - assert_eq!(jerk.angle.grade(), 0, "jerk at grade 0 (3+1 mod 4)"); - - // differentiate jerk - cycles back to grade 1 let snap = jerk.differentiate(); - assert_eq!( - snap.angle.grade(), - 1, - "snap at grade 1 (0+1 mod 4, full cycle)" - ); - - // test integration reverses grade progression - let recovered_jerk = snap.integrate(); - assert_eq!( - recovered_jerk.angle.grade(), - 0, - "integrate snap → jerk at grade 0" - ); - - let recovered_accel = recovered_jerk.integrate(); - assert_eq!( - recovered_accel.angle.grade(), - 3, - "integrate jerk → acceleration at grade 3" - ); - - let recovered_velocity = recovered_accel.integrate(); - assert_eq!( - recovered_velocity.angle.grade(), - 2, - "integrate acceleration → velocity at grade 2" - ); - - let recovered_position = recovered_velocity.integrate(); - assert_eq!( - recovered_position.angle.grade(), - 1, - "integrate velocity → position at grade 1" - ); - - // magnitude preserved through entire cycle - assert_eq!( - recovered_position.mag, position.mag, - "magnitude preserved through full differentiate/integrate cycle" - ); - // test kinematic levels are grade-dependent, not blade-dependent - let high_blade_pos = Geonum::new_with_blade(15.0, 1000, 0.0, 1.0); // blade 1000, grade 0 - let high_blade_vel = high_blade_pos.differentiate(); - let high_blade_acc = high_blade_vel.differentiate(); - let high_blade_jerk = high_blade_acc.differentiate(); + assert_eq!(position.angle.grade(), 0, "position grade 0"); + assert_eq!(velocity.angle.grade(), 1, "velocity grade 1"); + assert_eq!(acceleration.angle.grade(), 2, "acceleration grade 2"); + assert_eq!(jerk.angle.grade(), 3, "jerk grade 3"); + assert_eq!(snap.angle.grade(), 0, "snap back to grade 0"); - assert_eq!( - high_blade_pos.angle.grade(), - 0, - "position at grade 0 (1000 % 4)" - ); - assert_eq!(high_blade_vel.angle.grade(), 1, "velocity at grade 1"); - assert_eq!(high_blade_acc.angle.grade(), 2, "acceleration at grade 2"); - assert_eq!(high_blade_jerk.angle.grade(), 3, "jerk at grade 3"); - - // demonstrate grade determines physical meaning regardless of blade - let scalar_like_1 = Geonum::new_with_blade(5.0, 0, 0.0, 1.0); // blade 0, grade 0 - let scalar_like_2 = Geonum::new_with_blade(5.0, 4, 0.0, 1.0); // blade 4, grade 0 - let scalar_like_3 = Geonum::new_with_blade(5.0, 1000, 0.0, 1.0); // blade 1000, grade 0 - - assert_eq!(scalar_like_1.angle.grade(), 0, "blade 0 → grade 0"); - assert_eq!(scalar_like_2.angle.grade(), 0, "blade 4 → grade 0"); - assert_eq!(scalar_like_3.angle.grade(), 0, "blade 1000 → grade 0"); - - // all behave identically under differentiation - let deriv_1 = scalar_like_1.differentiate(); - let deriv_2 = scalar_like_2.differentiate(); - let deriv_3 = scalar_like_3.differentiate(); - - assert_eq!(deriv_1.angle.grade(), 1, "all differentiate to grade 1"); - assert_eq!(deriv_2.angle.grade(), 1, "regardless of starting blade"); - assert_eq!(deriv_3.angle.grade(), 1, "grade determines behavior"); - - // traditional calculus: d/dt requires limit definition and differentiation rules - // ∂f/∂t = lim(Δt→0) [f(t+Δt) - f(t)]/Δt with epsilon-delta proofs O(n) - // chain rule, product rule, quotient rule for composite functions O(n²) - // - // geonum: differentiation is π/2 rotation, integration is -π/2 rotation O(1) - // no limits, no symbolic manipulation, just grade cycling - - // traditional mechanics: kinematic hierarchy via successive differentiation - // position → velocity → acceleration → jerk requires operator stacking O(n) - // - // geonum: grade cycling 0→1→2→3→0 encodes entire hierarchy O(1) - // blade 1000 acts identical to blade 0 due to grade = blade % 4 - - println!("kinematic hierarchy via grade cycling:"); - println!( - " position: grade {} (blade {})", - position.angle.grade(), - position.angle.blade() - ); - println!( - " velocity: grade {} (blade {})", - velocity.angle.grade(), - velocity.angle.blade() - ); - println!( - " acceleration: grade {} (blade {})", - acceleration.angle.grade(), - acceleration.angle.blade() - ); - println!( - " jerk: grade {} (blade {})", - jerk.angle.grade(), - jerk.angle.blade() - ); - println!( - " snap: grade {} (blade {})", - snap.angle.grade(), - snap.angle.blade() + assert!( + velocity.near_mag(10.0), + "the quarter turn costs no magnitude" ); - println!("\ngrade cycles 0→1→2→3→0, blade accumulates history"); + assert!(snap.near_mag(10.0), "still 10 after a full cycle"); } #[test] -fn it_encodes_position() { - // position as geometric number without coordinate system dependency - let position = Geonum::new(3.0, 1.0, 4.0); // 3 units at π/4 - +fn it_carries_its_own_motion_in_the_quarter_turn() { + // a position needs no separately-tracked velocity to move. differentiate() turns it a + // quarter turn, and that IS the velocity the position is tangent to — same length, + // perpendicular. because the length survives, |v| = |r| is the tangential speed of + // circular motion at unit rate. scale by time for a displacement, add it, and one + // position has stepped its own orbit: rotate, scale, add, no second initial condition + // supplied. conventional kinematics carries r and v as independent vectors updated by + // dr/dt; here they are one number at blade n and blade n+1 + + let position = Geonum::new(10.0, 1.0, 3.0); // 10 m at π/3 + + // the velocity is the position turned a quarter turn, magnitude intact + let velocity = position.differentiate(); assert_eq!( - position.angle.blade(), - 0, - "position at blade 0 (fundamental spatial)" - ); - assert_eq!(position.mag, 3.0, "distance from origin"); - assert!( - (position.angle.grade_angle() - PI / 4.0).abs() < EPSILON, - "direction angle π/4" + velocity.angle.grade(), + 1, + "v is r's quarter turn, grade 0 → 1" ); - - // test displacement addition - let displacement = Geonum::new(2.0, 1.0, 6.0); // 2 units at π/6 - let new_position = position + displacement; - - // verify geometric addition produces expected result - let x1 = position.mag * position.angle.grade_angle().cos(); - let y1 = position.mag * position.angle.grade_angle().sin(); - let x2 = displacement.mag * displacement.angle.grade_angle().cos(); - let y2 = displacement.mag * displacement.angle.grade_angle().sin(); - let expected_length = ((x1 + x2).powi(2) + (y1 + y2).powi(2)).sqrt(); assert!( - (new_position.mag - expected_length).abs() < EPSILON, - "displacement addition matches vector mechanics" + velocity.near_mag(position.mag), + "|v| = |r|: the tangential speed of circular motion" ); - // test high-dimensional projections with meaningful assertions - let position_1000d = position.project_to_dimension(1000); - let position_million_d = position.project_to_dimension(1_000_000); - - // projections should be bounded by position magnitude + // displacement = v·t runs straight along the tangent — scale leaves the angle alone + let dt = 2.0; + let displacement = velocity.scale(dt); assert!( - position_1000d.abs() <= position.mag + EPSILON, - "1000D projection bounded by magnitude" + displacement.near_mag(20.0), + "displacement = |v|·t = 10×2 = 20" ); - assert!( - position_million_d.abs() <= position.mag + EPSILON, - "million-D projection bounded by magnitude" + assert_eq!( + displacement.angle, velocity.angle, + "the step is along the tangent" ); - // test scaling preserves projection ratios - let scaled_position = position.scale(5.0); - let scaled_1000d = scaled_position.project_to_dimension(1000); + // the displacement is a quarter turn off the radius: a tangent step, one Euler step + // of a circle assert!( - (scaled_1000d - 5.0 * position_1000d).abs() < EPSILON, - "scaling preserves dimensional relationships" + (displacement.angle - position.angle).near(&Angle::new(1.0, 2.0)), + "the step is perpendicular to the radius" ); - // dimension 4 points at 4×π/2 = 2π (full rotation back to start) - // cos(2π - θ) = cos(-θ) = cos(θ), so projections should be equal - let position_dim_0 = position.project_to_dimension(0); - let position_dim_4 = position.project_to_dimension(4); - - // these should be equal due to cos periodicity + // add it — the new position is where the motion carried it, √(r² + d²) out + let moved = position + displacement; + let expected = (position.mag * position.mag + displacement.mag * displacement.mag).sqrt(); assert!( - (position_dim_4 - position_dim_0).abs() < EPSILON, - "dimension 4 (2π) equals dimension 0 via cos periodicity: {} ≈ {}", - position_dim_4, - position_dim_0 - ); - - // traditional: position requires coordinate system setup and basis vectors - // million dimensions = million basis vectors in memory O(n) - // - // geonum: position exists independently, projects to any dimension on demand O(1) + moved.near_mag(expected), + "the tangent step landed at √(r² + d²)" + ) } #[test] -fn it_encodes_velocity() { - let initial_position = Geonum::new(8.0, 2.0, 5.0); // 8 units at 2π/5 - - // velocity via differentiation (π/2 rotation) - let velocity = initial_position.differentiate(); +fn it_grows_displacement_quadratically_from_derived_acceleration() { + // the kinematic equation d = v₀t + ½at² wants double integration and a Taylor + // expansion conventionally. here both terms are scalings of the derivative hierarchy: + // v₀t scales the velocity by t, ½at² scales the acceleration by ½t². the quadratic + // term outruns the linear as t grows — the ½t² overtaking the t - assert_eq!(initial_position.angle.blade(), 0, "position at blade 0"); - assert_eq!( - velocity.angle.blade(), - 1, - "velocity at blade 1 (π/2 rotated)" - ); - assert_eq!( - velocity.mag, initial_position.mag, - "differentiation preserves magnitude" - ); + let position = Geonum::new(5.0, 1.0, 4.0); // 5 m at π/4 + let velocity = position.differentiate(); // grade 1, |v| = 5 + let acceleration = velocity.differentiate(); // grade 2, |a| = 5 - // test velocity projections - let velocity_x = velocity.project_to_dimension(0); - let velocity_y = velocity.project_to_dimension(1); + let t = 3.0; + let linear = velocity.scale(t); // v₀t + let quadratic = acceleration.scale(0.5 * t * t); // ½at² + assert!(linear.near_mag(15.0), "v₀t = 5×3 = 15"); + assert!(quadratic.near_mag(22.5), "½at² = ½×5×9 = 22.5"); - // velocity magnitude from components - let velocity_magnitude = (velocity_x.powi(2) + velocity_y.powi(2)).sqrt(); + // velocity and acceleration are a quarter turn apart, so the two displacements are + // perpendicular and the total travel is √((v₀t)² + (½at²)²) + let displacement = linear + quadratic; + let expected = (15.0_f64 * 15.0 + 22.5 * 22.5).sqrt(); assert!( - (velocity_magnitude - velocity.mag).abs() < EPSILON, - "velocity components reconstruct magnitude" + displacement.near_mag(expected), + "total = √((v₀t)² + (½at²)²)" ); - // test integration recovers position - let recovered_position = velocity.integrate(); - let position_base = recovered_position.base_angle(); - - assert_eq!( - position_base.angle.blade(), - 0, - "integration returns to blade 0" - ); + // for equal |v₀| and |a|, the quadratic overtakes the linear at t = 2 + let ratio = quadratic.mag / linear.mag; assert!( - (position_base.mag - initial_position.mag).abs() < EPSILON, - "integration preserves magnitude" + (ratio - t / 2.0).abs() < EPSILON, + "quadratic/linear ratio is t/2" ); - // angle matches after base_angle reset + // from rest, the travel is pure ½at² assert!( - (position_base.angle.grade_angle() - initial_position.angle.grade_angle()).abs() < EPSILON, - "integration recovers original angle" - ); - - // traditional: velocity = dr/dt requires finite differences or symbolic differentiation - // numerical methods accumulate error, symbolic methods need expression trees O(n) - // - // geonum: velocity = position.differentiate() via π/2 rotation O(1) - // integration reverses the rotation, no numerical approximation + acceleration.scale(0.5 * t * t).near_mag(22.5), + "from rest: d = ½at²" + ) } #[test] -fn it_encodes_acceleration() { - let initial_position = Geonum::new(6.0, 1.0, 5.0); // 6 units at π/5 - - // climb derivative hierarchy - let velocity = initial_position.differentiate(); // blade 0 → 1 - let acceleration = velocity.differentiate(); // blade 1 → 2 - - assert_eq!(acceleration.angle.blade(), 2, "acceleration at blade 2"); - assert_eq!( - acceleration.mag, initial_position.mag, - "double differentiation preserves magnitude" - ); - - // F = ma with meaningful test - let mass = 2.5; // kg - let force = acceleration.scale(mass); - - assert_eq!( - force.angle.blade(), - 2, - "force at same blade as acceleration" - ); - assert!( - (force.mag - mass * acceleration.mag).abs() < EPSILON, - "F = ma via scaling" - ); - - // test that force and acceleration point same direction - assert_eq!( - force.angle, acceleration.angle, - "force parallel to acceleration" - ); +fn it_reads_kinematic_level_off_grade_not_blade() { + // kinematic level is grade (blade % 4), not blade. a position wound 1000 quarter + // turns out differentiates through the same grade sequence as one at blade 0 — the + // derivative order is dimension-blind + + let low = Geonum::new(5.0, 0.0, 1.0); // blade 0, grade 0 + let high = Geonum::new_with_blade(5.0, 1000, 0.0, 1.0); // blade 1000, grade 0 + + let mut a = low; + let mut b = high; + for _ in 0..4 { + a = a.differentiate(); + b = b.differentiate(); + assert_eq!(a.angle.grade(), b.angle.grade(), "same grade at any blade"); + } - // traditional: F = ma requires vector spaces and coordinate transformations - // second derivatives need d²r/dt² with finite difference approximations O(n²) - // - // geonum: two π/2 rotations give acceleration, scale by mass for force O(1) - // F = ma emerges from simple scaling, no coordinate frames needed + assert_eq!(b.angle.blade(), 1004, "blade accumulates the history"); + assert_eq!(b.angle.grade(), 0, "grade returns to 0 (1004 % 4)"); } #[test] -fn it_encodes_jerk() { - let initial_position = Geonum::new(10.0, 3.0, 7.0); // 10 units at 3π/7 - - // complete kinematic hierarchy - let velocity = initial_position.differentiate(); // blade 0 → 1 - let acceleration = velocity.differentiate(); // blade 1 → 2 - let jerk = acceleration.differentiate(); // blade 2 → 3 - let fourth_derivative = jerk.differentiate(); // blade 3 → 4 - - assert_eq!(jerk.angle.blade(), 3, "jerk at blade 3"); - assert_eq!( - fourth_derivative.angle.grade(), - 0, - "fourth derivative returns to grade 0" - ); +fn it_recovers_position_by_integrating_back_down_the_hierarchy() { + // integration is the inverse quarter turn (−π/2, taken forward as 3π/2). climb to + // jerk and integrate three times: base_angle recovers the position, same magnitude + // same direction, no drift because the rotation is exact - // magnitudes preserved through entire chain - assert!( - (jerk.mag - initial_position.mag).abs() < EPSILON, - "jerk preserves original magnitude" - ); - assert!( - (fourth_derivative.mag - initial_position.mag).abs() < EPSILON, - "fourth derivative preserves original magnitude" - ); + let position = Geonum::new(7.0, 2.0, 5.0); // [7, 2π/5] + let jerk = position.differentiate().differentiate().differentiate(); - // test triple integration returns to position - let recovered_accel = jerk.integrate(); - let recovered_vel = recovered_accel.integrate(); - let recovered_pos = recovered_vel.integrate(); - let final_position = recovered_pos.base_angle(); + let recovered = jerk.integrate().integrate().integrate().base_angle(); - assert_eq!( - final_position.angle.blade(), - 0, - "triple integration returns to blade 0" - ); assert!( - (final_position.mag - initial_position.mag).abs() < EPSILON, - "triple integration preserves magnitude" - ); - - // traditional: jerk d³r/dt³ requires third derivatives, snap d⁴r/dt⁴ requires fourth - // numerical methods compound error at each level O(n³), O(n⁴) - // - // geonum: unlimited derivatives via grade cycling, exact reversibility - // jerk, snap, crackle, pop... all just π/2 rotations O(1) + recovered.near(&position), + "integrate back to the same position" + ) } -#[test] -fn it_displaces_from_derived_velocity() { - // define initial position - let initial_position = Geonum::new(10.0, 1.0, 3.0); // 10m at π/3 (60°) - println!( - "initial position: length={}, angle={}, blade={}", - initial_position.mag, - initial_position.angle.grade_angle(), - initial_position.angle.blade() - ); - - // derive velocity from position (π/2 rotation) - let velocity = initial_position.differentiate(); - assert_eq!(velocity.angle.blade(), 1, "velocity at blade 1"); - assert_eq!( - velocity.mag, initial_position.mag, - "differentiation preserves magnitude" - ); - println!( - "derived velocity: length={}, angle={}, blade={}", - velocity.mag, - velocity.angle.grade_angle(), - velocity.angle.blade() - ); - - // compute displacement using derived velocity over time interval - let time_interval = 2.0; // seconds - let displacement = velocity.scale(time_interval); // d = v × t - assert_eq!(displacement.mag, 20.0, "displacement = 10 × 2 = 20"); - assert_eq!( - displacement.angle, velocity.angle, - "displacement preserves velocity direction" - ); - println!( - "displacement: length={}, angle={}, blade={}", - displacement.mag, - displacement.angle.grade_angle(), - displacement.angle.blade() - ); - - // add displacement to initial position - let final_position = initial_position + displacement; - println!( - "final position: length={}, angle={}, blade={}", - final_position.mag, - final_position.angle.grade_angle(), - final_position.angle.blade() - ); - - // assert final position is physically meaningful - // initial: 10m at π/3 (60°) - // displacement: 20m at π/3 + π/2 = 5π/6 (150°) +// ═══════════════════════════════════════════════════════════ +// act II: dynamic quantities are wedge and dot, not cross products and norms +// ═══════════════════════════════════════════════════════════ - // convert to cartesian to verify physics - let x0 = initial_position.mag * initial_position.angle.grade_angle().cos(); - let y0 = initial_position.mag * initial_position.angle.grade_angle().sin(); - let dx = displacement.mag * displacement.angle.grade_angle().cos(); - let dy = displacement.mag * displacement.angle.grade_angle().sin(); - let xf = final_position.mag * final_position.angle.grade_angle().cos(); - let yf = final_position.mag * final_position.angle.grade_angle().sin(); +#[test] +fn it_wedges_position_and_momentum_into_angular_momentum() { + // conventional mechanics builds L = r × p from a cross product over a basis — six + // component multiplies in 3D. here it is one wedge: a grade-2 bivector whose + // magnitude is |r||p||sinΔ|, and a radial momentum wedges to nothing - println!("\ncartesian verification:"); - println!(" initial: ({:.3}, {:.3})", x0, y0); - println!(" displacement: ({:.3}, {:.3})", dx, dy); - println!(" final: ({:.3}, {:.3})", xf, yf); + let r = Geonum::new(3.0, 1.0, 6.0); // 3 m at π/6 + let momentum = Geonum::new(8.0, 2.0, 3.0); // 8 kg·m/s at 2π/3, a quarter turn off r - // final position should equal initial + displacement in cartesian - assert!( - (xf - (x0 + dx)).abs() < EPSILON, - "x-component: {:.6} ≈ {:.6}", - xf, - x0 + dx - ); - assert!( - (yf - (y0 + dy)).abs() < EPSILON, - "y-component: {:.6} ≈ {:.6}", - yf, - y0 + dy - ); + let l = r.wedge(&momentum); + assert_eq!(l.angle.grade(), 2, "angular momentum is a bivector"); + assert!(l.near_mag(24.0), "|L| = 3×8×sin(π/2) = 24"); - // compute expected final position magnitude - let expected_magnitude = ((x0 + dx).powi(2) + (y0 + dy).powi(2)).sqrt(); + let radial = Geonum::new(8.0, 1.0, 6.0); // along r assert!( - (final_position.mag - expected_magnitude).abs() < EPSILON, - "final position magnitude matches vector addition: {:.6} ≈ {:.6}", - final_position.mag, - expected_magnitude - ); + r.wedge(&radial).near_mag(0.0), + "radial momentum sweeps no area" + ) +} - // test that velocity derived from position creates physically meaningful displacement - // the displacement moves us from 10m at 60° to a new position - // this proves differentiation produces a velocity that generates real motion +#[test] +fn it_wedges_lever_and_force_into_torque() { + // torque is τ = r × F, another cross product, and τ = dL/dt by a separate calculus + // argument. here τ = r∧F is one wedge, and τ = dL/dt is one differentiate() — the + // quarter turn that advances the angular momentum by a grade - // additional test: perpendicular velocity creates perpendicular displacement - let perpendicular_position = Geonum::new(10.0, 5.0, 6.0); // 10m at 5π/6 (150°) - let perpendicular_velocity = perpendicular_position.differentiate(); + let r = Geonum::new(2.0, 1.0, 6.0); // 2 m lever at π/6 + let force = Geonum::new_with_blade(10.0, 2, 1.0, 4.0); // 10 N, grade 2 - // angle difference between original and perpendicular: 5π/6 - π/3 = π/2 - let angle_diff = - (perpendicular_position.angle.grade_angle() - initial_position.angle.grade_angle()).abs(); + let torque = r.wedge(&force); assert!( - (angle_diff - PI / 2.0).abs() < EPSILON, - "positions are perpendicular" + r.scale(2.0).wedge(&force).near_mag(2.0 * torque.mag), + "double the lever, double the torque" ); - // their derived velocities should also be perpendicular - let velocity_angle_diff = - (perpendicular_velocity.angle.grade_angle() - velocity.angle.grade_angle()).abs(); - let normalized_diff = if velocity_angle_diff > PI { - 2.0 * PI - velocity_angle_diff - } else { - velocity_angle_diff - }; + let along = Geonum::new(10.0, 1.0, 6.0); // along r assert!( - (normalized_diff - PI / 2.0).abs() < EPSILON, - "derived velocities maintain perpendicularity" + r.wedge(&along).near_mag(0.0), + "parallel force exerts no torque" ); - println!("\nphysics verified: derivative creates meaningful velocity → displacement → motion"); - - // traditional kinematics: r(t) = r₀ + ∫v(t)dt requires integration - // numerical integration accumulates error, path integrals need discretization O(n) - // - // geonum: displacement = velocity.scale(time), position update via addition O(1) - // derivative creates velocity that produces real motion when scaled by time + // τ = dL/dt: differentiating the angular momentum advances it one grade + let momentum = Geonum::new_with_blade(4.0, 1, 1.0, 4.0); + let l = r.wedge(&momentum); + assert_eq!(l.angle.grade(), 2, "angular momentum is a bivector"); + assert_eq!( + l.differentiate().angle.grade(), + 3, + "dL/dt is one quarter turn past L — that is the torque" + ) } #[test] -fn it_squares_displacement_from_derived_acceleration() { - // kinematic equation: d = v₀t + ½at² - // demonstrates acceleration creates quadratic displacement growth - - // define initial position - let initial_position = Geonum::new(5.0, 1.0, 4.0); // 5m at π/4 (45°) - println!( - "initial position: length={}, angle={}, blade={}", - initial_position.mag, - initial_position.angle.grade_angle(), - initial_position.angle.blade() - ); +fn it_relates_angular_and_linear_motion_through_the_wedge() { + // v = ω × r and centripetal a = ω²r are cross-product and vector-identity results + // conventionally. here v = ω∧r gives |v| = ωr directly, and a = ω²r is the ω·ω dot + // scaling the radius - // derive velocity from position (π/2 rotation) - let initial_velocity = initial_position.differentiate(); - assert_eq!(initial_velocity.angle.blade(), 1, "velocity at blade 1"); - println!( - "initial velocity: length={}, angle={}, blade={}", - initial_velocity.mag, - initial_velocity.angle.grade_angle(), - initial_velocity.angle.blade() - ); + let omega = Geonum::new_with_blade(2.0, 1, 0.0, 1.0); // 2 rad/s, grade 1 + let r = Geonum::new(3.0, 0.0, 1.0); // 3 m radius - // derive acceleration from velocity (another π/2 rotation) - let acceleration = initial_velocity.differentiate(); - assert_eq!(acceleration.angle.blade(), 2, "acceleration at blade 2"); - assert_eq!( - acceleration.mag, initial_position.mag, - "double differentiation preserves magnitude" + let v = omega.wedge(&r); + assert!(v.near_mag(6.0), "|v| = ωr = 2×3 = 6"); + assert!( + omega.scale(2.0).wedge(&r).near_mag(12.0), + "double ω doubles v" ); - println!( - "acceleration: length={}, angle={}, blade={}", - acceleration.mag, - acceleration.angle.grade_angle(), - acceleration.angle.blade() + assert!( + omega.wedge(&r.scale(2.0)).near_mag(12.0), + "double r doubles v" ); - // compute displacement over time with constant acceleration - let time = 3.0; // seconds - - // first term: v₀t (linear displacement from initial velocity) - let linear_displacement = initial_velocity.scale(time); - assert_eq!(linear_displacement.mag, 15.0, "v₀t = 5 × 3 = 15"); - assert_eq!( - linear_displacement.angle, initial_velocity.angle, - "linear term preserves velocity direction" - ); - println!( - "\nlinear displacement (v₀t): length={}, angle={}", - linear_displacement.mag, - linear_displacement.angle.grade_angle() - ); + let a_c = r.scale(omega.dot(&omega).mag); + assert!(a_c.near_mag(12.0), "a = ω²r = 4×3 = 12") +} - // second term: ½at² (quadratic displacement from acceleration) - let time_squared = time * time; // t² - let quadratic_displacement = acceleration.scale(0.5 * time_squared); - assert_eq!( - quadratic_displacement.mag, 22.5, - "½at² = 0.5 × 5 × 9 = 22.5" - ); - assert_eq!( - quadratic_displacement.angle, acceleration.angle, - "quadratic term preserves acceleration direction" - ); - println!( - "quadratic displacement (½at²): length={}, angle={}", - quadratic_displacement.mag, - quadratic_displacement.angle.grade_angle() - ); +#[test] +fn it_dots_velocity_into_kinetic_energy() { + // KE = ½m|v|² needs the velocity vector's norm in n dimensions conventionally. here + // the self-dot v·v lands |v|² at grade 0, so energy is the velocity's own + // interaction — quadratic by construction, at any winding - // total displacement: combine linear and quadratic terms - let total_displacement = linear_displacement + quadratic_displacement; - println!( - "total displacement: length={}, angle={}, blade={}", - total_displacement.mag, - total_displacement.angle.grade_angle(), - total_displacement.angle.blade() - ); + let mass = 3.0; + let velocity = Geonum::new_with_blade(8.0, 1, 1.0, 7.0); // 8 m/s, grade 1 - // add total displacement to initial position - let final_position = initial_position + total_displacement; - println!( - "final position: length={}, angle={}, blade={}", - final_position.mag, - final_position.angle.grade_angle(), - final_position.angle.blade() - ); + let ke = velocity.dot(&velocity).scale(0.5 * mass); + assert_eq!(ke.angle.grade(), 0, "kinetic energy is a scalar"); + assert!(ke.near_mag(96.0), "KE = ½·3·64 = 96 J"); - // verify physics in cartesian coordinates - let x0 = initial_position.mag * initial_position.angle.grade_angle().cos(); - let y0 = initial_position.mag * initial_position.angle.grade_angle().sin(); - let dx_linear = linear_displacement.mag * linear_displacement.angle.grade_angle().cos(); - let dy_linear = linear_displacement.mag * linear_displacement.angle.grade_angle().sin(); - let dx_quad = quadratic_displacement.mag * quadratic_displacement.angle.grade_angle().cos(); - let dy_quad = quadratic_displacement.mag * quadratic_displacement.angle.grade_angle().sin(); - let xf = final_position.mag * final_position.angle.grade_angle().cos(); - let yf = final_position.mag * final_position.angle.grade_angle().sin(); - - println!("\ncartesian verification:"); - println!(" initial: ({:.3}, {:.3})", x0, y0); - println!( - " linear displacement: ({:.3}, {:.3})", - dx_linear, dy_linear - ); - println!(" quadratic displacement: ({:.3}, {:.3})", dx_quad, dy_quad); - println!( - " total displacement: ({:.3}, {:.3})", - dx_linear + dx_quad, - dy_linear + dy_quad - ); - println!(" final: ({:.3}, {:.3})", xf, yf); + let ke_double = velocity + .scale(2.0) + .dot(&velocity.scale(2.0)) + .scale(0.5 * mass); + assert!(ke_double.near_mag(384.0), "KE(2v) = 4×96 = 384 J"); - // final position should equal initial + linear + quadratic displacements - let expected_x = x0 + dx_linear + dx_quad; - let expected_y = y0 + dy_linear + dy_quad; - assert!( - (xf - expected_x).abs() < EPSILON, - "x-component: {:.6} ≈ {:.6}", - xf, - expected_x - ); + let high = Geonum::new_with_blade(8.0, 1000, 1.0, 7.0); assert!( - (yf - expected_y).abs() < EPSILON, - "y-component: {:.6} ≈ {:.6}", - yf, - expected_y - ); + high.dot(&high).scale(0.5 * mass).near_mag(96.0), + "same energy at blade 1000" + ) +} - // test that quadratic term dominates for large time - let large_time = 10.0; - let large_linear = initial_velocity.scale(large_time); - let large_quadratic = acceleration.scale(0.5 * large_time * large_time); +#[test] +fn it_dots_force_into_work_and_power() { + // work W = ∫F·dr is a path integral and power P = dW/dt a time derivative + // conventionally. here both are one dot at the angle between the vectors: W = F·d, + // P = F·v, full when aligned and gone across a perpendicular — no path, no clock - assert!( - large_quadratic.mag > large_linear.mag, - "quadratic term dominates for large t: {:.1} > {:.1}", - large_quadratic.mag, - large_linear.mag - ); + let force = Geonum::new(10.0, 1.0, 6.0); // 10 N at π/6 - // ratio should be t/2 for equal magnitude initial conditions - let ratio = large_quadratic.mag / large_linear.mag; + let displacement = Geonum::new(3.0, 1.0, 6.0); // 3 m, aligned assert!( - (ratio - large_time / 2.0).abs() < EPSILON, - "quadratic/linear ratio = t/2 = {:.1}", - ratio + force.dot(&displacement).near_mag(30.0), + "W = F·d = 10×3 = 30 J" ); - // test zero initial velocity case (pure acceleration from rest) - let rest_position = Geonum::new(5.0, 0.0, 1.0); // at rest - let rest_velocity = rest_position.differentiate(); - let rest_acceleration = rest_velocity.differentiate(); + let across = Geonum::new(3.0, 2.0, 3.0); // 2π/3, a quarter turn off the force + assert!(force.dot(&across).near_mag(0.0), "F⊥d does no work"); - // from rest: d = ½at² only - let rest_displacement = rest_acceleration.scale(0.5 * time * time); - assert_eq!(rest_displacement.mag, 22.5, "from rest: d = ½at²"); + let velocity = Geonum::new(4.0, 1.0, 6.0); // 4 m/s, aligned + assert!(force.dot(&velocity).near_mag(40.0), "P = F·v = 10×4 = 40 W"); - println!("\nphysics verified: acceleration creates quadratic displacement growth"); - println!("kinematic equation d = v₀t + ½at² emerges from double differentiation"); - - // traditional kinematics: d = v₀t + ½∫∫a(t)dt²dt requires double integration - // taylor series expansion, numerical quadrature methods O(n²) - // - // geonum: kinematic equation emerges from grade hierarchy - // v₀t from velocity scaling, ½at² from acceleration scaling - // no integration, no taylor series, just direct scaling operations O(1) + // opposing motion extracts energy: the dot lands negative, encoded at grade 2 + let opposing = velocity.negate(); // π apart + let extracted = force.dot(&opposing); + assert_eq!( + extracted.angle.grade(), + 2, + "opposing motion is negative power" + ); + assert!(extracted.near_mag(40.0), "energy extracted at 40 W") } #[test] -fn it_encodes_force() { - let mass = 3.0; // kg - let acceleration = Geonum::new_with_blade(4.0, 2, 1.0, 8.0); // 4 m/s² at blade 2 +fn it_dots_radius_into_rotational_inertia() { + // moment of inertia is I = ∫r²dm, a mass-distribution integral conventionally. for a + // point mass it is m(r·r) — the radius self-dot at grade 0 scaled by mass — and the + // parallel-axis shift composes from the same dot - // F = ma - let force = acceleration.scale(mass); + let mass = 2.0; + let r = Geonum::new(3.0, 0.0, 1.0); // 3 m from the axis - assert_eq!(force.angle.blade(), 2, "force at blade 2"); - assert!( - (force.mag - mass * acceleration.mag).abs() < EPSILON, - "F = ma" - ); - assert_eq!(force.angle, acceleration.angle, "force || acceleration"); + let inertia = mass * r.dot(&r).mag; + assert!((inertia - 18.0).abs() < EPSILON, "I = mr² = 2×9 = 18 kg·m²"); - // test impulse-momentum theorem: Δp = FΔt - // start with object at rest, apply force for time interval - let initial_velocity = Geonum::new(0.0, 0.0, 1.0); // at rest - let initial_momentum = initial_velocity.scale(mass); // p = 0 + let double = mass * r.scale(2.0).dot(&r.scale(2.0)).mag; + assert!((double - 72.0).abs() < EPSILON, "I(2r) = 2×36 = 72"); - assert_eq!(initial_momentum.mag, 0.0, "initial momentum is zero"); + let r_cm = Geonum::new(1.0, 0.0, 1.0); + let shift = Geonum::new(2.0, 0.0, 1.0); + let i_parallel = mass * r_cm.dot(&r_cm).mag + mass * shift.dot(&shift).mag; + assert!( + (i_parallel - 10.0).abs() < EPSILON, + "I_cm + md² = 2 + 8 = 10" + ) +} - // impulse = force × time - let time_interval = 2.0; // seconds - let impulse = force.scale(time_interval); // J = FΔt +// ═══════════════════════════════════════════════════════════ +// act III: force, momentum, and mass are scaling and blade arithmetic +// ═══════════════════════════════════════════════════════════ - assert_eq!(impulse.angle.blade(), 2, "impulse at same blade as force"); - assert!( - (impulse.mag - force.mag * time_interval).abs() < EPSILON, - "impulse = force × time" - ); +#[test] +fn it_scales_acceleration_into_force_with_mass_as_pure_scalar() { + // F = ma sits inside vector spaces and coordinate frames conventionally. here it is + // one scale: the acceleration's angle rides through unchanged (force ∥ acceleration), + // only the magnitude grows, and mass carries no angle at all - // final velocity from kinematic equation: v = at - let final_velocity = acceleration.scale(time_interval); - let final_momentum = final_velocity.scale(mass); // p = mv + let mass = 3.0; + let acceleration = Geonum::new_with_blade(5.0, 2, 1.0, 8.0); // 5 m/s², grade 2 - assert_eq!(final_momentum.angle.blade(), 2, "final momentum at blade 2"); - assert!( - (final_momentum.mag - mass * final_velocity.mag).abs() < EPSILON, - "final momentum = mass × final velocity" - ); + let force = acceleration.scale(mass); + assert_eq!(force.angle, acceleration.angle, "force ∥ acceleration"); + assert!(force.near_mag(15.0), "|F| = m|a| = 15 N"); - // verify impulse equals momentum change - let momentum_change = final_momentum.mag - initial_momentum.mag; + let m_here = force.mag / acceleration.mag; + let rotated = acceleration.rotate(Angle::new(1.0, 3.0)); + let m_rotated = rotated.scale(mass).mag / rotated.mag; assert!( - (momentum_change - impulse.mag).abs() < EPSILON, - "Δp = J (impulse-momentum theorem): {} ≈ {}", - momentum_change, - impulse.mag + (m_here - m_rotated).abs() < EPSILON, + "mass is rotation-invariant" ); - - // traditional mechanics: F = dp/dt requires time derivatives of momentum - // impulse J = ∫F dt needs integration, momentum updates via vector addition O(n) - // - // geonum: F = ma and p = mv via simple scaling - // impulse = force × time, no integration needed O(1) + assert!((m_here - 3.0).abs() < EPSILON, "m = F/a = 3 kg") } #[test] -fn it_encodes_momentum() { - let mass = 4.0; // kg - let velocity = Geonum::new_with_blade(6.0, 1, 2.0, 9.0); // blade 1, 2π/9 angle - - let momentum = velocity.scale(mass); - - assert_eq!(momentum.angle.blade(), 1, "momentum at blade 1"); - assert!( - (momentum.mag - mass * velocity.mag).abs() < EPSILON, - "p = mv" - ); - assert_eq!(momentum.angle, velocity.angle, "momentum || velocity"); - - // conservation via nilpotency - let self_wedge = momentum.wedge(&momentum); - assert!(self_wedge.mag < EPSILON, "p∧p = 0 (conservation)"); +fn it_scales_velocity_into_momentum_and_differentiates_it_to_force() { + // p = mv and F = dp/dt are separate vector statements conventionally. here p is a + // scale of the velocity at grade 1, F = dp/dt is one differentiate() to grade 2, and + // the impulse-momentum theorem Δp = FΔt is a scale by time - // elastic collision: momentum conservation - let mass2 = 2.0; // kg - let velocity2 = Geonum::new_with_blade(3.0, 1, 5.0, 6.0); // blade 1, 5π/6 angle - let momentum2 = velocity2.scale(mass2); + let mass = 4.0; + let velocity = Geonum::new_with_blade(3.0, 1, 0.0, 1.0); // 3 m/s, grade 1 - // total momentum before collision - let total_momentum_before = momentum + momentum2; - // blade accumulates: 1 + 1 + 3 (from blade preservation) = 5 - assert_eq!( - total_momentum_before.angle.blade(), - 5, - "total momentum blade from addition" - ); - - // after elastic collision (velocities exchange for equal masses demonstration) - // in real physics we'd solve conservation equations, here we verify invariant - let total_momentum_after = total_momentum_before; // conserved - - // verify conservation law holds + let momentum = velocity.scale(mass); + assert!(momentum.near_mag(12.0), "p = mv = 12 kg·m/s"); assert_eq!( - total_momentum_after.mag, total_momentum_before.mag, - "momentum magnitude conserved" + momentum.angle.grade(), + 1, + "momentum rides the velocity grade" ); assert_eq!( - total_momentum_after.angle, total_momentum_before.angle, - "momentum direction conserved" + momentum.differentiate().angle.grade(), + 2, + "dp/dt lands the force grade" ); - // rotation preserves conservation - let rotation = Angle::new(1.0, 7.0); // π/7 - let rotated_total = total_momentum_before.rotate(rotation); - + let acceleration = Geonum::new_with_blade(5.0, 2, 0.0, 1.0); // grade 2 + let force = acceleration.scale(mass); + let dt = 2.0; + let impulse = force.scale(dt); + let delta_p = acceleration.scale(dt).scale(mass); assert!( - (rotated_total.mag - total_momentum_before.mag).abs() < EPSILON, - "rotation preserves total momentum magnitude" - ); - - // nilpotency still holds for total momentum - let total_wedge = total_momentum_before.wedge(&total_momentum_before); - assert!(total_wedge.mag < EPSILON, "p_total∧p_total = 0"); - - // traditional: momentum conservation requires coordinate-free formulation - // p = mv with vector operations, conservation via dp/dt = 0 analysis O(n) - // - // geonum: momentum = velocity.scale(mass), conservation via nilpotency p∧p = 0 - // rotation invariance automatic through angle arithmetic O(1) + impulse.near(&delta_p), + "Δp = FΔt, the momentum the force delivers" + ) } -#[test] -fn it_encodes_angular_momentum() { - let position = Geonum::new(3.0, 1.0, 6.0); // 3 units at π/6 - let mass = 2.0; // kg - let velocity = Geonum::new_with_blade(4.0, 1, 1.0, 4.0); // blade 1, π/4 - let momentum = velocity.scale(mass); +// ═══════════════════════════════════════════════════════════ +// act IV: the self-wedge is universal — conservation is antisymmetry +// ═══════════════════════════════════════════════════════════ - // L = r ∧ p - let angular_momentum = position.wedge(&momentum); +#[test] +fn it_self_wedges_every_quantity_to_zero_conserved_or_not() { + // "nilpotency expresses conservation" is a tempting reading, but a∧a = 0 holds for + // every geonum because sin(θ−θ) = 0. fire it on a free momentum and on one changed by + // an impulse — both self-wedge to zero, so the identity carries no information about + // conservation. it witnesses that a state cannot repeat, a different fact - // angular momentum is bivector-like - assert_eq!(angular_momentum.angle.grade(), 2, "L at grade 2 (bivector)"); + let free = Geonum::new_with_blade(12.0, 1, 1.0, 5.0); // a momentum, no net force + let driven = free + Geonum::new_with_blade(3.0, 1, 1.0, 5.0); // the same, after an impulse - // magnitude encodes |r||p|sin(θ) - let angle_diff = (momentum.angle - position.angle).grade_angle(); - let expected_magnitude = position.mag * momentum.mag * angle_diff.sin().abs(); assert!( - (angular_momentum.mag - expected_magnitude).abs() < EPSILON, - "L magnitude matches |r||p|sin(θ)" - ); - - // conservation via nilpotency - let angular_self_wedge = angular_momentum.wedge(&angular_momentum); - assert!(angular_self_wedge.mag < EPSILON, "L∧L = 0 (conservation)"); - - // test torque changes angular momentum: τ = dL/dt - let force = Geonum::new_with_blade(5.0, 2, 1.0, 3.0); // blade 2, π/3 - let torque = position.wedge(&force); // τ = r ∧ F - - // torque is bivector-like, same grade as angular momentum - assert_eq!( - torque.angle.grade(), - 2, - "torque at grade 2 (bivector, same as L)" + free.wedge(&free).near_mag(0.0), + "free momentum self-wedges to 0" ); - - // torque magnitude |r||F||sin(θ)| - let torque_angle_diff = (force.angle - position.angle).grade_angle(); - let expected_torque = position.mag * force.mag * torque_angle_diff.sin().abs(); assert!( - (torque.mag - expected_torque).abs() < EPSILON, - "torque magnitude matches |r||F||sin(θ)|" + driven.wedge(&driven).near_mag(0.0), + "driven momentum self-wedges to 0 too" ); - // traditional mechanics: L = r × p requires cross product and basis vectors - // torque τ = r × F, angular momentum conservation via dL/dt = τ analysis O(n²) - // - // geonum: L = r.wedge(p) via angle addition, torque = r.wedge(F) - // conservation via nilpotency L∧L = 0, no coordinate systems needed O(1) + let position = Geonum::new(4.0, 1.0, 3.0); // never a conserved quantity + assert!( + position.wedge(&position).near_mag(0.0), + "so does anything else" + ) } #[test] -fn it_encodes_work() { - let force = Geonum::new(10.0, 1.0, 6.0); // 10 N at π/6, blade 0 (vector) - let displacement = Geonum::new(3.0, 1.0, 6.0); // 3m at π/6 (aligned) - - // W = F·d for aligned case - let work_interaction = force.dot(&displacement); - let work_magnitude = work_interaction.mag.abs(); // handle sign +fn it_conserves_momentum_because_the_interaction_forces_are_pi_apart() { + // momentum conservation is the antisymmetry of the interaction: F_ij = −F_ji, the + // force on 1 from 2 and on 2 from 1 a π rotation apart (Newton's third law). the two + // impulses cancel pairwise by that antisymmetry, so the total momentum has nothing to + // move it — no translation symmetry searched, the cancellation is read straight off - let expected_work = force.mag * displacement.mag; // cos(0) = 1 - assert!( - (work_magnitude - expected_work).abs() < EPSILON, - "aligned work W = F·d" - ); + let dt = 0.01; + let p1 = Geonum::new_with_blade(5.0, 1, 1.0, 5.0); + let p2 = Geonum::new_with_blade(3.0, 1, 2.0, 7.0); + let p_total = p1 + p2; - // perpendicular case: force at π/6, displacement at 2π/3 (difference = π/2) - let perpendicular_displacement = Geonum::new(3.0, 2.0, 3.0); // 2π/3 - let perpendicular_work = force.dot(&perpendicular_displacement); + let force_on_1 = Geonum::new_with_blade(9.0, 1, 1.0, 3.0); // the interaction force + let force_on_2 = force_on_1.negate(); // third law: equal and opposite, π apart - // compute the measured perpendicular work magnitude - let perpendicular_work_magnitude = perpendicular_work.mag.abs(); + let p1_next = p1 + force_on_1.scale(dt); // each body takes its impulse + let p2_next = p2 + force_on_2.scale(dt); - // physics: perpendicular force does zero work (cos(π/2) = 0) - // force blade 0 at π/6, displacement blade 0 at 2π/3 - // the dot product accounts for the π/2 angle difference assert!( - perpendicular_work_magnitude < EPSILON, - "perpendicular work = {:.6} ≈ 0 (F⊥d → W=0)", - perpendicular_work_magnitude - ); - - // test work-energy theorem: W = ΔKE - let mass = 2.0; // kg - let initial_velocity = Geonum::new(2.0, 1.0, 6.0); // 2 m/s at π/6 - - // kinetic energy: KE = ½mv² as geometric number - let v_squared = initial_velocity * initial_velocity; // v² gives scalar (blade 0) - let initial_ke = v_squared.scale(0.5 * mass); - - assert_eq!( - initial_ke.angle.grade(), - 0, - "KE at grade 0 (scalar from v²)" + force_on_1.scale(dt).near_mag(0.09), + "body 1 takes a real 0.09 impulse" ); + // yet the total does not move — the two impulses interfere to zero + let change = (p1_next + p2_next) - p_total; assert!( - (initial_ke.mag - 0.5 * mass * 4.0).abs() < EPSILON, - "initial KE = ½mv² = 4 J" - ); + change.mag < EPSILON, + "total momentum conserved: Δp_total = 0" + ) +} - // work done equals change in kinetic energy - // for aligned force and displacement, work increases KE - assert!( - work_interaction.mag > 0.0, - "positive work for aligned F and d" - ); +#[test] +fn it_conserves_angular_momentum_because_the_wedge_is_antisymmetric() { + // no symmetry search, no Lagrangian: conservation is the antisymmetry of the wedge. + // dL/dt = d(r∧v)/dt = v∧v + r∧a. the first is a self-wedge, zero for any v; the second + // is r∧a, zero whenever a is central (parallel to r). both vanish by antisymmetry, so + // dL/dt = 0 is read straight off the antisymmetric product + + let r0 = Geonum::new(4.0, 1.0, 5.0); + let v0 = Geonum::new_with_blade(2.0, 1, 1.0, 3.0); + let a0 = Geonum::new_with_angle(1.5, r0.angle + Angle::new(1.0, 1.0)); // central: r + π + + let spin = v0.wedge(&v0); // v∧v + let torque = r0.wedge(&a0); // r∧a + assert!(spin.near_mag(0.0), "v∧v = 0: the self-wedge vanishes"); + assert!(torque.near_mag(0.0), "r∧a = 0: central a is parallel to r"); + // dL/dt is their sum, so it vanishes term by term + + // integrate a circular orbit and read L across the trajectory — it holds because both + // wedge terms vanish at every step, which is Kepler's equal areas + let gm = 1.0; + let dt = 0.01; + let mut r = Geonum::new(1.0, 0.0, 1.0); // radius 1 + let mut v = Geonum::new_with_blade(1.0, 1, 0.0, 1.0); // √(GM/r) = 1, perpendicular + let l0 = r.wedge(&v).mag; + + let mut max_drift: f64 = 0.0; + for _ in 0..300 { + let a = Geonum::new_with_angle(gm / (r.mag * r.mag), r.angle + Angle::new(1.0, 1.0)); + v = (v + a.scale(dt)).base_angle(); // kick + r = (r + v.scale(dt)).base_angle(); // drift + max_drift = max_drift.max((r.wedge(&v).mag - l0).abs()); + } - // final velocity from energy conservation - // KE_final = KE_initial + W - let final_ke_magnitude = initial_ke.mag + work_interaction.mag; assert!( - final_ke_magnitude > initial_ke.mag, - "work increases kinetic energy: {:.2} J → {:.2} J", - initial_ke.mag, - final_ke_magnitude - ); + max_drift < 1e-9, + "L held across the orbit: max drift {max_drift:.1e}" + ) +} - // traditional mechanics: W = ∫F·dr requires path integration - // work-energy theorem via calculus of variations O(n) - // - // geonum: W = F.dot(d) direct computation - // perpendicular test proves geometric correctness without integration O(1) +// ═══════════════════════════════════════════════════════════ +// act V: energy — the conserved scalar is a magnitude +// ═══════════════════════════════════════════════════════════ +// +// momentum conservation above is the π pairing of forces; energy gets the same +// geometry. the oscillator state is ONE geonum: the phase point +// [x·√(k/2), v·√(m/2)] built from newton's integrated output. its squared +// magnitude is ½kx² + ½mv², so the conserved energy is a MAGNITUDE — the +// pythagorean readout of one geonum, the quadrature closing with newton doing +// the rotating. the KE ↔ PE exchange is the quarter turn walking the grade +// cycle, dE/dt = 0 is the kinetic credit F·v interfering with the potential +// debit kx·ẋ — one dot placed π apart — and dissipation is the unpartnered +// term: a damper's −cv² has no π partner, so the magnitude drains by exactly +// that dot. no lagrangian, no symmetry search: the energy is a magnitude, the +// dynamics is rotation, and rotation never touches magnitude + +const MASS: f64 = 2.0; +const SPRING_K: f64 = 8.0; +const OMEGA: f64 = 2.0; // √(k/m) +const AMPLITUDE: f64 = 1.5; // release displacement, from rest +const E0: f64 = 9.0; // ½k·A² +const DT: f64 = 1e-4; +const STEPS: usize = 31416; // one period T = 2π/ω = π + +// the phase point: displacement on the adjacent leg weighted √(k/2), velocity on +// the opposite leg weighted √(m/2). its squared magnitude is ½kx² + ½mv². +// the state rides the 0/π rays, so the dimension-0 projection reads ±mag exactly +fn phase(x: &Geonum, v: &Geonum) -> Geonum { + Geonum::new_from_cartesian( + x.project_to_dimension(0) * (SPRING_K / 2.0).sqrt(), + v.project_to_dimension(0) * (MASS / 2.0).sqrt(), + ) } -#[test] -fn it_encodes_kinetic_energy() { - let mass = 3.0; // kg - let velocity = Geonum::new_with_blade(8.0, 1, 1.0, 7.0); // 8 m/s at blade 1, π/7 - - // kinetic energy from velocity self-dot product: v·v = |v|² - let v_squared = velocity.dot(&velocity); // dot product gives scalar (blade 0) - assert_eq!(v_squared.angle.blade(), 0, "v² at blade 0 (scalar)"); - assert_eq!(v_squared.angle.grade(), 0, "v² at grade 0"); - assert!((v_squared.mag - 64.0).abs() < EPSILON, "v² = 8² = 64 m²/s²"); - - // kinetic energy: KE = ½mv² - let kinetic_energy = v_squared.scale(0.5 * mass); - assert_eq!(kinetic_energy.angle.blade(), 0, "KE at blade 0 (scalar)"); - assert!( - (kinetic_energy.mag - 96.0).abs() < EPSILON, - "KE = ½(3)(64) = 96 J" - ); +fn spring(x: &Geonum) -> Geonum { + x.negate().scale(SPRING_K) // hooke: −kx, the negate a π rotation +} - // test energy scaling: doubling velocity quadruples energy - let double_velocity = velocity.scale(2.0); // 16 m/s - assert!( - (double_velocity.mag - 16.0).abs() < EPSILON, - "doubled velocity = 16 m/s" - ); +// kick-drift under a supplied force — newton only, no energy constructed +fn kick_drift( + x: Geonum, + v: Geonum, + force: impl Fn(&Geonum, &Geonum) -> Geonum, +) -> (Geonum, Geonum) { + let a = force(&x, &v).scale(1.0 / MASS); + let v_next = (v + a.scale(DT)).base_angle(); + let x_next = (x + v_next.scale(DT)).base_angle(); + (x_next, v_next) +} - let double_v_squared = double_velocity.dot(&double_velocity); - assert!( - (double_v_squared.mag - 256.0).abs() < EPSILON, - "(2v)² = 256 = 4×64 m²/s²" - ); +#[test] +fn it_conserves_energy_as_the_phase_magnitude() { + let mut x = Geonum::new(AMPLITUDE, 0.0, 1.0); // released at +A + let mut v = Geonum::scalar(0.0); // from rest + + let mut max_energy_drift: f64 = 0.0; + let mut min_x = f64::INFINITY; + let mut max_speed: f64 = 0.0; + + for _ in 0..STEPS { + (x, v) = kick_drift(x, v, |x, _| spring(x)); + + // the energy read two ways: the phase magnitude squared, and the dot-built legs + let p = phase(&x, &v); + let pe = x.dot(&x).scale(0.5 * SPRING_K); + let ke = v.dot(&v).scale(0.5 * MASS); + let legs = pe + ke; // both grade 0 — magnitudes add + + // the phase magnitude squared IS ½kx² + ½mv² — the pythagorean readout + // of one geonum, exact at every step + assert!(p.dot(&p).near(&legs), "phase.mag² = pe + ke"); + + max_energy_drift = max_energy_drift.max((legs.mag - E0).abs() / E0); + min_x = min_x.min(x.project_to_dimension(0)); + max_speed = max_speed.max(v.mag); + } - let double_ke = double_v_squared.scale(0.5 * mass); + // the magnitude holds still across the whole period. the band is the + // integrator's, not the algebra's: kick-drift at dt = 1e-4 wobbles the + // measured energy at ~1e-4 relative, orders above near()'s tolerance assert!( - (double_ke.mag - 384.0).abs() < EPSILON, - "KE(2v) = 384 = 4×96 J" + max_energy_drift < 1e-3, + "energy rides the magnitude: drift {max_energy_drift:.2e}" ); - // verify quadratic relationship - let ratio = double_ke.mag / kinetic_energy.mag; + // while the legs swing full range — x out to −A, speed up to Aω. the motion + // is all in the angle; the magnitude never moved + assert!(min_x < -0.99 * AMPLITUDE, "x swings to −A: min {min_x:.3}"); assert!( - (ratio - 4.0).abs() < EPSILON, - "doubling velocity quadruples energy: ratio = 4.0" + max_speed > 0.99 * AMPLITUDE * OMEGA, + "speed reaches Aω: max {max_speed:.3}" ); +} - // test high-dimensional velocity (blade 1000) - let high_velocity = Geonum::new_with_blade(5.0, 1000, 2.0, 13.0); // blade 1000 - let high_v_squared = high_velocity.dot(&high_velocity); +#[test] +fn it_exchanges_the_legs_by_the_quarter_turn() { + let mut x = Geonum::new(AMPLITUDE, 0.0, 1.0); + let mut v = Geonum::scalar(0.0); + + // the phase point rotates clockwise at ω, so at the odd eighths of the period + // its angle is 7π/4, 5π/4, 3π/4, π/4 — grades 3, 2, 1, 0, the cycle walked once + let eighth = STEPS / 8; + + for n in 1..=STEPS { + (x, v) = kick_drift(x, v, |x, _| spring(x)); + if n % eighth != 0 { + continue; + } + + let i = n / eighth; + let p = phase(&x, &v); + let pe = x.dot(&x).scale(0.5 * SPRING_K); + let ke = v.dot(&v).scale(0.5 * MASS); + + match i { + 1 | 3 | 5 | 7 => { + // eighth turns: the exchange caught halfway, equal energy on each leg + let expected_grade = match i { + 1 => 3, + 3 => 2, + 5 => 1, + _ => 0, + }; + assert_eq!( + p.angle.grade(), + expected_grade, + "eighth {i}: the phase point walks the grade cycle" + ); + assert!( + (pe.mag - E0 / 2.0).abs() / E0 < 1e-3, + "eighth {i}: half the energy on the potential leg" + ); + assert!( + (ke.mag - E0 / 2.0).abs() / E0 < 1e-3, + "eighth {i}: half on the kinetic leg" + ); + } + 2 | 6 => { + // quarter turns: the potential leg empty, all energy kinetic + assert!(pe.mag / E0 < 1e-3, "quarter turn {i}: potential leg empty"); + assert!( + (ke.mag - E0).abs() / E0 < 1e-3, + "quarter turn {i}: all energy on the kinetic leg" + ); + } + _ => { + // half and full period: all energy back on the potential leg + assert!(ke.mag / E0 < 1e-3, "half turn {i}: kinetic leg empty"); + assert!( + (pe.mag - E0).abs() / E0 < 1e-3, + "half turn {i}: all energy on the potential leg" + ); + } + } + } +} - assert_eq!( - high_v_squared.angle.blade(), - 0, - "v² gives blade 0 even from blade 1000" - ); - assert!( - (high_v_squared.mag - 25.0).abs() < EPSILON, - "high-dim v² = 5² = 25 m²/s²" - ); +#[test] +fn it_cancels_the_kinetic_credit_against_the_potential_debit() { + let mut x = Geonum::new(AMPLITUDE, 0.0, 1.0); + let mut v = Geonum::scalar(0.0); + + let mut credit_sample = Geonum::scalar(0.0); + let mut debit_sample = Geonum::scalar(0.0); + + for n in 1..=STEPS { + (x, v) = kick_drift(x, v, |x, _| spring(x)); + + // the kinetic credit F·v and the potential debit kx·ẋ — the same dot, + // placed π apart by the force's negate. pointwise annihilation: + // dE/dt = 0 read as a π pair interfering, the same cancellation the + // third law gives momentum + let credit = spring(&x).dot(&v); + let debit = x.scale(SPRING_K).dot(&v); + assert!( + (credit + debit).near_mag(0.0), + "credit + debit interfere to zero at every step" + ); + + if n == STEPS / 8 { + credit_sample = credit; + debit_sample = debit; + } + } - let high_ke = high_v_squared.scale(0.5 * mass); + // and not by idleness: at t = T/8 each side carries kA²ω/2 = 18 W + let expected_power = SPRING_K * AMPLITUDE * AMPLITUDE * OMEGA / 2.0; assert!( - (high_ke.mag - 37.5).abs() < EPSILON, - "high-dim KE = ½(3)(25) = 37.5 J" - ); - - // test relativistic-like energy (velocity at different grades) - let grade_2_velocity = Geonum::new_with_blade(10.0, 2, 3.0, 11.0); // grade 2 - let grade_2_v_squared = grade_2_velocity.dot(&grade_2_velocity); - - assert_eq!( - grade_2_v_squared.angle.blade(), - 0, - "velocity at any grade gives scalar energy" + (credit_sample.mag - expected_power).abs() / expected_power < 1e-2, + "the credit carries kA²ω/2: {:.3}", + credit_sample.mag ); assert!( - (grade_2_v_squared.mag - 100.0).abs() < EPSILON, - "grade 2 v² = 10² = 100 m²/s²" + credit_sample.angle.is_opposite(&debit_sample.angle), + "credit and debit sit π apart — the pairing is the conservation" ); - - // traditional mechanics: T = ½mv² requires velocity magnitude |v⃗|² in n dimensions - // complexity O(n) for n-dimensional velocity vector magnitude - // energy separate from velocity, requires explicit formula application - // - // geonum: KE = ½m(v·v) emerges from geometric dot product - // same operation in any dimension or blade count O(1) - // energy encoded in velocity self-interaction, no separate formula needed } #[test] -fn it_encodes_potential_energy() { - let mass = 2.0; // kg - let height_position = Geonum::new(5.0, 0.0, 1.0); // 5m height, angle 0 - let gravity_field = Geonum::new(9.8, 1.0, 1.0); // 9.8 m/s² at π (downward) - - // potential energy from position-field interaction - let field_position_interaction = height_position.dot(&gravity_field); - - let signed_interaction = field_position_interaction.mag - * field_position_interaction - .angle - .project(Angle::new(0.0, 1.0)); - // positions at angle 0 and field at angle π are opposite - // dot product of opposite directions gives negative - assert!( - signed_interaction < 0.0, - "opposite directions give negative dot product" - ); +fn it_drains_energy_only_through_the_unpartnered_power() { + const DAMP_C: f64 = 0.4; // damping coefficient - let interaction_magnitude = signed_interaction.abs(); - assert!( - (interaction_magnitude - 49.0).abs() < EPSILON, - "h·g = 5×9.8 = 49 m²/s²" - ); + let mut x = Geonum::new(AMPLITUDE, 0.0, 1.0); + let mut v = Geonum::scalar(0.0); - let potential_energy = mass * interaction_magnitude; - assert!( - (potential_energy - 98.0).abs() < EPSILON, - "PE = m(h·g) = 2×49 = 98 J" - ); + let mut drained = 0.0; // ∫ F_damp·v dt — f64 at the boundary, like a loss readout - // test potential energy scaling with height - let double_height = height_position.scale(2.0); // 10m - assert!( - (double_height.mag - 10.0).abs() < EPSILON, - "doubled height = 10m" - ); + for _ in 0..STEPS { + (x, v) = kick_drift(x, v, |x, v| spring(x) + v.negate().scale(DAMP_C)); - let double_interaction = double_height.dot(&gravity_field); - let double_magnitude = - (double_interaction.mag * double_interaction.angle.project(Angle::new(0.0, 1.0))).abs(); - assert!( - (double_magnitude - 98.0).abs() < EPSILON, - "(2h)·g = 10×9.8 = 98 m²/s²" - ); + // the damper's power −cv² lands at grade 2 with no π partner + drained += v.negate().scale(DAMP_C).dot(&v).project_to_dimension(0) * DT; - let double_pe = mass * double_magnitude; - assert!((double_pe - 196.0).abs() < EPSILON, "PE(2h) = 196 = 2×98 J"); + // the spring pair still cancels under damping — its conservation is untouched + let credit = spring(&x).dot(&v); + let debit = x.scale(SPRING_K).dot(&v); + assert!( + (credit + debit).near_mag(0.0), + "the spring pair cancels under damping too" + ); + } - // verify linear scaling - let ratio = double_pe / potential_energy; + // the budget closes: every joule lost left through the unpartnered dot + let p = phase(&x, &v); + let e_final = p.dot(&p).mag; assert!( - (ratio - 2.0).abs() < EPSILON, - "doubling height doubles potential energy: ratio = 2.0" + (e_final - E0 - drained).abs() / E0 < 1e-2, + "energy loss = accumulated unpartnered power: {:.4} vs {:.4}", + e_final - E0, + drained ); - // test perpendicular field (no potential energy) - let horizontal_position = Geonum::new(5.0, 0.5, 1.0); // 5m at π/2 (horizontal) - let perpendicular_interaction = horizontal_position.dot(&gravity_field); - - // π/2 angle to π angle: difference is π/2 (perpendicular) - let perpendicular_magnitude = perpendicular_interaction.mag.abs(); + // and the drain follows the light-damping envelope E(T) = E0·e^(−cT/m) + let envelope = (-DAMP_C / MASS * std::f64::consts::PI).exp(); assert!( - perpendicular_magnitude < EPSILON, - "perpendicular position-field gives zero PE: {:.6} ≈ 0", - perpendicular_magnitude + (e_final / E0 - envelope).abs() < 0.05, + "measured decay {:.3} tracks the envelope {:.3}", + e_final / E0, + envelope ); - - // test high-dimensional potential energy - let high_position = Geonum::new_with_blade(3.0, 1000, 1.0, 11.0); // blade 1000 - let high_field = Geonum::new_with_blade(12.0, 500, 2.0, 13.0); // blade 500 - - let high_interaction = high_position.dot(&high_field); - assert_eq!( - high_interaction.angle.blade(), - 0, - "dot product gives blade 0 from any blade count" - ); - - // compute angle difference for expected value - let pos_angle = high_position.angle.grade_angle(); - let field_angle = high_field.angle.grade_angle(); - let angle_diff = (field_angle - pos_angle).abs(); - let cos_angle = angle_diff.cos(); - - let expected_magnitude = 3.0 * 12.0 * cos_angle.abs(); - let actual_magnitude = high_interaction.mag.abs(); - assert!( - (actual_magnitude - expected_magnitude).abs() < EPSILON, - "high-dim PE = 3×12×cos(angle) = {:.3}", - expected_magnitude - ); - - // test field reversal (negative work against field) - let upward_field = gravity_field.negate(); // reverse field direction - let upward_interaction = height_position.dot(&upward_field); - - let upward_scalar = - upward_interaction.mag * upward_interaction.angle.project(Angle::new(0.0, 1.0)); - assert!( - upward_scalar > 0.0, - "aligned position-field gives positive dot product" - ); - assert!( - (upward_scalar - 49.0).abs() < EPSILON, - "reversed field changes sign but not magnitude" - ); - - // traditional mechanics: V = mgh requires gravitational field definition - // field integral ∫g·dr for arbitrary paths and dimensions O(n) - // separate formulas for different field types (gravity, electric, etc) - // - // geonum: PE = m(position·field) unified for all fields - // same dot product in any dimension or blade count O(1) - // field type encoded in geometric relationship, not separate formulas -} - -#[test] -fn it_encodes_power() { - let force = Geonum::new_with_blade(15.0, 1, 1.0, 8.0); // 15 N at blade 1, π/8 - let velocity = Geonum::new_with_blade(4.0, 1, 1.0, 8.0); // 4 m/s at blade 1, π/8 (aligned) - - // power from force-velocity dot product: P = F·v - let power_interaction = force.dot(&velocity); - let power_scalar = - power_interaction.mag * power_interaction.angle.project(Angle::new(0.0, 1.0)); - assert_eq!( - power_interaction.angle.grade(), - 0, - "power encodes scalar polarity in grade 0/2" - ); - - // aligned force and velocity give maximum power - assert!( - (power_scalar - 60.0).abs() < EPSILON, - "P = F·v = 15×4 = 60 W for aligned case" - ); - - // test power at angle: force at π/8, velocity at π/6 - let angled_velocity = Geonum::new_with_blade(4.0, 1, 1.0, 6.0); // π/6 = 30° - let angled_power = force.dot(&angled_velocity); - - // angle difference: π/6 - π/8 = 4π/24 - 3π/24 = π/24 - let angle_diff = PI / 24.0; - let expected_power = 15.0 * 4.0 * angle_diff.cos(); - - let angled_scalar = angled_power.mag * angled_power.angle.project(Angle::new(0.0, 1.0)); - assert!( - (angled_scalar - expected_power).abs() < EPSILON, - "angled power = 15×4×cos(π/24) = {:.2} W", - expected_power - ); - - // test perpendicular case: force at π/8, velocity at π/8 + π/2 = 5π/8 - let perpendicular_velocity = Geonum::new_with_blade(4.0, 1, 5.0, 8.0); // 5π/8 - let perpendicular_power = force.dot(&perpendicular_velocity); - - let perpendicular_scalar = - perpendicular_power.mag * perpendicular_power.angle.project(Angle::new(0.0, 1.0)); - assert!( - perpendicular_scalar.abs() < EPSILON, - "perpendicular F⊥v gives zero power: {:.6} ≈ 0", - perpendicular_scalar - ); - - // test power scaling with force - let double_force = force.scale(2.0); // 30 N - assert!( - (double_force.mag - 30.0).abs() < EPSILON, - "doubled force = 30 N" - ); - - let double_force_power = double_force.dot(&velocity); - let double_power_scalar = - double_force_power.mag * double_force_power.angle.project(Angle::new(0.0, 1.0)); - assert!( - (double_power_scalar - 120.0).abs() < EPSILON, - "P(2F) = 120 = 2×60 W" - ); - - // test power scaling with velocity - let triple_velocity = velocity.scale(3.0); // 12 m/s - assert!( - (triple_velocity.mag - 12.0).abs() < EPSILON, - "tripled velocity = 12 m/s" - ); - - let triple_velocity_power = force.dot(&triple_velocity); - let triple_power_scalar = - triple_velocity_power.mag * triple_velocity_power.angle.project(Angle::new(0.0, 1.0)); - assert!( - (triple_power_scalar - 180.0).abs() < EPSILON, - "P(3v) = 180 = 3×60 W" - ); - - // verify linear scaling in both force and velocity - let force_ratio = double_power_scalar / power_scalar; - assert!( - (force_ratio - 2.0).abs() < EPSILON, - "doubling force doubles power: ratio = 2.0" - ); - - let velocity_ratio = triple_power_scalar / power_scalar; - assert!( - (velocity_ratio - 3.0).abs() < EPSILON, - "tripling velocity triples power: ratio = 3.0" - ); - - // test high-dimensional power (different blades) - let high_force = Geonum::new_with_blade(8.0, 250, 2.0, 11.0); // blade 250 - let high_velocity = Geonum::new_with_blade(6.0, 1000, 2.0, 11.0); // blade 1000 - - let high_power = high_force.dot(&high_velocity); - let high_power_scalar = high_power.mag * high_power.angle.project(Angle::new(0.0, 1.0)); - assert!( - high_power.angle == Angle::new(0.0, 1.0) || high_power.angle == Angle::new(1.0, 1.0), - "power encodes sign via scalar/bivector pair" - ); - - // blade 250 gives mod_4_angle ≈ 3.71, blade 1000 gives ≈ 0.57 - // they're π apart (opposite directions), so power is negative - assert!( - (high_power_scalar + 48.0).abs() < EPSILON, - "high-dim power = -48 W (opposite directions)" - ); - - // test negative power (force opposing velocity) - let opposing_force = force.negate(); - let opposing_power = opposing_force.dot(&velocity); - let opposing_scalar = opposing_power.mag * opposing_power.angle.project(Angle::new(0.0, 1.0)); - - assert!( - opposing_scalar < 0.0, - "opposing force-velocity gives negative power" - ); - assert!( - (opposing_scalar + 60.0).abs() < EPSILON, - "opposing power = -60 W (energy extraction)" - ); - - // traditional mechanics: P = F⃗·v⃗ requires vector dot products in n dimensions - // power = dW/dt requires work differentiation and time derivatives O(n) - // separate power formulas for different systems (mechanical, electrical, etc) - // - // geonum: P = F·v unified for all power types - // same dot product in any dimension or blade count O(1) - // negative power naturally represents energy extraction -} - -#[test] -fn it_encodes_torque() { - let position = Geonum::new(2.0, 1.0, 6.0); // 2m lever arm at π/6 - let force = Geonum::new_with_blade(10.0, 1, 1.0, 3.0); // 10 N at blade 1, π/3 - - // torque from position-force wedge product - let torque = position.wedge(&force); - - assert_eq!(position.angle.blade(), 0, "position at blade 0"); - assert_eq!(force.angle.blade(), 1, "force at blade 1"); - assert_eq!( - torque.angle.blade(), - 3, - "torque at blade 3 (0+1+2=3 for wedge)" - ); - assert_eq!(torque.angle.grade(), 3, "torque at grade 3"); - - // blade 1 force is at π/3 + π/2 = 5π/6 - // angle difference: 5π/6 - π/6 = 2π/3 - let angle_diff = force.angle - position.angle; - let expected_torque = 2.0 * 10.0 * angle_diff.grade_angle().sin().abs(); - assert!( - (torque.mag - expected_torque).abs() < EPSILON, - "τ = r×F×sin(2π/3) ≈ 17.3 N·m" - ); - - // test torque conservation through nilpotency - let torque_self_wedge = torque.wedge(&torque); - assert!(torque_self_wedge.mag < EPSILON, "τ∧τ = 0 (conservation)"); - - // test perpendicular force (maximum torque) - let perpendicular_force = force.differentiate(); // rotate force by π/2 - let max_torque = position.wedge(&perpendicular_force); - - let max_angle_diff = perpendicular_force.angle - position.angle; - let expected_max = 2.0 * perpendicular_force.mag * max_angle_diff.grade_angle().sin().abs(); - assert!( - (max_torque.mag - expected_max).abs() < EPSILON, - "perpendicular τ = {:.1} N·m", - expected_max - ); - - // test parallel force (zero torque) - let parallel_force = Geonum::new_with_blade(10.0, 0, 1.0, 6.0); // blade 0, same angle as position - let zero_torque = position.wedge(¶llel_force); - - assert!( - zero_torque.mag < EPSILON, - "parallel force gives zero torque: {:.6} ≈ 0", - zero_torque.mag - ); - - // test scaling lever arm - let double_position = position.scale(2.0); // 4m lever - let double_torque = double_position.wedge(&force); - - assert!( - (double_torque.mag - 2.0 * torque.mag).abs() < EPSILON, - "doubling lever arm doubles torque" - ); - - // test high-dimensional torque - let high_position = Geonum::new_with_blade(3.0, 0, 1.0, 9.0); // blade 0 - let high_force = Geonum::new_with_blade(8.0, 1000, 2.0, 7.0); // blade 1000 - let high_torque = high_position.wedge(&high_force); - - assert_eq!( - high_torque.angle.blade(), - 1001, - "torque blade = 0+1000+1 = 1001 (wedge adds π/2)" - ); - - // compute expected magnitude - let high_angle_diff = high_force.angle - high_position.angle; - let expected_high = 3.0 * 8.0 * high_angle_diff.grade_angle().sin().abs(); - - assert!( - (high_torque.mag - expected_high).abs() < EPSILON, - "high-dim τ = 3×8×sin(angle) = {:.3}", - expected_high - ); - - // traditional mechanics: τ⃗ = r⃗ × F⃗ requires 6 component cross product - // τ⃗ = [ry*Fz - rz*Fy, rz*Fx - rx*Fz, rx*Fy - ry*Fx] O(n²) in n dimensions - // - // geonum: τ = r∧F single wedge operation - // same operation in any dimension O(1) - // conservation built-in through nilpotency τ∧τ = 0 -} - -#[test] -fn it_encodes_angular_velocity() { - let radius = Geonum::new(3.0, 0.0, 1.0); // 3m radius - let angular_rate = 2.0; // rad/s - let angular_velocity = Geonum::new_with_blade(angular_rate, 1, 0.0, 1.0); // ω at blade 1 - - // linear velocity from angular velocity-radius wedge - let linear_velocity = angular_velocity.wedge(&radius); - - assert_eq!(radius.angle.blade(), 0, "radius at blade 0"); - assert_eq!(angular_velocity.angle.blade(), 1, "ω at blade 1"); - assert_eq!( - linear_velocity.angle.blade(), - 4, - "v at blade 4 (1+0+3=4 for wedge)" - ); - - // for perpendicular ω and r: |v| = ωr - assert!( - (linear_velocity.mag - 6.0).abs() < EPSILON, - "v = ωr = 2×3 = 6 m/s" - ); - - // test double angular velocity - let double_omega = angular_velocity.scale(2.0); // 4 rad/s - let double_linear = double_omega.wedge(&radius); - - assert!( - (double_linear.mag - 12.0).abs() < EPSILON, - "v(2ω) = 2ωr = 4×3 = 12 m/s" - ); - - // verify linear scaling - let omega_ratio = double_linear.mag / linear_velocity.mag; - assert!( - (omega_ratio - 2.0).abs() < EPSILON, - "doubling ω doubles v: ratio = 2.0" - ); - - // test double radius - let double_radius = radius.scale(2.0); // 6m - let radius_scaled = angular_velocity.wedge(&double_radius); - - assert!( - (radius_scaled.mag - 12.0).abs() < EPSILON, - "v(2r) = ω(2r) = 2×6 = 12 m/s" - ); - - // verify radius scaling - let radius_ratio = radius_scaled.mag / linear_velocity.mag; - assert!( - (radius_ratio - 2.0).abs() < EPSILON, - "doubling r doubles v: ratio = 2.0" - ); - - // test high-dimensional angular velocity - let high_radius = Geonum::new_with_blade(4.0, 1000, 1.0, 7.0); // blade 1000 - let high_omega = Geonum::new_with_blade(1.5, 500, 0.0, 1.0); // blade 500 - let high_linear = high_omega.wedge(&high_radius); - - assert_eq!( - high_linear.angle.blade(), - 1501, - "v blade = 500+1000+1 = 1501" - ); - - // compute expected magnitude - let angle_diff = high_radius.angle - high_omega.angle; - let expected_speed = 1.5 * 4.0 * angle_diff.grade_angle().sin().abs(); - - assert!( - (high_linear.mag - expected_speed).abs() < EPSILON, - "high-dim v = 1.5×4×sin(angle) = {:.3}", - expected_speed - ); - - // test centripetal acceleration: a = ω²r - let omega_squared = angular_velocity.dot(&angular_velocity); // ω² as scalar - let centripetal = radius.scale(omega_squared.mag); - - assert!( - (centripetal.mag - 12.0).abs() < EPSILON, - "a_c = ω²r = 4×3 = 12 m/s²" - ); - - // traditional mechanics: v⃗ = ω⃗ × r⃗ requires cross product O(n²) - // centripetal a⃗ = -ω²r⃗ requires separate formulas - // - // geonum: v = ω∧r unified wedge operation - // same operation in any dimension O(1) - // centripetal naturally emerges from ω²r scaling -} - -#[test] -fn it_encodes_mass_through_scaling() { - // IMPROVED from mechanics_test.rs:918-979 - // - // PROBLEMS with original test: - // 1. weak assertion: extracted_mass.angle.grade() == 2 doesnt test the angle value - // 2. confusing: mass extraction preserving "grade 2" makes no physical sense - // 3. handwavy: "mass emerges from geometric scaling" without proving the physics - // 4. no test: momentum-impulse relationships missing - // 5. no test: mass invariance under galilean transformations - // - // IMPROVEMENTS: - // - exact angle assertions for mass scaling relationships - // - test momentum = mass × velocity with precise blade tracking - // - prove F = ma through differentiation chain: x → v → a then scale by m - // - test impulse J = FΔt = Δp relationships - // - verify mass invariance under transformations - - // fundamental test: F = ma through scaling - let mass = 4.0; // kg - let acceleration = Geonum::new_with_blade(5.0, 2, 0.0, 1.0); // 5 m/s² at blade 2 (grade 2) - let force = acceleration.scale(mass); // F = ma - - assert_eq!(force.mag, 20.0, "F = ma = 4×5 = 20 N"); - assert_eq!( - force.angle, acceleration.angle, - "force preserves acceleration angle exactly" - ); - assert_eq!( - force.angle.blade(), - 2, - "force at blade 2 (same as acceleration)" - ); - assert_eq!( - force.angle.rem(), - 0.0, - "force angle value = 0 within π/2 segment" - ); - - // test momentum p = mv at velocity blade level - let velocity = Geonum::new_with_blade(3.0, 1, 0.0, 1.0); // 3 m/s at blade 1 (grade 1) - let momentum = velocity.scale(mass); // p = mv - - assert_eq!(momentum.mag, 12.0, "p = mv = 4×3 = 12 kg·m/s"); - assert_eq!( - momentum.angle, velocity.angle, - "momentum preserves velocity angle exactly" - ); - assert_eq!( - momentum.angle.blade(), - 1, - "momentum at blade 1 (velocity level)" - ); - - // prove F = dp/dt through differentiation - let momentum_rate = momentum.differentiate(); // dp/dt adds π/2 rotation - - assert_eq!( - momentum_rate.angle.blade(), - 2, - "dp/dt at blade 2 (force level)" - ); - assert_eq!( - momentum_rate.mag, momentum.mag, - "differentiation preserves magnitude" - ); - // momentum_rate represents force when time-scaled appropriately - - // test impulse-momentum theorem: J = FΔt = Δp - let delta_t = 0.5; // seconds - let impulse = force.scale(delta_t); // J = FΔt - - assert_eq!(impulse.mag, 10.0, "J = FΔt = 20×0.5 = 10 N·s"); - assert_eq!( - impulse.angle.blade(), - force.angle.blade(), - "impulse preserves force blade" - ); - - // impulse changes momentum - let initial_momentum = momentum; - let final_momentum = initial_momentum + impulse.copy_blade(&initial_momentum); // match blade levels - let delta_p = final_momentum.mag - initial_momentum.mag; - - assert!( - (delta_p - impulse.mag).abs() < EPSILON, - "Δp = J confirmed: {:.3} ≈ {:.3}", - delta_p, - impulse.mag - ); - - // test mass extraction: m = F/a - let extracted_mass_fa = force.mag / acceleration.mag; - assert_eq!(extracted_mass_fa, mass, "m = F/a = 20/5 = 4 kg"); - - // test mass extraction: m = p/v - let extracted_mass_pv = momentum.mag / velocity.mag; - assert_eq!(extracted_mass_pv, mass, "m = p/v = 12/3 = 4 kg"); - - // prove mass invariance under rotation - let rotation = Angle::new(1.0, 3.0); // π/3 rotation - let rotated_velocity = velocity.rotate(rotation); - let rotated_momentum = rotated_velocity.scale(mass); - let rotated_extracted_mass = rotated_momentum.mag / rotated_velocity.mag; - - assert_eq!( - rotated_extracted_mass, mass, - "mass invariant under rotation: 4 kg" - ); - assert_eq!( - rotated_momentum.angle, rotated_velocity.angle, - "rotated momentum preserves rotated velocity angle" - ); - - // test kinetic energy: KE = ½mv² - let v_squared = velocity.dot(&velocity); // v·v = |v|² - let kinetic_energy = 0.5 * mass * v_squared.mag; - - assert_eq!(kinetic_energy, 18.0, "KE = ½mv² = 0.5×4×9 = 18 J"); - - // test mass in high dimensions - prove scaling works everywhere - let high_dim_accel = Geonum::new_with_blade(7.0, 1000, 1.0, 8.0); // blade 1000 - let high_dim_force = high_dim_accel.scale(mass); - - assert_eq!( - high_dim_force.mag, 28.0, - "F = ma = 4×7 = 28 N in high dimension" - ); - assert_eq!( - high_dim_force.angle, high_dim_accel.angle, - "force preserves acceleration angle in dimension 1000" - ); - assert_eq!(high_dim_force.angle.blade(), 1000, "force at blade 1000"); - - // test relativistic mass scaling (simplified) - // at high velocity, mass increases by γ = 1/√(1-v²/c²) - let c = 299792458.0_f64; // speed of light m/s - let high_v = 0.8 * c; // 80% speed of light - let v_over_c = high_v / c; - let gamma = 1.0 / (1.0 - v_over_c * v_over_c).sqrt(); // ≈ 1.667 - let relativistic_mass = mass * gamma; - - assert!( - (relativistic_mass - 6.667).abs() < 0.01, - "relativistic mass ≈ 6.67 kg at 0.8c" - ); - - // KEY INSIGHTS: - // 1. mass is pure scalar - just a magnitude that scales other quantities - // 2. F = ma and p = mv preserve the angle of acceleration/velocity exactly - // 3. differentiation chain proves F = dp/dt geometrically - // 4. impulse-momentum theorem emerges from scaling relationships - // 5. mass extraction is simple division of magnitudes - // 6. kinetic energy comes from dot product (projection) - - // traditional mechanics: mass as fundamental property with separate equations - // geonum: mass as scaling factor in geometric relationships - // but mass itself has no blade/angle - its pure magnitude scaling -} - -#[test] -fn it_encodes_rotational_inertia() { - // IMPROVED from mechanics_test.rs:980-1051 - // - // PROBLEMS with original test: - // 1. workaround code: negative length check for dot product (lines 1000-1004) - // 2. weak physics: no test of angular momentum L = Iω - // 3. weak physics: no test of rotational kinetic energy KE = ½Iω² - // 4. confusing: "inertia emerges from geometric mass-radius relationships" vague - // 5. no test: parallel axis theorem I = I_cm + md² - // 6. no test: perpendicular axis theorem for planar objects - // - // IMPROVEMENTS: - // - clean dot product usage without workarounds - // - test angular momentum L = Iω with exact blade tracking - // - test rotational kinetic energy KE = ½Iω² - // - prove parallel axis theorem geometrically - // - test conservation of angular momentum L∧L = 0 - - // fundamental test: moment of inertia I = mr² - let mass = 2.0; // kg - let radius = Geonum::new(3.0, 0.0, 1.0); // 3m from rotation axis - - // compute I = mr² through dot product - let r_squared = radius.dot(&radius); // r·r = |r|² - let inertia = mass * r_squared.mag; - - assert_eq!(inertia, 18.0, "I = mr² = 2×9 = 18 kg·m²"); - assert_eq!(r_squared.angle.grade(), 0, "r·r produces scalar at grade 0"); - - // test angular momentum L = Iω - let omega = Geonum::new_with_blade(1.5, 1, 0.0, 1.0); // 1.5 rad/s at blade 1 - let angular_momentum = omega.scale(inertia); // L = Iω - - assert_eq!(angular_momentum.mag, 27.0, "L = Iω = 18×1.5 = 27 kg·m²/s"); - assert_eq!( - angular_momentum.angle, omega.angle, - "L preserves ω angle exactly" - ); - assert_eq!( - angular_momentum.angle.blade(), - 1, - "L at blade 1 (same as ω)" - ); - - // test rotational kinetic energy KE = ½Iω² - let omega_squared = omega.dot(&omega); // ω·ω = |ω|² - let rotational_ke = 0.5 * inertia * omega_squared.mag; - - assert_eq!( - rotational_ke, 20.25, - "KE_rot = ½Iω² = 0.5×18×2.25 = 20.25 J" - ); - - // test torque-angular acceleration: τ = Iα - let alpha = Geonum::new_with_blade(2.5, 2, 0.0, 1.0); // 2.5 rad/s² at blade 2 - let torque = alpha.scale(inertia); // τ = Iα - - assert_eq!(torque.mag, 45.0, "τ = Iα = 18×2.5 = 45 N·m"); - assert_eq!(torque.angle, alpha.angle, "τ preserves α angle exactly"); - assert_eq!(torque.angle.blade(), 2, "τ at blade 2 (same as α)"); - - // prove dL/dt = τ through differentiation - let l_rate = angular_momentum.differentiate(); // dL/dt adds π/2 - - assert_eq!(l_rate.angle.blade(), 2, "dL/dt at blade 2 (torque level)"); - assert_eq!( - l_rate.mag, angular_momentum.mag, - "differentiation preserves magnitude" - ); - // l_rate represents torque when properly scaled - - // test conservation of angular momentum through nilpotency - let l_wedge_l = angular_momentum.wedge(&angular_momentum); - assert!( - l_wedge_l.mag < EPSILON, - "L∧L = 0 (angular momentum conservation)" - ); - - // test parallel axis theorem: I = I_cm + md² - let center_of_mass_radius = Geonum::new(1.0, 0.0, 1.0); // 1m from CM - let distance_to_new_axis = Geonum::new(2.0, 0.0, 1.0); // 2m from CM to new axis - - let i_cm = mass * center_of_mass_radius.dot(¢er_of_mass_radius).mag; // 2×1 = 2 - let d_squared = distance_to_new_axis.dot(&distance_to_new_axis).mag; // 4 - let i_parallel = i_cm + mass * d_squared; - - assert_eq!(i_cm, 2.0, "I_cm = mr_cm² = 2×1 = 2 kg·m²"); - assert_eq!( - i_parallel, 10.0, - "I_parallel = I_cm + md² = 2 + 2×4 = 10 kg·m²" - ); - - // test scaling: doubling radius quadruples inertia - let double_radius = radius.scale(2.0); // 6m - let double_r_squared = double_radius.dot(&double_radius); - let scaled_inertia = mass * double_r_squared.mag; - - assert_eq!(scaled_inertia, 72.0, "I(2r) = m(2r)² = 2×36 = 72 kg·m²"); - assert_eq!(scaled_inertia / inertia, 4.0, "doubling r quadruples I"); - - // test high-dimensional rotational inertia - let high_dim_radius = Geonum::new_with_blade(4.0, 1000, 1.0, 7.0); // blade 1000 - let high_dim_r_squared = high_dim_radius.dot(&high_dim_radius); - let high_dim_inertia = mass * high_dim_r_squared.mag; - - assert_eq!( - high_dim_inertia, 32.0, - "I = mr² = 2×16 = 32 kg·m² in dimension 1000" - ); - assert_eq!( - high_dim_r_squared.angle.grade(), - 0, - "r·r scalar even at blade 1000" - ); - - // test angular impulse: ΔL = τΔt - let delta_t = 0.3; // seconds - let angular_impulse = torque.scale(delta_t); // τΔt - - assert_eq!( - angular_impulse.mag, 13.5, - "ΔL = τΔt = 45×0.3 = 13.5 kg·m²/s" - ); - assert_eq!( - angular_impulse.angle.blade(), - torque.angle.blade(), - "angular impulse preserves torque blade" - ); - - // KEY INSIGHTS: - // 1. dot product r·r gives clean |r|² without sign issues - // 2. complete rotational dynamics: L = Iω, KE = ½Iω², τ = Iα - // 3. conservation through nilpotency: L∧L = 0 - // 4. parallel axis theorem proven geometrically - // 5. angular impulse-momentum relationship tested - // 6. inertia is scalar quantity (mass × length²) - - // traditional mechanics: I = ∫r²dm requires mass distribution integration - // geonum: I = mr² through simple dot product r·r - // rotational dynamics emerge from scaling and differentiation -} - -#[test] -fn it_handles_energy_conservation() { - // IMPROVED from mechanics_test.rs:1053-1135 - // - // PROBLEMS with original: - // 1. workaround code for negative dot product (lines 1069-1073, 1077-1081, etc) - // 2. confusing claim: "energy conservation emerges from geometric nilpotency" - // 3. misunderstands physics: E∧E = 0 doesnt prove energy conservation - // 4. no test of actual energy conservation during motion - // 5. gravity field at "π" makes no physical sense - // - // IMPROVEMENTS: - // - clean dot product usage - // - test actual energy conservation: E_initial = E_final - // - test work-energy theorem: W = ΔKE - // - test pendulum energy exchange between KE and PE - // - remove misleading nilpotency claims - - let mass = 3.0; // kg - let g = 9.8; // m/s² gravitational acceleration - - // test 1: falling object energy conservation - // initial state: height h, velocity 0 - let initial_height = 10.0; // meters - let initial_velocity = 0.0; // m/s (at rest) - - let initial_pe = mass * g * initial_height; // mgh - let initial_ke = 0.5 * mass * initial_velocity * initial_velocity; // ½mv² - let total_energy = initial_pe + initial_ke; - - assert_eq!(initial_pe, 294.0, "PE = mgh = 3×9.8×10 = 294 J"); - assert_eq!(initial_ke, 0.0, "KE = 0 (at rest)"); - assert_eq!(total_energy, 294.0, "E_total = PE + KE = 294 J"); - - // after falling to height 4m - let final_height = 4.0; // meters - let height_fallen = initial_height - final_height; // 6 meters - - // use conservation of energy to find final velocity - // E_initial = E_final - // mgh_i + ½mv_i² = mgh_f + ½mv_f² - // solving for v_f: v_f = √(2g(h_i - h_f)) - let final_velocity = (2.0_f64 * g * height_fallen).sqrt(); - - let final_pe = mass * g * final_height; - let final_ke = 0.5 * mass * final_velocity * final_velocity; - let final_total = final_pe + final_ke; - - assert!( - (final_pe - 117.6).abs() < EPSILON, - "PE = mgh = 3×9.8×4 = 117.6 J" - ); - assert!( - (final_ke - 176.4).abs() < EPSILON, - "KE = ½mv² = 0.5×3×10.84² = 176.4 J" - ); - assert!( - (final_total - total_energy).abs() < EPSILON, - "energy conserved: E_final = {} ≈ E_initial = {}", - final_total, - total_energy - ); - - // test 2: work-energy theorem W = ΔKE - let force = Geonum::new(15.0, 0.0, 1.0); // 15 N at blade 0 (same direction as displacement) - let displacement = Geonum::new(4.0, 0.0, 1.0); // 4 m displacement - - // work = force · displacement (dot product) - let work = force.dot(&displacement); - assert_eq!(work.mag, 60.0, "W = F·d = 15×4 = 60 J"); - assert_eq!(work.angle.grade(), 0, "work is scalar at grade 0"); - - // if this work accelerates object from rest - // W = ΔKE = ½mv_f² - 0 - // v_f = √(2W/m) - let final_speed_from_work = (2.0 * work.mag / mass).sqrt(); - let ke_from_work = 0.5 * mass * final_speed_from_work * final_speed_from_work; - - assert!( - (ke_from_work - work.mag).abs() < 1e-10, - "work-energy theorem: W = ΔKE = {} J", - work.mag - ); - - // test 3: pendulum energy exchange - let pendulum_length = 2.0; // meters - let max_angle = Angle::new(1.0, 3.0); // π/3 radians (60°) - - // at maximum displacement: all PE, no KE - let max_height = pendulum_length * (1.0 - max_angle.grade_angle().cos()); // h = L(1 - cos θ) - let pe_max = mass * g * max_height; - - // at bottom: all KE, no PE (taking bottom as h=0) - let ke_bottom = pe_max; // energy conserved - let velocity_bottom = (2.0 * ke_bottom / mass).sqrt(); - - assert!( - (max_height - 1.0).abs() < EPSILON, - "h_max = L(1-cos60°) = 2×0.5 = 1 m" - ); - assert!( - (pe_max - 29.4).abs() < EPSILON, - "PE_max = mgh = 3×9.8×1 = 29.4 J" - ); - assert_eq!(ke_bottom, pe_max, "energy exchanges: PE_max → KE_bottom"); - assert!( - (velocity_bottom - 4.427).abs() < 0.001, - "v_bottom = √(2×KE/m) ≈ 4.43 m/s" - ); - - // test 4: spring potential energy U = ½kx² - let spring_k = 100.0; // N/m spring constant - let compression = 0.3; // meters - - let spring_pe = 0.5 * spring_k * compression * compression; - assert_eq!(spring_pe, 4.5, "U_spring = ½kx² = 0.5×100×0.09 = 4.5 J"); - - // release spring: PE → KE - let velocity_from_spring = (2.0 * spring_pe / mass).sqrt(); - assert!( - (velocity_from_spring - 1.732).abs() < 0.001, - "v = √(2U/m) = √(9/3) ≈ 1.73 m/s" - ); - - // test 5: power P = dE/dt - let energy_rate = 75.0; // watts (J/s) - let time_interval = 4.0; // seconds - let energy_delivered = energy_rate * time_interval; - - assert_eq!(energy_delivered, 300.0, "E = P×t = 75×4 = 300 J"); - - // test 6: nilpotency expresses conservation - // conserved quantities satisfy Q∧Q = 0 - let total_energy_geonum = Geonum::new(total_energy, 0.0, 1.0); - let energy_nilpotent = total_energy_geonum.wedge(&total_energy_geonum); - assert!( - energy_nilpotent.mag < EPSILON, - "E∧E = 0 expresses conservation" - ); - - // angular momentum also conserved → nilpotent - let l = Geonum::new(27.0, 1.0, 2.0); // angular momentum at π/2 - let l_nilpotent = l.wedge(&l); - assert!( - l_nilpotent.mag < EPSILON, - "L∧L = 0 expresses angular momentum conservation" - ); - - // KEY INSIGHTS: - // 1. energy conservation: E_initial = E_final for isolated systems - // 2. work-energy theorem: W = F·d = ΔKE - // 3. energy exchange: PE ↔ KE during motion - // 4. power is energy rate: P = dE/dt - // 5. nilpotency Q∧Q = 0 concisely expresses conservation of quantity Q - - // conservation is physical law observed through measurement - // nilpotency provides geometric expression of that conservation -} - -#[test] -fn it_handles_momentum_conservation() { - // test 1: two-body collision (elastic) - let m1 = 2.0; // kg - let m2 = 3.0; // kg - - // initial velocities - let v1_initial = Geonum::new_with_blade(5.0, 1, 0.0, 1.0); // 5 m/s at blade 1 - let v2_initial = Geonum::new_with_blade(-2.0, 1, 0.0, 1.0); // -2 m/s (opposite direction) - - // initial momenta - let p1_initial = v1_initial.scale(m1); // p = mv - let p2_initial = v2_initial.scale(m2); - - // total momentum (vector addition) - let p_total = p1_initial + p2_initial; - - assert_eq!(p1_initial.mag, 10.0, "p1 = m1×v1 = 2×5 = 10 kg·m/s"); - assert_eq!(p2_initial.mag.abs(), 6.0, "p2 = |m2×v2| = 3×2 = 6 kg·m/s"); - assert_eq!(p_total.mag, 4.0, "p_total = 10 - 6 = 4 kg·m/s"); - - // after elastic collision (example final velocities) - // conservation requires: m1v1f + m2v2f = m1v1i + m2v2i - let v1_final = Geonum::new_with_blade(-1.0, 1, 0.0, 1.0); // -1 m/s - let v2_final = Geonum::new_with_blade(2.0, 1, 0.0, 1.0); // 2 m/s - - let p1_final = v1_final.scale(m1); - let p2_final = v2_final.scale(m2); - let p_total_final = p1_final + p2_final; - - assert_eq!( - p_total_final.mag, 4.0, - "momentum conserved: p_total unchanged" - ); - assert_eq!( - p_total_final.angle, p_total.angle, - "momentum direction preserved" - ); - - // test 2: nilpotency expresses momentum conservation - let p_nilpotent = p_total.wedge(&p_total); - assert!( - p_nilpotent.mag < EPSILON, - "p∧p = 0 expresses momentum conservation" - ); - - // test 3: rocket propulsion (variable mass) - let rocket_mass = 1000.0; // kg - let exhaust_velocity = 3000.0; // m/s relative to rocket - let mass_flow_rate = 10.0; // kg/s - - let rocket_velocity = Geonum::new_with_blade(100.0, 1, 0.0, 1.0); // 100 m/s - let rocket_momentum = rocket_velocity.scale(rocket_mass); - - assert_eq!( - rocket_momentum.mag, 100000.0, - "p_rocket = mv = 1000×100 = 100000 kg·m/s" - ); - - // after burning fuel for 1 second - let ejected_mass = mass_flow_rate * 1.0; // 10 kg - let new_rocket_mass = rocket_mass - ejected_mass; - - // tsiolkovsky rocket equation: Δv = v_exhaust × ln(m0/m1) - let mass_ratio = rocket_mass / new_rocket_mass; - let delta_v = exhaust_velocity * mass_ratio.ln(); - - assert!((delta_v - 30.1).abs() < 0.1, "Δv ≈ 3000×ln(1.01) ≈ 30 m/s"); - - // test 4: center of mass momentum - // for system of particles, p_cm = M_total × v_cm - let particle_masses = [1.0, 2.0, 3.0]; // kg - let particle_velocities = [ - Geonum::new_with_blade(3.0, 1, 0.0, 1.0), - Geonum::new_with_blade(1.0, 1, 0.0, 1.0), - Geonum::new_with_blade(-1.0, 1, 0.0, 1.0), - ]; - - let total_mass: f64 = particle_masses.iter().sum(); - let mut total_momentum = Geonum::new_with_blade(0.0, 1, 0.0, 1.0); - - for (m, v) in particle_masses.iter().zip(particle_velocities.iter()) { - total_momentum = total_momentum + v.scale(*m); - } - - let v_cm = total_momentum.scale(1.0 / total_mass); - assert_eq!(total_momentum.mag, 2.0, "p_total = 3 + 2 - 3 = 2 kg·m/s"); - assert!( - (v_cm.mag - 0.333).abs() < 0.001, - "v_cm = p_total/M = 2/6 ≈ 0.33 m/s" - ); - - // test 5: angular momentum also conserved → nilpotent - let r = Geonum::new(3.0, 0.0, 1.0); // position vector - let p = Geonum::new_with_blade(4.0, 1, 0.0, 1.0); // momentum - let l = r.wedge(&p); // L = r × p - - let l_nilpotent = l.wedge(&l); - assert!( - l_nilpotent.mag < EPSILON, - "L∧L = 0 expresses angular momentum conservation" - ); - - // KEY INSIGHTS: - // 1. momentum conservation: p_initial = p_final in isolated systems - // 2. nilpotency p∧p = 0 geometrically expresses conservation - // 3. center of mass momentum: p_cm = M_total × v_cm - // 4. angular momentum L = r × p also conserved and nilpotent - // 5. conservation holds even with variable mass (rockets) -} - -#[test] -fn it_handles_angular_momentum_conservation() { - // test 1: spinning figure skater - // pulling arms in: smaller r, faster ω to conserve L = Iω - let initial_radius = 1.5; // m (arms extended) - let final_radius = 0.5; // m (arms pulled in) - let mass = 60.0; // kg - - // initial angular velocity - let initial_omega = Geonum::new_with_blade(2.0, 1, 0.0, 1.0); // 2 rad/s at blade 1 - - // moment of inertia I = mr² - let initial_inertia = mass * initial_radius * initial_radius; - let final_inertia = mass * final_radius * final_radius; - - // angular momentum L = Iω - let l_initial = initial_omega.scale(initial_inertia); - - // conservation: L_initial = L_final - // so ω_final = L_initial / I_final - let omega_final_magnitude = l_initial.mag / final_inertia; - let omega_final = Geonum::new_with_blade(omega_final_magnitude, 1, 0.0, 1.0); - - assert_eq!( - initial_inertia, 135.0, - "I_initial = mr² = 60×1.5² = 135 kg·m²" - ); - assert_eq!(final_inertia, 15.0, "I_final = mr² = 60×0.5² = 15 kg·m²"); - assert_eq!(l_initial.mag, 270.0, "L = Iω = 135×2 = 270 kg·m²/s"); - assert_eq!(omega_final.mag, 18.0, "ω_final = L/I = 270/15 = 18 rad/s"); - - // test 2: nilpotency expresses conservation - let l_wedge_l = l_initial.wedge(&l_initial); - assert!( - l_wedge_l.mag < EPSILON, - "L∧L = 0 expresses angular momentum conservation" - ); - - // test 3: planetary orbit (Kepler's second law) - // equal areas swept in equal times → L conservation - let r1 = Geonum::new(1.0e11, 0.0, 1.0); // 1 AU from sun - let v1 = Geonum::new_with_blade(30000.0, 1, 1.0, 2.0); // 30 km/s at π/2 to radius - - let r2 = Geonum::new(1.5e11, 0.0, 1.0); // 1.5 AU (farther) - // conservation: r1×v1 = r2×v2, so v2 = v1×(r1/r2) - let v2_magnitude = v1.mag * (r1.mag / r2.mag); - let v2 = Geonum::new_with_blade(v2_magnitude, 1, 1.0, 2.0); - - let l1 = r1.wedge(&v1); - let l2 = r2.wedge(&v2); - - assert_eq!( - v2.mag, 20000.0, - "v2 = v1×(r1/r2) = 30000×(1/1.5) = 20000 m/s" - ); - assert!( - (l1.mag - l2.mag).abs() < 1e15, - "orbital angular momentum conserved" - ); - - // test 4: gyroscope precession - // torque τ = dL/dt causes precession, not change in |L| - let spin_l = Geonum::new_with_blade(10.0, 3, 0.0, 1.0); // spinning top L at blade 3 - let torque = Geonum::new_with_blade(0.5, 2, 1.0, 2.0); // small torque at blade 2 - - // precession rate Ω = τ/L - let precession_rate = torque.mag / spin_l.mag; - assert_eq!(precession_rate, 0.05, "Ω = τ/L = 0.5/10 = 0.05 rad/s"); - - // L magnitude unchanged during precession - let dt = 0.1; // small time step - let d_l = torque.scale(dt); // dL = τ×dt - let l_new = spin_l + d_l; - - // magnitude approximately preserved for small precession - assert!( - (l_new.mag - spin_l.mag).abs() < 0.1, - "gyroscope |L| approximately constant during precession" - ); - - // test 5: collision with rotation - // ball hits rod at distance d from pivot - let ball_mass = 0.5; // kg - let ball_velocity = Geonum::new_with_blade(10.0, 1, 0.0, 1.0); // 10 m/s - let impact_distance = 0.8; // m from pivot - - // angular momentum imparted: L = r × p = d × mv - let ball_momentum = ball_velocity.scale(ball_mass); - let impact_position = Geonum::new(impact_distance, 0.0, 1.0); - let angular_impulse = impact_position.wedge(&ball_momentum); - - assert_eq!(ball_momentum.mag, 5.0, "p_ball = mv = 0.5×10 = 5 kg·m/s"); - assert_eq!(angular_impulse.mag, 4.0, "L = r×p = 0.8×5 = 4 kg·m²/s"); - - // rod begins rotating to conserve angular momentum - let rod_inertia = 2.0; // kg·m² about pivot - let rod_omega = angular_impulse.mag / rod_inertia; - assert_eq!(rod_omega, 2.0, "ω_rod = L/I = 4/2 = 2 rad/s"); - - // KEY INSIGHTS: - // 1. angular momentum L = r×p = Iω conserved without external torque - // 2. nilpotency L∧L = 0 expresses conservation geometrically - // 3. figure skater: smaller r → larger ω to conserve L - // 4. planetary orbits: Kepler's second law from L conservation - // 5. gyroscope: torque causes precession, |L| stays constant } diff --git a/tests/multivalued_test.rs b/tests/multivalued_test.rs new file mode 100644 index 0000000..bf4f8f5 --- /dev/null +++ b/tests/multivalued_test.rs @@ -0,0 +1,150 @@ +// the multivalued function is a scalar artifact +// +// complex analysis carries a whole apparatus for functions that "take multiple +// values" — branch cuts (choose a ray, tear the plane along it), principal +// values (pick one value, accept a discontinuity), riemann surfaces (glue the +// torn sheets into a spiral staircase so analysis works again). every piece of +// that apparatus re-adds the winding the representation dropped: +// +// - log z = ln r + iθ is "multivalued" only because θ was stored mod 2π. the +// angle geonum stores rides the winding line, so log is single-valued on +// the staircase — the riemann surface IS the blade +// - the branch cut is where the mod-2π tear lands: the principal value reads +// identical numbers for a point and that point carried once around the +// origin, then papers over the collapse with a discontinuity +// - √z has two sheets because pow(1/2) halves the angle: one loop of the +// base (blade +4) moves the root by π — the OTHER root. the ± ceremony is +// one dropped bit of winding, the monodromy group ℤ/2 is blade parity +// - the cube root cycles ℤ/3: one loop per root, three loops home +// +// run: cargo test --test multivalued_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// the full stored angle — winding included, the coordinate on the staircase +fn total(a: Angle) -> f64 { + a.blade() as f64 * PI / 2.0 + a.rem() +} + +// the principal-value foil: Im(Log z) recovered from cartesian shadows by +// atan2 — the conventional route, blind past one turn +fn principal_arg(z: &Geonum) -> f64 { + let (c, s) = z.angle.cos_sin(); + (z.mag * s).atan2(z.mag * c) +} + +#[test] +fn it_dissolves_the_branch_cut_by_keeping_the_winding() { + let z = Geonum::new(2.0, 1.0, 3.0); // [2, π/3] + let looped = z.rotate(Angle::new(2.0, 1.0)); // once around the origin + + // the geonum log distinguishes them: Im(log) climbs exactly 2π — the loop + // is data, blade 4 of it + assert_eq!( + looped.angle.blade(), + z.angle.blade() + 4, + "one loop = four quarter-turns of stored winding" + ); + assert!( + (total(looped.angle) - total(z.angle) - 2.0 * PI).abs() < 1e-12, + "Im(log) climbs 2π per loop — single-valued on the staircase" + ); + + // the principal value cannot: both points project to identical shadows, + // so atan2 returns the same argument and the 2π is gone. the branch cut + // is the tear this collapse forces — a property of the storage, not of log + assert!( + (principal_arg(&z) - principal_arg(&looped)).abs() < 1e-15, + "the principal value reads the loop and the point as one number" + ); +} + +#[test] +fn it_walks_the_sqrt_sheets_by_blade_parity() { + let z = Geonum::new(4.0, 2.0, 3.0); // [4, 2π/3] + + let root = z.pow(0.5); + assert!(root.near_mag(2.0), "|√z| = 2"); + assert!(root.angle.near(&Angle::new(1.0, 3.0)), "arg √z = π/3"); + + // carry z once around the origin and take the root again: the halved + // angle moves by π — the OTHER square root. the ± that algebra bolts onto + // √ is one bit of winding, read here as a position + let other_root = z.rotate(Angle::new(2.0, 1.0)).pow(0.5); + assert!(other_root.near_mag(root.mag), "same magnitude"); + assert!( + other_root.angle.is_opposite(&root.angle), + "one base loop lands the other root — π away" + ); + + // two loops return: the monodromy group ℤ/2 is blade parity + let twice = z.rotate(Angle::new(4.0, 1.0)).pow(0.5); + assert_eq!( + twice.angle.base_angle(), + root.angle.base_angle(), + "two loops of the base close the two-sheet cover" + ); +} + +#[test] +fn it_cycles_the_cube_roots_one_loop_per_root() { + let z = Geonum::new(8.0, 1.0, 2.0); // [8, π/2] + let step = Angle::new(2.0, 3.0); // 2π/3 — the root spacing + + // each loop of the base advances the cube root by one step of the cycle + let mut expected = z.pow(1.0 / 3.0); + assert!(expected.near_mag(2.0), "|∛z| = 2"); + + for k in 1..=3u32 { + let root_k = z + .rotate(Angle::new(2.0 * k as f64, 1.0)) // k loops + .pow(1.0 / 3.0); + expected = expected.rotate(step); + assert_eq!( + root_k.angle.base_angle(), + expected.angle.base_angle(), + "loop {k}: the root cycle advances one step — monodromy ℤ/3" + ); + } + + // and the third loop is home: the deck closed + let third = z.rotate(Angle::new(6.0, 1.0)).pow(1.0 / 3.0); + assert_eq!( + third.angle.base_angle(), + z.pow(1.0 / 3.0).angle.base_angle(), + "three loops of the base close the three-sheet cover" + ); +} + +#[test] +fn it_climbs_the_log_staircase_sheet_by_sheet() { + // the riemann surface of log — "an infinite spiral staircase" — is the + // external data structure analysis builds to restore the winding it + // discarded. the stored angle is born on the staircase: sheet k is blade + // 4k, and Im(log) is the winding coordinate read directly + let z = Geonum::new(1.0, 1.0, 4.0); // [1, π/4] + + let mut previous = total(z.angle); + for k in 1..=5usize { + let sheet_k = z.rotate(Angle::new(2.0 * k as f64, 1.0)); + + assert_eq!( + sheet_k.angle.base_angle(), + z.angle.base_angle(), + "sheet {k}: every sheet projects to the same shadow" + ); + assert_eq!( + sheet_k.angle.blade(), + z.angle.blade() + 4 * k, + "sheet {k}: the sheet number is the blade" + ); + + let height = total(sheet_k.angle); + assert!( + (height - previous - 2.0 * PI).abs() < 1e-9, + "sheet {k}: each step of the staircase is one 2π riser" + ); + previous = height; + } +} diff --git a/tests/music_test.rs b/tests/music_test.rs new file mode 100644 index 0000000..c7fde71 --- /dev/null +++ b/tests/music_test.rs @@ -0,0 +1,150 @@ +// the pythagorean comma is a t residue +// +// tuning theory is angle arithmetic on a log-frequency winding line: one +// octave is one full turn (blade 4), a just fifth is log₂(3/2) of a turn, and +// twenty-five centuries of temperament controversy is bookkeeping on the +// remainder: +// +// - pitch class vs pitch height is base angle vs blade: octave equivalence +// reads the angle mod one turn, the octave number IS the winding +// - stack twelve just fifths and the winding lands exactly seven octaves of +// blade — but a remainder survives: the pythagorean comma, 3¹²/2¹⁹, the t +// the lattice cannot absorb +// - equal temperament is the projection that forces closure: shave each +// fifth by comma/12 and the circle of fifths closes exactly, twelve +// lattice steps landing blade 28 dead +// - beats are the angle-difference frequency: two tones interfere at +// |f₁ − f₂|, the envelope read off wave_sum with no fourier apparatus +// +// run: cargo test --test music_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// pitch as an angle: one octave = one full turn above the reference A0 = 27.5 Hz +fn pitch(freq: f64) -> Angle { + Angle::new(2.0 * (freq / 27.5).log2(), 1.0) +} + +#[test] +fn it_overshoots_seven_octaves_by_the_comma() { + // a just fifth is log₂(3/2) of a turn. twelve of them accumulate exactly + // seven octaves of winding — blade 28 — plus a remainder no octave absorbs + let just_fifth = Angle::new(2.0 * 1.5_f64.log2(), 1.0); + + let mut stack = Angle::new(0.0, 1.0); + for _ in 0..12 { + stack = stack + just_fifth; + } + + assert_eq!( + stack.blade(), + 28, + "twelve fifths wind exactly seven octaves of blade" + ); + + // the survivor is the pythagorean comma — 3¹²/2¹⁹, computed here from the + // frequency ratio the greeks measured, not from the stack + let comma = 2.0 * PI * (3.0_f64.powi(12) / 2.0_f64.powi(19)).log2(); + assert!( + stack.near_rem(comma), + "the remainder is the comma: {:.6} rad — the t the lattice cannot absorb", + stack.rem() + ); + + // subtracting seven exact octaves isolates it as its own angle + let overshoot = stack - Angle::new(14.0, 1.0); // 7 turns = 14π + assert!( + overshoot.near_rad(comma), + "twelve fifths minus seven octaves IS the comma" + ); +} + +#[test] +fn it_closes_the_equal_tempered_circle_exactly() { + // the equal-tempered fifth is the rational lattice point 7/12 turn — 7π/6. + // twelve of them close: blade 28, remainder zero. the circle of fifths + // closes because temperament forced the fifth onto the lattice + let et_fifth = Angle::new(7.0, 6.0); + + let mut circle = Angle::new(0.0, 1.0); + for _ in 0..12 { + circle = circle + et_fifth; + } + + assert_eq!( + circle.blade(), + 28, + "the tempered circle winds seven octaves" + ); + assert!(circle.near_rem(0.0), "and closes dead — no comma"); + + // what temperament shaved: each fifth pays exactly comma/12 + let just_fifth = Angle::new(2.0 * 1.5_f64.log2(), 1.0); + let comma = 2.0 * PI * (3.0_f64.powi(12) / 2.0_f64.powi(19)).log2(); + let shave = just_fifth - et_fifth; + assert!( + shave.near_rad(comma / 12.0), + "equal temperament spreads the comma across the twelve fifths" + ); +} + +#[test] +fn it_mods_pitch_class_by_the_winding() { + // A4 and A5 are one pitch class at two heights: their angles share a base + // angle and differ by one full turn of blade. the octave number is the + // winding — blade/4 above the reference + let a4 = pitch(440.0); + let a5 = pitch(880.0); + + assert_eq!( + a4.base_angle(), + a5.base_angle(), + "same pitch class — the base angle" + ); + assert_eq!(a5.blade() - a4.blade(), 4, "one octave = one turn of blade"); + assert_eq!(a4.blade() / 4, 4, "A4 sits four octaves above A0"); + assert_eq!(a5.blade() / 4, 5, "A5 five — the height is the winding"); + + // C#5, four equal-tempered semitones up from A4, is a different class: + // the base angle moves even though the octave stays + let c_sharp_5 = pitch(440.0 * 2.0_f64.powf(4.0 / 12.0)); + assert_ne!( + c_sharp_5.base_angle(), + a4.base_angle(), + "a different pitch class lands a different base angle" + ); +} + +#[test] +fn it_beats_at_the_difference_angle() { + // 440 Hz against 442 Hz: the superposition's envelope pulses at the 2 Hz + // difference. each tone is a unit geonum at angle 2πft; the audible + // amplitude is their interference, 2|cos(π·Δf·t)| — read off wave_sum, + // no spectrum computed + let (f1, f2) = (440.0, 442.0); + + for t in [0.05, 0.1, 0.2, 0.25, 0.5] { + let tones: GeoCollection = [f1, f2] + .iter() + .map(|&f| Geonum::new_with_angle(1.0, Angle::new(2.0 * f * t, 1.0))) + .collect(); + + let envelope = 2.0 * (PI * (f2 - f1) * t).cos().abs(); + assert!( + tones.wave_sum().near_mag(envelope), + "t={t}: the amplitude rides the difference angle" + ); + } + + // the trough: a quarter beat-period in, the tones sit π apart and cancel — + // the silence between beats is destructive interference + let trough: GeoCollection = [f1, f2] + .iter() + .map(|&f| Geonum::new_with_angle(1.0, Angle::new(2.0 * f * 0.25, 1.0))) + .collect(); + assert!( + trough.wave_sum().near_mag(0.0), + "at t = 0.25 s the beat is silent — the arms cancel" + ); +} diff --git a/tests/numbers_test.rs b/tests/numbers_test.rs index 6e6455a..75641bc 100644 --- a/tests/numbers_test.rs +++ b/tests/numbers_test.rs @@ -1,5 +1,5 @@ use geonum::*; -use std::f64::consts::{PI, TAU}; +use std::f64::consts::PI; // small value for floating-point comparisons const EPSILON: f64 = 1e-10; @@ -73,38 +73,33 @@ fn its_a_vector() { #[test] fn its_a_real_number() { - // real numbers are just scalars on the real number line - // in geometric numbers, they have angle 0 (positive) or pi (negative) - - let real = Geonum::new(3.0, 0.0, 1.0); // real number as scalar - - // test addition with another real - let real2 = Geonum::new(4.0, 0.0, 1.0); // real number as scalar - - // convert to cartesian for addition - let sum_cartesian = real.mag + real2.mag; // 3 + 4 = 7 - - let sum = Geonum::new(sum_cartesian, 0.0, 1.0); // real number sum as scalar - - assert_eq!(sum.mag, 7.0); - assert_eq!(sum.angle, Angle::new(0.0, 1.0)); - - // test subtraction - let real3 = Geonum::new(10.0, 0.0, 1.0); // real number as scalar - - let real4 = Geonum::new(7.0, 0.0, 1.0); // real number as scalar - - // convert to cartesian for subtraction - let diff_cartesian = real3.mag - real4.mag; // 10 - 7 = 3 - - let diff = Geonum::new( - diff_cartesian.abs(), - if diff_cartesian >= 0.0 { 0.0 } else { 2.0 }, - if diff_cartesian >= 0.0 { 1.0 } else { 2.0 }, - ); // real number difference as scalar - - assert_eq!(diff.mag, 3.0); - assert_eq!(diff.angle, Angle::new(0.0, 1.0)); + // a real number is a scalar on a line: magnitude with angle 0 for positive, π for + // negative. addition and subtraction run through Geonum's own ops — same-angle + // magnitudes add, opposite angles (0 vs π) interfere + + let three = Geonum::new(3.0, 0.0, 1.0); // +3 + let four = Geonum::new(4.0, 0.0, 1.0); // +4 + + // same direction: the magnitudes add, the sum stays on the positive ray + let sum = three + four; + assert!(sum.near_mag(7.0), "3 + 4 = 7"); + assert_eq!(sum.angle.grade(), 0, "the sum is a positive real"); + + // subtraction is addition of the negative — 7 − 4 lands +3 on the positive ray + let seven = Geonum::new(7.0, 0.0, 1.0); + let diff = seven - four; + assert!(diff.near_mag(3.0), "7 − 4 = 3"); + assert_eq!(diff.angle.grade(), 0, "positive result on the 0 ray"); + + // a negative real sits a half turn away — 3 + (−7) lands −4 on the π ray + let neg_seven = Geonum::new(7.0, 1.0, 1.0); // −7 at angle π + let signed = three + neg_seven; + assert!(signed.near_mag(4.0), "3 + (−7) = −4"); + assert_eq!( + signed.angle.grade(), + 2, + "the negative result lands on the π ray" + ); } #[test] @@ -175,123 +170,51 @@ fn its_a_complex_number() { #[test] fn its_a_dual_number() { - // dual numbers have the form a + bε where ε² = 0 - // they're useful for automatic differentiation - - // traditional: dual numbers track f(x) and f'(x) separately - // geonum: differentiation is π/2 rotation - - // test dual number properties with automatic differentiation - let x = 3.0; - - // create function f(x) = x² - let f_x = Geonum::new(x * x, 0.0, 1.0); // f(3) = 9 - - // differentiate using geonum's automatic differentiation - let f_prime = f_x.differentiate(); // f'(x) via π/2 rotation - - // verify differentiation produces correct grade - assert_eq!(f_prime.angle.grade(), 1, "derivative at grade 1 (vector)"); - assert_eq!(f_prime.mag, 9.0, "differentiation preserves magnitude"); - - // test the dual unit property - // in dual numbers, ε represents the infinitesimal unit where ε² = 0 - // in geonum, we can represent this with angle relationships - let epsilon = Geonum::new(1.0, 2.0, 2.0); // dual unit as π angle - let epsilon_squared = epsilon * epsilon; - - // ε² should map back to scalar (blade 0 or 4) - assert_eq!(epsilon_squared.mag, 1.0); - // angle doubles: π + π = 2π ≡ 0 (mod 2π) - let angle_mod = epsilon_squared.angle.grade_angle(); - assert!( - angle_mod < EPSILON || (TAU - angle_mod) < EPSILON, - "ε² returns to scalar" - ); - - // demonstrate dual number arithmetic for f(x) = x³ - let x_cubed = x * x * x; // 27 - let f_cubic = Geonum::new(x_cubed, 0.0, 1.0); - - // derivative of x³ is 3x² = 3 * 9 = 27 - let f_cubic_prime = f_cubic.differentiate(); + // a dual number a + bε packs a value and its derivative into two slots, and ε² = 0 is the + // truncation keeping the derivative from folding onto the value. geonum needs no ε: + // differentiate is a quarter turn, so the value and its derivative sit at orthogonal + // grades — that grade separation is what ε² = 0 enforces. the derivative VALUE (2x) is + // read from the power in the angle (calculus_test::it_encodes_the_power_in_the_angle); + // here the structure is the point, not the number + + // f(x) = x² at x = 3: the value is a grade-0 scalar + let value = Geonum::new(9.0, 0.0, 1.0); // f(3) = 9 + + // the derivative is the value rotated a quarter turn — the ε slot, grade 1 + let derivative = value.differentiate(); assert_eq!( - f_cubic_prime.angle.grade(), + derivative.angle.grade(), 1, - "cubic derivative at grade 1" + "the derivative sits a quarter turn off the value" ); - assert_eq!(f_cubic_prime.mag, 27.0, "magnitude preserved as 27"); - - // second derivative: f''(x) = 6x = 18 - let f_cubic_double_prime = f_cubic_prime.differentiate(); - assert_eq!( - f_cubic_double_prime.angle.grade(), - 2, - "second derivative at grade 2" + assert!( + derivative.near_mag(value.mag), + "the quarter turn carries the magnitude, no ε algebra" ); - assert_eq!(f_cubic_double_prime.mag, 27.0, "magnitude still preserved"); - // for comparison with traditional dual numbers - // traditional: f(x+ε) = f(x) + f'(x)ε where ε² = 0 - // geonum: f.differentiate() rotates by π/2 to encode derivative - - // test with more complex function: f(x) = x² + 2x + 1 - let f_complex = Geonum::new(x * x + 2.0 * x + 1.0, 0.0, 1.0); // f(3) = 16 - let f_complex_prime = f_complex.differentiate(); - - // f'(x) = 2x + 2 = 8 at x=3 - // the magnitude is preserved, angle encodes derivative relationship - assert_eq!(f_complex_prime.mag, 16.0, "complex function magnitude"); - assert_eq!(f_complex_prime.angle.grade(), 1, "derivative grade"); - - // demonstrate dual number collection for tracking multiple derivatives - let f_dual_collection = GeoCollection::from(vec![ - Geonum::new(x * x, 0.0, 1.0), // f(x) = x² = 9 - Geonum::new(2.0 * x, 2.0, 2.0), // manually computed f'(x) = 2x = 6 at π - ]); - - // extract function value and derivative - let function_value = f_dual_collection[0].mag; // 9 - let derivative_value = f_dual_collection[1].mag; // 6 - - assert_eq!(function_value, 9.0, "f(3) = 9"); - assert_eq!(derivative_value, 6.0, "f'(3) = 6"); - - // verify the dual relationship - // in traditional dual numbers: (a + bε)² = a² + 2abε - // in geonum: angles encode this relationship geometrically - - let a = Geonum::scalar(3.0); - let b_epsilon = Geonum::new(2.0, 2.0, 2.0); // 2ε at angle π - - let dual_sum = a + b_epsilon; - let dual_squared = dual_sum * dual_sum; - - // verify the result maintains dual structure - assert!(dual_squared.mag > 0.0, "squared dual has magnitude"); - - // test chain rule with dual numbers - // for f(g(x)), derivative is f'(g(x)) * g'(x) - - let g_x = Geonum::new(2.0 * x, 0.0, 1.0); // g(x) = 2x = 6 - let f_of_g = Geonum::new(g_x.mag * g_x.mag, 0.0, 1.0); // f(g(x)) = (2x)² = 36 - - let f_of_g_prime = f_of_g.differentiate(); - assert_eq!( - f_of_g_prime.angle.grade(), - 1, - "chain rule derivative at grade 1" + // value ⊥ derivative — the dot vanishes. that orthogonality is what ε² = 0 buys the + // scalar dual number: the value and derivative slots cannot mix + assert!( + value.dot(&derivative).near_mag(0.0), + "value ⊥ derivative — the ε² = 0 separation, geometric" ); - assert_eq!(f_of_g_prime.mag, 36.0, "chain rule preserves magnitude"); - // key insight: dual numbers in traditional math require ε² = 0 constraint - // geonum achieves this naturally through angle arithmetic - // π/2 rotations encode differentiation without special dual algebra - - println!("Dual number autodiff via π/2 rotation:"); - println!(" f(x) = x² at x=3: {}", f_x.mag); - println!(" f'(x) via rotation: grade {}", f_prime.angle.grade()); - println!(" No ε² = 0 constraint needed"); + // ε² = 0 stops the scalar dual number at first order. geonum does not stop: differentiate + // on and the grade cycles 1 → 2 → 3 → 0, the higher derivatives landing in their own + // slots where the truncated dual number has nothing + let mut d = value; + for expected in [1usize, 2, 3, 0] { + d = d.differentiate(); + assert_eq!( + d.angle.grade(), + expected, + "differentiation cycles the grade past ε's first order" + ); + } + assert!( + d.near_mag(value.mag), + "magnitude preserved through the full cycle" + ); } #[test] @@ -503,26 +426,10 @@ fn its_a_tensor() { ); // === METRIC TENSOR === - // traditional: gᵢⱼ matrix for inner products - // geonum: metric emerges from angle relationships - - // minkowski metric: timelike = π angle (negative), spacelike = 0 angle (positive) - let timelike = Geonum::new(1.0, 1.0, 1.0); // π angle - let spacelike = Geonum::new(1.0, 0.0, 1.0); // 0 angle - - // inner product automatically handles metric signature - let interval = timelike * timelike + spacelike * spacelike; - - // timelike² gives negative contribution (cos(π) = -1) - // spacelike² gives positive contribution (cos(0) = 1) - // both squares have length 1 at angle 0, sum gives length 2 - assert_eq!(interval.mag, 2.0, "interval magnitude"); - assert_eq!(interval.angle.rem(), 0.0, "interval at angle 0"); - assert_eq!( - interval.angle.grade(), - 0, - "interval at scalar grade (blade 4)" - ); + // the metric signature is a π rotation, not a matrix of inner products: a basis squares to + // + or − by the angle it sits at, and a squared time axis at π/2 lands the negative grade. + // that proof lives in spacetime_test::its_a_metric_signature — deferred here so the tensor + // suite stays on tensor operations // === CHRISTOFFEL SYMBOLS === // traditional: Γⁱⱼₖ connection coefficients, O(n³) storage @@ -657,51 +564,38 @@ fn it_dualizes_log2_geometric_algebra_components() { } #[test] -fn it_keeps_information_entropy_zero() { - // information entropy measures uncertainty or randomness in a system - // a key property of geometric numbers is that dualization preserves information - // meaning two dual geonums contain exactly the same information - - // create a geometric number - let g1 = Geonum::new(3.0, 2.0, 3.0); // π/3 angle, blade 0 (scalar) - - // create a dual geometric number - // which is perpendicular to the original in angle - let g2 = Geonum::new_with_angle( - g1.mag, - g1.angle + Angle::new(1.0, 2.0), // add π/2 for dual - ); - - // demonstrate that these dual numbers preserve all original information - // we can recover the original from its dual - let recovered = Geonum::new_with_angle( - g2.mag, - g2.angle - Angle::new(1.0, 2.0), // subtract π/2 to recover - ); +fn it_dualizes_as_a_magnitude_preserving_involution() { + // the dual loses nothing because it is a magnitude-preserving involution: dual(dual(x)) + // returns x's grade and length untouched, in any dimension, where the Hodge star k→(n−k) + // needs the metric and the dimension count. the recovery is not a trivial unrotate — the + // dual genuinely moves (a half turn away) and comes back - // test that the recovered geonum equals the original - assert!(g1.near_mag(recovered.mag)); - assert_eq!(g1.angle, recovered.angle); + let g = Geonum::new(3.0, 2.0, 3.0); // [3, 2π/3], grade 1 + let d = g.dual(); // adds π, two blades - // compute the entropy of transformation between the original and its dual - // in classical information theory, the entropy formula is: -∑p_i * log2(p_i) - // but for a perfect dualization, this equals 0 (no information is lost) - - // reconstruct original data from both geonums - let original_data = (g1.mag, g1.angle.grade_angle()); - let dual_data = (g2.mag, g2.angle.grade_angle() - PI / 2.0); - - // compute difference (represents information loss if any) - let length_diff = (original_data.0 - dual_data.0).abs(); - let angle_diff = (original_data.1 - dual_data.1).abs(); + // the dual moved — a half turn away, not the identity dressed up + assert!( + d.angle.is_opposite(&g.angle), + "the dual is a half turn away, a real move" + ); + assert!(d.near_mag(g.mag), "and it keeps the length"); - // test that the entropy is zero (perfect information preservation) - assert!(length_diff < EPSILON); - assert!(angle_diff < EPSILON); + // dual of dual returns the original grade and magnitude — an involution, nothing lost + let back = d.dual(); + assert_eq!( + back.angle.grade(), + g.angle.grade(), + "dual∘dual returns the grade" + ); + assert!(back.near_mag(g.mag), "and the magnitude"); - // this demonstrates why geonum is so efficient: the dual representation - // preserves 100% of the information while enabling O(1) operations - // across any number of dimensions, keeping entropy at zero + // it holds a million dimensions out, where Hodge's k→(n−k) would need the dimension + let hi = Geonum::new_with_angle(3.0, Angle::new_with_blade(1_000_000, 2.0, 3.0)); + assert_eq!( + hi.dual().dual().angle.grade(), + hi.angle.grade(), + "involutive at any blade, no dimension consulted" + ); } #[test] @@ -766,151 +660,76 @@ fn its_a_bernoulli_number() { } #[test] -fn its_a_quadrature() { - // in geonum, quadrature refers to the perpendicular relationship between - // a geometric number and its dual (rotated by π/2) - // this is fundamental to how geonum represents mathematical operations - - // create a function f(x) = x² as a geonum transformation - let f = |x: Geonum| -> Geonum { - // square the input using geonum's multiplication - // for a geonum [r, θ], squaring gives [r², 2θ] - x * x - }; - - // exact result for ∫[0,1] x²dx = 1/3 - let exact_result = 1.0 / 3.0; - - // traditional numerical integration would sample multiple points - // but with geonum, we can use the fundamental theorem of calculus directly - // since differentiation is just rotation by π/2, integration is rotation by -π/2 - - // demonstrate geonum's geometric integration - // in geonum, integration rotates by -π/2, which is the inverse of differentiation - - // for the integral ∫x² dx = x³/3, we can demonstrate this geometrically - - // the antiderivative involves x³/3 - // but the key insight is that integration rotates the result by -π/2 - let antiderivative = |x: Geonum| -> Geonum { - // compute x³/3 - let x_cubed_over_3 = (x * x * x) / Geonum::new(3.0, 0.0, 1.0); - // integrate rotates by -π/2 - x_cubed_over_3.integrate() - }; - - // for bounds [0, 1], evaluate F(1) - F(0) - let upper = Geonum::new(1.0, 0.0, 1.0); - let lower = Geonum::new(0.0, 0.0, 1.0); - - let f_upper = antiderivative(upper); - let f_lower = antiderivative(lower); - - // the integral result is the difference - // both results are at blade 3 (trivector grade) after integration - let result = f_upper - f_lower; - - // the length is 1/3 - assert!(result.near_mag(exact_result)); - // integrate() adds 3 blades, so blade 0 → blade 3 for x³/3 - // then another integrate() adds 3 more: blade 3 → blade 5 - assert_eq!(result.angle.blade(), 5); - - // demonstrate the quadrature relationship between a function and its derivative - let x = Geonum::new(0.5, 0.0, 1.0); // Sample point x = 0.5 - - // original function f(x) = x² - let _fx = f(x); - - // in geonum, the derivative of a function is related to its quadrature - // for f(x) = x², the derivative f'(x) = 2x - - // compute the derivative at x = 0.5 analytically - let analytical_derivative = 2.0 * x.mag; // f'(0.5) = 2*0.5 = 1.0 - - // for polynomial functions in geonum representation, the derivative - // involves both magnitude scaling and angle rotation - // for f(x) = x² = [x², 0], the derivative is f'(x) = 2x = [2x, 0] - let numerical_derivative = 2.0 * x.mag; - - assert!((numerical_derivative - analytical_derivative).abs() < EPSILON); - - // prove dual representation preserving information - // a geonum and its dual (rotated by π/2) preserve all information - let g = Geonum::new(0.5, 1.0, 4.0); // π/4 - let g_dual = Geonum::new_with_angle( - g.mag, - g.angle + Angle::new(1.0, 2.0), // add π/2 +fn it_lands_quotients_at_grade_2_and_winds_the_sign_home() { + // division carries the inversion's π: 1/z inverts through the unit circle, a π rotation, so + // inv() adds two blades and every quotient lands two grades from its dividend. the sign of a + // ratio is a position, not a bit — B₄'s −1/30 reads +1/30 once its numerator's π and the + // inversion's π wind a full turn home + + // a positive-over-positive quotient lands at grade 2 — the inversion's π, not a negative + let quotient = Geonum::scalar(3.0) / Geonum::scalar(4.0); + assert!(quotient.near_mag(0.75), "3/4 = 0.75"); + assert_eq!( + quotient.angle.grade(), + 2, + "the quotient carries the inversion's π" ); - // recover original from dual - let recovered = Geonum::new_with_angle( - g_dual.mag, - g_dual.angle - Angle::new(1.0, 2.0), // subtract π/2 + // −1 over a positive: the numerator's π plus the inversion's π wind to 2π — a full turn + // home to grade 0, so (−1)/30 lands +1/30, a positive on the scalar ray + let neg_one = Geonum::new(1.0, 1.0, 1.0); // −1 at angle π + let b4 = neg_one / Geonum::scalar(30.0); + assert!(b4.near_mag(1.0 / 30.0), "|B₄| = 1/30"); + assert_eq!( + b4.angle.grade(), + 0, + "π (numerator) + π (inversion) = 2π, wound home" ); - // prove perfect information preservation (zero entropy) - assert!(g.near_mag(recovered.mag)); - assert_eq!(g.angle, recovered.angle); - - // demonstrate O(1) integration regardless of complexity - // integration is fundamentally a rotation operation in geonum - // this works for any function where the antiderivative can be represented - - // prove the fundamental quadrature relationship between sin and cos - // this showcases the true power of geonum's representation - - // in traditional understanding: sin'(x) = cos(x) and cos'(x) = -sin(x) - // in geonum, these relationships are represented by a 90° rotation - - // create sin(x) and cos(x) representations - let _sin_fn = Geonum::new(1.0, 1.0, 2.0); // Represents sin [1, π/2] - let _cos_fn = Geonum::new(1.0, 0.0, 1.0); // Represents cos [1, 0] - - // trigonometric function use in geonum is more nuanced - // based on the tests we've seen, we need to understand that: - // 1. sin is represented as [1, π/2] - // 2. cos is represented as [1, 0] - // 3. When we rotate sin by π/2, we get [1, π], which is -1 - - // the true quadrature relationship in geonum is that rotating by π/2 - // represents the operation of differentiation - // since sin'(x) = cos(x), let's express that relationship - - // create a point where we calculate these values (e.g., at x = 0) - // artifact of geonum automation: kept for conceptual understanding of trigonometric values - let _sin_at_zero = Geonum::new(0.0, 1.0, 2.0); // sin(0) = 0 - let cos_at_zero = Geonum::new(1.0, 0.0, 1.0); // cos(0) = 1 - - // instead of testing angle equality after rotation, we'll test - // the fundamental relationship between sin and cos functions - // sin(x+π/2) = cos(x) for all x - - // prove this at x = 0: sin(0+π/2) = sin(π/2) = 1 = cos(0) - let sin_shifted = Geonum::new(1.0, 1.0, 2.0); // sin(π/2) = 1 - - // prove sin(π/2) = cos(0) = 1 - assert!(sin_shifted.near_mag(cos_at_zero.mag)); + // read it as a signed scalar: the grade-0 landing projects to +1/30 — the winding is the sign + let signed = b4.mag * b4.angle.grade_angle().cos(); + assert!( + (signed - 1.0 / 30.0).abs() < EPSILON, + "the sign is the winding: +1/30 at grade 0" + ); +} - // similarly, verify the relationship cos(x+π/2) = -sin(x) - // at x = 0: cos(0+π/2) = cos(π/2) = 0 and -sin(0) = 0 - let cos_shifted = Geonum::new(0.0, 0.0, 1.0); // cos(π/2) = 0 - let neg_sin_at_zero = Geonum::new(0.0, 3.0, 2.0); // -sin(0) = 0 [angle π/2 + π = 3π/2] +#[test] +fn its_a_quadrature() { + // quadrature is the quarter turn between a geonum and its dual: cos and sin are one unit + // object read a π/2 apart, and that same quarter turn IS differentiation. the quadrature + // is why 2 components (length, angle) carry what GA spends 4 on — sin(θ+π/2) = cos(θ) + // folds the extra grades back - // test equality of magnitudes (both are 0) - assert!(cos_shifted.near_mag(0.0)); - assert!(neg_sin_at_zero.near_mag(0.0)); + let theta = Angle::new(2.0, 7.0); // some θ = 2π/7 - // prove the fundamental quadrature relationship in geonum: - // functions that differ by a π/2 phase represent derivatives/integrals of each other + // sin(θ + π/2) = cos(θ): the quarter turn maps one onto the other, exactly + let (cos_theta, _) = theta.cos_sin(); + let (_, sin_shifted) = (theta + Angle::new(1.0, 2.0)).cos_sin(); + assert!( + (cos_theta - sin_shifted).abs() < EPSILON, + "sin(θ+π/2) = cos(θ) — the quadrature identity" + ); - // this quadrature relationship is what allows geonum to compress 4 components - // (1 scalar + 2 vector + 1 bivector) into just 2 components (length and angle) - // while preserving all information + // that quarter turn is differentiation: a unit object and its derivative are perpendicular + let f = Geonum::new_with_angle(1.0, theta); + let f_prime = f.differentiate(); + assert!( + f.dot(&f_prime).near_mag(0.0), + "f ⊥ f′ — differentiation is the quadrature quarter turn" + ); - // this demonstrates how integration can be performed in O(1) time - // regardless of the function's complexity, by exploiting the - // fundamental quadrature relationship in the geonum representation + // integration is the inverse quarter turn — it returns f, magnitude and grade intact + let recovered = f_prime.integrate(); + assert_eq!( + recovered.angle.grade(), + f.angle.grade(), + "integrate undoes the quarter turn" + ); + assert!( + recovered.near_mag(f.mag), + "no magnitude lost across the round trip" + ); } #[test] diff --git a/tests/phasor_test.rs b/tests/phasor_test.rs new file mode 100644 index 0000000..5966389 --- /dev/null +++ b/tests/phasor_test.rs @@ -0,0 +1,210 @@ +// the phasor was always a geonum +// +// electrical engineering teaches complex impedance as a computational trick — +// "pretend the circuit is complex-valued, solve, take the real part" — and +// then spends a curriculum managing the pretense. there is no trick to manage: +// an impedance is a magnitude and an angle, a phasor is [mag, angle], and the +// circuit laws are geonum arithmetic +// +// - reactance sign is a grade: inductive current lags (quadrant I), the +// capacitive angle sits at grade 3 — above vs below resonance is a grade +// flip, not a ± on an imaginary part +// - resonance is interference: ωL and 1/ωC sit π apart and cancel by the +// opposite-angle branch of addition, leaving pure resistance +// - real and reactive power are the adj/opp split already in core: P = S·adj, +// Q = S·opp, and P² + Q² = S² is the quadrature closing +// - three-phase power exists because angles interfere: the balanced neutral +// carries nothing, and the delivered power is ripple-free because three +// second-harmonic ripples 2π/3 apart wave_sum to zero — constant torque, +// read off interference +// - power factor correction is angle arithmetic: inject the opp leg's +// opposite and the apparent power rotates home to grade 0 +// +// run: cargo test --test phasor_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +const R: f64 = 50.0; // Ω +const L: f64 = 0.1; // H +const C: f64 = 1e-5; // F — resonance at ω₀ = 1/√(LC) = 1000 rad/s + +// series RLC impedance at frequency ω: three geonums summed +fn impedance(omega: f64) -> Geonum { + let z_l = Geonum::new_with_angle(omega * L, Angle::new(1.0, 2.0)); // ωL at π/2 + let z_c = Geonum::new_with_angle(1.0 / (omega * C), Angle::new(3.0, 2.0)); // 1/ωC at 3π/2 + (z_l + z_c) + Geonum::new(R, 0.0, 1.0) +} + +#[test] +fn it_cancels_reactance_at_resonance() { + // at ω₀ the inductive and capacitive reactances are equal magnitudes π + // apart — they cancel by interference and the circuit is pure resistance + let z0 = impedance(1000.0); + assert!( + z0.near_mag(R), + "at resonance |Z| = R — the reactances cancelled" + ); + assert_eq!( + z0.angle.grade(), + 0, + "and the angle is home: pure resistance" + ); + assert!(z0.angle.near_rem(0.0), "no residual phase"); + + // above resonance the inductor wins: net reactance +150 at ω = 2000 + let z_high = impedance(2000.0); + assert!( + z_high.near_mag((R * R + 150.0 * 150.0).sqrt()), + "|Z| = √(50² + 150²)" + ); + assert_eq!(z_high.angle.grade(), 0, "inductive: quadrant I"); + assert!( + z_high.angle.near_rem(3.0_f64.atan()), + "phase = atan(150/50) — current lags" + ); + + // below resonance the capacitor wins: the reactance sign is a GRADE, not a + // minus on an imaginary part + let z_low = impedance(500.0); + assert_eq!( + z_low.angle.grade(), + 3, + "capacitive: quadrant IV — the sign flip across resonance is a grade flip" + ); +} + +#[test] +fn it_splits_power_into_adj_and_opp() { + // drive the ω = 2000 circuit with 120 V. apparent power S rides the + // impedance angle; real and reactive power are its two quadrature legs — + // the adj/opp split already in core + let v = 120.0; + let z = impedance(2000.0); + + // current magnitude by geonum division — the quotient carries the + // inversion's π (numbers_test lands quotients at grade 2); the magnitude + // is the reading + let i = Geonum::new(v, 0.0, 1.0) / z; + assert!(i.near_mag(v / z.mag), "|I| = |V|/|Z|"); + + let s = Geonum::new_with_angle(v * i.mag, z.angle); // apparent power at the impedance angle + let p = s.adj(); // real power — the aligned leg + let q = s.opp(); // reactive power — the quadrature leg + + // anchored to dissipation the test never constructed: real power is what + // the resistor burns (the watts on the bill), reactive is what the net + // reactance circulates (the VArs the utility penalizes) + assert!((p.mag - i.mag * i.mag * R).abs() < 1e-9, "P = I²R"); + assert!((q.mag - i.mag * i.mag * 150.0).abs() < 1e-9, "Q = I²X"); + + // the power triangle is the quadrature closing + assert!( + (p.mag * p.mag + q.mag * q.mag - s.mag * s.mag).abs() < 1e-6, + "P² + Q² = S²" + ); +} + +#[test] +fn it_cancels_the_neutral_by_interference() { + // three balanced phases 2π/3 apart: the neutral current is their wave_sum, + // and it vanishes because the angles interfere — not because a law says so + let balanced: GeoCollection = (0..3) + .map(|k| Geonum::new_with_angle(10.0, Angle::new(2.0 * k as f64, 3.0))) + .collect(); + assert!( + balanced.wave_sum().near_mag(0.0), + "the balanced neutral carries nothing" + ); + + // unbalance one phase and the neutral carries exactly the imbalance — the + // extra 2 A of phase 3, pointing along phase 3 + let unbalanced: GeoCollection = [10.0, 10.0, 12.0] + .iter() + .enumerate() + .map(|(k, &mag)| Geonum::new_with_angle(mag, Angle::new(2.0 * k as f64, 3.0))) + .collect(); + let neutral = unbalanced.wave_sum(); + assert!(neutral.near_mag(2.0), "the neutral reads the 2 A imbalance"); + assert_eq!( + neutral.angle.base_angle(), + Angle::new(4.0, 3.0).base_angle(), + "pointing along the heavy phase" + ); +} + +#[test] +fn it_delivers_ripple_free_power_in_three_phase() { + // per-phase instantaneous power is VI·cos φ plus a double-frequency ripple. + // the three ripples sit 2π/3 apart and wave_sum to zero at every instant — + // the motor sees constant torque. this cancellation is why three-phase + // exists + let (v, i) = (120.0, 5.0); + let phi = 0.8_f64.acos(); // power factor 0.8 + let omega = 2.0 * PI * 60.0; + + for t in [0.0, 0.001, 0.004, 0.007, 0.011] { + // the three ripple terms as geonums at 2ωt − φ + k·2π/3 + let ripples: GeoCollection = (0..3) + .map(|k| { + let angle = + Angle::new((2.0 * omega * t - phi) / PI, 1.0) + Angle::new(2.0 * k as f64, 3.0); + Geonum::new_with_angle(v * i, angle) + }) + .collect(); + assert!( + ripples.wave_sum().near_mag(0.0), + "t={t}: the second harmonics interfere to zero" + ); + + // foil: the raw time-domain products sum to the flat 3·VI·cos φ + let p_total: f64 = (0..3) + .map(|k| { + let theta = omega * t - 2.0 * PI * k as f64 / 3.0; + (2.0_f64).sqrt() * v * theta.cos() * (2.0_f64).sqrt() * i * (theta - phi).cos() + }) + .sum(); + assert!( + (p_total - 3.0 * v * i * phi.cos()).abs() < 1e-9, + "t={t}: total power is flat — the ripple left with the interference" + ); + } + + // the single-phase foil pulses: its ripple has no partners to cancel with + let single = |t: f64| { + let theta = omega * t; + 2.0 * v * i * theta.cos() * (theta - phi).cos() + }; + let swing = single(phi / (2.0 * omega)) - single(phi / (2.0 * omega) + PI / (2.0 * omega)); + assert!( + (swing - 2.0 * v * i).abs() < 1e-9, + "one phase alone swings by 2VI — the pulsating torque three-phase removes" + ); +} + +#[test] +fn it_corrects_power_factor_by_rotating_home() { + // a 0.8-power-factor load draws S = [100, φ]: 80 kW of work, 60 kVAr of + // circulation. a capacitor injects the opp leg's opposite — [60, 3π/2] — + // and the sum rotates home to grade 0. same work, less current: the + // utility's whole business case in one geonum addition + let phi = 0.8_f64.acos(); + let s_load = Geonum::new_with_angle(100.0, Angle::new(phi / PI, 1.0)); + let capacitor = Geonum::new_with_angle(s_load.opp().mag, Angle::new(3.0, 2.0)); + + let s_corrected = s_load + capacitor; + + assert!(s_corrected.near_mag(80.0), "|S| falls to the real power"); + assert!( + s_corrected.opp().near_mag(0.0), + "the reactive leg is gone — unity power factor" + ); + assert!( + (s_corrected.adj().mag - s_load.adj().mag).abs() < 1e-9, + "the real power is untouched — same work" + ); + assert!( + (s_corrected.mag / s_load.mag - 0.8).abs() < 1e-12, + "line current drops 20% for the same delivered work" + ); +} diff --git a/tests/quantum_computing_test.rs b/tests/quantum_computing_test.rs new file mode 100644 index 0000000..6e512f2 --- /dev/null +++ b/tests/quantum_computing_test.rs @@ -0,0 +1,160 @@ +// quantum search is a quarter-turn walk +// +// grover's algorithm lives in the 2-plane spanned by |target⟩ and |rest⟩, and +// everything in it is angle arithmetic geonum performs directly: +// +// - the state is [1, θ] with sin θ the target amplitude — one geonum where +// the conventional register holds 2^n complex amplitudes +// - the oracle is a reflection across |rest⟩, the diffusion a reflection +// across the start state — and two reflections compose to a rotation by +// twice the angle between the axes (quaternion_test), so one grover +// iteration IS rotate(2θ) +// - the O(√N) headline: the walk needs π/(4θ) steps with θ = asin(1/√N) — +// the quadratic speedup is an angle budget, π/4 of arc paid 2θ at a time +// - rotations compose, so the whole walk collapses to one multiplication: +// search 2^100 items in O(1). the conventional simulation stores 2^100 +// amplitudes — more than atoms — to track two numbers +// +// phase estimation gets the same treatment: QPE builds an ancilla register and +// an inverse QFT to squeeze n bits out of an eigenvalue's angle, because +// measurement collapses blade to grade. storage reads the angle whole +// +// run: cargo test --test quantum_computing_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// the search plane: |rest⟩ along angle 0, |target⟩ a quarter turn up. +// the state starts at θs = asin(1/√N) — almost all amplitude on |rest⟩ +fn rest_axis() -> Geonum { + Geonum::new(1.0, 0.0, 1.0) +} + +#[test] +fn it_flips_the_target_amplitude_with_the_oracle_reflection() { + // the oracle marks the target by flipping its amplitude's sign. as a + // reflection across |rest⟩ the target component lands π away — the sign + // flip is a position, read off the opp projection's angle + let theta = (1.0 / 32.0_f64).asin(); // N = 1024 + let state = Geonum::new_with_angle(1.0, Angle::new(theta / PI, 1.0)); + + let marked = state.reflect(&rest_axis()); + + let before = state.opp(); // target amplitude, |sin θ| with grade-encoded sign + let after = marked.opp(); + + assert!(before.near_mag(after.mag), "the oracle costs no amplitude"); + assert!( + after.angle.is_opposite(&before.angle), + "the flip is a π rotation of the target component, not a sign bit" + ); +} + +#[test] +fn it_composes_oracle_and_diffusion_into_one_rotation() { + // oracle = reflect across |rest⟩ (angle 0), diffusion = reflect across the + // start state (angle θs). two reflections compose to a rotation by twice + // the angle between the axes: one grover iteration is rotate(2θs) + let theta = (1.0 / 32.0_f64).asin(); + let start = Geonum::new_with_angle(1.0, Angle::new(theta / PI, 1.0)); + + let state = Geonum::new_with_angle(1.0, Angle::new(2.0 * theta / PI, 1.0)); // mid-walk + let iterated = state.reflect(&rest_axis()).reflect(&start); + let rotated = state.rotate(start.angle + start.angle); // rotate by 2θs + + assert!( + iterated.near_mag(rotated.mag), + "reflections cost no amplitude" + ); + assert_eq!( + iterated.angle.base_angle(), + rotated.angle.base_angle(), + "oracle then diffusion IS the 2θ rotation" + ); +} + +#[test] +fn it_walks_grover_to_the_target_in_pi_over_4_root_n_steps() { + // N = 1024: θ = asin(1/32). the state needs to travel from θ to π/2, paying + // 2θ per iteration — π/(4θ) ≈ (π/4)√N steps, the quadratic speedup counted + // as an angle budget. walk it with the actual double reflection + let n: f64 = 1024.0; + let theta = (1.0 / n.sqrt()).asin(); + let start = Geonum::new_with_angle(1.0, Angle::new(theta / PI, 1.0)); + + let optimal = (PI / (4.0 * theta) - 0.5).round() as usize; + assert_eq!( + optimal, 25, + "⌊π/4·√1024⌋ steps: 25 iterations, not 512 probes" + ); + + let mut state = start; + for _ in 0..optimal { + state = state.reflect(&rest_axis()).reflect(&start).base_angle(); + } + + // success probability is the squared opp projection — and it matches the + // textbook amplitude sin((2j+1)θ) the walk never computed + let success = state.opp().mag * state.opp().mag; + let textbook = ((2.0 * optimal as f64 + 1.0) * theta).sin().powi(2); + + assert!( + (success - textbook).abs() < 1e-9, + "the reflection walk lands the textbook amplitude" + ); + assert!( + success > 0.999, + "25 angle additions find 1 item in 1024: p = {success:.6}" + ); +} + +#[test] +fn it_searches_two_to_the_hundred_items_in_one_multiplication() { + // N = 2^100. the conventional state vector needs 2^100 amplitudes — more + // than atoms in the observable universe — and even the counted walk needs + // ~9·10^14 iterations. but rotations compose: j iterations of rotate(2θ) + // is one angle (2j+1)·θ, computed in one multiplication + let theta = 2.0_f64.powi(-50); // asin(2^-50) = 2^-50 to f64 precision + let optimal = (PI / (4.0 * theta)).floor() as u64; + + assert_eq!( + optimal, 884_279_719_003_555, + "⌊(π/4)·2^50⌋ iterations — the √N budget, counted without walking" + ); + + // the whole walk in one construction: the final angle lands the quarter + // turn exactly (the residual mis-rotation ~1e-15 rad sits below the angle + // lattice's boundary snap) + let final_angle = Angle::new((2.0 * optimal as f64 + 1.0) * theta / PI, 1.0); + assert!( + final_angle.near(&Angle::new(1.0, 2.0)), + "the state arrives at |target⟩ — the quarter turn, exactly" + ); + + let state = Geonum::new_with_angle(1.0, final_angle); + assert!( + state.opp().near_mag(1.0), + "success amplitude 1 to machine precision, for a search space no state vector can hold" + ); +} + +#[test] +fn it_reads_the_eigenphase_directly() { + // phase estimation: U|ψ⟩ = e^(2πiφ)|ψ⟩ and QPE spends n ancilla qubits plus + // an inverse QFT to estimate φ to n bits — ceremony that exists because + // measurement collapses blade to grade. apply U as the rotation it is and + // the eigenphase sits in the angle, whole, at full f64 precision. qubit + // control firmware already ships this bookkeeping as the virtual-Z gate — + // a software phase register the pulse hardware never sees + let phi = 1.0 / 2.0_f64.sqrt(); // an irrational eigenphase + let u_rotation = Angle::new(2.0 * phi, 1.0); // 2πφ + + let eigenstate = Geonum::new(1.0, 1.0, 7.0); // any eigenstate direction + let applied = eigenstate.rotate(u_rotation); + + let recovered = (applied.angle - eigenstate.angle).grade_angle() / (2.0 * PI); + assert!( + (recovered - phi).abs() < 1e-15, + "the eigenphase read whole: {recovered} vs {phi} — no ancilla register" + ); +} diff --git a/tests/renormalization_test.rs b/tests/renormalization_test.rs new file mode 100644 index 0000000..a649545 --- /dev/null +++ b/tests/renormalization_test.rs @@ -0,0 +1,84 @@ +// nothing falls off the manifold +// +// game engines call quaternion.normalize() on a schedule; matrix pipelines +// re-orthonormalize with gram-schmidt every N frames; rotation integrators +// drift off SO(n) and get projected back. the drift exists because those +// representations store redundant components constrained to a manifold +// (|q| = 1, RᵀR = I), and float arithmetic leaks through the constraint one +// rounding at a time. [mag, angle] stores no constraint: the magnitude is +// data rotation never touches, the blade is an integer, and the lattice +// re-anchors rational-π chains at every boundary crossing. there is no +// renormalize() in the api because there is no manifold to fall off +// +// the foil runs the planar case; the leak — redundant constrained components +// rounding off their constraint — is dimension-generic +// +// run: cargo test --test renormalization_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +#[test] +fn it_composes_a_million_rotations_without_renormalizing() { + // a million compositions of π/7 — the workload renormalization schedules + // exist for + let step = Angle::new(1.0, 7.0); + let mut chain = Geonum::new(1.0, 0.0, 1.0); + for _ in 0..1_000_000 { + chain = chain.rotate(step); + } + + // the angle lands the exact lattice target 10^6·π/7 — blade 285714 plus + // π/7 of remainder. every 7th step lands a π/2 boundary exactly, and the + // boundary snap re-anchors the chain's float error to the lattice + let exact = Angle::new_with_blade(285_714, 1.0, 7.0); + assert!( + chain.angle.near(&exact), + "a million steps land the lattice target" + ); + + // the matrix foil: the same million multiplications leak through the + // orthogonality constraint — the measured drift renormalize() exists for + let (c, s) = ((PI / 7.0).cos(), (PI / 7.0).sin()); + let (mut x, mut y) = (1.0_f64, 0.0_f64); // first column of Rⁿ + for _ in 0..1_000_000 { + (x, y) = (x * c - y * s, x * s + y * c); + } + let defect = (x * x + y * y - 1.0).abs(); + assert!( + defect > 4.0 * f64::EPSILON, + "the matrix column drifted off unit length: {defect:.2e}" + ); + eprintln!( + "matrix orthogonality defect after 10^6 multiplies: {defect:.2e} ({:.0} ulps); geonum magnitude defect: 0 bits", + defect / f64::EPSILON + ); +} + +#[test] +fn it_never_leaves_the_manifold_because_there_is_no_manifold() { + // |q| = 1 is a constraint quaternion arithmetic must maintain; RᵀR = I is + // a constraint matrix arithmetic must maintain. the magnitude here is not + // a constraint — it is data, and rotation's arithmetic never touches it, + // so a million compositions leave it bit-identical. no schedule, no + // projection back, nothing to maintain + let step = Geonum::new(1.0, 3.0, 11.0); // an awkward step, 3π/11 + + let mut unit = Geonum::new(1.0, 0.0, 1.0); + let mut payload = Geonum::new(3.7, 1.0, 6.0); // arbitrary magnitude rides too + for _ in 0..1_000_000 { + unit = unit.rotate(step.angle); + payload = payload.rotate(step.angle); + } + + assert_eq!( + unit.mag.to_bits(), + 1.0_f64.to_bits(), + "unit magnitude bit-stable through a million compositions" + ); + assert_eq!( + payload.mag.to_bits(), + 3.7_f64.to_bits(), + "any magnitude is bit-stable — it was never part of a constraint surface" + ); +} diff --git a/tests/sampling_test.rs b/tests/sampling_test.rs new file mode 100644 index 0000000..6080dc7 --- /dev/null +++ b/tests/sampling_test.rs @@ -0,0 +1,128 @@ +// aliasing is winding loss +// +// the sampling theorem, anti-aliasing filters, the wagon-wheel effect — the +// whole discipline of not-being-fooled-by-samples exists because a sampler +// projects a rotation onto its base angle and drops the winding: +// +// - a tone at f and a tone at f + fs produce IDENTICAL samples at rate fs: +// their per-sample angles differ by whole turns, which is blade — exactly +// the data the projection cannot hold. the anti-aliasing filter kills the +// winding before the projection lies about it +// - the wagon wheel spins backwards on film because the camera reads each +// frame's base angle by shortest arc: a 0.9-turn forward step reads as +// 0.1 turn backward, and every frame drops exactly one turn into the gap +// between stored blade and apparent motion +// - nyquist is a half-turn budget: a per-sample step under π reads +// faithfully, past π the shortest arc points the wrong way. fs > 2f is +// the condition that one base angle can carry the step unambiguously +// - the phase vocoder's unwrap stage — and its "phasiness" artifact — is +// this winding loss shipping in audio production: the STFT hop drops the +// blade and the vocoder guesses it back +// +// run: cargo test --test sampling_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// the angle a tone at frequency f has turned by sample k at rate fs +fn sampled(f: f64, k: usize, fs: f64) -> Angle { + Angle::new(2.0 * f * k as f64 / fs, 1.0) +} + +// what a sampler infers from one step: the shortest arc to the base angle — +// the conventional reading, blind to blade +fn shortest_arc(step: Angle) -> f64 { + let g = step.grade_angle(); + if g > PI { + g - 2.0 * PI + } else { + g + } +} + +#[test] +fn it_aliases_by_dropping_the_winding() { + // 3 Hz and 13 Hz sampled at 10 Hz: the classic alias pair + let fs = 10.0; + let (f_low, f_high) = (3.0, 13.0); + + for k in 1..=8usize { + let low = sampled(f_low, k, fs); + let high = sampled(f_high, k, fs); + + // the samples are identical — the projection cannot tell the tones apart + assert!( + high.base_angle().near(&low.base_angle()), + "sample {k}: f and f + fs project to the same reading" + ); + + // the difference was never zero — it sits in the blade, one full turn + // per sample, the winding the sampler drops + assert_eq!( + high.blade() - low.blade(), + 4 * k, + "sample {k}: the tones differ by exactly k turns of stored winding" + ); + } +} + +#[test] +fn it_spins_the_wagon_wheel_backwards() { + // a wheel at 9 Hz filmed at 10 fps: 0.9 of a turn per frame. the film + // reads each frame by shortest arc and sees 0.1 turn BACKWARD + let per_frame = Angle::new(2.0 * 9.0 / 10.0, 1.0); // 1.8π per frame + + let apparent = shortest_arc(per_frame); + assert!( + (apparent + 0.2 * PI).abs() < 1e-12, + "the screen shows −0.2π per frame — one wheel, spinning the wrong way" + ); + + // one second of film: ten frames. the stored rotation is nine full turns; + // the apparent rotation is minus one. every frame dropped exactly one turn + let mut wheel = Angle::new(0.0, 1.0); + for _ in 0..10 { + wheel = wheel + per_frame; + } + assert_eq!(wheel.blade(), 36, "the wheel turned nine times — blade 36"); + + let film_total = 10.0 * apparent; + assert!( + (film_total + 2.0 * PI).abs() < 1e-12, + "the film shows one backward turn" + ); + let dropped = (wheel.blade() as f64 * PI / 2.0 + wheel.rem()) - film_total; + assert!( + (dropped / (2.0 * PI) - 10.0).abs() < 1e-12, + "ten turns lost — one per frame, the winding the projection discards" + ); +} + +#[test] +fn it_bounds_faithful_reading_at_half_a_turn() { + let fs = 10.0; + + // 4 Hz at 10 Hz: 0.8π per sample — under the half-turn budget, the + // shortest arc reads the true step + let under = sampled(4.0, 1, fs); + assert!( + (shortest_arc(under) - 0.8 * PI).abs() < 1e-12, + "below nyquist the reading is faithful" + ); + + // 6 Hz at 10 Hz: 1.2π per sample — past the budget, the shortest arc + // points backward. fs > 2f is exactly the condition that one base angle + // carries the step whole: nyquist is a half-turn per sample + let over = sampled(6.0, 1, fs); + assert!( + (shortest_arc(over) + 0.8 * PI).abs() < 1e-12, + "past nyquist the shortest arc reads −0.8π for a +1.2π step" + ); + + // the two misread tones are alias partners: 6 = 10 − 4 folds onto 4 with + // the direction flipped — the mirror image the folding frequency creates + assert!( + (shortest_arc(over) + shortest_arc(under)).abs() < 1e-12, + "6 Hz reads as 4 Hz reversed — folded about fs/2" + ); +} diff --git a/tests/smith_chart_test.rs b/tests/smith_chart_test.rs new file mode 100644 index 0000000..e2b7792 --- /dev/null +++ b/tests/smith_chart_test.rs @@ -0,0 +1,169 @@ +// the smith chart is a protractor for a geonum +// +// microwave engineering's most arcane artifact — the smith chart, a paper disk +// of nested circles engineers rotate rulers on — is a graphical calculator for +// one geonum: the reflection coefficient Γ = (Z − Z₀)/(Z + Z₀). every chart +// move is an operation this library ships: +// +// - moving along a lossless line rotates Γ by 2βl — the chart's rim scale +// "wavelengths toward generator" is a protractor, and the impedance +// repeating every λ/2 is the rotation completing a turn. the blade counts +// the half-wavelengths of line walked +// - the quarter-wave transformer is a half turn: Γ → −Γ inverts impedance +// to Z₀²/Z — matching networks are rotations +// - a shorted stub dials pure reactance with length: |Γ| = 1 pins it to the +// rim, so no resistance can appear, and the λ/4 point turns a short into +// an open +// - the constant-VSWR circles are magnitude level sets: rotation moves along +// them, and the standing wave the SWR meter reads is the interference of +// the incident and reflected geonums +// +// run: cargo test --test smith_chart_test -- --show-output + +use geonum::*; + +const Z0: f64 = 50.0; // line impedance, the chart's center + +// reflection coefficient of a real load: Γ = (Z_L − Z₀)/(Z_L + Z₀) +fn reflection(z_load: f64) -> Geonum { + let num = Geonum::new(z_load, 0.0, 1.0) - Geonum::new(Z0, 0.0, 1.0); + let den = Geonum::new(z_load, 0.0, 1.0) + Geonum::new(Z0, 0.0, 1.0); + Geonum::new_with_angle( + num.mag / den.mag, + num.angle.base_angle() - den.angle.base_angle(), + ) +} + +// impedance back from Γ: Z = Z₀(1 + Γ)/(1 − Γ), assembled by geonum addition +// and read by angle subtraction. Div answers the transformation question — +// inversion event included, quotients landing grade 2 (numbers_test) — while +// the chart asks where the ratio points +fn impedance(gamma: Geonum) -> Geonum { + let num = Geonum::scalar(1.0) + gamma; + let den = Geonum::scalar(1.0) - gamma; + Geonum::new_with_angle(Z0 * num.mag / den.mag, num.angle - den.angle) +} + +// walking l wavelengths toward the generator rotates Γ clockwise by 2βl — +// Γ·e^(−2jβl), the rim scale's arrow. forward-only geometry writes the +// clockwise move as the complementary forward turn, so the walk accumulates +// winding +fn toward_generator(gamma: Geonum, wavelengths: f64) -> Geonum { + gamma.rotate(Angle::new(-4.0 * wavelengths, 1.0)) +} + +#[test] +fn it_repeats_impedance_every_half_wavelength() { + let gamma = reflection(100.0); // |Γ| = 1/3 at angle 0 + assert!(gamma.near_mag(1.0 / 3.0), "Γ of a 2:1 mismatch is 1/3"); + + // walk the line in quarter-wave hops: after two hops — half a wavelength — + // the chart has turned once and the load reappears + let half = toward_generator(toward_generator(gamma, 0.25), 0.25); + assert!( + impedance(half).near_mag(100.0), + "λ/2 down the line the load reappears" + ); + assert_eq!( + half.angle.blade() - gamma.angle.blade(), + 4, + "one turn stored per half wavelength — the walk's odometer" + ); + + // six hops: 3λ/2, same reading, three turns of winding + let mut walked = gamma; + for _ in 0..6 { + walked = toward_generator(walked, 0.25); + } + assert!(impedance(walked).near_mag(100.0), "3λ/2: same reading"); + assert_eq!( + walked.angle.blade() - gamma.angle.blade(), + 12, + "three turns stored — the line length never left the data" + ); +} + +#[test] +fn it_inverts_impedance_with_a_quarter_wave_half_turn() { + // λ/4 rotates Γ by π: Γ → −Γ, and Z₀(1−Γ)/(1+Γ) = Z₀²/Z_L — the + // transformer inverts through the chart's center + let gamma = reflection(100.0); + let quarter = toward_generator(gamma, 0.25); + + assert!( + quarter.angle.is_opposite(&gamma.angle.base_angle()), + "a quarter wave is a half turn of Γ" + ); + assert!( + impedance(quarter).near_mag(Z0 * Z0 / 100.0), + "Z_in = Z₀²/Z_L = 25 Ω — inversion by rotation" + ); + + // the classic matching move: insert a λ/4 section of Z₀' = √(50·100). + // in that section Γ' = (100 − 70.7)/(100 + 70.7); the half turn lands the + // input at exactly 50 Ω — matched, by one rotation + let z_section = (50.0_f64 * 100.0).sqrt(); + let num = 100.0 - z_section; + let den = 100.0 + z_section; + let gamma_section = Geonum::new(num / den, 0.0, 1.0); + let turned = gamma_section.rotate(Angle::new(1.0, 1.0)); + + let matched = Geonum::scalar(1.0) + turned; + let reflected = Geonum::scalar(1.0) - turned; + let z_in = z_section * matched.mag / reflected.mag; + assert!( + (z_in - 50.0).abs() < 1e-9, + "the λ/4 transformer lands 50 Ω dead — the match is a half turn" + ); +} + +#[test] +fn it_turns_a_short_into_an_open_and_dials_reactance_with_length() { + let gamma_short = Geonum::new(1.0, 1.0, 1.0); // Z = 0 → Γ = −1, the rim's left pole + let gamma_open = Geonum::new(1.0, 0.0, 1.0); // Z = ∞ → Γ = +1, the right pole + + // a shorted λ/4 stub reads open: the half turn swaps the poles + let stub_quarter = toward_generator(gamma_short, 0.25); + assert_eq!( + stub_quarter.angle.base_angle(), + gamma_open.angle.base_angle(), + "the shorted quarter-wave stub looks open — pole to pole in one half turn" + ); + + // a shorted λ/8 stub is a pure +j50 inductance: |Γ| = 1 keeps the point on + // the rim, so resistance cannot appear — length dials reactance and only + // reactance. the impedance lands grade 1, the reactive axis + let stub_eighth = toward_generator(gamma_short, 0.125); + let z_stub = impedance(stub_eighth); + assert!(z_stub.near_mag(Z0), "|Z| = Z₀·tan(π/4) = 50"); + assert_eq!( + z_stub.angle.grade(), + 1, + "pure reactance — the rim admits no resistive component" + ); +} + +#[test] +fn it_holds_vswr_on_a_magnitude_circle() { + // lossless line motion is rotation, so |Γ| is invariant — the chart's + // constant-VSWR circles are magnitude level sets + let gamma = reflection(100.0); + for wavelengths in [0.05, 0.11, 0.23, 0.4] { + assert!( + toward_generator(gamma, wavelengths).near_mag(gamma.mag), + "rotation never leaves the |Γ| circle" + ); + } + + // the standing wave is the interference of incident and reflected waves: + // V(l) = [1, βl] + [|Γ|, −βl]. aligned they peak at 1 + |Γ|, opposed they + // dip to 1 − |Γ| — the meter's VSWR is the interference ratio + let peak = Geonum::new(1.0, 0.0, 1.0) + Geonum::new_with_angle(gamma.mag, Angle::new(0.0, 1.0)); + let dip = Geonum::new(1.0, 1.0, 2.0) + Geonum::new_with_angle(gamma.mag, Angle::new(3.0, 2.0)); + + let vswr = peak.mag / dip.mag; + assert!( + (vswr - 2.0).abs() < 1e-12, + "VSWR = (1 + 1/3)/(1 − 1/3) = 2 — the 2:1 mismatch read as interference" + ); +} diff --git a/tests/spinor_test.rs b/tests/spinor_test.rs new file mode 100644 index 0000000..35b905a --- /dev/null +++ b/tests/spinor_test.rs @@ -0,0 +1,153 @@ +// the 720° mystery is one bit of winding +// +// spin-1/2 is taught as quantum weirdness: rotate an electron 360° and its +// state picks up −1, rotate 720° and it returns. the weirdness dissolves once +// the angle is stored instead of projected: +// +// - a spinor turns at HALF the physical rate, so a 2π physical rotation is a +// π spinor rotation — grade 2, the −1 position. 4π physical is 2π spinor — +// blade 4, grade 0, home. the −1 is a place on the winding line +// - observables read grade (blade mod 4), so the physical apparatus returns +// at 2π while the stored angle differs by blade 2 — the SU(2) → SO(3) +// double cover is exactly the bit of winding the projection forgets +// - neutron interferometry (rauch 1975) measured that bit: a 2π-rotated arm +// interferes destructively with an unrotated one. the experiment reads the +// angle the projection drops +// - the half-angle parameter spinors are built on, tan(θ/2), is the t geonum +// stores. the sandwich product RvR† exists to double the half-angle back +// into a rotation; cos_sin's rational formulas ARE that doubling, so +// rotation is one angle addition, no sandwich +// +// run: cargo test --test spinor_test -- --show-output + +use geonum::*; + +#[test] +fn it_lands_minus_one_at_2pi_because_spin_halves_the_angle() { + // the spinor turns at half rate: physical α → spinor α/2 + let physical_2pi = Angle::new(2.0, 1.0); // blade 4 + let physical_4pi = Angle::new(4.0, 1.0); // blade 8 + + let spinor_at_2pi = physical_2pi / 2.0; // π — blade 2 + let spinor_at_4pi = physical_4pi / 2.0; // 2π — blade 4 + + assert_eq!( + spinor_at_2pi.grade(), + 2, + "one full physical turn lands the spinor at −1 — grade 2, a position" + ); + assert_eq!( + spinor_at_4pi.grade(), + 0, + "two full turns bring it home — grade 0" + ); + + // the physical observable is blind to the difference: a vector rotated 2π + // returns to its base angle, so every measurement of the apparatus reads + // identity while the spinor sits at −1 + let apparatus = Geonum::new(1.0, 1.0, 5.0); + let turned = apparatus.rotate(physical_2pi); + assert_eq!( + turned.angle.base_angle(), + apparatus.angle.base_angle(), + "the apparatus returns at 2π — the projection reads identity" + ); + assert_eq!( + turned.angle.blade(), + apparatus.angle.blade() + 4, + "while the stored angle carries the turn the projection dropped" + ); +} + +#[test] +fn it_cancels_the_interferometer_at_2pi_physical_rotation() { + // rauch 1975: split a neutron beam, rotate one arm's spin through 2π with + // a magnetic field, recombine. the beams cancel — the fringe shift proves + // the 4π period. in geonum the rotated arm sits a π spinor rotation away + // and the recombination is one addition + let arm_a = Geonum::new(1.0, 1.0, 8.0); // reference arm + let arm_b_2pi = arm_a.rotate(Angle::new(2.0, 1.0) / 2.0); // 2π physical = π spinor + let arm_b_4pi = arm_a.rotate(Angle::new(4.0, 1.0) / 2.0); // 4π physical = 2π spinor + + assert!( + (arm_a + arm_b_2pi).near_mag(0.0), + "2π rotation: the arms interfere destructively — the measured minimum" + ); + assert!( + (arm_a + arm_b_4pi).near_mag(2.0), + "4π rotation: full constructive recovery — the measured period" + ); +} + +#[test] +fn it_stores_the_spinor_half_angle_as_t() { + // the spinor parametrization of a rotation α is built on tan(α/2) — the + // cayley parameter. geonum stores exactly that ratio as t, so the spinor's + // coordinate is the struct's native field, not a change of variables + for (p, d) in [(1.0, 5.0), (1.0, 7.0), (2.0, 5.0), (3.0, 7.0)] { + let alpha = Angle::new(p, d); // rotations within the first quadrant + let half_tangent = (alpha.grade_angle() / 2.0).tan(); + assert!( + (alpha.t() - half_tangent).abs() < 1e-15, + "t IS tan(α/2) — the spinor coordinate, stored" + ); + + // the sandwich RvR† exists to double the half-angle back into the + // rotation. cos_sin's rational formulas are that doubling — degree 2 + // in t — so the full rotation reads out with no sandwich + let t = alpha.t(); + let (cos_a, sin_a) = alpha.cos_sin(); + assert!( + (cos_a - (1.0 - t * t) / (1.0 + t * t)).abs() < 1e-15, + "cos α = (1−t²)/(1+t²) — the sandwich's double angle, rational in t" + ); + assert!( + (sin_a - 2.0 * t / (1.0 + t * t)).abs() < 1e-15, + "sin α = 2t/(1+t²) — same doubling, same readout" + ); + } +} + +#[test] +fn it_double_covers_by_collapsing_pi_into_a_turn() { + // SU(2) → SO(3) is 2-to-1: a spinor s and its negative s + π drive the + // same physical rotation. doubling maps the π gap to a 2π gap — a full + // turn, invisible to grade — so two distinct spinors, one rotation + let s = Angle::new(1.0, 5.0); + let minus_s = s + Angle::new(1.0, 1.0); // the ± partner, π away + + assert_ne!(s, minus_s, "two distinct spinor states"); + assert_eq!( + (s * 2.0).base_angle(), + (minus_s * 2.0).base_angle(), + "doubled, they land the same physical rotation — the cover is 2-to-1" + ); + assert_eq!( + (minus_s * 2.0).blade() - (s * 2.0).blade(), + 4, + "the two preimages differ by exactly one full turn of winding" + ); +} + +#[test] +fn it_untangles_two_twists_but_not_one() { + // the belt trick: a 2π twist in a belt cannot be undone without rotating + // the ends; a 4π twist can. π₁(SO(3)) = ℤ/2 read as blade parity: each + // full physical twist is a π spinor rotation — blade 2 — and the + // obstruction class is the grade the accumulated blades land on + let twist = Angle::new(2.0, 1.0) / 2.0; // one full twist = π spinor + + let mut belt = Angle::new(0.0, 1.0); + let expected_class = [2usize, 0, 2, 0]; // odd twists obstructed, even free + + for (n, expected) in expected_class.iter().enumerate() { + belt = belt + twist; + assert_eq!( + belt.grade(), + *expected, + "{} twist(s): class {} — parity is the only invariant", + n + 1, + expected + ); + } +} diff --git a/tests/stability_test.rs b/tests/stability_test.rs new file mode 100644 index 0000000..09fa63a --- /dev/null +++ b/tests/stability_test.rs @@ -0,0 +1,173 @@ +// the rotation number is a blade rate +// +// the rotation number of a circle map — dynamical systems' basic invariant — +// is conventionally defined through a LIFT: extend the map from the circle to +// the real line so the total rotation can be tracked, because the circle +// coordinate wraps it away. the lift is the unwrap ceremony with a formal +// name. geonum's heading never wrapped: the Angle is the lift, and the +// rotation number is blade accumulation per iteration, read off storage +// +// - a bare rational rotation closes its orbit and lands its winding exactly +// on the lattice; the golden rotation never closes — off every small +// rational, the three-gap residue geonum reads as a base-angle gap +// - inside a tongue the coupling captures the winding: the sine term parks +// the heading where the drive cancels (ρ = 0) or paces it to exactly one +// turn per step (ρ = 1) — mode locking is the blade rate landing rational +// - the arnold tongue has a measurable edge: locking onsets at K = 2πδ, +// the coupling strength that first cancels the detuning. below it the +// winding creeps past the rational; above it the count is exact +// +// control_test shipped the linear taxonomy (scale_rotate knobs) and +// mechanics_test act V the conserved magnitude — this is the nonlinear story +// +// run: cargo test --test stability_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// one step of the sine circle map θ' = θ + ω + K·sin θ. the coupling reads the +// heading's position rationally (cos_sin), the advance adds as an Angle, and +// the winding accumulates in the blade — no lift constructed +fn circle_map_step(heading: Angle, omega: f64, coupling: f64) -> Angle { + let (_, sin_theta) = heading.cos_sin(); + heading + Angle::new((omega + coupling * sin_theta) / PI, 1.0) +} + +#[test] +fn it_reads_the_rotation_number_off_the_blade_rate() { + // bare rotation at 3/8 of a turn: eight steps close the orbit and store + // exactly three turns — the rotation number 3/8 read as 12 blades / 32 + let mut rational = Angle::new(0.0, 1.0); + for _ in 0..8 { + rational = circle_map_step(rational, 2.0 * PI * 3.0 / 8.0, 0.0); + } + assert!( + rational.near(&Angle::new(6.0, 1.0)), + "eight steps of 3/8 turn: three turns exactly — blade 12, orbit closed" + ); + assert_eq!( + rational.base_angle(), + Angle::new(0.0, 1.0), + "the rational orbit returns to its start" + ); + + // the golden rotation: the blade rate converges on the irrational + let golden = (5.0_f64.sqrt() - 1.0) / 2.0; // 0.618... of a turn + let steps = 1000; + let mut heading = Angle::new(0.0, 1.0); + for _ in 0..steps { + heading = circle_map_step(heading, 2.0 * PI * golden, 0.0); + } + let rate = heading.blade() as f64 / (4.0 * steps as f64); + assert!( + (rate - golden).abs() < 1e-3, + "the blade rate reads the golden rotation number: {rate:.6}" + ); + + // and it never closes: no q ≤ 8 steps land back on the start — the golden + // ratio sits a measurable base-angle gap off every small rational + for q in 1..=8usize { + let advance = Angle::new(2.0 * q as f64 * golden, 1.0); + let position = advance.grade_angle(); + let gap = position.min(2.0 * PI - position); + assert!( + gap > 0.3, + "q = {q}: the orbit misses closure by {gap:.3} rad" + ); + } +} + +#[test] +fn it_arrests_the_winding_inside_the_zero_tongue() { + // drive ω = 0.5 against coupling K = 0.9 > ω: the sine term can fully + // cancel the drive, so the heading walks forward until it parks at the + // fixed point sin θ* = −ω/K and the winding stops — rotation number 0 + let (omega, coupling) = (0.5, 0.9); + let mut heading = Angle::new(0.0, 1.0); + for _ in 0..2000 { + heading = circle_map_step(heading, omega, coupling); + } + + assert!( + heading.blade() < 4, + "2000 iterations never complete one turn — the winding is arrested" + ); + let (_, sin_theta) = heading.cos_sin(); + assert!( + (sin_theta + omega / coupling).abs() < 1e-6, + "the heading parks where the drive cancels: sin θ* = −ω/K" + ); + + // the same drive uncoupled winds freely — the arrest is the tongue's work + let mut bare = Angle::new(0.0, 1.0); + for _ in 0..2000 { + bare = circle_map_step(bare, omega, 0.0); + } + assert!( + bare.blade() > 600, + "uncoupled, the same drive stores {} blades", + bare.blade() + ); +} + +#[test] +fn it_locks_the_mode_onto_the_rational() { + // detune the drive 2% past one turn per step. inside the 1:1 tongue the + // coupling absorbs the detuning: after the transient the heading advances + // EXACTLY one turn per iteration — four blades a step, the mode locked + // onto the rational, the base angle frozen + let omega = 2.0 * PI * 1.02; + let coupling = 0.3; // above the tongue edge 2π·0.02 + + let mut heading = Angle::new(0.0, 1.0); + for _ in 0..1000 { + heading = circle_map_step(heading, omega, coupling); + } + + let position_before = heading.grade_angle(); + let blade_before = heading.blade(); + for _ in 0..200 { + heading = circle_map_step(heading, omega, coupling); + } + + assert_eq!( + heading.blade() - blade_before, + 800, + "200 locked steps store exactly 200 turns — ρ = 1, rational to the blade" + ); + assert!( + (heading.grade_angle() - position_before).abs() < 1e-9, + "the base angle is frozen — the orbit rides the fixed point" + ); +} + +#[test] +fn it_measures_the_tongue_edge_in_coupling_strength() { + // the 1:1 tongue's edge sits at K = 2πδ — the coupling that first cancels + // the 2% detuning. below it the winding creeps past one turn per step; + // above it the count locks exact. the arnold tongue, measured as a + // threshold in K with the blade as the detector + let omega = 2.0 * PI * 1.02; // detuning δ = 0.02 turns → edge at K ≈ 0.126 + + let window_climb = |coupling: f64| -> usize { + let mut heading = Angle::new(0.0, 1.0); + for _ in 0..1000 { + heading = circle_map_step(heading, omega, coupling); + } + let before = heading.blade(); + for _ in 0..200 { + heading = circle_map_step(heading, omega, coupling); + } + heading.blade() - before + }; + + assert!( + window_climb(0.10) > 800, + "below the edge the winding creeps past the rational" + ); + assert_eq!( + window_climb(0.20), + 800, + "above the edge the count is exact — the tongue captured the detuning" + ); +} diff --git a/tests/thermo_test.rs b/tests/thermo_test.rs new file mode 100644 index 0000000..0828b5a --- /dev/null +++ b/tests/thermo_test.rs @@ -0,0 +1,191 @@ +// cycle work is a swept wedge +// +// thermodynamics reads a heat engine's work off the area its cycle encloses in +// the PV plane — then computes that area with path integrals. the area is a +// swept wedge sum (integral_test's primitive): put the origin at the cycle's +// center and the state vector sweeps the enclosure triangle by triangle, every +// segment carrying the same orientation +// +// - engine vs refrigerator is v ∧ w = −w ∧ v: reverse the traversal and +// every segment wedge negates — same machine, same area, opposite +// thermodynamic arrow +// - on the TS plane the carnot cycle is an exact rectangle: the heats are +// isotherm strips (wedges), the work is their gap, and the efficiency +// 1 − T_c/T_h is a magnitude ratio — the first law closes geometrically +// - the wick rotation β = it: the same energy gap drives quantum evolution +// as pure rotation (magnitude untouched — unitarity) and thermal +// weighting as pure scale (angle untouched — boltzmann). temperature sits +// a grade off time, and the swap exchanges wave_sum for total_magnitude +// +// run: cargo test --test thermo_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +// the state points of a rectangular cycle, as vectors from the cycle's center +fn corners_from_center(states: &[(f64, f64)], center: (f64, f64)) -> Vec { + states + .iter() + .map(|&(x, y)| { + Geonum::new_from_cartesian(x, y) - Geonum::new_from_cartesian(center.0, center.1) + }) + .collect() +} + +// the swept area: half the wedge magnitudes of consecutive state vectors, +// with every segment's orientation read off the wedge's angle offset +fn swept_area(corners: &[Geonum]) -> (f64, Vec) { + let mut wedges = Vec::new(); + let mut orientations = Vec::new(); + for k in 0..corners.len() { + let a = corners[k]; + let b = corners[(k + 1) % corners.len()]; + let w = a.wedge(&b); + // the wedge lands at a + b + π/2, plus π when the sweep is negative: + // the offset's grade is the orientation — 1 forward, 3 backward + orientations.push((w.angle - a.angle - b.angle).grade()); + wedges.push(w); + } + let total = GeoCollection::from(wedges).total_magnitude() * 0.5; + (total, orientations) +} + +#[test] +fn it_sweeps_cycle_work_as_an_oriented_wedge() { + // a rectangular cycle: expand at high pressure, drop pressure, compress + // at low pressure, rise again. V ∈ [1, 3], P ∈ [1, 2] — enclosed area 2 + let cycle = [(1.0, 2.0), (3.0, 2.0), (3.0, 1.0), (1.0, 1.0)]; // (V, P), engine order + let corners = corners_from_center(&cycle, (2.0, 1.5)); + + let (work, orientations) = swept_area(&corners); + assert!( + (work - 2.0).abs() < 1e-12, + "the cycle's work is the enclosed area: (ΔV)(ΔP) = 2" + ); + + // every segment sweeps the same way — the cycle turns coherently around + // its center, and the state vector's winding is the enclosure + assert_eq!( + orientations, + vec![3, 3, 3, 3], + "all four sweeps share one orientation — the engine's arrow" + ); +} + +#[test] +fn it_flips_engine_to_refrigerator_by_orientation() { + let cycle = [(1.0, 2.0), (3.0, 2.0), (3.0, 1.0), (1.0, 1.0)]; + let corners = corners_from_center(&cycle, (2.0, 1.5)); + + // reverse the traversal: every segment wedge lands π away — v ∧ w = −w ∧ v + // read as thermodynamics, the negation a position + for k in 0..corners.len() { + let a = corners[k]; + let b = corners[(k + 1) % corners.len()]; + let forward = a.wedge(&b); + let reversed = b.wedge(&a); + assert!( + reversed.angle.is_opposite(&forward.angle), + "segment {k}: reversing the sweep rotates the wedge π" + ); + assert!( + reversed.near_mag(forward.mag), + "segment {k}: and costs no area" + ); + } + + // same machine, same area, opposite arrow: the refrigerator encloses the + // identical 2 units with every orientation flipped + let reversed: Vec = corners.iter().rev().cloned().collect(); + let (work, orientations) = swept_area(&reversed); + assert!( + (work - 2.0).abs() < 1e-12, + "the reversed cycle encloses the same area" + ); + assert_eq!( + orientations, + vec![1, 1, 1, 1], + "with the opposite orientation — work consumed, not delivered" + ); +} + +#[test] +fn it_prices_carnot_efficiency_as_a_magnitude_ratio() { + // on the TS plane the carnot cycle is an exact rectangle: isotherms at + // T_h and T_c, adiabats at constant S. the heats are isotherm strips — + // wedges of ΔS with the temperature axis + let (t_hot, t_cold, delta_s) = (500.0, 300.0, 2.0); + + let q_hot = Geonum::new(delta_s, 0.0, 1.0).wedge(&Geonum::new(t_hot, 1.0, 2.0)); + let q_cold = Geonum::new(delta_s, 0.0, 1.0).wedge(&Geonum::new(t_cold, 1.0, 2.0)); + assert!(q_hot.near_mag(1000.0), "Q_h = T_h·ΔS — the hot strip"); + assert!(q_cold.near_mag(600.0), "Q_c = T_c·ΔS — the cold strip"); + + // the work is the enclosed TS rectangle, swept around its center + let cycle = [(1.0, t_cold), (1.0, t_hot), (3.0, t_hot), (3.0, t_cold)]; // (S, T), engine order: adiabat up, hot isotherm, adiabat down, cold isotherm + let corners = corners_from_center(&cycle, (2.0, 400.0)); + let (work, orientations) = swept_area(&corners); + + assert!( + (work - (t_hot - t_cold) * delta_s).abs() < 1e-9, + "W = (T_h − T_c)·ΔS — the rectangle between the strips" + ); + assert_eq!(orientations, vec![3, 3, 3, 3], "one coherent engine sweep"); + + // the first law closes geometrically: the enclosed area is the strip gap + assert!( + (work - (q_hot.mag - q_cold.mag)).abs() < 1e-9, + "W = Q_h − Q_c — no energy invented by the geometry" + ); + + // and carnot's bound is a magnitude ratio, no entropy calculus run + let efficiency = work / q_hot.mag; + assert!( + (efficiency - (1.0 - t_cold / t_hot)).abs() < 1e-12, + "η = W/Q_h = 1 − T_c/T_h = 0.4" + ); +} + +#[test] +fn it_swaps_wave_sum_for_total_magnitude_under_wick() { + // one two-level system, energy gap ε. quantum evolution applies the gap as + // ROTATION — scale_rotate with the magnitude knob at unity, so the state + // stays unit (unitarity). thermal weighting applies the same gap as SCALE — + // the angle knob at zero, so the weight never turns (boltzmann) + let epsilon = 1.3; + let (t, beta) = (0.9, 0.7); + + let ground = Geonum::new(1.0, 0.0, 1.0); + let excited_phase = ground.scale_rotate(1.0, Angle::new(epsilon * t / PI, 1.0)); + let excited_weight = ground.scale_rotate((-beta * epsilon).exp(), Angle::new(0.0, 1.0)); + + assert!( + excited_phase.near_mag(1.0), + "rotation never touches magnitude — unitarity" + ); + assert_eq!( + excited_weight.angle, ground.angle, + "scale never touches angle — boltzmann weighting" + ); + + // the wick rotation β = it exchanges which collection observable you + // compute: the propagator trace is the WAVE_SUM of the phases + // (interference), the partition function the TOTAL_MAGNITUDE of the + // weights (no interference — nothing left to interfere) + let quantum: GeoCollection = vec![ground, excited_phase].into(); + let thermal: GeoCollection = vec![ground, excited_weight].into(); + + assert!( + quantum + .wave_sum() + .near_mag(2.0 * (epsilon * t / 2.0).cos().abs()), + "the trace interferes: |1 + e^(iεt)| = 2|cos(εt/2)|" + ); + assert!( + (thermal.total_magnitude() - (1.0 + (-beta * epsilon).exp())).abs() < 1e-12, + "the partition function sums plain: Z = 1 + e^(−βε)" + ); + + // temperature sits a grade off time: same gap, same machinery, the + // quarter turn between the two knobs is the whole difference +} diff --git a/tests/whisper_test.rs b/tests/whisper_test.rs new file mode 100644 index 0000000..dc6c69c --- /dev/null +++ b/tests/whisper_test.rs @@ -0,0 +1,102 @@ +// the exp map is a workaround +// +// float density is position-dependent and conventional rotation formats pay +// for it: cos parks small angles beside 1.0, where floats sit 1.1e-16 apart — +// below ~10 nanoradians the cosine reads exactly 1.0 and the acos readback +// returns zero. the industry fixes are famous: lie log/exp maps for composing +// near-identity rotations, 2·atan2(|v|, w) instead of 2·acos(w) for reading +// them back — each one the half-tangent rediscovered per-crisis. geonum +// stores it: t = tan(θ/2) parks the same angles beside 0.0, where floats stay +// dense to 10^-308, so whispers compose at full relative precision with no +// map in or out, and one register spans a 10^-300 rad whisper to a +// 10^18-turn roar — ~318 orders of rotation +// +// fence, logged: Angle::new floors remainders below 1e-10 rad to t = 0 (the +// constructor's boundary snap), so deep whispers enter through from_parts +// +// run: cargo test --test whisper_test -- --show-output + +use geonum::*; +use std::f64::consts::PI; + +#[test] +fn it_freezes_the_cosine_where_t_stays_alive() { + let theta = 5e-9_f64; // five nanoradians + + // the conventional register: cos parks the whisper beside 1.0, where the + // nearest other float sits 1.1e-16 away — the whisper rounds to silence + assert!( + theta.cos() == 1.0, + "five nanoradians reads cos = 1.0 exactly" + ); + assert!( + theta.cos().acos() == 0.0, + "and the acos readback returns zero — the angle is gone" + ); + + // the half-tangent register: t parks the same whisper beside 0.0, dense + // territory — stored and read back at full relative precision + let whisper = Angle::new(theta / PI, 1.0); + assert!( + ((whisper.t() - (theta / 2.0).tan()) / (theta / 2.0)).abs() < 1e-9, + "t = tan(θ/2) holds the whisper to 16 digits" + ); + let (_, sin_theta) = whisper.cos_sin(); + assert!( + ((sin_theta - theta) / theta).abs() < 1e-9, + "the rational sine readout returns the whisper whole" + ); +} + +#[test] +fn it_composes_nanoradian_whispers_without_an_exp_map() { + let theta = 1e-9_f64; + let whisper = Angle::new(theta / PI, 1.0); + + // a million whispers, tangent-summed — no log map in, no exp map out + let mut accumulated = Angle::new(0.0, 1.0); + for _ in 0..1_000_000 { + accumulated = accumulated + whisper; + } + + let expected = 1e-3_f64; // a milliradian of truth + assert!( + ((accumulated.rem() - expected) / expected).abs() < 1e-8, + "10^6 nanoradian steps land a milliradian at full relative precision" + ); + + // the scalar-part foil: each whisper's cosine is already 1.0, so a + // million compositions store 1.0 and the readback is zero — every step + // lost before composition even starts + let mut w = 1.0_f64; + for _ in 0..1_000_000 { + w *= (theta / 2.0).cos(); + } + assert!( + 2.0 * w.acos() == 0.0, + "the w register composed a million whispers into silence" + ); +} + +#[test] +fn it_spans_the_whisper_to_the_roar_in_one_register() { + // the roar: a year of cesium — 1.16×10^18 quarter-turns (atomic_clock_test) + let year_blade = (4u128 * 31_536_000 * 9_192_631_770) as usize; + let roar = Angle::new_with_blade(year_blade, 0.0, 1.0); + + // the whisper: 10^-300 rad, entering through from_parts past the + // constructor's 1e-10 floor + let whisper_t = 5e-301_f64; + let both = roar + Angle::from_parts(0, whisper_t); + + assert_eq!(both.blade(), year_blade, "the roar's count survives exact"); + assert!( + (both.t() - whisper_t).abs() < 1e-310, + "the whisper's t survives beside it, bit for bit" + ); + + // the span: ~318 orders of magnitude in one register — a ratio too large + // for f64 to hold, so it is measured in logs + let span = (year_blade as f64 * PI / 2.0).log10() - (2.0 * whisper_t).log10(); + assert!(span > 300.0, "{span:.0} orders of rotation, one register"); +}