Files
GR-raytracing/tests/test_termination_oracle.c
T
wyj f7380cbf75 Feat: Rework ray termination into escaped/dark/unresolved/incomplete
Replace the position capture cutoff with a camera-relative dark threshold
shared by every backend, and carry explicit outcome/reason provenance
through the ray, RayPool, adaptive mesh, lens-map and replay paths.

- eval/eval_slab return SpacetimePointStatus; remove SPACETIME_RAY_CAPTURED
  and the Schwarzschild capture radius; decouple observer construction from
  ray position.
- RayEndpoint stores RayOutcome/RayReason plus the last trusted state;
  budget exhaustion is retryable UNRESOLVED, data/integration failures are
  INCOMPLETE.
- Normal dark terminal is L - L0 >= --dark-threshold (default 8), with L0
  taken at the camera event and kept distinct from the worldtube entry
  energy; photon energy and frequency ratio are never reset.
- Implement E/D/U triangle decisions with merged budget retries, persistent
  probe witnesses promoted in place by vertex identity, conformity settling,
  and approximate-black boundary provenance with achieved-scale statistics.
- Add RayPool continuation state and per-ray step budgets.
- Bump lens-map to v2 with explicit end/outcome/reason, approx_black,
  threshold/retry/geometry provenance and per-frame retry counts; reject v1.
- Gate production output on incomplete/error results, overridable with
  --allow-incomplete.
- Update AGENTS.md, the design document and usage docs; add the termination
  oracle and regression coverage.

make -B -j4 BUILD_TYPE=Debug test passes with bit-identical reference HDRs.
2026-10-05 06:22:47 -04:00

294 lines
13 KiB
C

