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.
753 lines
33 KiB
C
753 lines
33 KiB
C
#include "asymptotic.h"
|
|
#include "observer.h"
|
|
#include "ray.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 ObserverState flat_observer(double x, double y, double z) {
|
|
ObserverState o = {0};
|
|
o.coordinate_position[0] = x;
|
|
o.coordinate_position[1] = y;
|
|
o.coordinate_position[2] = z;
|
|
o.tetrad[0][0] = 1.0;
|
|
o.tetrad[1][1] = 1.0;
|
|
o.tetrad[2][2] = 1.0;
|
|
o.tetrad[3][3] = 1.0;
|
|
return o;
|
|
}
|
|
|
|
/* Synthetic flat exterior with a Minkowski end whose worldtube center follows
|
|
* x_c(t) = vx t + accel t^2 / 2. `constant` selects the closed quadratic path;
|
|
* otherwise the generic bracketed driver runs. */
|
|
typedef struct {
|
|
double vx;
|
|
double accel;
|
|
double radius;
|
|
double radius_rate;
|
|
double valid_t_min;
|
|
int constant;
|
|
double segment_t; /* Motion-segment boundary for the cross-segment test. */
|
|
int has_segment;
|
|
int end_descriptor_fails; /* Protocol-error injection. */
|
|
int unsupported_kind;
|
|
double invalid_center, invalid_halfwidth; /* Isolated invalid time window. */
|
|
int schwarzschild_kind; /* Declare a Schwarzschild monopole end. */
|
|
int sample_callback_fails; /* make escape_worldtube_sample return -1 */
|
|
int sample_invalid; /* valid = 0 */
|
|
int sample_nan_radius;
|
|
int sample_nonpositive_radius;
|
|
int fail_on_sample_call; /* 1-based callback invocation to fail. */
|
|
int sample_call_count;
|
|
} SyntheticContext;
|
|
|
|
static SpacetimePointStatus synthetic_eval(const SpacetimeSource *source,
|
|
double t, const double x[3],
|
|
MetricData *metric) {
|
|
(void)source;
|
|
(void)t;
|
|
(void)x;
|
|
*metric = (MetricData){.alpha = 1.0,
|
|
.gamma = {{1.0, 0.0, 0.0},
|
|
{0.0, 1.0, 0.0},
|
|
{0.0, 0.0, 1.0}}};
|
|
return SPACETIME_POINT_OK;
|
|
}
|
|
|
|
static SpacetimeRayStatus synthetic_classify(const SpacetimeSource *source,
|
|
double t, const double x[3]) {
|
|
(void)source;
|
|
(void)t;
|
|
(void)x;
|
|
return SPACETIME_RAY_ACTIVE;
|
|
}
|
|
|
|
static size_t synthetic_end_count(const SpacetimeSource *source) {
|
|
(void)source;
|
|
return 1;
|
|
}
|
|
|
|
static int synthetic_end(const SpacetimeSource *source, size_t index,
|
|
SpacetimeAsymptoticEnd *out) {
|
|
const SyntheticContext *context = source->context;
|
|
if (index != 0 || context->end_descriptor_fails)
|
|
return -1;
|
|
AsymptoticExteriorKind kind = ASYMPTOTIC_EXTERIOR_MINKOWSKI;
|
|
double mass = 0.0;
|
|
if (context->unsupported_kind) {
|
|
kind = (AsymptoticExteriorKind)999;
|
|
} else if (context->schwarzschild_kind) {
|
|
kind = ASYMPTOTIC_EXTERIOR_SCHWARZSCHILD_MONOPOLE;
|
|
mass = 1.0;
|
|
}
|
|
*out = (SpacetimeAsymptoticEnd){
|
|
.end_id = 0,
|
|
.exterior_kind = kind,
|
|
.mass = mass,
|
|
.frame_origin = {0.0, 0.0, 0.0},
|
|
.frame_axes = {{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}};
|
|
return 0;
|
|
}
|
|
|
|
static int synthetic_worldtube(const SpacetimeSource *source,
|
|
SpacetimeEndId end_id, double t,
|
|
SpacetimeEscapeWorldtubeSample *out) {
|
|
SyntheticContext *mutable_context = source->context;
|
|
const SyntheticContext *context = mutable_context;
|
|
if (end_id != 0)
|
|
return -1;
|
|
++mutable_context->sample_call_count;
|
|
if (context->fail_on_sample_call > 0 &&
|
|
mutable_context->sample_call_count == context->fail_on_sample_call)
|
|
return -1;
|
|
if (context->sample_callback_fails)
|
|
return -1;
|
|
if (context->sample_invalid) {
|
|
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
|
|
return 0;
|
|
}
|
|
if (context->sample_nan_radius) {
|
|
*out = (SpacetimeEscapeWorldtubeSample){.radius = NAN, .valid = 1};
|
|
return 0;
|
|
}
|
|
if (context->sample_nonpositive_radius) {
|
|
*out = (SpacetimeEscapeWorldtubeSample){.radius = 0.0, .valid = 1};
|
|
return 0;
|
|
}
|
|
if (!isfinite(t) || t < context->valid_t_min) {
|
|
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
|
|
return 0;
|
|
}
|
|
if (context->invalid_halfwidth > 0.0 &&
|
|
fabs(t - context->invalid_center) <= context->invalid_halfwidth) {
|
|
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
|
|
return 0;
|
|
}
|
|
if (context->has_segment && t < context->segment_t) {
|
|
/* Second segment: center moves toward +x as t decreases. */
|
|
*out = (SpacetimeEscapeWorldtubeSample){
|
|
.center = {context->segment_t - t, 0.0, 0.0},
|
|
.velocity = {-1.0, 0.0, 0.0},
|
|
.radius = context->radius,
|
|
.radius_rate = context->radius_rate,
|
|
.velocity_constant = context->constant,
|
|
.valid = 1};
|
|
return 0;
|
|
}
|
|
*out = (SpacetimeEscapeWorldtubeSample){
|
|
.center = {context->vx * t + 0.5 * context->accel * t * t, 0.0, 0.0},
|
|
.velocity = {context->vx + context->accel * t, 0.0, 0.0},
|
|
.radius = context->radius,
|
|
.radius_rate = context->radius_rate,
|
|
.velocity_constant = context->constant,
|
|
.valid = 1};
|
|
return 0;
|
|
}
|
|
|
|
static double synthetic_next_segment(const SpacetimeSource *source,
|
|
SpacetimeEndId end_id, double t) {
|
|
const SyntheticContext *context = source->context;
|
|
(void)end_id;
|
|
if (context->has_segment && t > context->segment_t)
|
|
return context->segment_t;
|
|
return NAN;
|
|
}
|
|
|
|
static void synthetic_destroy(SpacetimeSource *source) {
|
|
/* The test context lives on the stack, so it is not freed; but match the
|
|
* real destroy postcondition. */
|
|
source->context = NULL;
|
|
source->ops = NULL;
|
|
}
|
|
|
|
static const SpacetimeOps synthetic_ops = {
|
|
.eval = synthetic_eval,
|
|
.classify = synthetic_classify,
|
|
.asymptotic_end_count = synthetic_end_count,
|
|
.asymptotic_end = synthetic_end,
|
|
.escape_worldtube_sample = synthetic_worldtube,
|
|
.escape_worldtube_next_segment = synthetic_next_segment,
|
|
.destroy = synthetic_destroy,
|
|
};
|
|
|
|
static void test_fixed_sphere(void) {
|
|
SpacetimeSource source = {0};
|
|
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
|
|
AsymptoticRoute route;
|
|
|
|
const ObserverState inside = flat_observer(0.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &inside, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
|
|
"origin camera is inside");
|
|
|
|
const ObserverState outside = flat_observer(50.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &outside, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"outside ray toward sphere enters");
|
|
CHECK(fabs(route.activate_t + 40.0) < 1e-9, "fixed-sphere entry time");
|
|
CHECK(fabs(route.x[0] - 10.0) < 1e-9 && fabs(route.x[1]) < 1e-9 &&
|
|
fabs(route.x[2]) < 1e-9,
|
|
"fixed-sphere entry position");
|
|
|
|
CHECK(asymptotic_route_camera(&source, &outside, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"outside ray away misses");
|
|
CHECK(fabs(route.n_infinity[0] - 1.0) < 1e-12 &&
|
|
fabs(route.n_infinity[1]) < 1e-12,
|
|
"miss direction");
|
|
CHECK(fabs(route.frequency_ratio - 1.0) < 1e-12, "flat frequency ratio");
|
|
|
|
const ObserverState tangent = flat_observer(50.0, 10.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &tangent, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"tangent ray is not a crossing");
|
|
|
|
const ObserverState near_miss = flat_observer(50.0, 10.0 + 1e-6, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &near_miss,
|
|
(double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"near-tangent outside ray misses");
|
|
const ObserverState near_hit = flat_observer(50.0, 10.0 - 1e-6, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &near_hit,
|
|
(double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"near-tangent inside ray enters");
|
|
|
|
RayEndpoint endpoint;
|
|
CHECK(asymptotic_finish_escape(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
|
|
(double[]){-1.0, 0.0, 0.0}, 0.0,
|
|
&endpoint) == ASYMPTOTIC_OK &&
|
|
endpoint.outcome == RAY_OUTCOME_ESCAPED && endpoint.end_id == 0,
|
|
"finish outward crossing");
|
|
CHECK(fabs(endpoint.n_infinity[0] - 1.0) < 1e-12 &&
|
|
fabs(endpoint.frequency_ratio - 1.0) < 1e-12,
|
|
"finish direction and frequency");
|
|
|
|
spacetime_destroy(&source);
|
|
}
|
|
|
|
static void test_large_radius_quadratic(void) {
|
|
SpacetimeSource source = {0};
|
|
CHECK(spacetime_create_minkowski(&source, 1.0e12) == 0,
|
|
"create huge minkowski sphere");
|
|
AsymptoticRoute route;
|
|
const ObserverState hit = flat_observer(2.0e12, 5.0e11, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &hit, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"large-radius hit stays quadratic");
|
|
const ObserverState miss = flat_observer(2.0e12, 2.0e12, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &miss, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"large-radius miss stays quadratic");
|
|
spacetime_destroy(&source);
|
|
|
|
/* Small entry root a hair outside a large sphere: the cancellation-prone
|
|
* case for the naive formula. */
|
|
SpacetimeSource big = {0};
|
|
CHECK(spacetime_create_minkowski(&big, 1.0e9) == 0, "create 1e9 sphere");
|
|
const ObserverState just_outside = flat_observer(1.0e9 + 1e-3, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&big, &just_outside,
|
|
(double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"just-outside hit");
|
|
const double expected_delta =
|
|
just_outside.coordinate_position[0] - 1.0e9;
|
|
CHECK(fabs(-route.activate_t - expected_delta) <
|
|
1e-7 + 1e-11 * fabs(expected_delta),
|
|
"just-outside entry time within budget");
|
|
double residual;
|
|
CHECK(asymptotic_worldtube_value(&big, route.end_id, route.activate_t,
|
|
route.x, &residual) == 0 &&
|
|
fabs(residual) <= 1e-12 * 1.0e9 * 1.0e9,
|
|
"just-outside entry on worldtube");
|
|
spacetime_destroy(&big);
|
|
}
|
|
|
|
static void test_boundary_semantics_minkowski(void) {
|
|
SpacetimeSource source = {0};
|
|
CHECK(spacetime_create_minkowski(&source, 10.0) == 0,
|
|
"create minkowski");
|
|
AsymptoticRoute route;
|
|
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &on_boundary,
|
|
(double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
|
|
"on-boundary past-inward is inside");
|
|
CHECK(asymptotic_route_camera(&source, &on_boundary,
|
|
(double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"on-boundary past-outward escapes");
|
|
CHECK(asymptotic_route_camera(&source, &on_boundary,
|
|
(double[]){0.0, 1.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"on-boundary tangent escapes");
|
|
spacetime_destroy(&source);
|
|
}
|
|
|
|
static void test_boundary_semantics_generic(void) {
|
|
/* velocity_constant == 0 forces the generic bracketed driver. A finite
|
|
* history bounds the outward/tangent searches, which must not be reported
|
|
* as entries (they end as TIME_RANGE_EXHAUSTED instead). */
|
|
SyntheticContext context = {.radius = 10.0,
|
|
.valid_t_min = -100.0,
|
|
.constant = 0};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
const AsymptoticStatus inward = asymptotic_route_camera(
|
|
&source, &on_boundary, (double[]){-1.0, 0.0, 0.0}, &route);
|
|
CHECK(inward == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
|
|
"generic on-boundary inward is inside");
|
|
const AsymptoticStatus outward = asymptotic_route_camera(
|
|
&source, &on_boundary, (double[]){1.0, 0.0, 0.0}, &route);
|
|
CHECK(outward == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
|
|
"generic on-boundary outward is not an entry");
|
|
const AsymptoticStatus tangent = asymptotic_route_camera(
|
|
&source, &on_boundary, (double[]){0.0, 1.0, 0.0}, &route);
|
|
CHECK(tangent == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
|
|
"generic on-boundary tangent is not an entry");
|
|
}
|
|
|
|
static void test_negative_radius_root_guard(void) {
|
|
/* Deliberately bypasses a constructor: every sampled radius is finite and
|
|
* positive, but the algebraic root sits where R < 0. The cheap root-level
|
|
* guard must reject it instead of fabricating a negative-radius entry. */
|
|
SyntheticContext context = {.radius = 10.0,
|
|
.radius_rate = 2.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
CHECK(asymptotic_route_camera(&source, &on_boundary,
|
|
(double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"negative-radius algebraic root is rejected");
|
|
}
|
|
|
|
static void test_source_finalize(void) {
|
|
/* A real constructor already finalizes: finalize is idempotent. */
|
|
SpacetimeSource good = {0};
|
|
CHECK(spacetime_create_minkowski(&good, 10.0) == 0, "create minkowski");
|
|
CHECK(spacetime_source_finalize(&good) == 0, "valid source finalizes");
|
|
spacetime_destroy(&good);
|
|
|
|
/* A structurally valid synthetic source must pass, so the failure cases
|
|
* below are attributable to their specific defect rather than to the test
|
|
* ops themselves. */
|
|
SyntheticContext well_formed = {.radius = 10.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1};
|
|
SpacetimeSource valid_source = {.ops = &synthetic_ops,
|
|
.context = &well_formed};
|
|
CHECK(spacetime_source_finalize(&valid_source) == 0,
|
|
"well-formed synthetic source finalizes");
|
|
|
|
/* Structural protocol errors must be rejected before any ray trace. */
|
|
SyntheticContext bad_kind = {.radius = 10.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.unsupported_kind = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &bad_kind};
|
|
CHECK(spacetime_source_finalize(&source) != 0,
|
|
"unsupported exterior kind fails finalize");
|
|
|
|
SyntheticContext bad_desc = {.radius = 10.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.end_descriptor_fails = 1};
|
|
source = (SpacetimeSource){.ops = &synthetic_ops, .context = &bad_desc};
|
|
CHECK(spacetime_source_finalize(&source) != 0,
|
|
"broken end descriptor fails finalize");
|
|
}
|
|
|
|
static void test_motion_segment_domain(void) {
|
|
/* Segment 1 (t >= -50) is a static R=10 sphere; its quadratic root lies at
|
|
* s = 90, past the segment boundary. Segment 2 (t < -50) moves the center
|
|
* with velocity -1, so the true entry is at s = 70. The result must come
|
|
* from segment 2. */
|
|
SyntheticContext context = {.radius = 10.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.segment_t = -50.0,
|
|
.has_segment = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
CHECK(asymptotic_route_camera(&source, &camera,
|
|
(double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"entry found in the second motion segment");
|
|
CHECK(fabs(route.activate_t + 70.0) < 1e-6,
|
|
"second-segment entry, not the stale first-segment root");
|
|
}
|
|
|
|
static void test_schwarzschild_sample_failures(void) {
|
|
const ObserverState camera = flat_observer(0.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
SyntheticContext base = {.radius = 256.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.schwarzschild_kind = 1};
|
|
|
|
SyntheticContext callback = base;
|
|
callback.sample_callback_fails = 1;
|
|
SpacetimeSource s1 = {.ops = &synthetic_ops, .context = &callback};
|
|
CHECK(asymptotic_route_camera(&s1, &camera, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"schwarzschild callback failure is invalid");
|
|
|
|
SyntheticContext invalid = base;
|
|
invalid.sample_invalid = 1;
|
|
SpacetimeSource s2 = {.ops = &synthetic_ops, .context = &invalid};
|
|
CHECK(asymptotic_route_camera(&s2, &camera, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
|
|
"schwarzschild valid=0 is exhausted");
|
|
|
|
SyntheticContext nan = base;
|
|
nan.sample_nan_radius = 1;
|
|
SpacetimeSource s3 = {.ops = &synthetic_ops, .context = &nan};
|
|
CHECK(asymptotic_route_camera(&s3, &camera, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"schwarzschild NaN radius is invalid");
|
|
|
|
SyntheticContext zero = base;
|
|
zero.sample_nonpositive_radius = 1;
|
|
SpacetimeSource s4 = {.ops = &synthetic_ops, .context = &zero};
|
|
CHECK(asymptotic_route_camera(&s4, &camera, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"schwarzschild non-positive radius is invalid");
|
|
|
|
/* Second call fails: the containment sample (#1) succeeds with the camera
|
|
* outside, and schwarzschild_route's own sample (#2) is the one that fails.
|
|
* This locks the dedicated Schwarzschild sample handling. */
|
|
SyntheticContext second = base;
|
|
second.fail_on_sample_call = 2;
|
|
SpacetimeSource s5 = {.ops = &synthetic_ops, .context = &second};
|
|
const ObserverState outside = flat_observer(500.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&s5, &outside, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"schwarzschild_route second-sample failure is invalid");
|
|
}
|
|
|
|
static void test_end_protocol_error(void) {
|
|
const ObserverState inside = flat_observer(0.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
SyntheticContext bad = {.radius = 20.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.end_descriptor_fails = 1};
|
|
SpacetimeSource bad_source = {.ops = &synthetic_ops, .context = &bad};
|
|
CHECK(asymptotic_route_camera(&bad_source, &inside,
|
|
(double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_INVALID,
|
|
"bad end descriptor is an explicit protocol error");
|
|
|
|
SyntheticContext unsupported = {.radius = 20.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.unsupported_kind = 1};
|
|
SpacetimeSource unsupported_source = {.ops = &synthetic_ops,
|
|
.context = &unsupported};
|
|
CHECK(asymptotic_route_camera(&unsupported_source, &inside,
|
|
(double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_UNSUPPORTED,
|
|
"unsupported exterior with camera inside is not silently accepted");
|
|
|
|
/* The lifecycle layer, not just the pre-route, must refuse legacy fallback
|
|
* whenever ends are declared but broken. */
|
|
MetricSlab *slab = NULL;
|
|
CHECK(spacetime_load_slab(&bad_source, 0.0, -10.0, &slab) == 0,
|
|
"bad-descriptor slab");
|
|
GeodesicRayState state = {.coordinate_time = 0.0,
|
|
.x = {1.0, 0.0, 0.0},
|
|
.Pi = {0.0, 0.0, 0.0},
|
|
.log_alpha_p0 = 0.0,
|
|
.steps = 0};
|
|
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
|
|
.max_steps = 10};
|
|
RayEndpoint endpoint = {.frequency_ratio = 0.0,
|
|
.magnification = 1.0,
|
|
.end_id = SPACETIME_END_NONE,
|
|
.outcome = RAY_OUTCOME_INCOMPLETE};
|
|
CHECK(geodesic_advance_past_ray(slab, &state, -10.0, &config, &endpoint) ==
|
|
GEODESIC_ADVANCE_FAILED &&
|
|
endpoint.outcome == RAY_OUTCOME_INCOMPLETE &&
|
|
endpoint.reason == RAY_REASON_PROTOCOL_ERROR,
|
|
"advance rejects a declared-but-broken end without legacy");
|
|
spacetime_free_slab(slab);
|
|
}
|
|
|
|
static void test_interior_crossing_bisection_failure(void) {
|
|
/* radius 20.3 makes the exit land strictly between steps: the accepted
|
|
* step goes from F < 0 (t = -119.7) to F > 0 (t = -120.7). The invalid
|
|
* window sits on the first bisection midpoint (t = -120.2), while both
|
|
* accepted-step endpoints stay valid. */
|
|
SyntheticContext context = {.radius = 20.3,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 1,
|
|
.invalid_center = -120.2,
|
|
.invalid_halfwidth = 0.05};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState observer = flat_observer(100.0, 0.0, 0.0);
|
|
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
|
|
.max_steps = 2048};
|
|
const RayEndpoint endpoint = geodesic_trace_past(
|
|
&source, &observer, (double[]){-1.0, 0.0, 0.0}, &config);
|
|
CHECK(endpoint.outcome == RAY_OUTCOME_INCOMPLETE &&
|
|
endpoint.reason == RAY_REASON_TIME_RANGE_EXHAUSTED &&
|
|
endpoint.end_id == 0,
|
|
"interior crossing bisection propagates history exhaustion");
|
|
}
|
|
|
|
static void test_generic_bisection_failure(void) {
|
|
/* The isolated invalid window lands on a bisection midpoint while the
|
|
* bracket endpoints stay valid, so only the bisection can see it. With the
|
|
* strict F < 0 entry test, the bracket is s = 80 (F == 0) to s = 90
|
|
* (F < 0), so the first midpoint is t = -85. */
|
|
SyntheticContext context = {.radius = 20.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 0,
|
|
.invalid_center = -85.0,
|
|
.invalid_halfwidth = 1.0};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
|
|
"generic worldtube bisection propagates sample failure");
|
|
}
|
|
|
|
static void test_interior_history_exhaustion(void) {
|
|
SyntheticContext context = {.radius = 20.0,
|
|
.valid_t_min = -100.0,
|
|
.constant = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState observer = flat_observer(100.0, 0.0, 0.0);
|
|
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
|
|
.max_steps = 2048};
|
|
const RayEndpoint endpoint = geodesic_trace_past(
|
|
&source, &observer, (double[]){-1.0, 0.0, 0.0}, &config);
|
|
CHECK(endpoint.outcome == RAY_OUTCOME_INCOMPLETE &&
|
|
endpoint.reason == RAY_REASON_TIME_RANGE_EXHAUSTED &&
|
|
endpoint.end_id == 0,
|
|
"interior worldtube history exhaustion on a single trace");
|
|
|
|
RayPool pool;
|
|
CHECK(ray_pool_init(&pool, 1) == 0, "pool init");
|
|
CHECK(ray_pool_append(&pool, &observer, (double[]){-1.0, 0.0, 0.0}, 0, 0) ==
|
|
0,
|
|
"append exhaustion ray");
|
|
ray_pool_preroute(&pool, &source);
|
|
CHECK(pool.status[0] == RAY_POOL_PENDING, "exhaustion ray pends entry");
|
|
MetricSlab *slab = NULL;
|
|
CHECK(spacetime_load_slab(&source, -80.0, -3000.0, &slab) == 0,
|
|
"exhaustion slab");
|
|
ray_pool_activate_in_time_range(&pool, slab);
|
|
CHECK(pool.status[0] == RAY_POOL_ACTIVE, "exhaustion ray activates");
|
|
ray_pool_advance_active(&pool, slab, &config);
|
|
CHECK(pool.endpoint[0].outcome == RAY_OUTCOME_INCOMPLETE &&
|
|
pool.endpoint[0].reason == RAY_REASON_TIME_RANGE_EXHAUSTED &&
|
|
pool.endpoint[0].end_id == 0 &&
|
|
pool.status[0] == RAY_POOL_TERMINATED,
|
|
"interior worldtube history exhaustion on a RayPool");
|
|
spacetime_free_slab(slab);
|
|
ray_pool_destroy(&pool);
|
|
}
|
|
|
|
static void test_round_trip(void) {
|
|
SpacetimeSource source = {0};
|
|
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
|
|
MetricData metric = {.alpha = 1.0,
|
|
.gamma = {{1.0, 0.0, 0.0},
|
|
{0.0, 1.0, 0.0},
|
|
{0.0, 0.0, 1.0}}};
|
|
AsymptoticPhotonState canonical;
|
|
CHECK(asymptotic_canonical_from_backend(&source, 0, &metric, 0.0,
|
|
(double[]){3.0, 4.0, 0.0},
|
|
(double[]){-0.6, 0.8, 0.0}, 0.25,
|
|
&canonical) == 0,
|
|
"backend to canonical");
|
|
double x[3], Pi[3], log_alpha_p0;
|
|
CHECK(asymptotic_backend_from_canonical(&source, &metric, &canonical, x, Pi,
|
|
&log_alpha_p0) == 0,
|
|
"canonical to backend");
|
|
CHECK(fabs(x[0] - 3.0) < 1e-14 && fabs(x[1] - 4.0) < 1e-14 &&
|
|
fabs(Pi[0] + 0.6) < 1e-14 && fabs(Pi[1] - 0.8) < 1e-14 &&
|
|
fabs(log_alpha_p0 - 0.25) < 1e-14,
|
|
"round trip matches");
|
|
spacetime_destroy(&source);
|
|
}
|
|
|
|
static void test_moving_sphere(void) {
|
|
SyntheticContext context = {.vx = 0.5, .accel = 0.0, .radius = 25.0,
|
|
.valid_t_min = -1.0e30, .constant = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
AsymptoticRoute route;
|
|
|
|
const ObserverState head_on = flat_observer(100.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &head_on, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"head-on moving-sphere entry");
|
|
CHECK(fabs(route.activate_t + 150.0) < 1e-9, "head-on entry time");
|
|
CHECK(fabs(route.x[0] + 50.0) < 1e-9, "head-on entry position");
|
|
double value;
|
|
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
|
|
route.x, &value) == 0 &&
|
|
fabs(value) <= 1e-13 * 25.0 * 25.0,
|
|
"head-on entry lies on worldtube");
|
|
|
|
const ObserverState transverse = flat_observer(0.0, 40.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &transverse,
|
|
(double[]){0.0, -1.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"transverse moving-sphere entry");
|
|
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
|
|
route.x, &value) == 0 &&
|
|
fabs(value) <= 1e-13 * 25.0 * 25.0,
|
|
"transverse entry lies on worldtube");
|
|
|
|
const ObserverState away = flat_observer(100.0, 0.0, 0.0);
|
|
CHECK(asymptotic_route_camera(&source, &away, (double[]){1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
|
|
"co-moving ray misses");
|
|
}
|
|
|
|
static void test_accelerated_worldtube(void) {
|
|
SyntheticContext context = {.vx = 0.0, .accel = 0.02, .radius = 20.0,
|
|
.valid_t_min = -1.0e30, .constant = 0};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"accelerated worldtube entry");
|
|
double value;
|
|
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
|
|
route.x, &value) == 0 &&
|
|
fabs(value) <= 1e-13 * 20.0 * 20.0,
|
|
"accelerated entry lies on worldtube");
|
|
CHECK(route.activate_t < -40.0 && route.activate_t > -60.0,
|
|
"accelerated entry time in range");
|
|
|
|
context.valid_t_min = -30.0;
|
|
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
|
|
"exhausted history is not a miss");
|
|
}
|
|
|
|
static void test_accelerated_segment(void) {
|
|
SyntheticContext context = {.vx = 0.0,
|
|
.accel = 0.0,
|
|
.radius = 20.0,
|
|
.valid_t_min = -1.0e30,
|
|
.constant = 0,
|
|
.segment_t = -50.0,
|
|
.has_segment = 1};
|
|
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
|
|
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
|
|
AsymptoticRoute route;
|
|
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
|
|
&route) == ASYMPTOTIC_OK &&
|
|
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
|
|
"cross-segment entry");
|
|
CHECK(route.activate_t < context.segment_t,
|
|
"entry lies past the motion-segment boundary");
|
|
CHECK(fabs(route.activate_t + 65.0) < 1e-6, "cross-segment entry time");
|
|
double value;
|
|
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
|
|
route.x, &value) == 0 &&
|
|
fabs(value) <= 1e-13 * 20.0 * 20.0,
|
|
"cross-segment entry on worldtube");
|
|
}
|
|
|
|
static void test_ray_pool_lifecycle(void) {
|
|
SpacetimeSource source = {0};
|
|
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
|
|
const ObserverState observer = flat_observer(50.0, 0.0, 0.0);
|
|
RayPool pool;
|
|
CHECK(ray_pool_init(&pool, 2) == 0, "pool init");
|
|
CHECK(ray_pool_append(&pool, &observer, (double[]){-1.0, 0.0, 0.0}, 0, 0) == 0,
|
|
"append hit");
|
|
CHECK(ray_pool_append(&pool, &observer, (double[]){1.0, 0.0, 0.0}, 0, 1) == 0,
|
|
"append miss");
|
|
ray_pool_preroute(&pool, &source);
|
|
CHECK(pool.status[0] == RAY_POOL_PENDING &&
|
|
pool.activate_t[0] < observer.coordinate_time - 1.0,
|
|
"entry ray stays pending until entry time");
|
|
CHECK(pool.status[1] == RAY_POOL_TERMINATED &&
|
|
pool.endpoint[1].outcome == RAY_OUTCOME_ESCAPED,
|
|
"miss ray escapes during pre-route");
|
|
|
|
MetricSlab *early = NULL;
|
|
CHECK(spacetime_load_slab(&source, -20.0, -30.0, &early) == 0, "early slab");
|
|
ray_pool_activate_in_time_range(&pool, early);
|
|
CHECK(pool.status[0] == RAY_POOL_PENDING, "entry ray not active early");
|
|
spacetime_free_slab(early);
|
|
|
|
MetricSlab *covering = NULL;
|
|
CHECK(spacetime_load_slab(&source, 0.0, -100.0, &covering) == 0,
|
|
"covering slab");
|
|
ray_pool_activate_in_time_range(&pool, covering);
|
|
CHECK(pool.status[0] == RAY_POOL_ACTIVE &&
|
|
fabs(pool.t[0] - pool.activate_t[0]) < 1e-30,
|
|
"entry ray activates at entry time");
|
|
spacetime_free_slab(covering);
|
|
ray_pool_destroy(&pool);
|
|
spacetime_destroy(&source);
|
|
}
|
|
|
|
int main(void) {
|
|
test_fixed_sphere();
|
|
test_large_radius_quadratic();
|
|
test_round_trip();
|
|
test_moving_sphere();
|
|
test_accelerated_worldtube();
|
|
test_accelerated_segment();
|
|
test_boundary_semantics_minkowski();
|
|
test_boundary_semantics_generic();
|
|
test_negative_radius_root_guard();
|
|
test_source_finalize();
|
|
test_motion_segment_domain();
|
|
test_schwarzschild_sample_failures();
|
|
test_end_protocol_error();
|
|
test_generic_bisection_failure();
|
|
test_interior_history_exhaustion();
|
|
test_interior_crossing_bisection_failure();
|
|
test_ray_pool_lifecycle();
|
|
if (failures == 0)
|
|
puts("asymptotic regression passed");
|
|
else
|
|
fprintf(stderr, "%d asymptotic regression failures\n", failures);
|
|
return failures == 0 ? 0 : 1;
|
|
}
|