singularity-rs/tests/physics_test.rs
xfy 334e65fc76 test(physics): mirror Kerr RK45 loop in CPU, cover spin=0 degeneracy + spin>0 capture
Phase 2 shipped the Kerr deriv() and the adaptive RK45 integration loop in
the shader (black_hole.wgsl:260, 320-390), but the CPU mirror in physics.rs
only covered the single-step Kerr derivative (kerr_bending_accel), not the
loop. That left the AGENTS.md 'CPU <-> shader mirror' contract broken for
Phase 2: the spin-dependent capture radius, the adaptive step control, and
the budget = accepted-steps-only / dt_min forced-accept semantics existed
only on the GPU, with nothing testable on the CPU side.

Add a faithful CPU mirror and the loop-level tests that close the gap:

- rk45_step(): Dormand-Prince step mirroring black_hole.wgsl:260-285, using
  kerr_bending_accel so it is also the Phase 1 step at chi=0.
- is_captured_rk45(): the full adaptive loop mirroring black_hole.wgsl:320-390
  — same seeding (total_path / steps), same reject/retry at dt_min (the
  forced-accept floor that prevents infinite retry), same accept-then-refine,
  and the spin-dependent capture radius r+(chi) via kerr_horizon.

Tests (tests/physics_test.rs):
- spin=0 RK45 loop captures below bcrit and escapes above (degeneracy).
- spin=0.9 still captures a b~2.0 ray (horizon shrinks but not past b<bcrit).
- capture set does not grow as spin increases across a bcrit-straddling sweep.
- rk45_step error shrinks monotonically with dt (the property the adaptive
  loop's reject/accept decision depends on).

Note: probing the error scaling showed it falls between 2nd and 4th order in
dt, not a clean 5th, because of the per-stage normalize() projection the
shader (and now the mirror) applies. The loop only relies on monotonicity, so
this is correct-as-shipped; documented in a doc-comment on rk45_step so a
future reader is not misled by the '45' in the name.

cargo test: 17 passed (3 Phase 1 inline + 14 integration, up from 12).
2026-07-14 15:51:53 +08:00

184 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use singularity_rs::physics;
#[test]
fn public_bcrt_constant_is_correct() {
// 3*sqrt(3)/2 ≈ 2.598076
let expected = 1.5 * 3.0_f32.sqrt();
assert!((physics::BCRIT - expected).abs() < 1e-5);
}
#[test]
fn kerr_isco_at_zero_is_schwarzschild() {
// spin=0 → ISCO = 6M = 3 Rs (Rs=1).
let isco = physics::kerr_isco(0.0);
assert!((isco - 3.0).abs() < 1e-3, "spin=0 ISCO should be 3.0, got {}", isco);
}
#[test]
fn kerr_isco_at_extremal_is_half_rs() {
// spin=1 → ISCO = M = Rs/2 = 0.5.
let isco = physics::kerr_isco(1.0);
assert!((isco - 0.5).abs() < 1e-3, "spin=1 ISCO should be 0.5, got {}", isco);
}
#[test]
fn kerr_isco_is_monotonically_decreasing() {
let a = physics::kerr_isco(0.3);
let b = physics::kerr_isco(0.6);
let c = physics::kerr_isco(0.9);
assert!(a > b, "0.3 > 0.6: {} vs {}", a, b);
assert!(b > c, "0.6 > 0.9: {} vs {}", b, c);
}
#[test]
fn kerr_horizon_at_zero_is_rs() {
// spin=0 → r+ = Rs = 1.0.
let r = physics::kerr_horizon(0.0);
assert!((r - 1.0).abs() < 1e-3, "spin=0 horizon should be 1.0, got {}", r);
}
#[test]
fn kerr_horizon_at_extremal_is_half_rs() {
// spin=1 → r+ = M = 0.5.
let r = physics::kerr_horizon(1.0);
assert!((r - 0.5).abs() < 1e-3, "spin=1 horizon should be 0.5, got {}", r);
}
#[test]
fn kerr_horizon_is_monotonically_decreasing() {
let a = physics::kerr_horizon(0.3);
let b = physics::kerr_horizon(0.6);
let c = physics::kerr_horizon(0.9);
assert!(a > b, "0.3 > 0.6: {} vs {}", a, b);
assert!(b > c, "0.6 > 0.9: {} vs {}", b, c);
}
#[test]
fn kerr_bending_accel_degenerates_to_schwarzschild_at_zero_spin() {
// At χ=0 the Kerr bending accel must equal the Schwarzschild one.
let pos = bevy::math::Vec3::new(3.0, 1.0, 4.0);
let dir = bevy::math::Vec3::new(0.2, -0.1, -0.97).normalize();
let schw = physics::bending_accel(pos, dir);
let kerr = physics::kerr_bending_accel(pos, dir, 0.0);
let diff = (schw - kerr).length();
assert!(diff < 1e-6, "spin=0 Kerr should match Schwarzschild; diff = {}", diff);
}
#[test]
fn kerr_bending_accel_nonzero_off_axis_at_nonzero_spin() {
// At χ>0 the drag term must produce a different accel (frame-dragging exists).
let pos = bevy::math::Vec3::new(3.0, 1.0, 4.0);
let dir = bevy::math::Vec3::new(0.2, -0.1, -0.97).normalize();
let schw = physics::bending_accel(pos, dir);
let kerr = physics::kerr_bending_accel(pos, dir, 0.8);
let diff = (schw - kerr).length();
assert!(diff > 1e-4, "spin=0.8 Kerr should differ from Schwarzschild; diff = {}", diff);
}
// ---- Loop-level CPU ↔ shader mirror tests (adaptive RK45 + Kerr) ----
// These exercise the full integration loop (`is_captured_rk45`) that mirrors
// the shader's black_hole.wgsl:320-390 loop, not just the single-step deriv.
#[test]
fn rk45_at_zero_spin_captures_below_bcrit() {
// spin=0 Kerr loop must reproduce the Schwarzschild capture boundary.
let eye = bevy::math::Vec3::new(0.0, 0.0, 50.0);
let dir = bevy::math::Vec3::new(0.0, 2.0, -50.0).normalize(); // b ~ 2.0 < bcrit
let b = physics::impact_parameter(eye, dir);
assert!(b < physics::BCRIT, "b {} should be < bcrit", b);
assert!(
physics::is_captured_rk45(eye, dir, 2000, 0.0),
"spin=0 ray below bcrit should be captured by the RK45 loop"
);
}
#[test]
fn rk45_at_zero_spin_escapes_above_bcrit() {
let eye = bevy::math::Vec3::new(0.0, 0.0, 50.0);
let dir = bevy::math::Vec3::new(0.0, 10.0, -50.0).normalize(); // b ~ 9.8 >> bcrit
let b = physics::impact_parameter(eye, dir);
assert!(b > physics::BCRIT);
assert!(
!physics::is_captured_rk45(eye, dir, 2000, 0.0),
"spin=0 ray above bcrit should escape the RK45 loop"
);
}
#[test]
fn rk45_higher_spin_still_captures_a_grazing_ray() {
// A ray that would be captured at spin=0 (b < bcrit) must remain captured
// at high spin — the horizon shrinks, but a b ~ 2.0 ray still plunges in.
let eye = bevy::math::Vec3::new(0.0, 0.0, 50.0);
let dir = bevy::math::Vec3::new(0.0, 2.0, -50.0).normalize();
assert!(
physics::is_captured_rk45(eye, dir, 2000, 0.9),
"spin=0.9 ray at b~2.0 should still be captured"
);
}
#[test]
fn rk45_capture_radius_shrinks_with_spin() {
// Near the critical impact parameter, a higher-spin hole (smaller horizon,
// prograde frame-dragging) is *easier* for a prograde ray to escape. With a
// fixed step count, count captures across a sweep of impact parameters and
// assert the capture set does not grow as spin increases — i.e. the boundary
// does not move outward. This is the robust, sign-agnostic assertion.
let eye = bevy::math::Vec3::new(0.0, 0.0, 50.0);
let count_captures = |chi: f32| -> usize {
(0..=40)
.map(|i| {
let y = 1.6 + (i as f32) * 0.06; // b sweeps ~1.6 .. ~4.0, straddling bcrit
let dir = bevy::math::Vec3::new(0.0, y, -50.0).normalize();
physics::is_captured_rk45(eye, dir, 400, chi) as usize
})
.sum()
};
let c0 = count_captures(0.0);
let c_hi = count_captures(0.9);
assert!(
c_hi <= c0,
"higher spin should not capture more rays across the bcrit sweep; \
spin=0 captures={}, spin=0.9 captures={}",
c0,
c_hi
);
}
#[test]
fn rk45_step_error_shrinks_monotonically_with_dt() {
// The Dormand-Prince error estimate |y5 y4| must shrink monotonically as
// dt shrinks — this is the property the shader's adaptive loop relies on to
// decide reject/retry vs accept (black_hole.wgsl:326-335). It does NOT need
// to be a clean 5th-order power law here: the per-stage `normalize(dir + …)`
// projection in both the shader and the mirror makes the *realized* error
// scaling fall between 2nd and 4th order depending on geometry. We assert
// only what the loop actually depends on: smaller dt ⇒ smaller error, by a
// factor strictly greater than 1, across a halving sequence. This is the
// load-bearing correctness property; pinning a specific order would be
// testing a model of the integrator, not the integrator as shipped.
let pos = bevy::math::Vec3::new(4.0, 0.5, 0.0);
let dir = bevy::math::Vec3::new(0.0, 0.0, -1.0);
let mut prev = f32::INFINITY;
for &dt in &[0.4_f32, 0.2, 0.1, 0.05, 0.025] {
let err = physics::rk45_step(pos, dir, dt, 0.0).err;
assert!(
err < prev,
"error should decrease as dt shrinks: dt={} err={} prev={}",
dt,
err,
prev
);
prev = err;
}
// And the shrink is meaningful — the largest dt's error is at least 10x the
// smallest (rules out the error being flat / dominated by a constant floor).
let err_big = physics::rk45_step(pos, dir, 0.4, 0.0).err;
let err_small = physics::rk45_step(pos, dir, 0.025, 0.0).err;
assert!(
err_big / err_small.max(1e-18) > 10.0,
"error should span >10x across the dt range; big={} small={}",
err_big,
err_small
);
}