/*
* Independent physics oracle for the ray-termination policy (plan P0).
*
* This test does not read production endpoints for its central assertions.
* It builds Schwarzschild-KS states independently and checks:
* 1. the two radial null branches dr/ds = 1 and dr/ds = (2M-r)/(2M+r);
* 2. the critical impact parameter b = 3 sqrt(3) M and photon sphere r = 3M;
* 3. camera energy normalization E_camera = 1 and tetrad orthonormality;
* 4. the threshold proxy identity ln(p^0) = L - ln(alpha) with
* L = ln(alpha p^0).
*
* The radial-branch assertions are integrated with the production RK4 RHS in
* src/geodesic.c so that an error in the 3+1 reduction is caught against a
* closed-form invariant rather than against a second copy of the same algebra.
*/
#include "asymptotic_schwarzschild.h"
#include "geodesic.h"
#include "observer.h"
#include "spacetime.h"
#include <math.h>
#include <stdio.h>
static int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, message); \
++failures; \
} \
} while (0)
/* Static Eulerian orthonormal tetrad at x = (r0, 0, 0) for r0 > 0. At this
* point the KS spatial metric is diagonal, so the principal axes are already
* orthonormal (up to the radial scale sqrt(gamma_rr)). */
static void radial_static_observer(const MetricData *metric, double r0,
ObserverState *out) {
*out = (ObserverState){.coordinate_time = 0.0,
.coordinate_position = {r0, 0.0, 0.0}};
const double alpha = metric->alpha;
out->tetrad[0][0] = 1.0 / alpha;
for (int i = 0; i < 3; ++i)
out->tetrad[0][i + 1] = -metric->beta[i] / alpha;
const double radial_scale = sqrt(metric->gamma[0][0]);
out->tetrad[1][1] = 1.0 / radial_scale;
out->tetrad[2][2] = 1.0;
out->tetrad[3][3] = 1.0;
}
/* Integrate a purely radial past ray with the production stepper and return
* its final state. Output endpoint is not inspected. */
static int trace_radial(const SpacetimeSource *source, const ObserverState *o,
double direction, GeodesicRayState *state) {
MetricData metric;
if (spacetime_eval(source, o->coordinate_time, o->coordinate_position,
&metric) != SPACETIME_POINT_OK)
return -1;
const double n[3] = {direction, 0.0, 0.0};
if (geodesic_initialize_past_ray_metric(&metric, o, n, state))
return -1;
const GeodesicTraceConfig config = {.coordinate_time_step = 0.02,
.max_steps = 400,
.threshold = {.kind = THRESHOLD_DISABLED, .value = 0.0, .policy_version = 0}};
MetricSlab *slab = NULL;
if (spacetime_load_slab(source, 0.0, -1000.0, &slab))
return -1;
RayEndpoint endpoint = {.end_id = SPACETIME_END_NONE,
.outcome = RAY_OUTCOME_INCOMPLETE};
const GeodesicAdvanceResult result =
geodesic_advance_past_ray(slab, state, -1000.0, &config, &endpoint);
spacetime_free_slab(slab);
return result == GEODESIC_ADVANCE_FAILED ? -1 : 0;
}
static void test_radial_branches(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0) == 0,
"create schwarzschild");
const double r0 = 10.0;
MetricData metric;
CHECK(spacetime_eval(&source, 0.0, (double[]){r0, 0.0, 0.0}, &metric) ==
SPACETIME_POINT_OK,
"metric at r0");
ObserverState observer;
radial_static_observer(&metric, r0, &observer);
/* Branch dr/ds = +1: the closed-form solution is r = r0 + s. */
GeodesicRayState outward;
CHECK(trace_radial(&source, &observer, 1.0, &outward) == 0, "outward trace");
const double s_out = -outward.coordinate_time;
const double invariant_out = outward.x[0] - r0 - s_out;
CHECK(fabs(invariant_out) < 1e-6, "outward branch r = r0 + s");
/* Branch dr/ds = (2M-r)/(2M+r): the closed-form invariant is
* (r-2M) + 4M ln(r-2M) + s = const. */
GeodesicRayState inward;
CHECK(trace_radial(&source, &observer, -1.0, &inward) == 0, "inward trace");
const double s_in = -inward.coordinate_time;
const double c0 = (r0 - 2.0) + 4.0 * log(r0 - 2.0);
const double c1 = (inward.x[0] - 2.0) + 4.0 * log(inward.x[0] - 2.0) + s_in;
CHECK(inward.x[0] > 2.0, "inward branch stays outside the horizon");
CHECK(inward.x[0] < r0, "inward branch decreases r");
CHECK(fabs(c1 - c0) < 1e-6, "inward branch closed-form invariant");
/* Both branches are time-reversal partners: the outward and inward states
* reach the same |dr/ds| magnitude in opposite senses at r0. */
CHECK(outward.x[0] > r0, "outward branch increases r");
spacetime_destroy(&source);
}
/* The production dark policy is the camera-relative growth A_0 = L - L_0,
* independent of the backend. This oracle retains the stationary-KS
* conserved Killing energy A_K = L - ln|E_K| as an independent cross-check of
* the same ray: it verifies E_K conservation and the identity
* A_K - A_0 = -ln|alpha_0 - beta_0.Pi_0|. It is not the production
* criterion. */
static void test_killing_energy_reference(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0) == 0,
"create schwarzschild for killing reference");
const double r0 = 10.0;
MetricData start_metric;
CHECK(spacetime_eval(&source, 0.0, (double[]){r0, 0.0, 0.0}, &start_metric) ==
SPACETIME_POINT_OK,
"metric for killing reference");
ObserverState observer;
radial_static_observer(&start_metric, r0, &observer);
GeodesicRayState start;
CHECK(geodesic_initialize_past_ray_metric(&start_metric, &observer,
(double[]){1.0, 0.0, 0.0},
&start) == 0,
"initialize killing ray");
double beta0 = 0.0;
for (int i = 0; i < 3; ++i)
beta0 += start_metric.beta[i] * start.Pi[i];
const double ek0 = exp(start.log_alpha_p0) * (start_metric.alpha - beta0);
CHECK(isfinite(ek0) && fabs(ek0) > 0.0, "nonzero Killing energy");
GeodesicRayState end;
CHECK(trace_radial(&source, &observer, 1.0, &end) == 0,
"trace killing reference ray");
MetricData end_metric;
CHECK(spacetime_eval(&source, end.coordinate_time, end.x, &end_metric) ==
SPACETIME_POINT_OK,
"metric at killing reference end");
double beta1 = 0.0;
for (int i = 0; i < 3; ++i)
beta1 += end_metric.beta[i] * end.Pi[i];
const double ek1 = exp(end.log_alpha_p0) * (end_metric.alpha - beta1);
CHECK(fabs(ek1 / ek0 - 1.0) < 1e-6,
"Killing energy conserved along the geodesic");
const double a0 = end.log_alpha_p0 - start.log_alpha_p0;
const double ak = end.log_alpha_p0 - log(fabs(ek1));
const double predicted = -log(fabs(start_metric.alpha - beta0));
CHECK(fabs((ak - a0) - predicted) < 1e-9,
"A_K - A_0 equals the initial boost factor");
spacetime_destroy(&source);
}
static void test_critical_parameters(void) {
const double b_crit = 3.0 * sqrt(3.0);
CHECK(!isfinite(asymptotic_schwarzschild_turning_rho(b_crit - 1e-6)),
"no turning point below b_crit");
CHECK(!isfinite(asymptotic_schwarzschild_turning_rho(3.0)),
"no turning point for a deeply plunging ray");
const double just_above = asymptotic_schwarzschild_turning_rho(b_crit + 1e-6);
CHECK(isfinite(just_above) && just_above > 3.0 && just_above < 3.01,
"turning radius approaches the photon sphere at b_crit");
const double b6 = asymptotic_schwarzschild_turning_rho(6.0);
CHECK(isfinite(b6) && b6 > 3.0, "turning radius above the photon sphere");
/* Verify the turning radius is an independent root of
* f(rho) = rho^3 - b^2 rho + 2 b^2. */
const double residual = b6 * b6 * b6 - 36.0 * b6 + 72.0;
CHECK(fabs(residual) < 1e-9, "turning radius satisfies the radial equation");
}
static void test_observer_normalization(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0) == 0,
"create schwarzschild for observer");
ObserverCamera camera = {.coordinate_time = 0.0,
.position = {30.0, 0.0, 0.0},
.velocity = {0.0, 0.0, 0.0},
.look_ra_deg = 0.0,
.look_dec_deg = 0.0};
MetricData metric;
ObserverState observer;
CHECK(spacetime_eval(&source, 0.0, camera.position, &metric) ==
SPACETIME_POINT_OK,
"metric at camera");
CHECK(observer_from_coordinate_camera(&metric, &camera, &observer, NULL) ==
OBSERVER_BUILD_OK,
"build observer");
/* Orthonormality of the production tetrad, independently of the geodesic
* layer: g(e_a, e_b) = diag(-1, 1, 1, 1). */
for (int a = 0; a < 4; ++a) {
for (int b = 0; b < 4; ++b) {
const double *ea = observer.tetrad[a];
const double *eb = observer.tetrad[b];
double inner = -metric.alpha * metric.alpha * ea[0] * eb[0];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
inner += metric.gamma[i][j] * (ea[i + 1] + metric.beta[i] * ea[0]) *
(eb[j + 1] + metric.beta[j] * eb[0]);
const double expected = a == b ? (a == 0 ? -1.0 : 1.0) : 0.0;
CHECK(fabs(inner - expected) < 1e-10, "tetrad orthonormal");
}
}
const double local[3] = {0.3, 0.5, 0.9};
const double norm = sqrt(local[0] * local[0] + local[1] * local[1] +
local[2] * local[2]);
const double direction[3] = {local[0] / norm, local[1] / norm,
local[2] / norm};
GeodesicRayState state;
CHECK(geodesic_initialize_past_ray_metric(&metric, &observer, direction,
&state) == 0,
"initialize past ray");
/* gamma is diagonal at (30, 0, 0): gamma_xx = 1 + 2/r. */
double gamma_inv[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
gamma_inv[i][j] = (i == j) ? 1.0 / metric.gamma[i][j] : 0.0;
/* Null constraint gamma^{ij} Pi_i Pi_j = 1. */
double null_residual = 0.0;
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
null_residual += gamma_inv[i][j] * state.Pi[i] * state.Pi[j];
CHECK(fabs(null_residual - 1.0) < 1e-10, "null constraint preserved");
/* Observed energy -g(k, e0) = 1 for the unit observer four-velocity. The
* photon four-momentum is reconstructed from the stored state:
* p^0 = exp(L)/alpha and p^i = alpha p^0 gamma^{ij} Pi_j - beta^i p^0. */
const double k0 = exp(state.log_alpha_p0) / metric.alpha;
double k[4] = {k0, 0.0, 0.0, 0.0};
for (int i = 0; i < 3; ++i) {
double covariant = 0.0;
for (int j = 0; j < 3; ++j)
covariant += gamma_inv[i][j] * state.Pi[j];
k[i + 1] = metric.alpha * k0 * covariant - metric.beta[i] * k0;
}
const double *e0 = observer.tetrad[0];
double inner = -metric.alpha * metric.alpha * k[0] * e0[0];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
inner += metric.gamma[i][j] * (k[i + 1] + metric.beta[i] * k[0]) *
(e0[j + 1] + metric.beta[j] * e0[0]);
CHECK(fabs(inner + 1.0) < 1e-10, "camera energy normalized to one");
spacetime_destroy(&source);
}
static void test_threshold_proxies(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0) == 0,
"create schwarzschild for proxies");
const double r0 = 30.0;
MetricData metric;
CHECK(spacetime_eval(&source, 0.0, (double[]){r0, 0.0, 0.0}, &metric) ==
SPACETIME_POINT_OK,
"metric for proxies");
ObserverState observer;
radial_static_observer(&metric, r0, &observer);
const double direction[3] = {1.0, 0.0, 0.0};
GeodesicRayState state;
CHECK(geodesic_initialize_past_ray_metric(&metric, &observer, direction,
&state) == 0,
"initialize proxy ray");
/* L = ln(alpha p^0) is stored; ln(p^0) = L - ln(alpha). Recompute p^0 from
* the tetrad and direction independently. */
const double k0 = observer.tetrad[0][0] - direction[0] * observer.tetrad[1][0] -
direction[1] * observer.tetrad[2][0] -
direction[2] * observer.tetrad[3][0];
const double log_p0 = state.log_alpha_p0 - log(metric.alpha);
CHECK(fabs(log_p0 - log(k0)) < 1e-12,
"ln(p^0) = L - ln(alpha) with L = ln(alpha p^0)");
/* For a static observer far outside, alpha -> 1 and the two proxies agree
* to O(M/r); this documents why a fixed L threshold is not a fixed p^0
* threshold. */
CHECK(fabs(state.log_alpha_p0 - log_p0) > 1e-3,
"L and ln(p^0) differ near the hole");
spacetime_destroy(&source);
}
int main(void) {
test_radial_branches();
test_killing_energy_reference();
test_critical_parameters();
test_observer_normalization();
test_threshold_proxies();
if (failures != 0) {
fprintf(stderr, "termination oracle: %d failure(s)\n", failures);
return 1;
}
return 0;
}