diff --git a/nr_spacetime_movie_renderer_design.md b/nr_spacetime_movie_renderer_design.md index 40e054e..fe8e868 100644 --- a/nr_spacetime_movie_renderer_design.md +++ b/nr_spacetime_movie_renderer_design.md @@ -790,6 +790,15 @@ ray-tracing 的 OpenMP worker 与 geodesic 热循环不写任何日志(既有 报告,与数值 `INCOMPLETE` 区分;replay 使用文件中保存的几何与顶点诊断,不虚构 stop 状态,也不把未重试的旧失败当作新失败重复计数。 +`RayReason` 的诊断粒度是追加式扩展:原 coarse reason 的数值 0..9 冻结不变, +更细的失败原因一律追加在 `RAY_REASON_IO_ERROR` 之后,`RAY_REASON_COUNT` 只作 +计数/未知 sentinel 且不写入 wire。lens-map 的二进制布局(v2/v3)与字段偏移不 +改变,新 reader 读旧值原样、读追加值按新名解释,并拒绝 `>= RAY_REASON_COUNT`; +旧 reader 因 coarse 校验仍会安全拒绝它不认识的新值。每个细原因通过纯函数 +`ray_reason_category()` 归入既有 coarse 类别(PROTOCOL_ERROR、 +INTEGRATION_ERROR、UNSUPPORTED、IO_ERROR 等),供只需要粗分类的调用方使用。 +该扩展只细化失败状态的命名与上报,不改变任何物理终态判据、终止分类或 retry 规则。 + --- # 18A. 渐近外区、escape worldtube 与 endpoint 协议 diff --git a/src/frame.c b/src/frame.c index e52120e..52ce7ff 100644 --- a/src/frame.c +++ b/src/frame.c @@ -1661,7 +1661,7 @@ int frame_lens_mesh_refine_with_progress( endpoint = (RayEndpoint){.magnification = 1.0, .end_id = SPACETIME_END_NONE, .outcome = RAY_OUTCOME_INCOMPLETE, - .reason = RAY_REASON_PROTOCOL_ERROR, + .reason = RAY_REASON_INVALID_CONTINUATION, .stop_coordinate_time = NAN, .threshold_value = NAN}; } else { diff --git a/src/geodesic.c b/src/geodesic.c index af2b84d..edb8eb0 100644 --- a/src/geodesic.c +++ b/src/geodesic.c @@ -31,10 +31,120 @@ const char *ray_reason_name(RayReason reason) { return "PROTOCOL_ERROR"; case RAY_REASON_IO_ERROR: return "IO_ERROR"; + case RAY_REASON_INVALID_ARGUMENT: + return "INVALID_ARGUMENT"; + case RAY_REASON_UNKNOWN_STEPPER: + return "UNKNOWN_STEPPER"; + case RAY_REASON_INVALID_STEPPER_CONFIG: + return "INVALID_STEPPER_CONFIG"; + case RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID: + return "ASYMPTOTIC_LIFECYCLE_INVALID"; + case RAY_REASON_CAMERA_PREROUTE_FAILED: + return "CAMERA_PREROUTE_FAILED"; + case RAY_REASON_INVALID_ROUTE_KIND: + return "INVALID_ROUTE_KIND"; + case RAY_REASON_INVALID_CONTINUATION: + return "INVALID_CONTINUATION"; + case RAY_REASON_METRIC_INTERNAL_ERROR: + return "METRIC_INTERNAL_ERROR"; + case RAY_REASON_END_DESCRIPTOR_FAILED: + return "END_DESCRIPTOR_FAILED"; + case RAY_REASON_WORLDTUBE_SAMPLE_FAILED: + return "WORLDTUBE_SAMPLE_FAILED"; + case RAY_REASON_WORLDTUBE_GEOMETRY_INVALID: + return "WORLDTUBE_GEOMETRY_INVALID"; + case RAY_REASON_WORLDTUBE_EVALUATION_FAILED: + return "WORLDTUBE_EVALUATION_FAILED"; + case RAY_REASON_OUTSIDE_WORLDTUBE: + return "OUTSIDE_WORLDTUBE"; + case RAY_REASON_ESCAPE_LOCALIZATION_FAILED: + return "ESCAPE_LOCALIZATION_FAILED"; + case RAY_REASON_ESCAPE_TRANSFER_FAILED: + return "ESCAPE_TRANSFER_FAILED"; + case RAY_REASON_INVALID_ESCAPE_DIRECTION: + return "INVALID_ESCAPE_DIRECTION"; + case RAY_REASON_INVALID_STEP_INTERVAL: + return "INVALID_STEP_INTERVAL"; + case RAY_REASON_TIME_STEP_UNREPRESENTABLE: + return "TIME_STEP_UNREPRESENTABLE"; + case RAY_REASON_MIN_STEP_REACHED: + return "MIN_STEP_REACHED"; + case RAY_REASON_REJECTION_LIMIT: + return "REJECTION_LIMIT"; + case RAY_REASON_NONFINITE_TRIAL: + return "NONFINITE_TRIAL"; + case RAY_REASON_SUBINTEGRATION_TARGET_INVALID: + return "SUBINTEGRATION_TARGET_INVALID"; + case RAY_REASON_SUBINTEGRATION_TARGET_MISSED: + return "SUBINTEGRATION_TARGET_MISSED"; + case RAY_REASON_ESCAPE_EVENT_UNCONFIRMED: + return "ESCAPE_EVENT_UNCONFIRMED"; + case RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED: + return "THRESHOLD_EVENT_UNCONFIRMED"; + case RAY_REASON_SLAB_LOAD_FAILED: + return "SLAB_LOAD_FAILED"; + case RAY_REASON_COUNT: + break; } return "UNKNOWN"; } +int ray_reason_valid(RayReason reason) { + return (unsigned)reason < (unsigned)RAY_REASON_COUNT; +} + +RayReason ray_reason_category(RayReason reason) { + switch (reason) { + /* Frozen coarse reasons map to themselves. */ + case RAY_REASON_NONE: + case RAY_REASON_REDSHIFT_LIMIT: + case RAY_REASON_BUDGET_EXHAUSTED: + case RAY_REASON_TIME_RANGE_EXHAUSTED: + case RAY_REASON_OUT_OF_DOMAIN: + case RAY_REASON_INVALID_METRIC: + case RAY_REASON_INTEGRATION_ERROR: + case RAY_REASON_UNSUPPORTED: + case RAY_REASON_PROTOCOL_ERROR: + case RAY_REASON_IO_ERROR: + return reason; + /* Protocol-derived detail reasons collapse onto PROTOCOL_ERROR. */ + case RAY_REASON_INVALID_ARGUMENT: + case RAY_REASON_UNKNOWN_STEPPER: + case RAY_REASON_INVALID_STEPPER_CONFIG: + case RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID: + case RAY_REASON_CAMERA_PREROUTE_FAILED: + case RAY_REASON_INVALID_ROUTE_KIND: + case RAY_REASON_INVALID_CONTINUATION: + case RAY_REASON_METRIC_INTERNAL_ERROR: + case RAY_REASON_END_DESCRIPTOR_FAILED: + case RAY_REASON_WORLDTUBE_SAMPLE_FAILED: + case RAY_REASON_WORLDTUBE_GEOMETRY_INVALID: + case RAY_REASON_WORLDTUBE_EVALUATION_FAILED: + case RAY_REASON_OUTSIDE_WORLDTUBE: + case RAY_REASON_ESCAPE_LOCALIZATION_FAILED: + case RAY_REASON_ESCAPE_TRANSFER_FAILED: + return RAY_REASON_PROTOCOL_ERROR; + /* Integration-derived reasons, plus the legacy sky-direction failure that + * historically surfaced as an integration error. */ + case RAY_REASON_INVALID_ESCAPE_DIRECTION: + case RAY_REASON_INVALID_STEP_INTERVAL: + case RAY_REASON_TIME_STEP_UNREPRESENTABLE: + case RAY_REASON_MIN_STEP_REACHED: + case RAY_REASON_REJECTION_LIMIT: + case RAY_REASON_NONFINITE_TRIAL: + case RAY_REASON_SUBINTEGRATION_TARGET_INVALID: + case RAY_REASON_SUBINTEGRATION_TARGET_MISSED: + case RAY_REASON_ESCAPE_EVENT_UNCONFIRMED: + case RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED: + return RAY_REASON_INTEGRATION_ERROR; + case RAY_REASON_SLAB_LOAD_FAILED: + return RAY_REASON_IO_ERROR; + case RAY_REASON_COUNT: + break; + } + return (RayReason)RAY_REASON_COUNT; /* documented UNKNOWN sentinel */ +} + static double dot(const double a[3], const double b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } @@ -477,25 +587,26 @@ static int dp_advance_one(const MetricSlab *slab, const double t0 = s->coordinate_time; const double remaining = t0 - left; if (!(remaining > 0.0) || !(proposal > 0.0)) { - *reason = RAY_REASON_INTEGRATION_ERROR; + *reason = RAY_REASON_INVALID_STEP_INTERVAL; return -1; } const int boundary_limited = remaining < proposal; double target = boundary_limited ? left : representable_target(t0, proposal); if (!(target < t0)) { - *reason = RAY_REASON_INTEGRATION_ERROR; + *reason = RAY_REASON_TIME_STEP_UNREPRESENTABLE; return -1; } double actual_h = t0 - target; /* A boundary-limited final step may legitimately fall below min_step; any * other sub-floor representable interval is an integration failure. */ if (!boundary_limited && actual_h < config->min_step) { - *reason = RAY_REASON_INTEGRATION_ERROR; + *reason = RAY_REASON_MIN_STEP_REACHED; return -1; } unsigned int consecutive = 0; int rejected_any = 0; int last_reject_stage = 0; + int last_reject_nonfinite = 0; SpacetimePointStatus last_stage_status = SPACETIME_POINT_OK; while (1) { const State before = *s; @@ -559,9 +670,17 @@ static int dp_advance_one(const MetricSlab *slab, rejected_any = 1; s->previous_rejected = 1; last_reject_stage = stage_reject; + last_reject_nonfinite = (status == DP_TRIAL_NONFINITE); if (++consecutive >= config->consecutive_rejection_limit) { - *reason = last_reject_stage ? reason_from_point_status(last_stage_status) - : RAY_REASON_INTEGRATION_ERROR; + /* A specific stage reason is more informative than the quota itself and + * is preserved; only a pure error-estimate rejection (or a nonfinite + * trial) reports the quota cause. */ + if (last_reject_stage) + *reason = reason_from_point_status(last_stage_status); + else if (last_reject_nonfinite) + *reason = RAY_REASON_NONFINITE_TRIAL; + else + *reason = RAY_REASON_REJECTION_LIMIT; return -1; } if (factor > 1.0) @@ -571,12 +690,12 @@ static int dp_advance_one(const MetricSlab *slab, shrunk = remaining; target = (shrunk >= remaining) ? left : representable_target(t0, shrunk); if (!(target < t0)) { - *reason = RAY_REASON_INTEGRATION_ERROR; + *reason = RAY_REASON_TIME_STEP_UNREPRESENTABLE; return -1; } actual_h = t0 - target; if (actual_h < config->min_step) { - *reason = RAY_REASON_INTEGRATION_ERROR; + *reason = RAY_REASON_MIN_STEP_REACHED; return -1; } } @@ -653,17 +772,23 @@ typedef enum { ASYM_LIFECYCLE_PROTOCOL_ERROR } AsymLifecycleMode; -static AsymLifecycleMode asym_lifecycle_mode(const SpacetimeSource *source) { +static AsymLifecycleMode asym_lifecycle_mode(const SpacetimeSource *source, + RayReason *failure) { + *failure = RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID; const size_t count = spacetime_asymptotic_end_count(source); if (count == 0) return ASYM_LIFECYCLE_NONE; for (size_t i = 0; i < count; ++i) { SpacetimeAsymptoticEnd end; - if (spacetime_asymptotic_end(source, i, &end)) + if (spacetime_asymptotic_end(source, i, &end)) { + *failure = RAY_REASON_END_DESCRIPTOR_FAILED; return ASYM_LIFECYCLE_PROTOCOL_ERROR; + } if (end.exterior_kind != ASYMPTOTIC_EXTERIOR_MINKOWSKI && - end.exterior_kind != ASYMPTOTIC_EXTERIOR_SCHWARZSCHILD_MONOPOLE) + end.exterior_kind != ASYMPTOTIC_EXTERIOR_SCHWARZSCHILD_MONOPOLE) { + *failure = RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID; return ASYM_LIFECYCLE_PROTOCOL_ERROR; + } } return ASYM_LIFECYCLE_READY; } @@ -672,12 +797,18 @@ static AsymLifecycleMode asym_lifecycle_mode(const SpacetimeSource *source) { * within one accepted step. Re-integrates from `before` with fractional step * sizes; `after` lands on the outside end of the bracket. Returns * ASYMPTOTIC_OK, ASYMPTOTIC_TIME_RANGE_EXHAUSTED (a midpoint fell into a - * history hole), or ASYMPTOTIC_INVALID. */ + * history hole), or ASYMPTOTIC_INVALID. On a non-TIME_RANGE failure + * `*fail_reason` carries the classified cause: an RK4 subintegration failure + * (whose underlying metric reason is discarded by this collapsed path) is + * ESCAPE_LOCALIZATION_FAILED, while a worldtube value failure is + * WORLDTUBE_EVALUATION_FAILED. */ static AsymptoticStatus localize_worldtube_crossing(const MetricSlab *slab, SpacetimeEndId end_id, const State *before, double h, State *after, + RayReason *fail_reason, unsigned long *rhs_total) { + *fail_reason = RAY_REASON_ESCAPE_LOCALIZATION_FAILED; double f_lo = 0.0, f_hi = 1.0; for (int iteration = 0; iteration < 64; ++iteration) { const double f = 0.5 * (f_lo + f_hi); @@ -688,8 +819,11 @@ static AsymptoticStatus localize_worldtube_crossing(const MetricSlab *slab, double value; const int status = asymptotic_worldtube_value( slab->source, end_id, mid.coordinate_time, mid.x, &value); - if (status != ASYMPTOTIC_OK) + if (status != ASYMPTOTIC_OK) { + if (status != ASYMPTOTIC_TIME_RANGE_EXHAUSTED) + *fail_reason = RAY_REASON_WORLDTUBE_EVALUATION_FAILED; return (AsymptoticStatus)status; + } if (value >= 0.0) f_hi = f; else @@ -732,7 +866,7 @@ static int dp_subintegrate(const MetricSlab *slab, return 0; } if (!(t_target < before->coordinate_time)) { - *reason_out = RAY_REASON_INTEGRATION_ERROR; + *reason_out = RAY_REASON_SUBINTEGRATION_TARGET_INVALID; return -1; } State work = *before; @@ -755,7 +889,7 @@ static int dp_subintegrate(const MetricSlab *slab, work.coordinate_time = t_target; } if (fabs(work.coordinate_time - t_target) > snap) { - *reason_out = RAY_REASON_INTEGRATION_ERROR; + *reason_out = RAY_REASON_SUBINTEGRATION_TARGET_MISSED; return -1; } work.coordinate_time = t_target; @@ -789,7 +923,7 @@ static AsymptoticStatus dp_localize_worldtube_crossing( const double span = t_end - t0; const double stop_tol = fmax(1e-12 * fabs(span), 2.0 * time_ulp(t0)); - *integration_reason = RAY_REASON_PROTOCOL_ERROR; + *integration_reason = RAY_REASON_WORLDTUBE_EVALUATION_FAILED; for (int iteration = 0; iteration < 256; ++iteration) { if (fabs(span) * (f_hi - f_lo) <= stop_tol) break; @@ -1223,25 +1357,34 @@ static int dp_localize_threshold_bracket(const MetricSlab *slab, * the step so it cannot cross a motion-segment boundary (which would break the * constant-velocity polynomial used by the dense event layer). On success * *event_left is the tightened left bound; on TIME_RANGE_EXHAUSTED - * *time_range_end carries the end that exhausted its history. */ + * *time_range_end carries the end that exhausted its history. On + * ASYMPTOTIC_INVALID `*failure` carries the classified cause + * (END_DESCRIPTOR_FAILED / WORLDTUBE_SAMPLE_FAILED / WORLDTUBE_GEOMETRY_INVALID). */ static AsymptoticStatus worldtube_prestep_scan( const SpacetimeSource *source, size_t end_count, double t, double step_left, - double *event_left, SpacetimeEndId *time_range_end) { + double *event_left, SpacetimeEndId *time_range_end, RayReason *failure) { *event_left = step_left; + *failure = RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID; for (size_t i = 0; i < end_count; ++i) { SpacetimeAsymptoticEnd end; - if (spacetime_asymptotic_end(source, i, &end)) + if (spacetime_asymptotic_end(source, i, &end)) { + *failure = RAY_REASON_END_DESCRIPTOR_FAILED; return ASYMPTOTIC_INVALID; + } SpacetimeEscapeWorldtubeSample sample; - if (spacetime_escape_worldtube_sample(source, end.end_id, t, &sample)) + if (spacetime_escape_worldtube_sample(source, end.end_id, t, &sample)) { + *failure = RAY_REASON_WORLDTUBE_SAMPLE_FAILED; return ASYMPTOTIC_INVALID; + } if (!sample.valid) { *time_range_end = end.end_id; return ASYMPTOTIC_TIME_RANGE_EXHAUSTED; } if (!(sample.radius > 0.0) || !isfinite(sample.radius) || - !isfinite(sample.radius_rate)) + !isfinite(sample.radius_rate)) { + *failure = RAY_REASON_WORLDTUBE_GEOMETRY_INVALID; return ASYMPTOTIC_INVALID; + } if (!sample.velocity_constant) return ASYMPTOTIC_UNSUPPORTED; const double boundary = @@ -1284,7 +1427,7 @@ static RayReason reason_from_point_status(SpacetimePointStatus status) { case SPACETIME_POINT_INVALID_METRIC: return RAY_REASON_INVALID_METRIC; case SPACETIME_POINT_INTERNAL_ERROR: - return RAY_REASON_PROTOCOL_ERROR; + return RAY_REASON_METRIC_INTERNAL_ERROR; default: return RAY_REASON_INTEGRATION_ERROR; } @@ -1370,11 +1513,12 @@ static int threshold_reached(const MetricSlab *slab, /* Legacy no-declared-ends backends may still report a region escape. There * is no position-based physical capture; a failed sky direction is reported - * as an integration failure, never as capture. */ + * as an invalid escape direction (historically an integration failure), never + * as capture. */ static GeodesicAdvanceResult legacy_escape(const MetricSlab *slab, const State *s, RayEndpoint *out) { if (escaped_direction(slab, s->coordinate_time, s, out->n_infinity) != 0) { - set_incomplete(out, RAY_REASON_INTEGRATION_ERROR, s); + set_incomplete(out, RAY_REASON_INVALID_ESCAPE_DIRECTION, s); return GEODESIC_ADVANCE_FAILED; } out->outcome = RAY_OUTCOME_ESCAPED; @@ -1392,7 +1536,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( slab_left_time > s->coordinate_time) { if (out != NULL) { out->outcome = RAY_OUTCOME_INCOMPLETE; - out->reason = RAY_REASON_PROTOCOL_ERROR; + out->reason = RAY_REASON_INVALID_ARGUMENT; record_final_state(out, s); out->threshold_value = NAN; } @@ -1401,7 +1545,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( /* An unknown stepper code must be rejected explicitly, exactly like an * unknown wire code; "not DP54" must never fall through to the RK4 path. */ if (!stepper_known(config->stepper)) { - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_UNKNOWN_STEPPER, s); return GEODESIC_ADVANCE_FAILED; } const int use_dp = config->stepper == GEODESIC_STEPPER_DP54; @@ -1412,16 +1556,18 @@ GeodesicAdvanceResult geodesic_advance_past_ray( out->accepted_step_limit = config->max_steps; if (use_dp) { if (!dp_config_valid(config)) { - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_INVALID_STEPPER_CONFIG, s); return GEODESIC_ADVANCE_FAILED; } } else if (config->coordinate_time_step <= 0 || !config->max_steps) { - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_INVALID_STEPPER_CONFIG, s); return GEODESIC_ADVANCE_FAILED; } - const AsymLifecycleMode mode = asym_lifecycle_mode(slab->source); + RayReason lifecycle_reason = RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID; + const AsymLifecycleMode mode = + asym_lifecycle_mode(slab->source, &lifecycle_reason); if (mode == ASYM_LIFECYCLE_PROTOCOL_ERROR) { - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, lifecycle_reason, s); return GEODESIC_ADVANCE_FAILED; } const int directed = mode == ASYM_LIFECYCLE_READY; @@ -1467,9 +1613,10 @@ GeodesicAdvanceResult geodesic_advance_past_ray( double event_left = step_left; if (directed) { SpacetimeEndId tr_end = SPACETIME_END_NONE; + RayReason scan_failure = RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID; const AsymptoticStatus scan = worldtube_prestep_scan( slab->source, end_count, s->coordinate_time, step_left, &event_left, - &tr_end); + &tr_end, &scan_failure); if (scan == ASYMPTOTIC_UNSUPPORTED) { set_incomplete(out, RAY_REASON_UNSUPPORTED, s); return GEODESIC_ADVANCE_FAILED; @@ -1480,7 +1627,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( return GEODESIC_ADVANCE_TERMINATED; } if (scan != ASYMPTOTIC_OK) { - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, scan_failure, s); return GEODESIC_ADVANCE_FAILED; } } @@ -1506,7 +1653,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( SpacetimeAsymptoticEnd end; if (spacetime_asymptotic_end(slab->source, i, &end)) { restore_accepted_trajectory(s, &before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_END_DESCRIPTOR_FAILED, s); return GEODESIC_ADVANCE_FAILED; } double f_before, f_after; @@ -1524,14 +1671,16 @@ GeodesicAdvanceResult geodesic_advance_past_ray( } if (before_status != ASYMPTOTIC_OK || after_status != ASYMPTOTIC_OK) { restore_accepted_trajectory(s, &before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_WORLDTUBE_EVALUATION_FAILED, s); return GEODESIC_ADVANCE_FAILED; } if (f_before > 0.0 || f_after <= 0.0) continue; State crossing; + RayReason locate_failure = RAY_REASON_ESCAPE_LOCALIZATION_FAILED; const AsymptoticStatus localized = localize_worldtube_crossing( - slab, end.end_id, &before, h, &crossing, &rhs_total); + slab, end.end_id, &before, h, &crossing, &locate_failure, + &rhs_total); s->rhs_evaluations = rhs_total; s->rejected_steps = reject_total; if (localized == ASYMPTOTIC_TIME_RANGE_EXHAUSTED) { @@ -1542,7 +1691,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( } if (localized != ASYMPTOTIC_OK) { restore_accepted_trajectory(s, &before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, locate_failure, s); return GEODESIC_ADVANCE_FAILED; } const AsymptoticStatus transfer = asymptotic_finish_escape( @@ -1567,7 +1716,10 @@ GeodesicAdvanceResult geodesic_advance_past_ray( return GEODESIC_ADVANCE_TERMINATED; } restore_accepted_trajectory(s, &before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, transfer == ASYMPTOTIC_UNSUPPORTED + ? RAY_REASON_UNSUPPORTED + : RAY_REASON_ESCAPE_TRANSFER_FAILED, + s); return GEODESIC_ADVANCE_FAILED; } continue; @@ -1615,7 +1767,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( SpacetimeAsymptoticEnd end; if (spacetime_asymptotic_end(slab->source, i, &end)) { restore_accepted_trajectory(s, &step_before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_END_DESCRIPTOR_FAILED, s); return GEODESIC_ADVANCE_FAILED; } double f_before, f_after; @@ -1658,7 +1810,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( * a multi-end ray legitimately sits outside some ends. */ if (end_count == 1) { restore_accepted_trajectory(s, &step_before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_OUTSIDE_WORLDTUBE, s); return GEODESIC_ADVANCE_FAILED; } continue; @@ -1732,14 +1884,14 @@ GeodesicAdvanceResult geodesic_advance_past_ray( if (event_retry_limit(s, config, &step_before, &event_consecutive, &reject_total)) { s->rhs_evaluations = rhs_total; - set_incomplete(out, RAY_REASON_INTEGRATION_ERROR, s); + set_incomplete(out, RAY_REASON_ESCAPE_EVENT_UNCONFIRMED, s); return GEODESIC_ADVANCE_FAILED; } retry_step = 1; break; } State crossing; - RayReason event_reason = RAY_REASON_PROTOCOL_ERROR; + RayReason event_reason = RAY_REASON_WORLDTUBE_EVALUATION_FAILED; const AsymptoticStatus localized = dp_localize_worldtube_crossing( slab, config, end.end_id, &step_before, h, b_lo, b_hi, &crossing, &event_reason, &rhs_total, &reject_total); @@ -1776,7 +1928,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( } if (end_fail != ASYMPTOTIC_OK) { restore_accepted_trajectory(s, &step_before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, RAY_REASON_WORLDTUBE_EVALUATION_FAILED, s); return GEODESIC_ADVANCE_FAILED; } if (retry_step) @@ -1849,7 +2001,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( if (event_retry_limit(s, config, &step_before, &event_consecutive, &reject_total)) { s->rhs_evaluations = rhs_total; - set_incomplete(out, RAY_REASON_INTEGRATION_ERROR, s); + set_incomplete(out, RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED, s); return GEODESIC_ADVANCE_FAILED; } continue; @@ -1870,7 +2022,7 @@ GeodesicAdvanceResult geodesic_advance_past_ray( if (event_retry_limit(s, config, &step_before, &event_consecutive, &reject_total)) { s->rhs_evaluations = rhs_total; - set_incomplete(out, RAY_REASON_INTEGRATION_ERROR, s); + set_incomplete(out, RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED, s); return GEODESIC_ADVANCE_FAILED; } continue; @@ -1932,7 +2084,10 @@ GeodesicAdvanceResult geodesic_advance_past_ray( return GEODESIC_ADVANCE_TERMINATED; } restore_accepted_trajectory(s, &step_before); - set_incomplete(out, RAY_REASON_PROTOCOL_ERROR, s); + set_incomplete(out, transfer == ASYMPTOTIC_UNSUPPORTED + ? RAY_REASON_UNSUPPORTED + : RAY_REASON_ESCAPE_TRANSFER_FAILED, + s); return GEODESIC_ADVANCE_FAILED; } } @@ -1971,21 +2126,29 @@ RayEndpoint geodesic_trace_past(const SpacetimeSource *source, .accepted_steps = 0, .threshold_value = NAN}; record_final_state(&out, NULL); - if (!source || !observer || !config || fabs(dot(n, n) - 1) > 1e-10) + if (!source || !observer || !config || n == NULL) { + out.reason = RAY_REASON_INVALID_ARGUMENT; return out; + } + const double direction_norm2 = dot(n, n); + if (!isfinite(direction_norm2) || fabs(direction_norm2 - 1.0) > 1e-10) { + out.reason = RAY_REASON_INVALID_ARGUMENT; + return out; + } if (!stepper_known(config->stepper)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_UNKNOWN_STEPPER; return out; } const int use_dp = config->stepper == GEODESIC_STEPPER_DP54; if (use_dp) { if (!dp_config_valid(config)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_INVALID_STEPPER_CONFIG; return out; } } else if (config->coordinate_time_step <= 0 || !config->max_steps) { + out.reason = RAY_REASON_INVALID_STEPPER_CONFIG; return out; } out.lookback_limit = use_dp ? config->max_lookback_time : 0.0; @@ -2006,7 +2169,7 @@ RayEndpoint geodesic_trace_past(const SpacetimeSource *source, } if (route_status != ASYMPTOTIC_OK) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_CAMERA_PREROUTE_FAILED; return out; } if (route.kind == ASYMPTOTIC_ROUTE_ESCAPED) { @@ -2027,7 +2190,7 @@ RayEndpoint geodesic_trace_past(const SpacetimeSource *source, if (route.kind != ASYMPTOTIC_ROUTE_INSIDE && route.kind != ASYMPTOTIC_ROUTE_ENTRY) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_INVALID_ROUTE_KIND; return out; } State state = {.coordinate_time = route.activate_t, @@ -2055,7 +2218,7 @@ RayEndpoint geodesic_trace_past(const SpacetimeSource *source, MetricSlab *slab = NULL; if (spacetime_load_slab(source, route.activate_t, last_time - 1.0, &slab)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_IO_ERROR; + out.reason = RAY_REASON_SLAB_LOAD_FAILED; return out; } if (geodesic_advance_past_ray(slab, &state, last_time, config, &out) == @@ -2081,21 +2244,24 @@ RayEndpoint geodesic_trace_past_from_state(const SpacetimeSource *source, .threshold_value = NAN}; record_final_state(&out, NULL); if (!source || !state_in || !config || !config->max_steps || - state_in->steps >= config->max_steps) + state_in->steps >= config->max_steps) { + out.reason = RAY_REASON_INVALID_ARGUMENT; return out; + } if (!stepper_known(config->stepper)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_UNKNOWN_STEPPER; return out; } const int use_dp = config->stepper == GEODESIC_STEPPER_DP54; if (use_dp) { if (!dp_config_valid(config)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_PROTOCOL_ERROR; + out.reason = RAY_REASON_INVALID_STEPPER_CONFIG; return out; } } else if (config->coordinate_time_step <= 0) { + out.reason = RAY_REASON_INVALID_STEPPER_CONFIG; return out; } out.lookback_limit = use_dp ? config->max_lookback_time : 0.0; @@ -2115,7 +2281,7 @@ RayEndpoint geodesic_trace_past_from_state(const SpacetimeSource *source, MetricSlab *slab = NULL; if (spacetime_load_slab(source, state.coordinate_time, last_time, &slab)) { out.outcome = RAY_OUTCOME_INCOMPLETE; - out.reason = RAY_REASON_IO_ERROR; + out.reason = RAY_REASON_SLAB_LOAD_FAILED; return out; } if (geodesic_advance_past_ray(slab, &state, last_time, config, &out) == diff --git a/src/geodesic.h b/src/geodesic.h index 56a7f0c..0267b10 100644 --- a/src/geodesic.h +++ b/src/geodesic.h @@ -14,7 +14,15 @@ typedef enum { } RayOutcome; /* Diagnostic reason. Different DARK reasons must not create a mesh seam; the - * reason is for accounting and provenance only. */ + * reason is for accounting and provenance only. + * + * Wire compatibility: the original coarse reasons keep their frozen numeric + * codes 0..9 exactly. Every appended detail reason therefore has a larger + * numeric value, and an older reader that validates `reason > RAY_REASON_IO_ERROR` + * safely rejects a map produced with a detail reason instead of silently + * reinterpreting it. A current reader accepts the frozen 0..9 codes and the + * appended detail codes, and rejects anything at or above RAY_REASON_COUNT. + * RAY_REASON_COUNT is a sentinel/count, never a serialized wire value. */ typedef enum { RAY_REASON_NONE = 0, RAY_REASON_REDSHIFT_LIMIT, @@ -25,13 +33,59 @@ typedef enum { RAY_REASON_INTEGRATION_ERROR, RAY_REASON_UNSUPPORTED, RAY_REASON_PROTOCOL_ERROR, - RAY_REASON_IO_ERROR + RAY_REASON_IO_ERROR, + /* ---- Appended detail reasons (ids > RAY_REASON_IO_ERROR). ---- */ + /* Protocol-derived. */ + RAY_REASON_INVALID_ARGUMENT, /* null/nonunit/bad left bound */ + RAY_REASON_UNKNOWN_STEPPER, /* unrecognized stepper code */ + RAY_REASON_INVALID_STEPPER_CONFIG, /* stepper config rejected */ + RAY_REASON_ASYMPTOTIC_LIFECYCLE_INVALID, /* declared ends inconsistent */ + RAY_REASON_CAMERA_PREROUTE_FAILED, /* preroute returned INVALID */ + RAY_REASON_INVALID_ROUTE_KIND, /* unknown pre-route kind */ + RAY_REASON_INVALID_CONTINUATION, /* retry without valid payload */ + RAY_REASON_METRIC_INTERNAL_ERROR, /* SPACETIME_POINT_INTERNAL_ERROR */ + RAY_REASON_END_DESCRIPTOR_FAILED, /* end descriptor retrieval failed */ + RAY_REASON_WORLDTUBE_SAMPLE_FAILED, /* worldtube sample callback failed */ + RAY_REASON_WORLDTUBE_GEOMETRY_INVALID, /* nonfinite/nonpositive radius/rate */ + RAY_REASON_WORLDTUBE_EVALUATION_FAILED, /* worldtube value eval invalid */ + RAY_REASON_OUTSIDE_WORLDTUBE, /* single-end pre-step F > geom_tol */ + RAY_REASON_ESCAPE_LOCALIZATION_FAILED, /* crossing localizer invalid */ + RAY_REASON_ESCAPE_TRANSFER_FAILED, /* finish_escape returned invalid */ + RAY_REASON_INVALID_ESCAPE_DIRECTION, /* legacy sky normalize invalid */ + /* Integration-derived. */ + RAY_REASON_INVALID_STEP_INTERVAL, /* remaining/proposal nonpositive */ + RAY_REASON_TIME_STEP_UNREPRESENTABLE, /* target >= t0 after rounding */ + RAY_REASON_MIN_STEP_REACHED, /* actual_h < min_step */ + RAY_REASON_REJECTION_LIMIT, /* error-estimate rejections exhausted */ + RAY_REASON_NONFINITE_TRIAL, /* nonfinite trial hit reject quota */ + RAY_REASON_SUBINTEGRATION_TARGET_INVALID,/* subintegrate target in future */ + RAY_REASON_SUBINTEGRATION_TARGET_MISSED, /* subintegr. discrepancy > snap */ + RAY_REASON_ESCAPE_EVENT_UNCONFIRMED, /* escape event retry exhausted */ + RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED, /* threshold event retry exhausted */ + /* I/O-derived. */ + RAY_REASON_SLAB_LOAD_FAILED, /* spacetime_load_slab failed */ + RAY_REASON_COUNT /* sentinel: valid ids are < COUNT */ } RayReason; /* Stable name for a RayReason (never NULL; out-of-range values return * "UNKNOWN"). Pure and thread-safe, so it may be called from any reporter. */ const char *ray_reason_name(RayReason reason); +/* Nonzero when `reason` is one of the valid wire/provenance codes + * (0 <= reason < RAY_REASON_COUNT). Pure and thread-safe. */ +int ray_reason_valid(RayReason reason); + +/* Coarse category of a reason, for callers that only need the historical + * bucket. The frozen coarse reasons 0..9 map to themselves; each appended + * detail reason maps onto one of the existing coarse categories + * (PROTOCOL_ERROR / INTEGRATION_ERROR / UNSUPPORTED / IO_ERROR). + * ESCAPE_LOCALIZATION_FAILED historically surfaced as PROTOCOL_ERROR (the RK4 + * crossing localizer collapsed to ASYMPTOTIC_INVALID at its caller) and + * INVALID_ESCAPE_DIRECTION historically surfaced as INTEGRATION_ERROR. + * Out-of-range values return the documented sentinel RAY_REASON_COUNT. Pure + * and thread-safe. */ +RayReason ray_reason_category(RayReason reason); + /* Monitored quantity used by the dark-redshift termination policy. `LOG_P0` * is ln(p^0) = L - ln(alpha); it differs from `LOG_ALPHA_P0` by a local * function of position and must not be confused with the true infinity diff --git a/src/lens_map.c b/src/lens_map.c index 72a326a..f4a007c 100644 --- a/src/lens_map.c +++ b/src/lens_map.c @@ -123,7 +123,7 @@ static int valid_mesh(const FrameLensMesh *m) { for (size_t i = 0; i < m->vertex_count; ++i) { const LensVertex *v = &m->vertices[i]; if (!v->traced || v->outcome > RAY_OUTCOME_INCOMPLETE || - v->reason > RAY_REASON_IO_ERROR || !isfinite(v->image_x) || + !ray_reason_valid(v->reason) || !isfinite(v->image_x) || !isfinite(v->image_y) || !isfinite(v->log_frequency_ratio) || !unit_vector(v->camera_direction)) return 0; @@ -296,7 +296,7 @@ int lens_map_read(const char *path, LensMapProvenance *provenance, failed = failed || read_double(file, &v->log_frequency_ratio, &crc) || read_u32(file, &end_id, &crc) || read_u32(file, &outcome, &crc) || read_u32(file, &reason, &crc) || outcome > RAY_OUTCOME_INCOMPLETE || - reason > RAY_REASON_IO_ERROR; + !ray_reason_valid((RayReason)reason); v->end_id = (SpacetimeEndId)end_id; v->outcome = (RayOutcome)outcome; v->reason = (RayReason)reason; diff --git a/src/main.c b/src/main.c index fc45866..5fa8199 100644 --- a/src/main.c +++ b/src/main.c @@ -974,7 +974,7 @@ static void ray_pool_status_counts(const RayPool *rays, size_t *pending, * result visible with its reason and frame. * ------------------------------------------------------------------------- */ -#define RAY_DIAG_REASON_UNKNOWN_INDEX ((size_t)RAY_REASON_IO_ERROR + 1u) +#define RAY_DIAG_REASON_UNKNOWN_INDEX ((size_t)RAY_REASON_COUNT) #define RAY_DIAG_REASON_COUNT (RAY_DIAG_REASON_UNKNOWN_INDEX + 1u) #define RAY_DIAG_REPS_PER_REASON 3u @@ -1076,7 +1076,7 @@ static void raydiag_report_flush(const RayDiagReport *report, size_t frame_id, long long generation, long long slab) { if (report->total == 0) return; - fprintf(stderr, "Ray failures: frame %zu camera_t=%.9g phase=%s", frame_id, + fprintf(stderr, "Ray failures: frame %zu camera_t=%.17g phase=%s", frame_id, camera_time, phase); if (generation >= 0) fprintf(stderr, " generation=%lld", generation); @@ -1108,12 +1108,12 @@ static void raydiag_report_flush(const RayDiagReport *report, size_t frame_id, if (d->has_persistent) fprintf(stderr, " vertex=%zu", d->persistent_id); if (d->has_film) - fprintf(stderr, " film=(%.6g, %.6g)", d->film_x, d->film_y); + fprintf(stderr, " film=(%.17g, %.17g)", d->film_x, d->film_y); fprintf(stderr, " trusted=%d", d->trusted); if (d->trusted) { - fprintf(stderr, " stop_t=%.9g", d->stop_time); + fprintf(stderr, " stop_t=%.17g", d->stop_time); if (d->has_position) - fprintf(stderr, " pos=(%.9g, %.9g, %.9g)", d->position[0], + fprintf(stderr, " pos=(%.17g, %.17g, %.17g)", d->position[0], d->position[1], d->position[2]); } fprintf(stderr, " accepted=%llu rejected=%llu rhs=%llu\n", d->accepted, @@ -1349,7 +1349,7 @@ static void report_unresolved_frame(const Settings *s, mesh->vertices[i].outcome == RAY_OUTCOME_UNRESOLVED) ++unresolved_vertices; fprintf(stderr, - "UNRESOLVED/BUDGET_EXHAUSTED: frame %zu camera_t=%.9g " + "UNRESOLVED/BUDGET_EXHAUSTED: frame %zu camera_t=%.17g " "blocking_triangles=%zu unresolved_samples=%zu\n", frame_id, camera_time, stats->budget_incomplete_triangles, unresolved_vertices); @@ -1362,11 +1362,11 @@ static void report_unresolved_frame(const Settings *s, if (!v->traced || v->outcome != RAY_OUTCOME_UNRESOLVED) continue; fprintf(stderr, - " unresolved sample: frame=%zu vertex=%zu film=(%.6g, %.6g) " + " unresolved sample: frame=%zu vertex=%zu film=(%.17g, %.17g) " "continuation_t=", frame_id, i, v->image_x, v->image_y); if (continuation_valid) - fprintf(stderr, "%.9g", v->continuation_t); + fprintf(stderr, "%.17g", v->continuation_t); else fputs("-", stderr); fprintf(stderr, " accepted=%llu rejected=%llu rhs=%llu\n", @@ -2201,7 +2201,8 @@ static int trace_movie_generation(Movie *movie, const Settings *s, if (frame_vertex_continuation_state(&fs->vertex, &state)) { fprintf(stderr, "Ray trace generation %zu: frame %zu sample %zu retry is " - "missing a valid continuation payload.\n", + "missing a valid continuation payload " + "(reason=INVALID_CONTINUATION).\n", generation, movie->frames[f].frame_id, sample); free(reported); ray_pool_destroy(&rays); diff --git a/src/ray.c b/src/ray.c index 520ff8f..c005972 100644 --- a/src/ray.c +++ b/src/ray.c @@ -150,7 +150,7 @@ void ray_pool_preroute(RayPool *p, const SpacetimeSource *source) { p->endpoint[i].outcome = RAY_OUTCOME_INCOMPLETE; p->endpoint[i].reason = status == ASYMPTOTIC_UNSUPPORTED ? RAY_REASON_UNSUPPORTED - : RAY_REASON_PROTOCOL_ERROR; + : RAY_REASON_CAMERA_PREROUTE_FAILED; p->endpoint[i].end_id = route.end_id; p->status[i] = RAY_POOL_FAILED; continue; @@ -182,7 +182,7 @@ void ray_pool_preroute(RayPool *p, const SpacetimeSource *source) { if (route.kind != ASYMPTOTIC_ROUTE_INSIDE && route.kind != ASYMPTOTIC_ROUTE_ENTRY) { p->endpoint[i].outcome = RAY_OUTCOME_INCOMPLETE; - p->endpoint[i].reason = RAY_REASON_PROTOCOL_ERROR; + p->endpoint[i].reason = RAY_REASON_INVALID_ROUTE_KIND; p->status[i] = RAY_POOL_FAILED; continue; } diff --git a/tests/test_asymptotic.c b/tests/test_asymptotic.c index 3e1bea4..95cdd06 100644 --- a/tests/test_asymptotic.c +++ b/tests/test_asymptotic.c @@ -499,11 +499,61 @@ static void test_end_protocol_error(void) { 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, + endpoint.reason == RAY_REASON_END_DESCRIPTOR_FAILED, "advance rejects a declared-but-broken end without legacy"); spacetime_free_slab(slab); } +/* A worldtube sample callback failure and an invalid worldtube geometry are + * distinguished from each other and from the lifecycle/descriptor failure + * above when the same advance entry point classifies its pre-step scan. */ +static void test_advance_worldtube_failure_reasons(void) { + const GeodesicTraceConfig config = {.coordinate_time_step = 1.0, + .max_steps = 10}; + 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}; + + SyntheticContext sample_fail = {.radius = 20.0, + .valid_t_min = -1.0e30, + .constant = 1, + .sample_callback_fails = 1}; + SpacetimeSource source = {.ops = &synthetic_ops, .context = &sample_fail}; + MetricSlab *slab = NULL; + CHECK(spacetime_load_slab(&source, 0.0, -10.0, &slab) == 0, + "sample-fail slab"); + 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_WORLDTUBE_SAMPLE_FAILED, + "worldtube sample callback failure is its own reason"); + spacetime_free_slab(slab); + + SyntheticContext bad_geometry = {.radius = 20.0, + .valid_t_min = -1.0e30, + .constant = 1, + .sample_nan_radius = 1}; + source = (SpacetimeSource){.ops = &synthetic_ops, .context = &bad_geometry}; + CHECK(spacetime_load_slab(&source, 0.0, -10.0, &slab) == 0, + "geometry slab"); + endpoint = (RayEndpoint){.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_WORLDTUBE_GEOMETRY_INVALID, + "invalid worldtube geometry is its own reason"); + 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 @@ -753,6 +803,7 @@ int main(void) { test_motion_segment_domain(); test_schwarzschild_sample_failures(); test_end_protocol_error(); + test_advance_worldtube_failure_reasons(); test_nonconstant_preroute_unsupported(); test_piecewise_constant_history_hole(); test_interior_history_exhaustion(); diff --git a/tests/test_frame.c b/tests/test_frame.c index e977396..e1f080b 100644 --- a/tests/test_frame.c +++ b/tests/test_frame.c @@ -1418,6 +1418,10 @@ int main(void) { .outcome = RAY_OUTCOME_DARK, .reason = RAY_REASON_REDSHIFT_LIMIT, .end_id = SPACETIME_END_NONE, .traced = 1}; + /* An appended detail reason with its coherent outcome must survive the + * frozen v3 schema exactly. */ + dv[2].outcome = RAY_OUTCOME_INCOMPLETE; + dv[2].reason = RAY_REASON_REJECTION_LIMIT; dv[0].trace_accepted_steps = 11; dv[0].trace_rejected_steps = 2; dv[0].trace_rhs_evaluations = 79; dv[1].trace_accepted_steps = 5; @@ -1458,6 +1462,8 @@ int main(void) { dloaded.frames[0].mesh.vertices[0].trace_rejected_steps != 2 || dloaded.frames[0].mesh.vertices[0].trace_rhs_evaluations != 79 || dloaded.frames[0].mesh.vertices[1].trace_accepted_steps != 5 || + dloaded.frames[0].mesh.vertices[2].outcome != RAY_OUTCOME_INCOMPLETE || + dloaded.frames[0].mesh.vertices[2].reason != RAY_REASON_REJECTION_LIMIT || dloaded.frames[0].mesh.triangles[0].level != 1) { fputs("lens-map v3 DP54 field round-trip regression failed\n", stderr); lens_map_destroy(&dloaded); unlink(dp_path); goto done; @@ -1497,6 +1503,41 @@ int main(void) { lens_map_destroy(&dloaded); } } + /* RAY_REASON_COUNT is a sentinel, never a valid wire reason: a map that + * stores it (or anything above it) must be rejected rather than + * reinterpreted. Vertex 0's reason field starts at byte 304 in this v3 + * layout (176 provenance + 48 frame header + 80 vertex prefix). */ + { + if (lens_map_write(dp_path, 4, 3, 30.0, &dp, &df, 1)) { + fputs("lens-map sentinel-reason fixture write failed\n", stderr); + unlink(dp_path); goto done; + } + const uint32_t sentinel = (uint32_t)RAY_REASON_COUNT; + FILE *bad = fopen(dp_path, "r+b"); + int bad_failed = bad == NULL || fseek(bad, 304, SEEK_SET) || + fwrite(&sentinel, sizeof sentinel, 1, bad) != 1; + if (bad != NULL && fclose(bad)) bad_failed = 1; + if (bad_failed || !lens_map_read(dp_path, NULL, &dloaded) || + dloaded.frames != NULL || dloaded.frame_count != 0) { + fputs("lens-map sentinel reason rejection regression failed\n", stderr); + lens_map_destroy(&dloaded); unlink(dp_path); goto done; + } + lens_map_destroy(&dloaded); + } + /* The in-memory writer must also reject the sentinel; the wire test above + * cannot isolate reason validation from the frame CRC. */ + { + const RayReason saved_reason = dv[2].reason; + dv[2].reason = (RayReason)RAY_REASON_COUNT; + const int rejected = + lens_map_write(dp_path, 4, 3, 30.0, &dp, &df, 1) != 0; + dv[2].reason = saved_reason; + if (!rejected) { + fputs("lens-map in-memory sentinel reason write regression failed\n", + stderr); + unlink(dp_path); goto done; + } + } unlink(dp_path); } /* Legacy v2 import: a real v2 map (no adaptive fields, no cost counters) diff --git a/tests/test_geodesic.c b/tests/test_geodesic.c index 5a83e1c..8089e11 100644 --- a/tests/test_geodesic.c +++ b/tests/test_geodesic.c @@ -7,26 +7,103 @@ static int nearly_equal(double a, double b) { return fabs(a - b) < 1e-12; } -/* Every enumerator must have a stable label, and an out-of-range value must be - * reported as UNKNOWN rather than indexing past a table. */ +/* Every enumerator must have a stable label, appended detail codes must be + * distinct and valid, the coarse category helper must stay inside the coarse + * range, and both out-of-range directions (negative and >= COUNT) must be + * rejected as UNKNOWN/sentinel. The frozen wire ids 0..9 are pinned. */ static int check_reason_names(void) { - const RayReason reasons[] = { - RAY_REASON_NONE, RAY_REASON_REDSHIFT_LIMIT, - RAY_REASON_BUDGET_EXHAUSTED, RAY_REASON_TIME_RANGE_EXHAUSTED, - RAY_REASON_OUT_OF_DOMAIN, RAY_REASON_INVALID_METRIC, - RAY_REASON_INTEGRATION_ERROR, RAY_REASON_UNSUPPORTED, - RAY_REASON_PROTOCOL_ERROR, RAY_REASON_IO_ERROR}; + static const char *const frozen[] = { + "NONE", "REDSHIFT_LIMIT", "BUDGET_EXHAUSTED", + "TIME_RANGE_EXHAUSTED", "OUT_OF_DOMAIN", "INVALID_METRIC", + "INTEGRATION_ERROR", "UNSUPPORTED", "PROTOCOL_ERROR", + "IO_ERROR"}; int failed = 0; - for (size_t i = 0; i < sizeof reasons / sizeof reasons[0]; ++i) { - const char *name = ray_reason_name(reasons[i]); - if (name == NULL || name[0] == '\0' || strcmp(name, "UNKNOWN") == 0) { - fprintf(stderr, "reason %d has no stable label\n", (int)reasons[i]); + for (size_t i = 0; i < sizeof frozen / sizeof frozen[0]; ++i) { + const RayReason r = (RayReason)i; + if (!ray_reason_valid(r) || strcmp(ray_reason_name(r), frozen[i]) != 0 || + ray_reason_category(r) != r) { + fprintf(stderr, "frozen reason id %zu is not stable\n", i); failed = 1; } } - const int unknown = (int)RAY_REASON_IO_ERROR + 7; - if (strcmp(ray_reason_name((RayReason)unknown), "UNKNOWN") != 0) { - fputs("out-of-range reason is not UNKNOWN\n", stderr); + if ((unsigned)RAY_REASON_IO_ERROR + 1u >= (unsigned)RAY_REASON_COUNT) { + fputs("no appended detail reasons\n", stderr); + failed = 1; + } + for (unsigned i = 0; i < (unsigned)RAY_REASON_COUNT; ++i) { + const RayReason r = (RayReason)i; + const char *name = ray_reason_name(r); + if (!ray_reason_valid(r) || name == NULL || name[0] == '\0' || + strcmp(name, "UNKNOWN") == 0) { + fprintf(stderr, "reason %u has no stable label\n", i); + failed = 1; + } + if (!ray_reason_valid(ray_reason_category(r))) { + fprintf(stderr, "reason %u has no valid category\n", i); + failed = 1; + } + } + if (ray_reason_category(RAY_REASON_WORLDTUBE_SAMPLE_FAILED) != + RAY_REASON_PROTOCOL_ERROR || + ray_reason_category(RAY_REASON_OUTSIDE_WORLDTUBE) != + RAY_REASON_PROTOCOL_ERROR || + ray_reason_category(RAY_REASON_ESCAPE_LOCALIZATION_FAILED) != + RAY_REASON_PROTOCOL_ERROR || + ray_reason_category(RAY_REASON_REJECTION_LIMIT) != + RAY_REASON_INTEGRATION_ERROR || + ray_reason_category(RAY_REASON_INVALID_ESCAPE_DIRECTION) != + RAY_REASON_INTEGRATION_ERROR || + ray_reason_category(RAY_REASON_SLAB_LOAD_FAILED) != RAY_REASON_IO_ERROR) { + fputs("detail reasons map to the wrong coarse category\n", stderr); + failed = 1; + } + if (ray_reason_valid((RayReason)RAY_REASON_COUNT) || + ray_reason_valid((RayReason)-1)) { + fputs("out-of-range reason accepted as valid\n", stderr); + failed = 1; + } + if (strcmp(ray_reason_name((RayReason)RAY_REASON_COUNT), "UNKNOWN") != 0) { + fputs("sentinel reason name is not UNKNOWN\n", stderr); + failed = 1; + } + const RayReason unknown = (RayReason)((unsigned)RAY_REASON_COUNT + 7u); + if (strcmp(ray_reason_name(unknown), "UNKNOWN") != 0 || + ray_reason_valid(unknown) || + ray_reason_category(unknown) != (RayReason)RAY_REASON_COUNT) { + fputs("unknown reason is not the UNKNOWN sentinel\n", stderr); + failed = 1; + } + return failed; +} + +/* A NULL, non-finite or non-unit direction must be rejected as INVALID_ARGUMENT + * without dereferencing the direction or running any trace. */ +static int check_invalid_directions(const SpacetimeSource *source, + const ObserverState *observer) { + const GeodesicTraceConfig config = {.coordinate_time_step = 0.25, + .max_steps = 100}; + int failed = 0; + const RayEndpoint null_dir = + geodesic_trace_past(source, observer, NULL, &config); + if (null_dir.outcome != RAY_OUTCOME_INCOMPLETE || + null_dir.reason != RAY_REASON_INVALID_ARGUMENT) { + fputs("NULL direction is not INVALID_ARGUMENT\n", stderr); + failed = 1; + } + const double nan_dir[3] = {NAN, 0.0, 0.0}; + const RayEndpoint nan = + geodesic_trace_past(source, observer, nan_dir, &config); + if (nan.outcome != RAY_OUTCOME_INCOMPLETE || + nan.reason != RAY_REASON_INVALID_ARGUMENT) { + fputs("NaN direction is not INVALID_ARGUMENT\n", stderr); + failed = 1; + } + const double nonunit_dir[3] = {2.0, 0.0, 0.0}; + const RayEndpoint nonunit = + geodesic_trace_past(source, observer, nonunit_dir, &config); + if (nonunit.outcome != RAY_OUTCOME_INCOMPLETE || + nonunit.reason != RAY_REASON_INVALID_ARGUMENT) { + fputs("non-unit direction is not INVALID_ARGUMENT\n", stderr); failed = 1; } return failed; @@ -35,8 +112,7 @@ static int check_reason_names(void) { static int check_ray(const SpacetimeSource *source, const ObserverState *observer, const double local_direction[3], - const double expected[3]) { - const GeodesicTraceConfig config = {.coordinate_time_step = 0.25, + const double expected[3]) { const GeodesicTraceConfig config = {.coordinate_time_step = 0.25, .max_steps = 100}; RayEndpoint ray = geodesic_trace_past(source, observer, local_direction, &config); @@ -63,6 +139,7 @@ int main(void) { !spacetime_slab_eval(slab, 0.25, (double[]){0.0, 0.0, 0.0}, &metric)) return 1; int result = check_reason_names() || + check_invalid_directions(&source, &observer) || check_ray(&source, &observer, (double[]){1.0, 0.0, 0.0}, (double[]){0.0, 0.0, -1.0}) || check_ray(&source, &observer, (double[]){0.0, 0.0, 1.0}, diff --git a/tests/test_geodesic_adaptive.c b/tests/test_geodesic_adaptive.c index fcd9f50..8f27484 100644 --- a/tests/test_geodesic_adaptive.c +++ b/tests/test_geodesic_adaptive.c @@ -345,26 +345,26 @@ static void test_minkowski_finite_interval(void) { RayEndpoint out = blank_endpoint(); CHECK(geodesic_advance_past_ray(s, &bad_state, -0.5, &bad, &out) == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_PROTOCOL_ERROR, + out.reason == RAY_REASON_INVALID_STEPPER_CONFIG, "zero atol rejected"); bad = dp_config(1e-9, 0.5, 1.0e6); bad.max_lookback_time = 0.0; CHECK(geodesic_advance_past_ray(s, &bad_state, -0.5, &bad, &out) == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_PROTOCOL_ERROR, + out.reason == RAY_REASON_INVALID_STEPPER_CONFIG, "zero lookback rejected"); bad = dp_config(1e-9, 0.5, 1.0e6); bad.min_step = 2.0; bad.max_step = 1.0; CHECK(geodesic_advance_past_ray(s, &bad_state, -0.5, &bad, &out) == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_PROTOCOL_ERROR, + out.reason == RAY_REASON_INVALID_STEPPER_CONFIG, "inverted step bounds rejected"); bad = dp_config(1e-9, 0.5, 1.0e6); bad.consecutive_rejection_limit = 0; CHECK(geodesic_advance_past_ray(s, &bad_state, -0.5, &bad, &out) == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_PROTOCOL_ERROR, + out.reason == RAY_REASON_INVALID_STEPPER_CONFIG, "zero reject limit rejected"); spacetime_free_slab(s); } @@ -669,7 +669,7 @@ static void test_fixture_fatal_and_bounds(void) { geodesic_advance_past_ray(slab, &state, -0.5, &config, &out); spacetime_free_slab(slab); CHECK(r == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_PROTOCOL_ERROR, + out.reason == RAY_REASON_METRIC_INTERNAL_ERROR, "stage INTERNAL_ERROR is a direct protocol failure"); CHECK(state.rhs_evaluations == 2u, "internal error not retried"); } @@ -694,7 +694,7 @@ static void test_fixture_fatal_and_bounds(void) { geodesic_advance_past_ray(slab, &state, -10.0, &hmin, &out); spacetime_free_slab(slab); CHECK(r == GEODESIC_ADVANCE_FAILED && - out.reason == RAY_REASON_INTEGRATION_ERROR, + out.reason == RAY_REASON_MIN_STEP_REACHED, "step below hmin is an integration error"); CHECK(state.rejected_steps >= 1u, "hmin path rejected before failing"); @@ -1786,7 +1786,7 @@ static void test_unknown_stepper_rejected(void) { const RayEndpoint traced = geodesic_trace_past( &source, &observer, (double[]){1.0, 0.0, 0.0}, &config); CHECK(traced.outcome == RAY_OUTCOME_INCOMPLETE && - traced.reason == RAY_REASON_PROTOCOL_ERROR, + traced.reason == RAY_REASON_UNKNOWN_STEPPER, "trace_past rejects an unknown stepper"); GeodesicRayState state; @@ -1794,7 +1794,7 @@ static void test_unknown_stepper_rejected(void) { const RayEndpoint resumed = geodesic_trace_past_from_state(&source, &state, &config); CHECK(resumed.outcome == RAY_OUTCOME_INCOMPLETE && - resumed.reason == RAY_REASON_PROTOCOL_ERROR, + resumed.reason == RAY_REASON_UNKNOWN_STEPPER, "trace_past_from_state rejects an unknown stepper"); MetricSlab *slab = NULL; @@ -1805,7 +1805,7 @@ static void test_unknown_stepper_rejected(void) { spacetime_free_slab(slab); CHECK(r == GEODESIC_ADVANCE_FAILED && advanced.outcome == RAY_OUTCOME_INCOMPLETE && - advanced.reason == RAY_REASON_PROTOCOL_ERROR, + advanced.reason == RAY_REASON_UNKNOWN_STEPPER, "advance rejects an unknown stepper"); spacetime_destroy(&source); } @@ -2041,6 +2041,35 @@ static void test_event_step_time_precision(void) { fabs(null_residual(&metric, state.Pi) - 1.0) < 1e-12, "time-precision fixture stays null"); } + + /* At a huge coordinate-time origin a nominal step below the local ULP + * cannot define a representable nonzero interval: report the exact + * TIME_STEP_UNREPRESENTABLE cause instead of committing a zero-length step. */ + { + const double origin = 1.0e17; + FixtureContext c; + memset(&c, 0, sizeof c); + c.radius = 1.0e30; + SpacetimeSource source = {.ops = &fixture_ops, .context = &c}; + GeodesicRayState state; + memset(&state, 0, sizeof state); + state.coordinate_time = origin; + state.integration_start_time = origin; + state.Pi[0] = -1.0; + GeodesicTraceConfig config = dp_config(1e-9, 1e-12, 1.0e30); + config.min_step = 1e-12; + config.max_step = 1.0; + MetricSlab *slab = NULL; + CHECK(spacetime_load_slab(&source, origin, origin - 1.0e30, &slab) == 0, + "unrepresentable slab"); + RayEndpoint out = blank_endpoint(); + const GeodesicAdvanceResult r = + geodesic_advance_past_ray(slab, &state, origin - 1.0e30, &config, &out); + spacetime_free_slab(slab); + CHECK(r == GEODESIC_ADVANCE_FAILED && + out.reason == RAY_REASON_TIME_STEP_UNREPRESENTABLE, + "sub-ULP step is unrepresentable"); + } } /* A crossing whose dense root is absorbed at theta == 1 (F_after strictly @@ -2099,8 +2128,10 @@ static void test_event_boundary_roundoff_exit(void) { "boundary roundoff start escapes outward"); } -/* A single-end state clearly outside its worldtube is a protocol error, not an - * unbounded search that can only end as budget exhaustion. */ +/* A single-end state clearly outside its worldtube is reported with the exact + * OUTSIDE_WORLDTUBE cause, not an unbounded search that can only end as budget + * exhaustion, and the last trusted accepted state plus the real cost counters + * are preserved (the failed event step is never committed). */ static void test_event_clearly_outside_protocol(void) { FixtureContext c; memset(&c, 0, sizeof c); @@ -2121,8 +2152,15 @@ static void test_event_clearly_outside_protocol(void) { spacetime_free_slab(slab); CHECK(r == GEODESIC_ADVANCE_FAILED && out.outcome == RAY_OUTCOME_INCOMPLETE && - out.reason == RAY_REASON_PROTOCOL_ERROR, - "clearly-outside single end is a protocol error"); + out.reason == RAY_REASON_OUTSIDE_WORLDTUBE, + "clearly-outside single end is an explicit failure"); + CHECK(out.stop_coordinate_time == 0.0 && out.final_x[0] == 2.0 && + out.final_x[1] == 0.0 && out.final_x[2] == 0.0 && + out.accepted_steps == 0u, + "outside-worldtube failure preserves the last trusted state"); + CHECK(state.coordinate_time == 0.0 && state.x[0] == 2.0 && + state.steps == 0u && state.rhs_evaluations > 0ul, + "outside-worldtube failure keeps the trusted state and real cost"); } /* Escape and threshold are both localized on the actual trajectory and ordered @@ -2529,7 +2567,7 @@ static void test_event_threshold_confirmation_exhausted(void) { spacetime_free_slab(slab); CHECK(r == GEODESIC_ADVANCE_FAILED && out.outcome == RAY_OUTCOME_INCOMPLETE && - out.reason == RAY_REASON_INTEGRATION_ERROR, + out.reason == RAY_REASON_THRESHOLD_EVENT_UNCONFIRMED, "unconfirmable threshold is an incomplete integration failure"); CHECK(out.outcome != RAY_OUTCOME_DARK, "unconfirmable threshold is not rewritten into DARK"); @@ -2539,6 +2577,35 @@ static void test_event_threshold_confirmation_exhausted(void) { CHECK(out.rhs_evaluations > 0ul, "actual RHS cost is recorded"); } +/* A pure error-estimate rejection that exhausts the consecutive-rejection quota + * reports the exact REJECTION_LIMIT cause. With a one-rejection quota the + * limit is reached before any minimum-step check, so this is not MIN_STEP. */ +static void test_rejection_limit_reason(void) { + AlphaContext c; + memset(&c, 0, sizeof c); + c.k = 1.0; + c.radius = 10.0; + SpacetimeSource source = {.ops = &alpha_ops, .context = &c}; + GeodesicRayState state; + alpha_state(0.2, c.k, &state); + GeodesicTraceConfig config = dp_config(1e-15, 1.0, 10.0); + config.max_step = 1.0; + config.min_step = 1.0; + config.consecutive_rejection_limit = 1; + MetricSlab *slab = NULL; + CHECK(spacetime_load_slab(&source, 0.0, -5.0, &slab) == 0, + "reject-limit slab"); + RayEndpoint out = blank_endpoint(); + const GeodesicAdvanceResult r = + geodesic_advance_past_ray(slab, &state, -5.0, &config, &out); + spacetime_free_slab(slab); + CHECK(r == GEODESIC_ADVANCE_FAILED && + out.reason == RAY_REASON_REJECTION_LIMIT, + "error-estimate rejection quota reports REJECTION_LIMIT"); + CHECK(state.coordinate_time == 0.0 && state.rejected_steps >= 1u, + "rejected trial did not advance the accepted state"); +} + /* Uniform unit scaling: with every length/time quantity scaled by 1e-9 the * same flat escape must give event time/scale ~ 3 and x/scale ~ 5, i.e. no * absolute coordinate-time floor breaks small units. */ @@ -2611,6 +2678,7 @@ int main(void) { test_event_worldtube_polynomial_time_origin(); test_event_threshold_closed_endpoint(); test_event_threshold_confirmation_exhausted(); + test_rejection_limit_reason(); test_event_unit_scaling(); if (failures != 0) { fprintf(stderr, "geodesic adaptive regression: %d failure(s)\n", failures); diff --git a/tests/test_ray_diagnostics.py b/tests/test_ray_diagnostics.py index 20f0686..f886771 100644 --- a/tests/test_ray_diagnostics.py +++ b/tests/test_ray_diagnostics.py @@ -11,8 +11,9 @@ refusal must not recommend a larger retry budget for a pure integration error. A deterministic DP54 integration error is produced without a large workload: strict tolerances, ``min == initial == max`` step, and one allowed rejection -make the first trial step fail as ``INTEGRATION_ERROR``. Small 16x8 scenes -keep every case short. Two-frame movie coverage uses the existing +make the first trial step fail. The rejection quota is reached before any +minimum-step check, so the exact detailed reason is ``REJECTION_LIMIT``. Small +16x8 scenes keep every case short. Two-frame movie coverage uses the existing ``test_observer_`` fixture track duplicated to two rows (the Schwarzschild metric is stationary, so the same tetrad is valid at both times); the test then checks that each failing sample is counted once across @@ -48,9 +49,10 @@ COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 16, '--height', 8, '--fov-deg', 80, '--exposure', 1e-3, '--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4] -# Deterministic DP54 integration failure: tight tolerances, a single fixed step -# bound, and one allowed rejection. Every ray rejects its first trial step and -# reports INTEGRATION_ERROR instead of a fabricated terminal category. +# Deterministic DP54 rejection-quota failure: tight tolerances, a single fixed +# step bound, and one allowed rejection. Every ray rejects its first trial step +# and reports the exact REJECTION_LIMIT reason instead of a fabricated terminal +# category. INJECT = ['--integrator', 'dp54', '--ode-rtol', '1e-15', '--ode-atol-x', '1e-15', '--ode-atol-pi', '1e-15', '--ode-atol-l', '1e-15', @@ -139,7 +141,7 @@ with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-', '--output', single) err = single_run.stderr assert 'Ray failures:' in err, err - assert 'INCOMPLETE=' in err and 'INTEGRATION_ERROR' in err, err + assert 'INCOMPLETE=' in err and 'REJECTION_LIMIT' in err, err assert frame_reported(err, 0), err if not is_debug(single_run): assert 'ray failure:' not in err, err @@ -154,7 +156,7 @@ with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-', '--output', verbose) err = verbose_run.stderr assert 'ray failure:' in err, err - assert 'reason=INTEGRATION_ERROR' in err, err + assert 'reason=REJECTION_LIMIT' in err, err assert 'vertex=' in err and 'film=(' in err, err assert 'accepted=' in err and 'rejected=' in err and 'rhs=' in err, err # Bounded detail: at most RAY_DIAG_REPS_PER_REASON per reason, remainder @@ -241,7 +243,7 @@ with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-', '--output', tmp / f'map_refused.{ext}', ok=False) err = refused.stderr assert frame_reported(err, 0) and frame_reported(err, 1), err - assert 'INTEGRATION_ERROR' in err, err + assert 'REJECTION_LIMIT' in err, err assert 'phase=import' in err, err assert 'Incomplete render refused' in err, err assert err.index('frame 0') < err.index('Incomplete render refused'), err @@ -255,7 +257,7 @@ with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-', '--verbose', '--output', replay) err = replay_run.stderr assert 'Ray failures:' in err and frame_reported(err, 0), err - assert 'INTEGRATION_ERROR' in err and 'phase=import' in err, err + assert 'REJECTION_LIMIT' in err and 'phase=import' in err, err assert 'stop_t=' not in err and 'trusted=1' not in err, err # 6) Budget exhaustion is a distinct, always-on message, and its refusal