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).
This commit is contained in:
parent
9afab04308
commit
334e65fc76
155
src/physics.rs
155
src/physics.rs
@ -95,7 +95,160 @@ pub fn kerr_bending_accel(pos: Vec3, dir: Vec3, chi: f32) -> Vec3 {
|
|||||||
radial + drag
|
radial + drag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silence unused-import warning for Vec4 if not used; kept for future expansion.
|
// ---- RK45 (Dormand-Prince) adaptive integrator ----
|
||||||
|
// CPU mirror of the shader's `rk45_step` (black_hole.wgsl:260) and adaptive
|
||||||
|
// loop (black_hole.wgsl:320-390). Keep these in lockstep with the shader: the
|
||||||
|
// Butcher-tableau coefficients, the dt_min forced-accept floor, and the
|
||||||
|
// budget = accepted-steps-only semantics must all match, or the loop-level
|
||||||
|
// capture/escape tests below pass on code the shader contradicts.
|
||||||
|
|
||||||
|
/// Result of one Dormand-Prince step: the 5th-order solution advanced by `dt`,
|
||||||
|
/// plus the position error estimate `|y5 - y4|` used for step-size control.
|
||||||
|
/// Mirrors the shader's `RkStep` struct (black_hole.wgsl:254).
|
||||||
|
pub struct RkStep {
|
||||||
|
pub pos: Vec3,
|
||||||
|
pub dir: Vec3,
|
||||||
|
pub err: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Dormand-Prince RK45 step against the Kerr geodesic. `chi = a/M ∈ [0,1]`.
|
||||||
|
/// At chi=0 the derivative reduces to `bending_accel`, so this is also the
|
||||||
|
/// Phase 1 integrator step. Mirrors `rk45_step` in black_hole.wgsl:260-285.
|
||||||
|
///
|
||||||
|
/// **Implemented order note:** the per-stage `normalize(dir + …)` projection
|
||||||
|
/// (faithful to the shader) makes the *realized* error estimate shrink between
|
||||||
|
/// 2nd and 4th order in dt depending on geometry, not a clean 5th order — see
|
||||||
|
/// the `rk45_step_error_shrinks_monotonically_with_dt` test. The adaptive loop
|
||||||
|
/// only depends on the error being monotone in dt, which holds. Naming follows
|
||||||
|
/// the shader ("RK45") for the tableau, not as a precision guarantee.
|
||||||
|
pub fn rk45_step(pos: Vec3, dir: Vec3, dt: f32, chi: f32) -> RkStep {
|
||||||
|
// k_i = deriv(p_i, d_i); dpos = dir, ddir = kerr_bending_accel.
|
||||||
|
let k1p = dir;
|
||||||
|
let k1d = kerr_bending_accel(pos, dir, chi);
|
||||||
|
|
||||||
|
let p2 = pos + k1p * (dt * 0.2);
|
||||||
|
let d2 = (dir + k1d * (dt * 0.2)).normalize();
|
||||||
|
let k2p = d2;
|
||||||
|
let k2d = kerr_bending_accel(p2, d2, chi);
|
||||||
|
|
||||||
|
let p3 = pos + (k1p * 0.075 + k2p * 0.225) * dt;
|
||||||
|
let d3 = (dir + (k1d * 0.075 + k2d * 0.225) * dt).normalize();
|
||||||
|
let k3p = d3;
|
||||||
|
let k3d = kerr_bending_accel(p3, d3, chi);
|
||||||
|
|
||||||
|
let p4 = pos + (k1p * 0.3 + k2p * -0.9 + k3p * 1.2) * dt;
|
||||||
|
let d4 = (dir + (k1d * 0.3 + k2d * -0.9 + k3d * 1.2) * dt).normalize();
|
||||||
|
let k4p = d4;
|
||||||
|
let k4d = kerr_bending_accel(p4, d4, chi);
|
||||||
|
|
||||||
|
let p5 = pos + (k1p * -11.0 / 54.0 + k2p * 2.5 + k3p * -70.0 / 27.0 + k4p * 35.0 / 27.0) * dt;
|
||||||
|
let d5 = (dir + (k1d * -11.0 / 54.0 + k2d * 2.5 + k3d * -70.0 / 27.0 + k4d * 35.0 / 27.0) * dt)
|
||||||
|
.normalize();
|
||||||
|
let k5p = d5;
|
||||||
|
let k5d = kerr_bending_accel(p5, d5, chi);
|
||||||
|
|
||||||
|
let p6 = pos
|
||||||
|
+ (k1p * 1631.0 / 55296.0
|
||||||
|
+ k2p * 175.0 / 512.0
|
||||||
|
+ k3p * 575.0 / 13824.0
|
||||||
|
+ k4p * 44275.0 / 110592.0
|
||||||
|
+ k5p * 253.0 / 4096.0)
|
||||||
|
* dt;
|
||||||
|
let d6 = (dir
|
||||||
|
+ (k1d * 1631.0 / 55296.0
|
||||||
|
+ k2d * 175.0 / 512.0
|
||||||
|
+ k3d * 575.0 / 13824.0
|
||||||
|
+ k4d * 44275.0 / 110592.0
|
||||||
|
+ k5d * 253.0 / 4096.0)
|
||||||
|
* dt)
|
||||||
|
.normalize();
|
||||||
|
let k6p = d6;
|
||||||
|
let _k6d = kerr_bending_accel(p6, d6, chi); // 6th stage eval (6th-order weights k1..k5 only)
|
||||||
|
|
||||||
|
// 5th-order solution (advances the state).
|
||||||
|
let new_pos = pos
|
||||||
|
+ (k1p * 37.0 / 378.0
|
||||||
|
+ k3p * 250.0 / 621.0
|
||||||
|
+ k4p * 125.0 / 594.0
|
||||||
|
+ k5p * 512.0 / 1771.0)
|
||||||
|
* dt;
|
||||||
|
let new_dir = (dir
|
||||||
|
+ (k1d * 37.0 / 378.0
|
||||||
|
+ k3d * 250.0 / 621.0
|
||||||
|
+ k4d * 125.0 / 594.0
|
||||||
|
+ k5d * 512.0 / 1771.0)
|
||||||
|
* dt)
|
||||||
|
.normalize();
|
||||||
|
// 4th-order solution (for the error estimate only).
|
||||||
|
let pos4 = pos
|
||||||
|
+ (k1p * 2825.0 / 27648.0
|
||||||
|
+ k3p * 18575.0 / 48384.0
|
||||||
|
+ k4p * 13525.0 / 55296.0
|
||||||
|
+ k5p * 277.0 / 14336.0
|
||||||
|
+ k6p * 0.25)
|
||||||
|
* dt;
|
||||||
|
let err = (new_pos - pos4).length();
|
||||||
|
RkStep {
|
||||||
|
pos: new_pos,
|
||||||
|
dir: new_dir,
|
||||||
|
err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a Kerr geodesic with the adaptive RK45 loop. Returns true if the
|
||||||
|
/// ray is captured (crosses r < r+(chi)). Mirrors the shader's integration
|
||||||
|
/// loop (black_hole.wgsl:320-390): budget = accepted steps, rejected steps
|
||||||
|
/// retry at smaller dt (down to dt_min, which is a forced-accept floor), and
|
||||||
|
/// the capture radius is the spin-dependent horizon r+(chi) (= Rs at chi=0).
|
||||||
|
///
|
||||||
|
/// `steps` is the hard cap on *accepted* steps (matches `uniforms.steps`).
|
||||||
|
pub fn is_captured_rk45(start_pos: Vec3, start_dir: Vec3, steps: u32, chi: f32) -> bool {
|
||||||
|
let mut pos = start_pos;
|
||||||
|
let mut dir = start_dir;
|
||||||
|
|
||||||
|
// Same seeding as the shader: total_path from eye distance + escape radius.
|
||||||
|
let eye_dist = pos.length();
|
||||||
|
let escape_r = (eye_dist * 2.0).max(100.0);
|
||||||
|
let total_path = eye_dist + escape_r;
|
||||||
|
let dt_init = total_path / steps as f32;
|
||||||
|
let dt_min = dt_init * 0.25;
|
||||||
|
let dt_max = dt_init * 4.0;
|
||||||
|
let tol = 1e-3;
|
||||||
|
let r_plus = kerr_horizon(chi);
|
||||||
|
|
||||||
|
let mut dt = dt_init;
|
||||||
|
let mut budget = steps;
|
||||||
|
|
||||||
|
while budget > 0 {
|
||||||
|
let step = rk45_step(pos, dir, dt, chi);
|
||||||
|
let err = step.err;
|
||||||
|
|
||||||
|
if err > tol * 10.0 && dt > dt_min {
|
||||||
|
// Reject: shrink to dt_min and retry (does not consume budget).
|
||||||
|
dt = dt_min;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Accept: consume one budget unit.
|
||||||
|
budget -= 1;
|
||||||
|
if err <= tol * 10.0 {
|
||||||
|
dt = (dt * (tol / err.max(1e-12)).powf(0.2)).clamp(dt_min, dt_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = step.pos;
|
||||||
|
let r = new_pos.length();
|
||||||
|
if r < r_plus {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if r > escape_r {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pos = new_pos;
|
||||||
|
dir = step.dir;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused-import warning for Vec4 (legacy placeholder; retained).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn _phantom(_v: Vec4) {}
|
fn _phantom(_v: Vec4) {}
|
||||||
|
|
||||||
|
|||||||
@ -74,3 +74,110 @@ fn kerr_bending_accel_nonzero_off_axis_at_nonzero_spin() {
|
|||||||
let diff = (schw - kerr).length();
|
let diff = (schw - kerr).length();
|
||||||
assert!(diff > 1e-4, "spin=0.8 Kerr should differ from Schwarzschild; diff = {}", diff);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user