Compare commits

..
9 Commits
Author SHA1 Message Date
wyj 2c88bbc7e6 Doc: Define authoritative design document hygiene rules 2026-10-10 01:50:42 -04:00
wyj 80f9dcb3a3 Feat: Add post-tone-map mesh diagnostics with RGBA overlays
Color antialiased half-edges by ray outcome with configurable Catppuccin colors and default opacity 0.5.

Rasterize premultiplied RGBA8 overlays on the producer and composite in place after writing the clean image. Keep single-frame, movie, and replay output consistent.

Add overlay, CLI, and queue ownership regressions and document the final output architecture.
2026-10-10 01:46:32 -04:00
wyj 4053d5d0c8 Fix: Stop lens-map vertex decoding on read failure 2026-10-09 00:17:39 -04:00
wyj 7c980c35aa Benchmark: Compare quadratic entry precision and arithmetic cost
Generate plain long-double and experimental FMA variants from the production entry kernel. Compare exact-rational references, geometric validation, fallback counts and repeated timings with self-contained fixtures.

Validate cached build dependencies and reject stale timing output. Count all unconfirmed entry outcomes independently of reference classification.
2026-10-09 00:15:02 -04:00
wyj 789549ed2f Fix: Validate asymptotic entries with precise roots and fallback
Use scaled long-double quadratic arithmetic without explicit FMA. Validate entry candidates against backend geometry and localize uncertain entries along the original exterior trajectory.

Preserve conservative miss semantics and propagate concrete entry failures. Add production-sample and numerical regression coverage.
2026-10-09 00:14:54 -04:00
wyj 95c05f84d9 Fix: Distinguish concrete ray failure causes 2026-10-08 05:07:34 -04:00
wyj 76ed705d37 Fix: Report ray failure reasons and affected frames 2026-10-08 00:16:43 -04:00
wyj af8b83007f Fix: Separate normal renderer output from diagnostics 2026-10-07 22:14:56 -04:00
wyj 69a8cb5647 Feat: Add single-frame observer coordinate time option 2026-10-07 21:12:55 -04:00
46 changed files with 9002 additions and 485 deletions

No files matched your search

+9
View File
@@ -85,6 +85,15 @@ catalog 内部数据保留 `(direction, temperature, amplitude)`,而非 RGB。
性能 benchmark 记录必须保留完整、可复制的命令及原始终端输出,不能只记录汇总耗时或吞吐量;输出中的 build/cache、输入加载、工作线程、处理数量与 fallback 等统计是后续正确归因性能变化的证据。
## 权威设计文档卫生
- 仓库级文档规则应具有跨任务适用性,不夹带单次任务的细节或案例。
- `nr_spacetime_movie_renderer_design.md` 应简明描述当前确定的架构、物理与数值约定、模块边界、数据流及 ownership;尚未确定的问题须明确标为待验证。
- 写最终设计,不写 agent 工作过程、对话经过、实现日记或备选方案淘汰史。已排除的临时设想不要改写成长期禁止条款;必要的物理与架构约束仍须保留。
- 决策依据只保留理解设计所必需的简要理由。实验过程、性能数据及详细对照放到符合仓库卫生要求的独立记录中,设计文档按需引用。
- 使用说明集中到 `usage.md`;README 保留面向使用者的简介与示例,避免在权威设计文档中重复罗列。
- 设计变更应改写并整合原有相关章节,删除过时或重复表述,检查跨章节一致性;不要通过不断追加补充段落堆积历史。
## 仓库卫生与短期产物
只有对本项目有长期记录价值、且值得进入 public repo 的测试与 benchmark 才纳入 git。
+33 -5
View File
@@ -36,7 +36,7 @@ TARGET_BASENAME := $(SPACETIME)_sky
OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME)
CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c
.PHONY: all backend clean run test tone-map-test sensor-bloom-test sensor-bloom-bench hip-psf-test hip-psf-bench fast-psf-fftw-bench minkowski schwarzschild alcubierre FORCE
.PHONY: all backend clean run test tone-map-test sensor-bloom-test sensor-bloom-bench mesh-overlay-test hip-psf-test hip-psf-bench fast-psf-fftw-bench minkowski schwarzschild alcubierre FORCE
ifneq ($(filter 0 1,$(PSF_EVENT_SINK)),$(PSF_EVENT_SINK))
$(error Unknown PSF_EVENT_SINK '$(PSF_EVENT_SINK)'; choose 0 or 1)
@@ -108,6 +108,8 @@ TEST_OUT_DIR := $(OBJECT_DIR)/$(HDR_BUILD_TAG)
TEST_TARGET := $(TEST_OUT_DIR)/test_geodesic
ADAPTIVE_GEODESIC_TEST_TARGET := $(TEST_OUT_DIR)/test_geodesic_adaptive
ASYMPTOTIC_TEST_TARGET := $(TEST_OUT_DIR)/test_asymptotic
ASYMPTOTIC_ENTRY_TEST_TARGET := $(TEST_OUT_DIR)/test_asymptotic_entry
ASYMPTOTIC_QUADRATIC_TEST_TARGET := $(TEST_OUT_DIR)/test_asymptotic_quadratic
ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET := $(TEST_OUT_DIR)/test_asymptotic_schwarzschild
TERMINATION_ORACLE_TEST_TARGET := $(TEST_OUT_DIR)/test_termination_oracle
FRAME_TEST_TARGET := $(TEST_OUT_DIR)/test_frame
@@ -124,6 +126,7 @@ TONE_MAP_TEST_TARGET := $(TEST_OUT_DIR)/test_tone_map
MOVIE_OUTPUT_TEST_TARGET := $(TEST_OUT_DIR)/test_movie_output
SENSOR_BLOOM_TEST_TARGET := $(TEST_OUT_DIR)/test_sensor_bloom
SENSOR_BLOOM_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_sensor_bloom
MESH_OVERLAY_TEST_TARGET := $(TEST_OUT_DIR)/test_mesh_overlay
ifeq ($(PSF_BACKEND),hip)
TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)_hip
@@ -217,9 +220,20 @@ $(ADAPTIVE_GEODESIC_TEST_TARGET): tests/test_geodesic_adaptive.c $(COMMON_SOURCE
$(ASYMPTOTIC_TEST_TARGET): tests/test_asymptotic.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
# The test includes asymptotic.c to cover its private floating-point kernel.
$(ASYMPTOTIC_QUADRATIC_TEST_TARGET): tests/test_asymptotic_quadratic.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $(filter-out src/asymptotic.c,$^) $(LDLIBS) -o $@
$(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET): tests/test_asymptotic_schwarzschild.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -DSPACETIME_SCHWARZSCHILD -Isrc $^ $(LDLIBS) -o $@
# Independent core regression for the backend-free numerical entry localizer.
# It links only the new module and the shared spacetime dispatch wrapper: no
# analytic backend, no geodesic integrator and no asymptotic.c are required,
# so it stays exercisable independently of the route integration.
$(ASYMPTOTIC_ENTRY_TEST_TARGET): tests/test_asymptotic_entry.c src/asymptotic_entry.c src/spacetime_common.c src/asymptotic_entry.h src/asymptotic.h src/geodesic.h src/spacetime.h src/observer.h | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_asymptotic_entry.c src/asymptotic_entry.c src/spacetime_common.c $(LDLIBS) -o $@
$(FRAME_TEST_TARGET): tests/test_frame.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
@@ -254,10 +268,10 @@ $(FAST_PSF_FFTW_TEST_TARGET): tests/test_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCE
$(TONE_MAP_TEST_TARGET): tests/test_tone_map.c src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_tone_map.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
# The movie-output queue links production optics + fast_psf_fftw only, so it
# The movie-output queue links production optics, mesh overlay and FFTW only, so it
# needs neither a catalog nor ray tracing.
$(MOVIE_OUTPUT_TEST_TARGET): tests/test_movie_output.c src/movie_output.c src/movie_output.h src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_movie_output.c src/movie_output.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
$(MOVIE_OUTPUT_TEST_TARGET): tests/test_movie_output.c src/movie_output.c src/movie_output.h src/mesh_overlay.c src/mesh_overlay.h src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_movie_output.c src/movie_output.c src/mesh_overlay.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
$(FAST_PSF_FFTW_BENCH_TARGET): tests/benchmark_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
@@ -271,6 +285,11 @@ $(SENSOR_BLOOM_TEST_TARGET): tests/test_sensor_bloom.c src/sensor_bloom.c src/se
$(SENSOR_BLOOM_BENCH_TARGET): tests/benchmark_sensor_bloom.c src/sensor_bloom.c src/sensor_bloom.h | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/benchmark_sensor_bloom.c src/sensor_bloom.c $(LDLIBS) -o $@
# The mesh-overlay regression links only the standalone overlay module: it
# needs neither a catalog, ray tracing, FFTW, nor an output writer.
$(MESH_OVERLAY_TEST_TARGET): tests/test_mesh_overlay.c src/mesh_overlay.c src/mesh_overlay.h | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_mesh_overlay.c src/mesh_overlay.c $(LDLIBS) -o $@
# The FFTW-vs-spatial test is meaningful only in the CPU PSF build.
ifneq ($(CPU_FFTW_SOURCES),)
FAST_PSF_FFTW_TEST_DEP := $(FAST_PSF_FFTW_TEST_TARGET)
@@ -280,12 +299,14 @@ FAST_PSF_FFTW_TEST_DEP :=
FAST_PSF_FFTW_TEST_RUN :=
endif
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(ASYMPTOTIC_TEST_TARGET) $(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET) $(TERMINATION_ORACLE_TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET)
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(ASYMPTOTIC_TEST_TARGET) $(ASYMPTOTIC_ENTRY_TEST_TARGET) $(ASYMPTOTIC_QUADRATIC_TEST_TARGET) $(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET) $(TERMINATION_ORACLE_TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET) $(MESH_OVERLAY_TEST_TARGET)
$(TEST_OUT_DIR)/test_observer_minkowski
$(TEST_OUT_DIR)/test_observer_schwarzschild
$(TEST_TARGET)
$(ADAPTIVE_GEODESIC_TEST_TARGET)
$(ASYMPTOTIC_TEST_TARGET)
$(ASYMPTOTIC_ENTRY_TEST_TARGET)
$(ASYMPTOTIC_QUADRATIC_TEST_TARGET)
$(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET)
$(TERMINATION_ORACLE_TEST_TARGET)
$(FRAME_TEST_TARGET)
@@ -297,8 +318,12 @@ test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(A
$(TONE_MAP_TEST_TARGET)
$(MOVIE_OUTPUT_TEST_TARGET)
$(SENSOR_BLOOM_TEST_TARGET)
$(MESH_OVERLAY_TEST_TARGET)
python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_adaptive_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_ray_diagnostics.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_output_streams.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_mesh_overlay_cli.py $(BUILD_DIR)
tone-map-test: $(TONE_MAP_TEST_TARGET)
$(TONE_MAP_TEST_TARGET)
@@ -306,6 +331,9 @@ tone-map-test: $(TONE_MAP_TEST_TARGET)
sensor-bloom-test: $(SENSOR_BLOOM_TEST_TARGET)
$(SENSOR_BLOOM_TEST_TARGET)
mesh-overlay-test: $(MESH_OVERLAY_TEST_TARGET)
$(MESH_OVERLAY_TEST_TARGET)
sensor-bloom-bench: $(SENSOR_BLOOM_BENCH_TARGET)
fast-psf-fftw-bench: $(FAST_PSF_FFTW_BENCH_TARGET)
+4
View File
@@ -192,6 +192,10 @@ tone-mapped image; `--draw-mesh` additionally writes the final image-plane
triangles, so one command produces both
`output/imgs/schwarzschild_test_grid.png` (no mesh) and
`output/imgs/schwarzschild_test_grid_mesh.png` (mesh overlay).
The antialiased mesh is drawn after tone mapping: gray escape half-edges,
purple dark half-edges, yellow budget-unresolved half-edges, and red failure
half-edges, using Catppuccin Mocha defaults. Color and opacity settings are
documented in `usage.md`.
```sh
mkdir -p output/imgs
+1
View File
@@ -119,6 +119,7 @@ mkdir -p output/imgs
### 示例:叠加网格的合成测试星表
这个示例使用 `assets/sky_grid_5deg.csv` 检查 Schwarzschild 时空中的引力透镜效果与自适应网格细分。主输出是不带网格的成品图;`--draw-mesh` 会额外写出最终的像平面三角网格,因此同一次命令会同时生成 `output/imgs/schwarzschild_test_grid.png`(无网格)和 `output/imgs/schwarzschild_test_grid_mesh.png`(网格叠加)。
网格在 tone mapping 后以抗锯齿半边叠加。默认采用 Catppuccin Mocha:逃逸为灰色、暗终态为紫色、预算耗尽未决为黄色、真实失败为红色,未追踪为蓝色。颜色与透明度配置详见 `usage.md`。
```sh
mkdir -p output/imgs
+232
View File
@@ -0,0 +1,232 @@
# quadratic_precision — stable entry quadratic: precision vs. cost
Self-contained, reproducible benchmark comparing four arithmetic realisations of
the **same** asymptotic-entry algebra. Nothing here modifies production code,
the `Makefile`, or git state.
## Question
The production camera pre-route solves the relative-distance quadratic
```
F(s) = |d + q s|^2 - (R0 - rr s)^2 = a s^2 + b s + c
d = x_cur - c_frame, q = w_frame + v_frame
```
in `long double`, with a stable root formula, an ordinary discriminant
`b*b - 4*a*c` and entry slope, a common power-of-two scaling, an
uncertainty band, geometric validation and a numerical fallback. Which matters
for accuracy and which for speed?
Four variants share the *entire* rest of the module (scaling, uncertainty band,
validation, fallback, dispatch) and are generated from the current
`src/asymptotic.c` by rewriting exactly one lexical region:
| variant | coefficients | type | discriminant / slope |
|---------------------|--------------|-------------|----------------------|
| `ld_plain` | as production| `long double` | plain `b*b - 4*a*c`, plain slope |
| `ld_fma` | as production| `long double` | compensated `fmal` (experimental arm) |
| `double_fma` | ordinary `+=`| `double` | compensated `fma` |
| `double_fma_coeff` | `fma` dots, `fma` products | `double` | compensated `fma` |
`double_fma` is the control that isolates *type* precision from *FMA*; the two
double variants isolate *coefficient accumulation*.
## Generation and build
`build.py` locates the kernel region between two lexical anchors in the current
working-tree `src/asymptotic.c`:
* start: the comment `/* Long-double coefficients of the relative-distance
quadratic`
* end: the forward declaration `static void minkowski_route_entry(...)`
It asserts each anchor and the presence of `entry_quadratic_coeffs`,
`entry_solve`, `entry_discriminant` exactly once, then emits four full-module
copies under `<output>/generated/`. Every other line (dispatch, fallback,
`asymptotic_route_camera`, Schwarzschild path, …) is copied verbatim. If a
future edit moves an anchor or changes the region, generation **fails** rather
than silently measuring the wrong code.
`ld_plain` retains the production kernel verbatim; `ld_fma` rewrites only the
discriminant body and the entry slope. Double
variants replace `long double`→`double`, `fmal`/`fabsl`/`fmaxl`/`frexpl`/
`scalbnl`/`sqrtl`/`copysignl`→their double forms, `LDBL_*`→`DBL_*`, and strip
`L` suffixes from literals. A guard rejects any remaining `long double`
promotion, `L` literal or `*l`/`fmal` call in the double kernel section, so no
double expression can be re-promoted to `long double`. `double_fma_coeff`
additionally changes the coefficient accumulation to `fma`.
The probe `#include`s the generated module, so the private static
`entry_quadratic_coeffs`/`entry_solve` and the public `asymptotic_route_camera`
that are exercised are the **actual generated code**, not an independent copy.
Compile flags, identical for all four variants:
```
-std=c11 -march=native -O2 -DNDEBUG -ffp-contract=off -fopenmp
```
`-ffp-contract=off` prevents implicit FMA contraction from silently changing
the `ld_plain` arm; explicit `fma`/`fmal` still lower as written.
The probe links a minimal production subset (`geodesic.c`,
`asymptotic_entry.c`, `asymptotic_schwarzschild.c`, `spacetime_common.c`,
`spacetime_minkowski.c`) plus libm. No FFTW, PNG, catalog or observer-track
data are needed.
## Inputs
All fixtures are frozen as IEEE-754 hex literals (`cases.json` and the
generated C header are bit-identical), including the two production grazing
rows reproduced from the Alcubierre pre-route (`x=(0,-24,0)`, `centre=2t`,
`v=2`, `R=5`, canonical `w` hex values). Families:
* **curated / adversarial**: head-on hit/miss, clear miss, grazing
`y=nextafter(R,0)`, exact tangent, near-boundary, `1e10 + 0.5, R=1`
cancellation, large-`t` `centre=0.1t`, exact linear `a==0` (inward and
initially outward), translated-origin cancellation.
* **fixed**: axes and three oblique frames, radii
`1e-100…1e100`, `D/R ∈ {1+ulp,2,10,100,512,1024,1e4,1e8,1e10}`, impact
ratios `{0,.5,.99,1-1e-6,nextafter(1,0),1,nextafter(1,∞),1+1e-6,1.1}`.
The `512`/`1024` ratios sample the crossover where long-double coefficient
rounding meets the `128 ε_D R²` geometry tolerance (`≈512`) and the double
crossover (`≈11`), separating fallback rates from the all-UNCERTAIN `1e10`
regime.
* **moving**: `v ∈ {0,.1,2,10}` along a transverse direction.
* **growing**: `rr = -1` and `nextafter(-1,∓∞)`, **inward and
initially-outward** photons (`w = ∓tow`), axis and oblique.
* **shrinking**: `rr>0`, labelled/domain-checked algebra (radius would go
negative on the open past; roots outside `R0 - rr·s > 0` are non-physical).
* **route**: `rr = 0` plus **`rr<0` growing inward/outward** families
(positive radius on the whole past, `valid_t_min=-1e300`) across all three
direction frames, driven through the public `asymptotic_route_camera`. The
oblique-2 growing-outward family reproduces the double kernel false-MISS
cases, where a kernel MISS bypasses the fallback and the route escapes
directly; the route reference uses the actual normalised canonical `w`, so
normalisation effects are explicit.
Exact counts are emitted by the run (and in `summary.json`); they are not
hard-coded here.
## Reference oracle
Inputs are exact, so they are represented as `fractions.Fraction` (Python
stdlib). `d`, `q`, `a`, `b`, `c`, the discriminant and polynomial residuals
are **exact rationals**; only `sqrt` uses `decimal` (`--precision`, default
160). A custom hex parser handles both `%a` (double) and `%La` (long double)
without `float.fromhex`, which would silently drop a 64-bit long-double mantissa
to 53 bits. Reference classification mirrors production semantics: `c<0` is
INSIDE (not an entry failure), `c==0` uses the boundary slope, `a==0` is the
exact linear branch, a real double root/tangent is MISS.
Route references are built from the **actual canonical state the probe printed**
(`canonx*`, `canonw*`) joined with the callback samples at `t0`, so the 1-ulp
normalisation of `w` and both callback models (`centre = c0 + v(t-t0)` and the
production `centre = v t`) are handled explicitly.
## Metrics
Kernel:
* status counts (ENTRY/MISS/UNCERTAIN) vs the reference, **false MISS**
(reference ENTER, kernel MISS) and **false candidate** (reference MISS,
kernel ENTRY);
* root error in ULP of the returned double root and relative error, for the
common reference-ENTER set and the intersection where *all* variants returned
ENTRY (so more UNCERTAIN cannot look better by selection);
* polynomial residual at the candidate root using the actual printed
coefficients, the exact ideal polynomial, and the nearest-double-rounded
reference root as a baseline;
* `a` exact-zero vs kernel-zero, `a` sign mismatches, `a` collapse/spurious
non-zero, and conditioning labels (`near_linear`, `late`, `ill_conditioned`).
Outward/growing and near-linear cases are ill-conditioned; a rounded-zero `a`
can turn a very late true entry into a false MISS, so these are reported
separately and are not treated as a universal precision claim.
Route:
* kind/status vs reference, false MISS, false candidate, camera-INSIDE
mismatches, total `INVALID/ENTRY_UNCONFIRMED` outcomes independent of reference
classification (a conservative refusal to confirm is not a false escape);
* the **public kernel classification at the actual canonical inputs**
(`kern_status` in the route CSV), so a reference ENTER / kernel MISS / route
ESCAPED (`kernel_false_miss_escaped`) is visible and is not confused with
canonical normalisation;
* fast-path vs fallback (`entry_fallback_evaluations`), `F/tol` for accepted
entries, and Pi/`L_camera` preservation.
The reference enforces `R0 - rr·s > 0` for quadratic roots; a positive root
outside the physical radius domain is reported as `domain_clipped` and is not
counted as a physical ENTER/false-MISS. A 160-vs-240-digit consistency check is
run for every curated and near-linear/late/domain-clipped id and must report the
same status, discriminant sign and nearest-double root.
Timing:
* kernel: coefficient assembly + `entry_solve`, serial, `noinline` +
`volatile` sink, and an inline-asm opaque loop index so GCC cannot hoist the
pure kernel out of the repetition loop (identical for all variants). A
linearity check runs the kernel at 1× and 2× calls and reports the ratio
(expect ≈2) to prove per-call execution.
* route: public pre-route, OpenMP `static` schedule + reductions, with
route-kind and fallback counts captured so a timing gain cannot come from
silently escaping more rays. `--threads` defaults to 4.
Codegen evidence is collected with `objdump`/`nm`: for the double variants
`entry_solve` contains hardware `vfmadd` and zero x87; for `ld_fma` it contains
x87 plus `fmal` PLT relocations (extended-precision FMA is a libm software
routine on x86-64).
## Run
```sh
python3 benchmarks/quadratic_precision/run.py \
--output-dir /tmp/opencode/quadratic-comparison
```
Useful overrides: `--rounds N`, `--threads T`, `--precision P`,
`--kernel-target-calls N`, `--route-target-calls N`, `--skip-build` (reuse the
last build), `--repo PATH`, `--cc CC`.
Artifacts (all under `--output-dir`):
```
generated/{ld_plain,ld_fma,double_fma,double_fma_coeff}.c
cases/quadratic_cases.h, cases/cases.json
environment.json, build_manifest.json
raw/kernel_<v>.csv, raw/route_<v>.csv
raw/kernel_metrics_<v>.csv, raw/route_metrics_<v>.csv
raw/curated_kernel.csv, raw/curated_route.csv
raw/microbench_<v>_r*.json, raw/routebench_<v>_r*.json
raw/fma_verify.json, raw/{linearity,curated}*
summary.json
logs/{build_commands,accuracy_*,microbench_*,routebench_*}.log
```
`summary.json` is the machine-readable aggregate; the script also prints the
coverage counts, per-variant ULP distributions, the curated case table, codegen
evidence and median ns/call.
## Interpretation caveats
* FMA improves the rounding of one product only. It **cannot** restore the
information already lost in the double inputs and in the coefficient sums
(`d = x - c`, `q = w + v`, and the `qq`/`dq`/`dd` accumulations). The data
should be read stage by stage.
* A kernel UNCERTAIN is not a failure: the shared route fallback may confirm the
entry. A kernel **MISS bypasses the fallback entirely**, so growing/outward
false-MISS families are exercised through dedicated `rr<0` route fixtures and
reported both as the raw kernel decision (`kern_status`) and the route
outcome. This benchmark does **not** claim that the fallback rescues a raw
kernel MISS on the same case; it only observes that more fallback is used to
confirm inaccurate candidates.
* The double uncertainty band differs from the long-double one by the precision
term: production uses `64·(DBL_EPSILON + LDBL_EPSILON)·scale` while the
double variants use `64·(DBL_EPSILON + DBL_EPSILON)·scale`; since
`LDBL_EPSILON ≪ DBL_EPSILON` this is roughly a factor of two in the band
width, which only matters exactly at the UNCERTAIN/MISS border.
* Number/scale results are machine- and compiler-specific; `environment.json`
records the source hash, compiler, flags and platform.
* This benchmark does not establish a universal guarantee, only the sampled
behaviour on the frozen fixture set.
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env python3
"""Generate the four precision variants of the asymptotic entry kernel and build
one self-contained probe executable per variant.
This module never edits the production tree. It reads the *current working
tree* ``src/asymptotic.c``, extracts the "entry kernel" region delimited by two
lexical anchors, emits four generated full-module copies under the scratch
directory, and compiles a probe that textually includes exactly one copy so the
private static ``entry_solve``/``entry_quadratic_coeffs`` are exercised as the
real generated code (not an independent toy copy).
Variants (all share the same stable-entry algebra, scaling, uncertainty policy,
validation and fallback; only the arithmetic under test changes):
ld_plain long double coefficients/solver, ordinary b*b-4ac and slope
ld_fma reconstructed experimental compensated fma disc/slope
double_fma double type/solver with compensated fma disc/slope,
ordinary coefficient accumulation (type-precision control)
double_fma_coeff double like double_fma plus fma coefficient accumulation
Only the kernel region is rewritten. Every other production dispatch/fallback
path is copied verbatim.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Fixed compiler configuration. Identical for all four variants so the
# comparison is not confounded by flags. ``-ffp-contract=off`` disables the
# implicit FMA contraction GCC enables at -O2 for this target, so the ld_plain
# arm cannot silently gain extra precision; explicit fma()/fmal() calls still
# lower to hardware FMA.
# ---------------------------------------------------------------------------
BASE_FLAGS = [
"-std=c11",
"-march=native",
"-O2",
"-DNDEBUG",
"-ffp-contract=off",
"-fopenmp",
]
VARIANTS = ("ld_plain", "ld_fma", "double_fma", "double_fma_coeff")
# Common production translation units needed by the probe. Chosen as the
# minimal dependency closure of the entry kernel plus the public pre-route:
# geodesic initialisation, the numerical entry localizer, the Schwarzschild
# exterior referenced by the full module, the dispatch wrapper and the
# Minkowski provider. main/asymptotic/frame/catalog/PSF/FFTW are excluded.
COMMON_SOURCES = (
"src/geodesic.c",
"src/asymptotic_entry.c",
"src/asymptotic_schwarzschild.c",
"src/spacetime_common.c",
"src/spacetime_minkowski.c",
)
START_ANCHOR = "/* Long-double coefficients of the relative-distance quadratic"
FWD_PREFIX = "static void minkowski_route_entry("
END_ANCHOR = (
"static void minkowski_route_entry(const SpacetimeAsymptoticEnd *end, "
"double t0,"
)
class BuildError(RuntimeError):
pass
def _replace_once(text: str, old: str, new: str, what: str) -> str:
count = text.count(old)
if count != 1:
raise BuildError(f"expected exactly one occurrence of {what}, found {count}")
return text.replace(old, new, 1)
def locate_kernel(lines: list[str]) -> tuple[int, int]:
"""Return [start, end) line indices of the rewritten kernel region.
start is the 'Long-double coefficients' comment; end is the forward
declaration of ``minkowski_route_entry`` (preserved verbatim).
"""
starts = [i for i, l in enumerate(lines) if l.startswith(START_ANCHOR)]
if len(starts) != 1:
raise BuildError(
f"kernel start anchor must appear exactly once, found {len(starts)}"
)
start = starts[0]
ends = [
i
for i, l in enumerate(lines)
if i > start and l.startswith(FWD_PREFIX)
]
if not ends:
raise BuildError("kernel end anchor (minkowski_route_entry) not found")
end = ends[0]
if not lines[end].startswith(END_ANCHOR):
raise BuildError(
"kernel end anchor does not match the expected signature; "
"production source changed"
)
region = "".join(lines[start:end])
for needle in (
"typedef struct {",
"static EntryQuadratic entry_quadratic_coeffs(",
"static EntrySolveResult entry_solve(",
"static long double entry_discriminant(",
"EntryQuadratic;",
):
if region.count(needle) != 1:
raise BuildError(
f"kernel region must contain exactly one '{needle}', "
f"found {region.count(needle)}"
)
# The region must not contain the forward declaration or any later route
# code: those must be byte-for-byte preserved.
if "minkowski_preroute" in region or "AsymptoticStatus asymptotic_route" in region:
raise BuildError("kernel region overran into preserved route code")
return start, end
def transform_ld_plain(kernel: str) -> str:
"""Convert a compensated experimental kernel back to plain long double."""
old_disc = (
" const long double four_a = 4.0L * k->a;\n"
" const long double ac = four_a * k->c;\n"
" const long double ac_error = fmal(four_a, k->c, -ac);\n"
" *scale = k->b * k->b + fabsl(ac);\n"
" return fmal(k->b, k->b, -ac) - ac_error;\n"
)
new_disc = (
" const long double four_a = 4.0L * k->a;\n"
" const long double ac = four_a * k->c;\n"
" *scale = k->b * k->b + fabsl(ac);\n"
" return k->b * k->b - ac;\n"
)
out = _replace_once(kernel, old_disc, new_disc, "ld_plain discriminant body")
old_slope = "const long double slope = fmal(2.0L * k->a, s, k->b);"
new_slope = "const long double slope = 2.0L * k->a * s + k->b;"
out = _replace_once(out, old_slope, new_slope, "ld_plain slope")
return out
def _assert_double_clean(t: str) -> None:
"""The rewritten double kernel must contain no long-double promotion."""
forbidden = ("long double", "fmal(", "fabsl(", "fmaxl(", "frexpl(",
"scalbnl(", "sqrtl(", "copysignl(", "LDBL_")
for tok in forbidden:
if tok in t:
raise BuildError(f"double kernel still contains '{tok}'")
if re.search(r"(?<=[0-9])L(?![A-Za-z0-9_])", t):
raise BuildError("double kernel still contains an L-suffixed literal")
def transform_to_double(kernel: str) -> str:
"""Convert the kernel from long double to double arithmetic.
Explicit fmal->fma, *l->*, LDBL_*->DBL_*, long double->double and strips the
L suffix from the (few) decimal literals. Explicit fma calls are retained
(double_fma keeps compensated discriminant/slope).
"""
t = kernel
for old, new in (
("fmal(", "fma("),
("fabsl(", "fabs("),
("fmaxl(", "fmax("),
("frexpl(", "frexp("),
("scalbnl(", "scalbn("),
("sqrtl(", "sqrt("),
("copysignl(", "copysign("),
):
t = t.replace(old, new)
t = t.replace("LDBL_MIN", "DBL_MIN")
t = t.replace("LDBL_EPSILON", "DBL_EPSILON")
t = t.replace("long double", "double")
# Strip L suffixes from decimal literals; otherwise 4.0L/2.0L would
# re-promote the double expression and confound type accuracy/timing.
t = re.sub(r"(?<=[0-9])L(?![A-Za-z0-9_])", "", t)
_assert_double_clean(t)
return t
def transform_double_fma_coeff(kernel: str) -> str:
"""double variants plus fma coefficient accumulation and products."""
dbl = transform_to_double(kernel)
old_block = (
" double qq = 0.0, dot_dq = 0.0, dot_dd = 0.0;\n"
" for (int i = 0; i < 3; ++i) {\n"
" qq += q[i] * q[i];\n"
" dot_dq += d[i] * q[i];\n"
" dot_dd += d[i] * d[i];\n"
" }\n"
" const double R0_ld = (double)R0;\n"
" const double rr_ld = (double)rr;\n"
" EntryQuadratic k;\n"
" k.a = qq - rr_ld * rr_ld;\n"
" k.b = 2.0 * (dot_dq + R0_ld * rr_ld);\n"
" k.c = dot_dd - R0_ld * R0_ld;\n"
)
new_block = (
" double qq = 0.0, dot_dq = 0.0, dot_dd = 0.0;\n"
" for (int i = 0; i < 3; ++i) {\n"
" qq = fma(q[i], q[i], qq);\n"
" dot_dq = fma(d[i], q[i], dot_dq);\n"
" dot_dd = fma(d[i], d[i], dot_dd);\n"
" }\n"
" const double R0_ld = (double)R0;\n"
" const double rr_ld = (double)rr;\n"
" EntryQuadratic k;\n"
" k.a = fma(-rr_ld, rr_ld, qq);\n"
" k.b = 2.0 * fma(R0_ld, rr_ld, dot_dq);\n"
" k.c = fma(-R0_ld, R0_ld, dot_dd);\n"
)
return _replace_once(dbl, old_block, new_block, "double_fma_coeff block")
def generate_variants(repo: Path, out_dir: Path) -> dict:
"""Read current src/asymptotic.c and emit the four variant modules."""
src_path = repo / "src" / "asymptotic.c"
text = src_path.read_text()
lines = text.splitlines(keepends=True)
start, end = locate_kernel(lines)
kernel = "".join(lines[start:end])
prefix = "".join(lines[:start])
suffix = "".join(lines[end:])
sha = hashlib.sha256(text.encode()).hexdigest()
# Production now uses plain long-double arithmetic. Reconstruct the former
# compensated arm so the original four-way comparison remains reproducible.
plain_disc = (
" const long double four_a = 4.0L * k->a;\n"
" const long double ac = four_a * k->c;\n"
" *scale = k->b * k->b + fabsl(ac);\n"
" return k->b * k->b - ac;\n"
)
fused_disc = plain_disc.replace(
" *scale =", " const long double ac_error = fmal(four_a, k->c, -ac);\n *scale ="
).replace("return k->b * k->b - ac;", "return fmal(k->b, k->b, -ac) - ac_error;")
fused = _replace_once(kernel, plain_disc, fused_disc, "ld_fma discriminant body")
fused = _replace_once(
fused, "const long double slope = 2.0L * k->a * s + k->b;",
"const long double slope = fmal(2.0L * k->a, s, k->b);", "ld_fma slope"
)
kernels = {
"ld_plain": kernel,
"ld_fma": fused,
"double_fma": transform_to_double(fused),
"double_fma_coeff": transform_double_fma_coeff(fused),
}
gen_dir = out_dir / "generated"
gen_dir.mkdir(parents=True, exist_ok=True)
paths = {}
metas = {}
for name in VARIANTS:
body = kernels[name]
banner = (
f"/* GENERATED FILE - do not edit.\n"
f" * variant: {name}\n"
f" * source: src/asymptotic.c sha256={sha}\n"
f" * region lines [{start + 1}, {end}] rewritten; all other code verbatim.\n"
f" */\n"
)
out = banner + prefix + body + suffix
path = gen_dir / f"{name}.c"
path.write_text(out)
paths[name] = path
metas[name] = {
"path": str(path),
"sha256": hashlib.sha256(out.encode()).hexdigest(),
"kernel_sha256": hashlib.sha256(body.encode()).hexdigest(),
}
return {
"source": str(src_path),
"source_sha256": sha,
"kernel_line_range": [start + 1, end],
"variants": metas,
}
def cc_version(cc: str) -> str:
try:
out = subprocess.run(
[cc, "--version"], capture_output=True, text=True, check=False
)
return (out.stdout or out.stderr).splitlines()[0] if out.stdout or out.stderr else ""
except OSError:
return ""
def environment(repo: Path, cc: str, threads: int) -> dict:
src_path = repo / "src" / "asymptotic.c"
env = {
"repo": str(repo),
"cc": cc,
"cc_version": cc_version(cc),
"base_flags": list(BASE_FLAGS),
"threads": threads,
"source_sha256": hashlib.sha256(src_path.read_bytes()).hexdigest(),
"git_head": _git(repo, "rev-parse", "HEAD"),
"git_status_short": _git(repo, "status", "--short"),
"uname": os.uname().sysname + " " + os.uname().release + " " + os.uname().machine,
"cpu_model": _cpu_model(),
}
return env
def _git(repo: Path, *args: str) -> str:
try:
out = subprocess.run(
["git", "-C", str(repo), *args], capture_output=True, text=True, check=False
)
return out.stdout.strip()
except OSError:
return ""
def _cpu_model() -> str:
try:
for line in Path("/proc/cpuinfo").read_text().splitlines():
if line.startswith("model name"):
return line.split(":", 1)[1].strip()
except OSError:
pass
return "unknown"
def compile_common(repo: Path, out_dir: Path, cc: str, flags: list[str]) -> list[Path]:
obj_dir = out_dir / "build" / "common"
obj_dir.mkdir(parents=True, exist_ok=True)
objs = []
for rel in COMMON_SOURCES:
src = repo / rel
obj = obj_dir / (Path(rel).stem + ".o")
cmd = [cc, *flags, f"-I{repo / 'src'}", "-c", str(src), "-o", str(obj)]
_run(cmd, out_dir, f"compile common {rel}")
objs.append(obj)
return objs
def compile_probe(
repo: Path,
out_dir: Path,
cc: str,
flags: list[str],
variant: str,
probe_src: Path,
cases_dir: Path,
common_objs: list[Path],
) -> Path:
"""Compile the probe (which #includes the generated variant) and link."""
exe_dir = out_dir / "build" / "bin"
exe_dir.mkdir(parents=True, exist_ok=True)
obj = out_dir / "build" / f"probe_{variant}.o"
variant_src = out_dir / "generated" / f"{variant}.c"
cmd = [
cc,
*flags,
f"-I{repo / 'src'}",
f"-I{cases_dir}",
f'-DPROBE_VARIANT_SOURCE="{variant_src}"',
f'-DPROBE_VARIANT_NAME="{variant}"',
"-c",
str(probe_src),
"-o",
str(obj),
]
_run(cmd, out_dir, f"compile probe {variant}")
exe = exe_dir / f"probe_{variant}"
link = [cc, *flags, str(obj), *[str(o) for o in common_objs], "-lm", "-o", str(exe)]
_run(link, out_dir, f"link probe {variant}")
return exe
def _run(cmd: list[str], out_dir: Path, what: str) -> None:
log_dir = out_dir / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
with open(log_dir / "build_commands.log", "a") as fh:
fh.write(what + "\n$ " + " ".join(cmd) + "\n")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
except OSError as exc:
raise BuildError(f"{what}: {exc}") from exc
with open(log_dir / "build_commands.log", "a") as fh:
fh.write(f"exit={proc.returncode}\n")
if proc.stdout:
fh.write(proc.stdout)
if proc.stderr:
fh.write(proc.stderr)
if proc.returncode != 0:
raise BuildError(
f"{what} failed (exit {proc.returncode}); see logs/build_commands.log\n"
+ (proc.stderr or "")[-4000:]
)
def build_all(repo: Path, out_dir: Path, cc: str, flags: list[str], threads: int) -> dict:
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "logs").mkdir(parents=True, exist_ok=True)
variant_info = generate_variants(repo, out_dir)
common = compile_common(repo, out_dir, cc, flags)
probe_src = repo / "benchmarks" / "quadratic_precision" / "fixtures" / "probe.c"
cases_dir = out_dir / "cases"
exes = {}
for name in VARIANTS:
exes[name] = compile_probe(
repo, out_dir, cc, flags, name, probe_src, cases_dir, common
)
result = {"variants": variant_info["variants"],
"source_sha256": variant_info["source_sha256"],
"base_flags": list(flags),
"dependencies": dependency_hashes(repo),
"compiler": {"command": cc, "version": cc_version(cc)},
"probe_sha256": _file_sha(probe_src),
"cases_header_sha256": _file_sha(cases_dir / "quadratic_cases.h")}
result["executables"] = {k: str(v) for k, v in exes.items()}
result["common_objects"] = [str(o) for o in common]
(out_dir / "build_manifest.json").write_text(json.dumps(result, indent=2) + "\n")
return result
def _file_sha(path: Path):
if not Path(path).exists():
return None
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def dependency_hashes(repo: Path):
# Include all project headers conservatively, including transitive includes.
paths = {repo / rel for rel in COMMON_SOURCES}
paths.update((repo / "src").rglob("*.h"))
return {str(p.relative_to(repo)): _file_sha(p) for p in sorted(paths)}
def verify_manifest(repo: Path, out_dir: Path, flags: list[str], cc: str):
"""Check that an existing build matches the current source, flags and
fixtures. Returns (manifest, list_of_mismatch_reasons). Regenerates the
variant sources as a side effect so their hashes can be compared."""
manifest = json.loads((out_dir / "build_manifest.json").read_text())
reasons = []
if manifest.get("dependencies") != dependency_hashes(repo):
reasons.append("dependencies")
if manifest.get("compiler") != {"command": cc, "version": cc_version(cc)}:
reasons.append("compiler")
current = generate_variants(repo, out_dir)
if manifest.get("source_sha256") != current["source_sha256"]:
reasons.append("source_sha256")
if list(manifest.get("base_flags", [])) != list(flags):
reasons.append("base_flags")
for name, meta in current["variants"].items():
if manifest.get("variants", {}).get(name, {}).get("sha256") != meta["sha256"]:
reasons.append(f"variant:{name}")
probe_src = repo / "benchmarks" / "quadratic_precision" / "fixtures" / "probe.c"
if manifest.get("probe_sha256") != _file_sha(probe_src):
reasons.append("probe_sha256")
cases_header = out_dir / "cases" / "quadratic_cases.h"
if cases_header.exists() and manifest.get("cases_header_sha256") != _file_sha(cases_header):
reasons.append("cases_header_sha256")
for name, exe in manifest.get("executables", {}).items():
if not Path(exe).exists():
reasons.append(f"missing_exe:{name}")
return manifest, reasons
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", default=str(Path(__file__).resolve().parents[2]))
ap.add_argument("--output-dir", default="/tmp/opencode/quadratic-comparison")
ap.add_argument("--cc", default=os.environ.get("CC", "cc"))
ap.add_argument("--threads", type=int, default=4)
args = ap.parse_args()
repo = Path(args.repo).resolve()
out = Path(args.output_dir).resolve()
try:
info = build_all(repo, out, args.cc, BASE_FLAGS, args.threads)
except BuildError as exc:
print(f"build failed: {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(info, indent=2))
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""Deterministic fixture generation for the quadratic precision benchmark.
All values are frozen as IEEE-754 hex literals in the generated C header and as
hex strings in cases.json, so the C probe and the Python oracle see bit-for-bit
identical inputs. No external catalog/observer/slab data is required.
Case families (kernel):
curated explicit adversarial / production fixtures
fixed fixed sphere, axis and oblique directions
moving constant sphere velocity (radial/transverse), inward photons
growing rr < 0 (radius grows along the past parameter), both inward
(toward-center) and initially-outward photons, axis + oblique
shrinking rr > 0, kernel only (radius would go negative on the far past)
cancellation translated/far-origin style input cancellation
Route cases use only rr == 0 (positive radius on the whole open past segment)
plus the two production grazing rows.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
# Radii spanning subnormal-adjacent to huge scales.
RADII = [1e-100, 1e-10, 1.0, 1e10, 1e100]
DIST = [
math.nextafter(1.0, math.inf),
2.0,
10.0,
100.0,
512.0,
1024.0,
1e4,
1e8,
1e10,
]
IMPACTS = [
0.0,
0.5,
0.99,
1.0 - 1e-6,
math.nextafter(1.0, 0.0),
1.0,
math.nextafter(1.0, math.inf),
1.0 + 1e-6,
1.1,
]
VELS = [0.0, 0.1, 2.0, 10.0]
RR_GROW = [
-1.0,
math.nextafter(-1.0, -math.inf),
math.nextafter(-1.0, math.inf),
-0.999999,
]
def norm3(v):
n = math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
return [v[0] / n, v[1] / n, v[2] / n]
def cross(a, b):
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]]
# Fixed direction frames: (tow, perp). `tow` points from the sphere centre to
# the camera; `w = -tow` is the inward (toward-centre) past direction.
DIRS_AXIS = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])
DIRS_OBLIQUE1 = (
norm3([1.0, 2.0, 3.0]),
norm3(cross(norm3([1.0, 2.0, 3.0]), [0.0, 0.0, 1.0])),
)
DIRS_OBLIQUE2 = (
norm3([-2.0, 1.0, 0.7]),
norm3(cross(norm3([-2.0, 1.0, 0.7]), [0.0, 1.0, 0.0])),
)
DIRS = [DIRS_AXIS, DIRS_OBLIQUE1, DIRS_OBLIQUE2]
def _case(cid, category, x, c, w, v, R0, rr):
return {
"id": cid,
"category": category,
"x": list(x),
"c": list(c),
"w": list(w),
"v": list(v),
"R0": R0,
"rr": rr,
}
def curated_kernel_cases():
"""Explicit adversarial and production-reproduction fixtures."""
cases = []
t63 = float.fromhex("0x1.fa8f5c28f5c29p+3")
t64 = float.fromhex("0x1.fb17e4b17e4b1p+3")
w63 = [-float.fromhex("0x1.d4afba4704cap-2"),
float.fromhex("0x1.c7378f8e872d1p-1"),
float.fromhex("0x1.15bad4e30e8ddp-8")]
w64 = [-float.fromhex("0x1.f98ae1a782104p-2"),
float.fromhex("0x1.bd3bb364ac492p-1"),
float.fromhex("0x1.102d2a1c6ac74p-7")]
# Production Alcubierre grazing rows: x=(0,-24,0), centre=2t, v=2, R=5.
cases.append(_case(0, "real63", [0.0, -24.0, 0.0],
[2.0 * t63, 0.0, 0.0], w63, [2.0, 0.0, 0.0], 5.0, 0.0))
cases.append(_case(1, "real64", [0.0, -24.0, 0.0],
[2.0 * t64, 0.0, 0.0], w64, [2.0, 0.0, 0.0], 5.0, 0.0))
D = 10.0
R = 5.0
cases += [
_case(2, "headon_hit", [D, 0.0, 0.0], [0.0] * 3, [-1.0, 0.0, 0.0],
[0.0] * 3, R, 0.0),
_case(3, "headon_miss", [D, 0.0, 0.0], [0.0] * 3, [1.0, 0.0, 0.0],
[0.0] * 3, R, 0.0),
_case(4, "clear_miss", [D, 6.0, 0.0], [0.0] * 3, [-1.0, 0.0, 0.0],
[0.0] * 3, R, 0.0),
_case(5, "grazing_in", [D, math.nextafter(R, 0.0), 0.0], [0.0] * 3,
[-1.0, 0.0, 0.0], [0.0] * 3, R, 0.0),
_case(6, "exact_tangent", [D, R, 0.0], [0.0] * 3, [-1.0, 0.0, 0.0],
[0.0] * 3, R, 0.0),
_case(7, "grazing_out", [D, math.nextafter(R, math.inf), 0.0],
[0.0] * 3, [-1.0, 0.0, 0.0], [0.0] * 3, R, 0.0),
_case(8, "near_boundary_hit",
[math.nextafter(R, math.inf), 0.0, 0.0], [0.0] * 3,
[-1.0, 0.0, 0.0], [0.0] * 3, R, 0.0),
# 1e10 + 0.5, R=1: c loses the transverse term, b*b-4ac rounds to 0.
_case(9, "cancel_1e10_05", [1e10, 0.5, 0.0], [0.0] * 3,
[-1.0, 0.0, 0.0], [0.0] * 3, 1.0, 0.0),
_case(10, "cancel_1e10_1", [1e10, 1.0, 0.0], [0.0] * 3,
[-1.0, 0.0, 0.0], [0.0] * 3, 1.0, 0.0),
]
# Large-t positive reconstructed minimum (tests/test_asymptotic.c).
camera_x = float.fromhex("0x1.6bcc41e901908p+46")
t0 = 1e15
vx = 0.1
cases.append(_case(11, "large_t_01t", [camera_x, 0.0, 0.0],
[vx * t0, 0.0, 0.0], [-1.0, 0.0, 0.0], [vx, 0.0, 0.0],
10.0, 0.0))
# Exact linear growing sphere (a == 0), inward.
cases.append(_case(12, "linear_grow_in", [100.0, 0.0, 0.0], [0.0] * 3,
[-1.0, 0.0, 0.0], [0.0] * 3, 10.0, -1.0))
# Exact linear, initially outward: constant gap, honest miss.
cases.append(_case(13, "linear_grow_out", [100.0, 0.0, 0.0], [0.0] * 3,
[1.0, 0.0, 0.0], [0.0] * 3, 10.0, -1.0))
# Near-linear oblique, inward vs initially outward, rr just around -1.
tow = DIRS_OBLIQUE1[0]
perp = DIRS_OBLIQUE1[1]
for rr in RR_GROW:
for sign, tag in ((-1.0, "in"), (1.0, "out")):
w = [sign * tow[i] for i in range(3)]
x = [50.0 * tow[i] + 0.25 * perp[i] for i in range(3)]
cases.append(_case(len(cases), f"nearlin_{tag}", x, [0.0] * 3, w,
[0.0] * 3, 4.0, rr))
# Translated-origin style cancellation: d = x - c with x = c + small.
base = 1e10
cbase = [base, -base, base * 0.5]
xb = [cbase[0] + 10.0, cbase[1] + 0.5, cbase[2] + 0.0]
cases.append(_case(len(cases), "translated_origin", xb, cbase,
[-1.0, 0.0, 0.0], [0.0] * 3, 1.0, 0.0))
return cases
def _broad_fixed(cid):
for di, (tow, perp) in enumerate(DIRS):
for R in RADII:
for dr in DIST:
for ir in IMPACTS:
D = dr * R
b = ir * R
x = [D * tow[i] + b * perp[i] for i in range(3)]
w = [-tow[i] for i in range(3)]
yield _case(cid, f"fixed_d{di}", x, [0.0] * 3, w, [0.0] * 3,
R, 0.0)
cid += 1
return cid
def _broad_moving(cid):
for (tow, perp) in DIRS:
for R in (1e-10, 1.0, 1e10):
for dr in (2.0, 100.0, 1e4):
for ir in (0.0, 0.99, 1.0, 1.1):
for vel in VELS:
D = dr * R
b = ir * R
x = [D * tow[i] + b * perp[i] for i in range(3)]
w = [-tow[i] for i in range(3)]
v = [vel * perp[i] for i in range(3)]
yield _case(cid, "moving", x, [0.0] * 3, w, v, R, 0.0)
cid += 1
return cid
def _broad_growing(cid):
for di, (tow, perp) in enumerate(DIRS):
for sign, tag in ((-1.0, "in"), (1.0, "out")):
for rr in RR_GROW:
for dr in (2.0, 10.0, 100.0, 1e4, 1e8):
for ir in (0.0, 0.5, 0.99, 1.0, 1.1):
D = dr * 4.0
b = ir * 4.0
x = [D * tow[i] + b * perp[i] for i in range(3)]
w = [sign * tow[i] for i in range(3)]
yield _case(cid, f"growing_{tag}", x, [0.0] * 3, w,
[0.0] * 3, 4.0, rr)
cid += 1
return cid
def _broad_shrinking(cid):
for (tow, perp) in (DIRS_AXIS, DIRS_OBLIQUE1):
for dr in (2.0, 10.0, 100.0):
for ir in (0.0, 0.99, 1.0):
D = dr * 10.0
b = ir * 10.0
x = [D * tow[i] + b * perp[i] for i in range(3)]
w = [-tow[i] for i in range(3)]
yield _case(cid, "shrinking", x, [0.0] * 3, w, [0.0] * 3, 10.0,
0.1)
cid += 1
return cid
def _materialize(gen, cases):
for c in gen:
cases.append(c)
def _build_all():
cases = curated_kernel_cases()
cid = 1000
for gen in (_broad_fixed, _broad_moving, _broad_growing, _broad_shrinking):
gen_cases = []
_materialize(gen(cid), gen_cases)
if gen_cases:
cid = gen_cases[-1]["id"] + 1
cases.extend(gen_cases)
return cases
# ---------------------------------------------------------------------------
def _route(cid, category, t0, obs, direction, c0, v, R0, rr, model=0,
valid_t_min=-1e300):
return {
"id": cid,
"category": category,
"t0": t0,
"obs": list(obs),
"dir": list(direction),
"c0": list(c0),
"v": list(v),
"R0": R0,
"rr": rr,
"valid_t_min": valid_t_min,
"model": model,
}
def route_cases():
cases = []
t63 = float.fromhex("0x1.fa8f5c28f5c29p+3")
t64 = float.fromhex("0x1.fb17e4b17e4b1p+3")
w63 = [-float.fromhex("0x1.d4afba4704cap-2"),
float.fromhex("0x1.c7378f8e872d1p-1"),
float.fromhex("0x1.15bad4e30e8ddp-8")]
w64 = [-float.fromhex("0x1.f98ae1a782104p-2"),
float.fromhex("0x1.bd3bb364ac492p-1"),
float.fromhex("0x1.102d2a1c6ac74p-7")]
# Production rows reproduced through the Alcubierre-style callback (model 1).
cases.append(_route(0, "real63", t63, [0.0, -24.0, 0.0], w63,
[2.0 * t63, 0.0, 0.0], [2.0, 0.0, 0.0], 5.0, 0.0,
model=1))
cases.append(_route(1, "real64", t64, [0.0, -24.0, 0.0], w64,
[2.0 * t64, 0.0, 0.0], [2.0, 0.0, 0.0], 5.0, 0.0,
model=1))
# Same rows through the input-stable callback (model 0).
cases.append(_route(2, "real63_stable", t63, [0.0, -24.0, 0.0], w63,
[2.0 * t63, 0.0, 0.0], [2.0, 0.0, 0.0], 5.0, 0.0,
model=0))
cases.append(_route(3, "real64_stable", t64, [0.0, -24.0, 0.0], w64,
[2.0 * t64, 0.0, 0.0], [2.0, 0.0, 0.0], 5.0, 0.0,
model=0))
R = 5.0
cases += [
_route(10, "headon_hit", 0.0, [10.0, 0.0, 0.0], [-1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(11, "headon_miss", 0.0, [10.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(12, "clear_miss", 0.0, [10.0, 6.0, 0.0], [-1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(13, "grazing_in", 0.0, [10.0, math.nextafter(R, 0.0), 0.0],
[-1.0, 0.0, 0.0], [0.0] * 3, [0.0] * 3, R, 0.0),
_route(14, "exact_tangent", 0.0, [10.0, R, 0.0], [-1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(15, "grazing_out", 0.0,
[10.0, math.nextafter(R, math.inf), 0.0], [-1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(16, "camera_inside", 0.0, [2.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
_route(17, "near_boundary_hit", 0.0,
[math.nextafter(R, math.inf), 0.0, 0.0], [-1.0, 0.0, 0.0],
[0.0] * 3, [0.0] * 3, R, 0.0),
]
# Broad fixed-sphere subset through the public route.
cid = 100
for (tow, perp) in (DIRS_AXIS, DIRS_OBLIQUE1):
for R0 in (1e-10, 1.0, 1e10):
for dr in (2.0, 10.0, 100.0, 512.0, 1024.0, 1e4):
for ir in (0.0, 0.5, 0.99, 1.0, 1.1):
D = dr * R0
b = ir * R0
obs = [D * tow[i] + b * perp[i] for i in range(3)]
direction = [-tow[i] for i in range(3)]
cases.append(_route(cid, f"route_fixed_d{dr:g}", 0.0, obs,
direction, [0.0] * 3, [0.0] * 3, R0,
0.0))
cid += 1
# Moving spheres (v = 2 tow), model 0.
for ir in (0.0, 0.99, 1.1):
cases.append(_route(cid, "route_moving", 0.0, [100.0, 0.0, 0.0],
[-1.0, 0.0, 0.0], [0.0] * 3, [2.0, 0.0, 0.0], 5.0,
0.0))
cid += 1
# Growing worldtubes (rr <= 0, positive radius on the whole past), inward
# and initially-outward photons, axis + oblique. These mirror the
# growing_out kernel false-MISS family and exercise the public route where
# a kernel MISS bypasses the fallback entirely. valid_t_min is far below
# any sampled time so no artificial history clip is introduced.
R0 = 4.0
for (tow, perp) in DIRS:
for sign, tag in ((-1.0, "in"), (1.0, "out")):
for rr in RR_GROW:
for dr in (2.0, 10.0, 100.0, 512.0, 1024.0, 1e4):
for ir in (0.0, 1.0, 1.1):
D = dr * R0
b = ir * R0
obs = [D * tow[i] + b * perp[i] for i in range(3)]
direction = [sign * tow[i] for i in range(3)]
cases.append(_route(cid, f"route_grow_{tag}_d{dr:g}",
0.0, obs, direction, [0.0] * 3,
[0.0] * 3, R0, rr))
cid += 1
return cases
# ---------------------------------------------------------------------------
def _hex(x):
return float(x).hex()
def _fmt(v):
return _hex(v)
def write_header(kernel, route, out_path: Path):
lines = []
lines.append("/* Generated by benchmarks/quadratic_precision/cases.py. */\n")
lines.append("#ifndef QUADRATIC_CASES_H\n#define QUADRATIC_CASES_H\n")
lines.append("typedef struct {\n int id;\n const char *category;\n"
" double x[3], c[3], w[3], v[3];\n double R0, rr;\n"
"} QuadKernelCase;\n\n")
lines.append("typedef struct {\n int id;\n const char *category;\n"
" double t0;\n double obs[3];\n double dir[3];\n"
" double c0[3];\n double v[3];\n double R0, rr;\n"
" double valid_t_min;\n int model;\n} QuadRouteCase;\n\n")
lines.append("static const QuadKernelCase quad_kernel_cases[] = {\n")
for c in kernel:
lines.append(
" {.id=%d,.category=\"%s\","
".x={%s,%s,%s},.c={%s,%s,%s},.w={%s,%s,%s},.v={%s,%s,%s},"
".R0=%s,.rr=%s},\n"
% (c["id"], c["category"], *[_fmt(z) for z in c["x"]],
*[_fmt(z) for z in c["c"]], *[_fmt(z) for z in c["w"]],
*[_fmt(z) for z in c["v"]], _fmt(c["R0"]), _fmt(c["rr"])))
lines.append("};\n")
lines.append("static const int quad_kernel_case_count = %d;\n\n"
% len(kernel))
lines.append("static const QuadRouteCase quad_route_cases[] = {\n")
for c in route:
lines.append(
" {.id=%d,.category=\"%s\",.t0=%s,"
".obs={%s,%s,%s},.dir={%s,%s,%s},.c0={%s,%s,%s},.v={%s,%s,%s},"
".R0=%s,.rr=%s,.valid_t_min=%s,.model=%d},\n"
% (c["id"], c["category"], _fmt(c["t0"]),
*[_fmt(z) for z in c["obs"]], *[_fmt(z) for z in c["dir"]],
*[_fmt(z) for z in c["c0"]], *[_fmt(z) for z in c["v"]],
_fmt(c["R0"]), _fmt(c["rr"]), _fmt(c["valid_t_min"]),
c["model"]))
lines.append("};\n")
lines.append("static const int quad_route_case_count = %d;\n"
% len(route))
lines.append("#endif\n")
out_path.write_text("".join(lines))
def write_json(kernel, route, out_path: Path):
def enc(c):
d = {}
for k, v in c.items():
if isinstance(v, float):
d[k] = v.hex()
elif isinstance(v, list):
d[k] = [z.hex() for z in v]
else:
d[k] = v
return d
out_path.write_text(
json.dumps(
{"deterministic": True, "kernel": [enc(c) for c in kernel],
"route": [enc(c) for c in route]},
indent=1,
)
+ "\n")
def build():
kernel = _build_all()
route = route_cases()
return kernel, route
if __name__ == "__main__":
k, r = build()
print(f"kernel cases: {len(k)}, route cases: {len(r)}")
cats = {}
for c in k:
cats[c["category"]] = cats.get(c["category"], 0) + 1
for key in sorted(cats):
print(f" {key}: {cats[key]}")
rcats = {}
for c in r:
rcats[c["category"]] = rcats.get(c["category"], 0) + 1
print("route categories:")
for key in sorted(rcats):
print(f" {key}: {rcats[key]}")
@@ -0,0 +1,614 @@
/* Quadratic precision benchmark probe.
*
* Compiled once per generated variant, with
* -DPROBE_VARIANT_SOURCE=".../ld_fma.c" -DPROBE_VARIANT_NAME="ld_fma"
* The probe #includes the generated full module, so the private static
* entry_quadratic_coeffs / entry_solve and the public asymptotic_route_camera
* are the *actual generated* code. No production file is modified.
*
* Modes:
* accuracy -- run every kernel case through the generated kernel and every
* route case through the generated public pre-route; emit CSVs.
* microbench -- timed repeated kernel assembly + entry_solve (serial).
* routebench -- timed repeated public pre-route (OpenMP, static schedule).
*/
#include PROBE_VARIANT_SOURCE
#include <float.h>
#include <math.h>
#include <omp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "quadratic_cases.h"
#ifndef PROBE_VARIANT_NAME
#define PROBE_VARIANT_NAME "unknown"
#endif
/* ------------------------------------------------------------------ */
/* Exact-value coefficient printing: long double uses %La (full mantissa),
* double uses %a. _Generic selects the right printer without knowing the
* generated EntryQuadratic field type. */
static void coeff_ld(FILE *f, long double v) { fprintf(f, "%La", v); }
static void coeff_d(FILE *f, double v) { fprintf(f, "%a", v); }
#define PRINT_COEFF(f, v) \
_Generic((v), long double : coeff_ld, double : coeff_d)((f), (v))
/* ------------------------------------------------------------------ */
/* Fixture source: flat identity metric, one Minkowski end whose worldtube is
* a constant-velocity (optionally linearly growing/shrinking) sphere.
*
* model 0 (input-stable): center = c0 + v*(t - t0), R = R0 + rr*(t - t0)
* model 1 (production-style): center = v*t, R = R0 + rr*t
*
* model 0 evaluates exactly at the camera: center(t0) == c0, R(t0) == R0, so
* the kernel inputs are the frozen case values. model 1 mirrors the
* Alcubierre-style callback (used only with rr == 0). */
typedef struct {
double c0[3];
double v[3];
double R0;
double rr;
double t0;
double valid_t_min;
int model;
} FixtureContext;
static double fixture_center(const FixtureContext *ctx, int i, double t) {
if (ctx->model == 0)
return ctx->c0[i] + ctx->v[i] * (t - ctx->t0);
return ctx->v[i] * t;
}
static double fixture_radius(const FixtureContext *ctx, double t) {
if (ctx->model == 0)
return ctx->R0 + ctx->rr * (t - ctx->t0);
return ctx->R0 + ctx->rr * t;
}
static SpacetimePointStatus fixture_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 fixture_classify(const SpacetimeSource *source,
double t, const double x[3]) {
(void)source;
(void)t;
(void)x;
return SPACETIME_RAY_ACTIVE;
}
static size_t fixture_end_count(const SpacetimeSource *source) {
(void)source;
return 1;
}
static int fixture_end(const SpacetimeSource *source, size_t index,
SpacetimeAsymptoticEnd *out) {
const FixtureContext *ctx = source->context;
if (index != 0)
return -1;
*out = (SpacetimeAsymptoticEnd){
.end_id = 0,
.exterior_kind = ASYMPTOTIC_EXTERIOR_MINKOWSKI,
.mass = 0.0,
.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 fixture_worldtube(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
SpacetimeEscapeWorldtubeSample *out) {
const FixtureContext *ctx = source->context;
if (end_id != 0)
return -1;
if (!isfinite(t) || t < ctx->valid_t_min) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
*out = (SpacetimeEscapeWorldtubeSample){
.center = {fixture_center(ctx, 0, t), fixture_center(ctx, 1, t),
fixture_center(ctx, 2, t)},
.velocity = {ctx->v[0], ctx->v[1], ctx->v[2]},
.radius = fixture_radius(ctx, t),
.radius_rate = ctx->rr,
.velocity_constant = 1,
.valid = 1};
return 0;
}
static double fixture_next_segment(const SpacetimeSource *source,
SpacetimeEndId end_id, double t) {
(void)source;
(void)end_id;
(void)t;
return NAN;
}
static void fixture_destroy(SpacetimeSource *source) {
source->context = NULL;
source->ops = NULL;
}
static const SpacetimeOps fixture_ops = {
.eval = fixture_eval,
.classify = fixture_classify,
.asymptotic_end_count = fixture_end_count,
.asymptotic_end = fixture_end,
.escape_worldtube_sample = fixture_worldtube,
.escape_worldtube_next_segment = fixture_next_segment,
.destroy = fixture_destroy,
};
/* ------------------------------------------------------------------ */
static void print_environment(void) {
fprintf(stdout,
"{\"variant\":\"%s\",\"sizeof_long_double\":%zu,\"LDBL_MANT_DIG\":%d,"
"\"DBL_MANT_DIG\":%d,\"LDBL_MAX_EXP\":%d,\"hardware_threads\":%d,"
"\"omp_max_threads\":%d}\n",
PROBE_VARIANT_NAME, sizeof(long double), LDBL_MANT_DIG, DBL_MANT_DIG,
LDBL_MAX_EXP, omp_get_num_procs(), omp_get_max_threads());
}
/* ------------------------------------------------------------------ */
/* accuracy mode */
static void mode_accuracy(const char *kernel_out, const char *route_out) {
FILE *kf = fopen(kernel_out, "w");
if (kf == NULL) {
fprintf(stderr, "cannot open %s\n", kernel_out);
exit(2);
}
fprintf(kf,
"id,category,status,sigma,x0,x1,x2,c0,c1,c2,w0,w1,w2,v0,v1,v2,R0,rr,"
"a,b,c\n");
for (int i = 0; i < quad_kernel_case_count; ++i) {
const QuadKernelCase *c = &quad_kernel_cases[i];
EntryQuadratic k = entry_quadratic_coeffs(c->x, c->c, c->w, c->v, c->R0,
c->rr);
double s = -1.0;
EntrySolveResult r = entry_solve(&k, &s);
fprintf(kf, "%d,%s,%d,%a", c->id, c->category, (int)r, s);
for (int j = 0; j < 3; ++j)
fprintf(kf, ",%a", c->x[j]);
for (int j = 0; j < 3; ++j)
fprintf(kf, ",%a", c->c[j]);
for (int j = 0; j < 3; ++j)
fprintf(kf, ",%a", c->w[j]);
for (int j = 0; j < 3; ++j)
fprintf(kf, ",%a", c->v[j]);
fprintf(kf, ",%a,%a,", c->R0, c->rr);
PRINT_COEFF(kf, k.a);
fputc(',', kf);
PRINT_COEFF(kf, k.b);
fputc(',', kf);
PRINT_COEFF(kf, k.c);
fputc('\n', kf);
}
fclose(kf);
FILE *rf = fopen(route_out, "w");
if (rf == NULL) {
fprintf(stderr, "cannot open %s\n", route_out);
exit(2);
}
fprintf(rf,
"id,category,status,kind,failure_reason,fallback_evals,pi_match,"
"lcam_match,F,tol,why,x0,x1,x2,Pi0,Pi1,Pi2,ninf0,ninf1,ninf2,"
"canon_t,canonx0,canonx1,canonx2,canonw0,canonw1,canonw2,"
"cbx0,cbx1,cbx2,cbr,cbok,ct00,ct01,ct02,rt0,cb0ok,"
"kern_ok,kern_status,kern_sigma,kern_a,kern_b,kern_c,"
"logcamera,logentry,"
"t0,oc0,oc1,oc2,d0,d1,d2,fc0,fc1,fc2,v0,v1,v2,"
"R0,rr,model,reason_name\n");
for (int i = 0; i < quad_route_case_count; ++i) {
const QuadRouteCase *c = &quad_route_cases[i];
FixtureContext ctx = {.c0 = {c->c0[0], c->c0[1], c->c0[2]},
.v = {c->v[0], c->v[1], c->v[2]},
.R0 = c->R0,
.rr = c->rr,
.t0 = c->t0,
.valid_t_min = c->valid_t_min,
.model = c->model};
SpacetimeSource source = {.ops = &fixture_ops, .context = &ctx};
ObserverState obs = {0};
obs.coordinate_time = c->t0;
for (int j = 0; j < 3; ++j)
obs.coordinate_position[j] = c->obs[j];
obs.tetrad[0][0] = 1.0;
obs.tetrad[1][1] = 1.0;
obs.tetrad[2][2] = 1.0;
obs.tetrad[3][3] = 1.0;
int canon_ok = 0;
AsymptoticPhotonState canon = {0};
GeodesicRayState st = {0};
{
MetricData metric;
if (spacetime_eval(&source, c->t0, obs.coordinate_position, &metric) ==
SPACETIME_POINT_OK &&
geodesic_initialize_past_ray_metric(&metric, &obs, c->dir, &st) == 0 &&
asymptotic_canonical_from_backend(&source, 0, &metric, c->t0, st.x,
st.Pi, st.log_alpha_p0,
&canon) == 0)
canon_ok = 1;
}
AsymptoticRoute route;
AsymptoticStatus status =
asymptotic_route_camera(&source, &obs, c->dir, &route);
int pi_match = 1, lcam_match = 1;
if (canon_ok && status == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY) {
for (int j = 0; j < 3; ++j)
if (route.Pi[j] != st.Pi[j])
pi_match = 0;
if (route.log_alpha_p0_camera != st.log_alpha_p0)
lcam_match = 0;
}
double F = NAN, tol = NAN;
RayReason why = RAY_REASON_NONE;
if (status == ASYMPTOTIC_OK && route.kind == ASYMPTOTIC_ROUTE_ENTRY)
asymptotic_entry_geometry(&source, route.end_id, route.activate_t,
route.x, &F, &tol, &why);
SpacetimeEscapeWorldtubeSample cb = {0};
int cbok = 0;
{
const double tt = (status == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY)
? route.activate_t
: c->t0;
if (spacetime_escape_worldtube_sample(&source, route.end_id, tt, &cb) ==
0 &&
cb.valid)
cbok = 1;
}
/* Worldtube sample at the segment start used by the quadratic kernel. */
SpacetimeEscapeWorldtubeSample cb0 = {0};
int cb0ok = 0;
if (spacetime_escape_worldtube_sample(&source, route.end_id, c->t0,
&cb0) == 0 &&
cb0.valid)
cb0ok = 1;
/* Reproduce the first-segment public kernel classification at the actual
* canonical inputs, so a reference ENTER / kernel MISS / route ESCAPED is
* directly visible and not confused with canonical normalisation. */
int kern_ok = 0;
int kern_status = -1;
double kern_sigma = -1.0;
EntryQuadratic kk = {0};
SpacetimeAsymptoticEnd end_desc;
if (canon_ok && cb0ok &&
spacetime_asymptotic_end(&source, 0, &end_desc) == 0) {
double c_frame[3], v_frame[3];
backend_position_to_frame(&end_desc, cb0.center, c_frame);
backend_vector_to_frame(&end_desc, cb0.velocity, v_frame);
kk = entry_quadratic_coeffs(canon.x, c_frame, canon.w, v_frame,
cb0.radius, cb0.radius_rate);
kern_status = (int)entry_solve(&kk, &kern_sigma);
kern_ok = 1;
}
fprintf(rf, "%d,%s,%d,%d,%d,%u,%d,%d,%a,%a,%d", c->id, c->category,
(int)status, (int)route.kind, (int)route.failure_reason,
route.entry_fallback_evaluations, pi_match, lcam_match, F, tol,
(int)why);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", route.x[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", route.Pi[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", route.n_infinity[j]);
fprintf(rf, ",%a", canon.t);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", canon.x[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", canon.w[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", cb.center[j]);
fprintf(rf, ",%a,%d", cb.radius, cbok);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", cb0.center[j]);
fprintf(rf, ",%a,%d", cb0.radius, cb0ok);
fprintf(rf, ",%d,%d,%a,", kern_ok, kern_status, kern_sigma);
PRINT_COEFF(rf, kk.a);
fputc(',', rf);
PRINT_COEFF(rf, kk.b);
fputc(',', rf);
PRINT_COEFF(rf, kk.c);
fprintf(rf, ",%a,%a", route.log_alpha_p0_camera, route.log_alpha_p0);
fprintf(rf, ",%a", c->t0);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", c->obs[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", c->dir[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", c->c0[j]);
for (int j = 0; j < 3; ++j)
fprintf(rf, ",%a", c->v[j]);
fprintf(rf, ",%a,%a,%d,%s\n", c->R0, c->rr, c->model,
ray_reason_name(route.failure_reason));
}
fclose(rf);
}
/* ------------------------------------------------------------------ */
/* microbench mode: coefficient assembly + entry_solve, serial. */
static volatile double g_kernel_sink;
static __attribute__((noinline)) double kernel_batch(const QuadKernelCase *cs,
int n, long reps) {
double acc = 0.0;
for (long r = 0; r < reps; ++r) {
for (int i = 0; i < n; ++i) {
/* Force the index through an opaque register so the compiler cannot
* hoist the pure coefficient solve out of the repetition loop or prove
* the loaded fixture invariant. Identical for every variant. */
int idx = i;
__asm__ __volatile__("" : "+r"(idx) : : "memory");
const QuadKernelCase *c = cs + idx;
EntryQuadratic k = entry_quadratic_coeffs(c->x, c->c, c->w, c->v,
c->R0, c->rr);
double s = 0.0;
acc += (double)entry_solve(&k, &s) + s * 1e-300;
}
}
return acc;
}
static void mode_microbench(long target_calls, const char *out) {
const int n = quad_kernel_case_count;
long reps = target_calls / n;
if (reps < 1)
reps = 1;
/* Warm-up outside the timed region. */
g_kernel_sink += kernel_batch(quad_kernel_cases, n, 1);
const double t0 = omp_get_wtime();
const double acc = kernel_batch(quad_kernel_cases, n, reps);
const double t1 = omp_get_wtime();
g_kernel_sink += acc;
const long calls = (long)n * reps;
const double seconds = t1 - t0;
FILE *f = fopen(out, "w");
if (f == NULL) {
fprintf(stderr, "cannot open %s\n", out);
exit(2);
}
fprintf(f,
"{\"variant\":\"%s\",\"mode\":\"microbench\",\"cases\":%d,"
"\"reps\":%ld,\"calls\":%ld,\"seconds\":%.9f,\"ns_per_call\":%.6f,"
"\"sink\":%.17g}\n",
PROBE_VARIANT_NAME, n, reps, calls, seconds,
seconds * 1e9 / (double)calls, g_kernel_sink);
fclose(f);
fprintf(stdout, "microbench %s: %ld calls in %.6f s (%.2f ns/call)\n",
PROBE_VARIANT_NAME, calls, seconds, seconds * 1e9 / (double)calls);
}
/* ------------------------------------------------------------------ */
/* routebench mode: public asymptotic_route_camera, OpenMP static. */
typedef struct {
FixtureContext *ctxs;
SpacetimeSource *srcs;
ObserverState *obss;
const QuadRouteCase *rcs;
int n;
} Preloaded;
typedef struct {
long entry, escaped, inside, time_exhausted, invalid, unsupported, other;
long fallback_sum;
} RouteCounts;
static volatile double g_route_sink;
static __attribute__((noinline)) void route_batch(const Preloaded *p, long reps,
int threads, double *seconds,
RouteCounts *counts) {
const int n = p->n;
long entry = 0, escaped = 0, inside = 0, texh = 0, inv = 0, unsup = 0,
other = 0, fallback = 0;
const long total = (long)n * reps;
const double t0 = omp_get_wtime();
#pragma omp parallel num_threads(threads) reduction(+ : entry, escaped, inside, texh, inv, unsup, other, fallback)
{
#pragma omp for schedule(static)
for (long k = 0; k < total; ++k) {
long kk = k;
__asm__ __volatile__("" : "+r"(kk) : : "memory");
const int i = (int)(kk % n);
AsymptoticRoute route;
const AsymptoticStatus st = asymptotic_route_camera(
&p->srcs[i], &p->obss[i], p->rcs[i].dir, &route);
fallback += (long)route.entry_fallback_evaluations;
if (st == ASYMPTOTIC_OK) {
switch (route.kind) {
case ASYMPTOTIC_ROUTE_ENTRY:
++entry;
break;
case ASYMPTOTIC_ROUTE_ESCAPED:
++escaped;
break;
case ASYMPTOTIC_ROUTE_INSIDE:
++inside;
break;
case ASYMPTOTIC_ROUTE_TIME_RANGE_EXHAUSTED:
++texh;
break;
default:
++other;
break;
}
} else if (st == ASYMPTOTIC_UNSUPPORTED) {
++unsup;
} else {
++inv;
}
}
}
*seconds = omp_get_wtime() - t0;
counts->entry = entry;
counts->escaped = escaped;
counts->inside = inside;
counts->time_exhausted = texh;
counts->invalid = inv;
counts->unsupported = unsup;
counts->other = other;
counts->fallback_sum = fallback;
}
static void mode_routebench(long target_calls, int threads, double max_seconds,
const char *out) {
const int n = quad_route_case_count;
Preloaded p;
p.n = n;
p.rcs = quad_route_cases;
p.ctxs = malloc(sizeof(FixtureContext) * (size_t)n);
p.srcs = malloc(sizeof(SpacetimeSource) * (size_t)n);
p.obss = malloc(sizeof(ObserverState) * (size_t)n);
if (!p.ctxs || !p.srcs || !p.obss) {
fprintf(stderr, "allocation failure\n");
exit(2);
}
for (int i = 0; i < n; ++i) {
const QuadRouteCase *c = &quad_route_cases[i];
p.ctxs[i] = (FixtureContext){.c0 = {c->c0[0], c->c0[1], c->c0[2]},
.v = {c->v[0], c->v[1], c->v[2]},
.R0 = c->R0,
.rr = c->rr,
.t0 = c->t0,
.valid_t_min = c->valid_t_min,
.model = c->model};
p.srcs[i] = (SpacetimeSource){.ops = &fixture_ops, .context = &p.ctxs[i]};
p.obss[i] = (ObserverState){0};
p.obss[i].coordinate_time = c->t0;
for (int j = 0; j < 3; ++j)
p.obss[i].coordinate_position[j] = c->obs[j];
p.obss[i].tetrad[0][0] = 1.0;
p.obss[i].tetrad[1][1] = 1.0;
p.obss[i].tetrad[2][2] = 1.0;
p.obss[i].tetrad[3][3] = 1.0;
}
/* Calibrate with one pass, then size reps to the call/time budgets. */
double cal_seconds = 0.0;
RouteCounts cal_counts;
route_batch(&p, 1, threads, &cal_seconds, &cal_counts);
const double per_call = cal_seconds / (double)n;
long reps = target_calls / n;
if (reps < 1)
reps = 1;
if (per_call > 0.0) {
const long by_time = (long)(max_seconds / (per_call * (double)n));
if (by_time < 1)
reps = 1;
else if (reps > by_time)
reps = by_time;
}
double seconds = 0.0;
RouteCounts counts;
route_batch(&p, reps, threads, &seconds, &counts);
g_route_sink += (double)counts.entry + (double)counts.escaped;
FILE *f = fopen(out, "w");
if (f == NULL) {
fprintf(stderr, "cannot open %s\n", out);
exit(2);
}
fprintf(f,
"{\"variant\":\"%s\",\"mode\":\"routebench\",\"cases\":%d,"
"\"reps\":%ld,\"calls\":%ld,\"threads\":%d,\"cal_seconds\":%.9f,"
"\"seconds\":%.9f,\"ns_per_call\":%.6f,\"entry\":%ld,\"escaped\":%ld,"
"\"inside\":%ld,\"time_exhausted\":%ld,\"invalid\":%ld,"
"\"unsupported\":%ld,\"other\":%ld,\"fallback_sum\":%ld}\n",
PROBE_VARIANT_NAME, n, reps, (long)n * reps, threads, cal_seconds,
seconds, seconds * 1e9 / (double)((long)n * reps), counts.entry,
counts.escaped, counts.inside, counts.time_exhausted, counts.invalid,
counts.unsupported, counts.other, counts.fallback_sum);
fclose(f);
fprintf(stdout,
"routebench %s: %ld calls in %.6f s on %d threads (%.2f ns/call)\n",
PROBE_VARIANT_NAME, (long)n * reps, seconds, threads,
seconds * 1e9 / (double)((long)n * reps));
free(p.ctxs);
free(p.srcs);
free(p.obss);
}
/* ------------------------------------------------------------------ */
static void usage(const char *argv0) {
fprintf(stderr,
"usage:\n"
" %s accuracy --kernel-out K.csv --route-out R.csv\n"
" %s microbench --out F.json [--target-calls N]\n"
" %s routebench --out F.json [--target-calls N] [--threads T]"
" [--max-seconds S]\n",
argv0, argv0, argv0);
}
static const char *arg_value(int argc, char **argv, const char *flag) {
for (int i = 1; i + 1 < argc; ++i)
if (strcmp(argv[i], flag) == 0)
return argv[i + 1];
return NULL;
}
int main(int argc, char **argv) {
print_environment();
if (argc < 2) {
usage(argv[0]);
return 1;
}
if (strcmp(argv[1], "accuracy") == 0) {
const char *k = arg_value(argc, argv, "--kernel-out");
const char *r = arg_value(argc, argv, "--route-out");
if (!k || !r) {
usage(argv[0]);
return 1;
}
mode_accuracy(k, r);
return 0;
}
if (strcmp(argv[1], "microbench") == 0) {
const char *out = arg_value(argc, argv, "--out");
const char *tc = arg_value(argc, argv, "--target-calls");
if (!out) {
usage(argv[0]);
return 1;
}
mode_microbench(tc ? atol(tc) : 10000000L, out);
return 0;
}
if (strcmp(argv[1], "routebench") == 0) {
const char *out = arg_value(argc, argv, "--out");
const char *tc = arg_value(argc, argv, "--target-calls");
const char *th = arg_value(argc, argv, "--threads");
const char *ms = arg_value(argc, argv, "--max-seconds");
if (!out) {
usage(argv[0]);
return 1;
}
mode_routebench(tc ? atol(tc) : 1000000L, th ? atoi(th) : 4,
ms ? atof(ms) : 20.0, out);
return 0;
}
usage(argv[0]);
return 1;
}
+571
View File
@@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""High-precision reference for the quadratic entry benchmark.
Inputs are exact IEEE-754 values, so they are represented exactly as
``fractions.Fraction``. The relative-distance quadratic coefficients, the
discriminant and the polynomial residuals are therefore *exact* rationals; only
the square root needs ``decimal`` (160 significant digits by default). This
avoids ``float.fromhex`` (which would silently drop a long-double coefficient to
53 bits) and keeps the sign of a near-zero discriminant exact.
Reference classification mirrors the production contract:
* c < 0 -> camera already INSIDE (never an entry failure)
* c == 0 -> boundary slope b decides; b < 0 enters at once, b == 0 with a < 0
enters later, otherwise no crossing
* c > 0 -> smallest positive root with inward slope; a missing/tangent root
is MISS. ``a == 0`` is the exact linear branch.
"""
from __future__ import annotations
import csv
import json
import math
from decimal import Decimal, localcontext
from fractions import Fraction
DEFAULT_PREC = 160
def parse_hex(s: str) -> Fraction:
"""Exact rational value of a C ``%a`` / ``%La`` hex float literal."""
s = s.strip()
low = s.lower()
if "nan" in low:
raise ValueError("NaN input")
if "inf" in low:
return Fraction(0) # only used for flags; never present in fixtures
neg = False
if s and s[0] in "+-":
neg = s[0] == "-"
s = s[1:]
if s[:2].lower() == "0x":
s = s[2:]
mant, _, exp_s = s.partition("p")
if not exp_s:
mant, _, exp_s = s.partition("P")
exp = int(exp_s) if exp_s else 0
ip, _, fp = mant.partition(".")
digits = (ip + fp) or "0"
val = Fraction(int(digits, 16), 1)
shift = exp - 4 * len(fp)
if shift >= 0:
val *= Fraction(2) ** shift
else:
val /= Fraction(2) ** (-shift)
return -val if neg else val
def frac_to_dec(fr: Fraction, prec: int = DEFAULT_PREC) -> Decimal:
with localcontext() as ctx:
ctx.prec = prec
return Decimal(fr.numerator) / Decimal(fr.denominator)
def hex_to_dec(s: str, prec: int = DEFAULT_PREC) -> Decimal:
return frac_to_dec(parse_hex(s), prec)
def classify(a: Fraction, b: Fraction, c: Fraction, prec: int,
R0: Fraction | None = None, rr: Fraction | None = None):
"""Return (status, root_decimal_or_None, info).
``R0``/``rr`` (radius at the segment start and dR/dt) enforce the physical
positive-radius domain R(s) = R0 - rr*s > 0 along the past parameter. A
positive root outside that domain is not a physical entry: it is reported
as MISS with ``info['domain_clipped']`` set (shrinking worldtubes).
"""
info = {"a_zero": a == 0, "disc_sign": 0, "domain_clipped": False}
def domain_ok(root: Fraction) -> bool:
if R0 is None:
return True
return (R0 - (rr if rr is not None else Fraction(0)) * root) > 0
if c < 0:
return "INSIDE", None, info
if c == 0:
if b < 0:
return "ENTER", Decimal(0), info
if b == 0:
if a < 0:
return "ENTER", Decimal(0), info
return "MISS", None, info
if a < 0:
root = -b / a
if root > 0 and domain_ok(root):
return "ENTER", frac_to_dec(root, prec), info
if root > 0:
info["domain_clipped"] = True
return "MISS", None, info
return "MISS", None, info
if a == 0:
if b < 0:
root = -c / b
if root > 0:
if domain_ok(root):
return "ENTER", frac_to_dec(root, prec), info
info["domain_clipped"] = True
return "MISS", None, info
disc = b * b - 4 * a * c
info["disc_sign"] = (disc > 0) - (disc < 0)
if disc < 0:
return "MISS", None, info
if disc == 0:
return "MISS", None, info # tangency is not a crossing
with localcontext() as ctx:
ctx.prec = prec
sd = frac_to_dec(disc, prec).sqrt()
ad = frac_to_dec(a, prec)
bd = frac_to_dec(b, prec)
r1 = (-bd - sd) / (2 * ad)
r2 = (-bd + sd) / (2 * ad)
pos = sorted(r for r in (r1, r2) if r > 0)
for r in pos:
if 2 * ad * r + bd < 0: # inward (outside -> inside) slope
rfr = _dec_to_frac_snapshot(r)
if domain_ok(rfr):
return "ENTER", r, info
info["domain_clipped"] = True
return "MISS", None, info
def _dec_to_frac_snapshot(d: Decimal) -> Fraction:
return Fraction(d)
def coeffs(x, c, w, v, R0, rr):
d = [x[i] - c[i] for i in range(3)]
q = [w[i] + v[i] for i in range(3)]
qq = sum(q[i] * q[i] for i in range(3))
dq = sum(d[i] * q[i] for i in range(3))
dd = sum(d[i] * d[i] for i in range(3))
a = qq - rr * rr
b = 2 * (dq + R0 * rr)
cq = dd - R0 * R0
return a, b, cq, qq
def load_cases(path):
data = json.loads(open(path).read())
kernel = {}
for c in data["kernel"]:
kernel[c["id"]] = {
"category": c["category"],
"x": [parse_hex(z) for z in c["x"]],
"c": [parse_hex(z) for z in c["c"]],
"w": [parse_hex(z) for z in c["w"]],
"v": [parse_hex(z) for z in c["v"]],
"R0": parse_hex(c["R0"]),
"rr": parse_hex(c["rr"]),
}
route = {c["id"]: c for c in data["route"]}
return kernel, route
def kernel_reference(case, prec):
a, b, cq, qq = coeffs(case["x"], case["c"], case["w"], case["v"],
case["R0"], case["rr"])
status, root, info = classify(a, b, cq, prec, case["R0"], case["rr"])
dd = sum(xi * xi for xi in
(case["x"][i] - case["c"][i] for i in range(3)))
return {
"status": status,
"root": root,
"a": a,
"b": b,
"c": cq,
"qq": qq,
"dd": dd,
"R0": case["R0"],
"rr": case["rr"],
"a_zero": info["a_zero"],
"disc_sign": info["disc_sign"],
"domain_clipped": info["domain_clipped"],
}
def ulp_dec(cand: float) -> Decimal:
return frac_to_dec(Fraction(math.ulp(cand)))
def dec_of_float(f: float) -> Decimal:
return frac_to_dec(Fraction(f), 300)
def poly_resid(a: Fraction, b: Fraction, c: Fraction, s: Fraction) -> Fraction:
return a * s * s + b * s + c
def poly_scale(a: Fraction, b: Fraction, c: Fraction, s: Fraction) -> Fraction:
return abs(a) * s * s + abs(b) * s + abs(c)
def analyze_kernel_variant(rows, refs, prec):
"""Return per-case records for one variant's kernel CSV."""
out = []
for r in rows:
cid = int(r["id"])
ref = refs[cid]
status = int(r["status"])
cand = float.fromhex(r["sigma"]) if r["sigma"] not in ("", "nan") else float("nan")
cand_fr = parse_hex(r["sigma"]) if r["sigma"] not in ("", "nan") else None
ak = parse_hex(r["a"])
bk = parse_hex(r["b"])
ck = parse_hex(r["c"])
rec = {
"id": cid,
"category": r["category"],
"variant_status": status,
"ref_status": ref["status"],
"a_zero_ref": ref["a_zero"],
"a_zero_kernel": ak == 0,
"a_sign_ref": _sign(ref["a"]),
"a_sign_kernel": _sign(ak),
"disc_sign_ref": ref["disc_sign"],
"late": False,
"near_linear": False,
"near_boundary": False,
"ill_conditioned": False,
"domain_clipped": ref["domain_clipped"],
"root_err_ulps": None,
"root_rel_err": None,
"resid_kernel_rel": None,
"resid_ideal_rel": None,
"baseline_resid_rel": None,
}
# conditioning flags
if ref["status"] == "ENTER" and ref["root"] is not None:
s = ref["root"]
scale = max(abs(ref["R0"]), Fraction(1))
rec["late"] = bool(s > frac_to_dec(scale, prec) * (10 ** 6))
rec["near_linear"] = abs(ref["a"]) <= Fraction(1, 10**10) * max(
ref["qq"], ref["rr"] * ref["rr"], Fraction(1)
)
r0sq = ref["R0"] * ref["R0"]
dd = ref["dd"]
rec["near_boundary"] = bool(
dd + r0sq != 0
and abs(ref["c"]) <= Fraction(1, 10**8) * (dd + r0sq)
)
rec["ill_conditioned"] = bool(
rec["near_linear"] or rec["late"] or rec["near_boundary"]
or r["category"].startswith("growing_out")
or r["category"] == "shrinking"
)
# root-level comparison
if status == 1 and ref["status"] == "ENTER" and ref["root"] is not None and cand_fr is not None:
err = abs(frac_to_dec(cand_fr, prec) - ref["root"])
u = ulp_dec(cand)
if u > 0:
rec["root_err_ulps"] = float(err / u)
if ref["root"] != 0:
rec["root_rel_err"] = float(err / abs(ref["root"]))
rec["resid_kernel_rel"] = _rel(
poly_resid(ak, bk, ck, cand_fr), ak, bk, ck, cand_fr
)
rec["resid_ideal_rel"] = _rel(
poly_resid(ref["a"], ref["b"], ref["c"], cand_fr),
ref["a"], ref["b"], ref["c"], cand_fr,
)
try:
nearest = float(ref["root"])
nfr = Fraction(nearest)
rec["baseline_resid_rel"] = _rel(
poly_resid(ref["a"], ref["b"], ref["c"], nfr),
ref["a"], ref["b"], ref["c"], nfr,
)
except (OverflowError, ValueError):
rec["baseline_resid_rel"] = None
out.append(rec)
return out
def _sign(fr: Fraction) -> int:
return (fr > 0) - (fr < 0)
def _rel(resid: Fraction, a, b, c, s) -> float:
scale = poly_scale(a, b, c, s)
if scale == 0:
return 0.0
return float(abs(resid) / scale)
def route_reference(row, prec):
x = [parse_hex(row[f"canonx{i}"]) for i in range(3)]
w = [parse_hex(row[f"canonw{i}"]) for i in range(3)]
v = [parse_hex(row[f"v{i}"]) for i in range(3)]
center = [parse_hex(row[f"ct0{i}"]) for i in range(3)]
rr = parse_hex(row["rr"])
R0 = parse_hex(row["rt0"]) if row["rt0"] not in ("", "nan") else parse_hex(row["R0"])
a, b, cq, qq = coeffs(x, center, w, v, R0, rr)
status, root, info = classify(a, b, cq, prec, R0, rr)
return status, root, a, b, cq, qq, info["domain_clipped"]
STATUS_NAME = {-1: "INVALID", 0: "MISS", 1: "ENTRY", 2: "UNCERTAIN"}
KIND_NAME = {
0: "INSIDE",
1: "ENTRY",
2: "ESCAPED",
3: "TIME_RANGE_EXHAUSTED",
4: "INVALID",
}
def analyze_route_variant(rows, prec, dbl_eps=2.220446049250313e-16):
out = []
for r in rows:
(ref_status, ref_root, a, b, cq, qq,
ref_domain_clipped) = route_reference(r, prec)
status = int(r["status"])
kind = int(r["kind"])
F = _maybe_dec(r["F"])
tol = _maybe_dec(r["tol"])
kern_ok = r.get("kern_ok", "0") == "1"
kern_status = int(r["kern_status"]) if kern_ok else None
rec = {
"id": int(r["id"]),
"category": r["category"],
"ref_status": ref_status,
"ref_domain_clipped": ref_domain_clipped,
"status": status,
"kind": kind,
"kind_name": KIND_NAME.get(kind, "?"),
"failure_reason": int(r["failure_reason"]),
"fallback": int(r["fallback_evals"]),
"pi_match": int(r["pi_match"]),
"lcam_match": int(r["lcam_match"]),
"kern_status": kern_status if kern_ok else "",
"kern_sigma": r.get("kern_sigma", ""),
"F_over_tol": None,
"inside_mismatch": False,
"false_miss": False,
"false_candidate": False,
"unconfirmed": kind == 4 and r["reason_name"] == "ENTRY_UNCONFIRMED",
"kernel_false_miss": False,
"kernel_false_miss_escaped": False,
}
if ref_status == "INSIDE":
if kind != 0:
rec["inside_mismatch"] = True
elif ref_status == "ENTER":
if kind == 2:
rec["false_miss"] = True
elif ref_status == "MISS":
if kind == 1:
rec["false_candidate"] = True
if ref_status == "ENTER" and kern_ok and kern_status == 0:
rec["kernel_false_miss"] = True
if kind == 2:
rec["kernel_false_miss_escaped"] = True
if F is not None and tol is not None and tol > 0:
rec["F_over_tol"] = float(F / tol)
out.append(rec)
return out
def _maybe_dec(s):
if s in ("", "nan"):
return None
low = s.lower()
if "inf" in low:
return None
return hex_to_dec(s, 300)
def summarize_kernel(records_by_variant, refs):
"""Aggregate counts, ULP distributions, and common-ENTRY intersections."""
variants = list(records_by_variant.keys())
by_id = {v: {} for v in variants}
for v in variants:
for rec in records_by_variant[v]:
by_id[v][rec["id"]] = rec
ids = sorted(refs.keys())
summary = {"variants": {}}
for v in variants:
recs = by_id[v]
counts = {
"enter": 0, "miss": 0, "uncertain": 0, "false_miss": 0,
"false_uncertain": 0, "false_candidate": 0,
"a_zero_ref": 0, "a_zero_kernel": 0, "a_sign_mismatch": 0,
"a_zero_collapse": 0, "a_spurious_nonzero": 0,
"domain_clipped_candidate": 0,
"ill_conditioned_enter": 0,
"near_boundary_enter": 0,
"domain_clipped_cases": 0,
"late_ref_enter": 0,
}
ulps = []
rels = []
for cid in ids:
rec = recs[cid]
st = rec["variant_status"]
if st == 1:
counts["enter"] += 1
elif st == 0:
counts["miss"] += 1
elif st == 2:
counts["uncertain"] += 1
if rec["ref_status"] == "ENTER" and st == 0:
counts["false_miss"] += 1
if rec["ref_status"] == "ENTER" and st == 2:
counts["false_uncertain"] += 1
if rec["ref_status"] == "MISS" and st == 1:
if rec["domain_clipped"]:
counts["domain_clipped_candidate"] += 1
else:
counts["false_candidate"] += 1
if rec["a_zero_ref"]:
counts["a_zero_ref"] += 1
if rec["a_zero_kernel"]:
counts["a_zero_kernel"] += 1
if not rec["a_zero_ref"] and rec["a_zero_kernel"]:
counts["a_zero_collapse"] += 1
if rec["a_zero_ref"] and not rec["a_zero_kernel"]:
counts["a_spurious_nonzero"] += 1
if rec["ill_conditioned"] and rec["ref_status"] == "ENTER":
counts["ill_conditioned_enter"] += 1
if rec["near_boundary"] and rec["ref_status"] == "ENTER":
counts["near_boundary_enter"] += 1
if rec["domain_clipped"]:
counts["domain_clipped_cases"] += 1
if (rec["a_sign_ref"] != 0 and rec["a_sign_kernel"] != 0
and rec["a_sign_ref"] != rec["a_sign_kernel"]):
counts["a_sign_mismatch"] += 1
if rec["late"]:
counts["late_ref_enter"] += 1
if rec["root_err_ulps"] is not None:
ulps.append(rec["root_err_ulps"])
if rec["root_rel_err"] is not None:
rels.append(rec["root_rel_err"])
summary["variants"][v] = {
"counts": counts,
"root_ulp_all_ref_enter": _dist(ulps),
"root_rel_all_ref_enter": _dist(rels),
}
# Common reference-ENTER subset that every variant solved as ENTRY.
common = []
for cid in ids:
if refs[cid]["status"] != "ENTER":
continue
if all(by_id[v][cid]["variant_status"] == 1 for v in variants):
common.append(cid)
summary["common_enter_ids"] = len(common)
for v in variants:
vals = []
vals_nonlate = []
rels_nonlate = []
for cid in common:
rec = by_id[v][cid]
e = rec["root_err_ulps"]
if e is not None:
vals.append(e)
if not rec["ill_conditioned"]:
vals_nonlate.append(e)
if rec["root_rel_err"] is not None:
rels_nonlate.append(rec["root_rel_err"])
summary["variants"][v]["root_ulp_common_enter"] = _dist(vals)
summary["variants"][v]["root_ulp_common_enter_wellcond"] = _dist(vals_nonlate)
summary["variants"][v]["root_rel_common_enter_wellcond"] = _dist(rels_nonlate)
return summary
def _dist(vals):
if not vals:
return {"n": 0}
vs = sorted(vals)
n = len(vs)
def pct(p):
idx = min(n - 1, max(0, int(math.ceil(p * n)) - 1))
return vs[idx]
return {
"n": n,
"min": vs[0],
"median": pct(0.5),
"p95": pct(0.95),
"max": vs[-1],
}
def summarize_route(records_by_variant):
summary = {"variants": {}}
for v, recs in records_by_variant.items():
counts = {
"inside_ok": 0, "inside_mismatch": 0, "false_miss": 0,
"false_candidate": 0, "unconfirmed": 0, "fallback_used": 0,
"accepted_entry": 0, "F_over_tol_gt1": 0, "pi_mismatch": 0,
"lcam_mismatch": 0, "kernel_false_miss": 0,
"kernel_false_miss_escaped": 0, "ref_domain_clipped": 0,
}
fmax = 0.0
by_cat = {}
for rec in recs:
cat = by_cat.setdefault(rec["category"], {"cases": 0, "fallback": 0,
"entry": 0, "escaped": 0})
cat["cases"] += 1
if rec["kind"] == 0 and rec["ref_status"] == "INSIDE":
counts["inside_ok"] += 1
if rec["inside_mismatch"]:
counts["inside_mismatch"] += 1
if rec["false_miss"]:
counts["false_miss"] += 1
if rec["false_candidate"]:
counts["false_candidate"] += 1
if rec["unconfirmed"]:
counts["unconfirmed"] += 1
if rec["kernel_false_miss"]:
counts["kernel_false_miss"] += 1
if rec["kernel_false_miss_escaped"]:
counts["kernel_false_miss_escaped"] += 1
if rec["ref_domain_clipped"]:
counts["ref_domain_clipped"] += 1
if rec["fallback"] > 0:
counts["fallback_used"] += 1
cat["fallback"] += 1
if rec["kind"] == 1:
counts["accepted_entry"] += 1
cat["entry"] += 1
if rec["F_over_tol"] is not None:
fmax = max(fmax, rec["F_over_tol"])
if rec["F_over_tol"] > 1.0:
counts["F_over_tol_gt1"] += 1
if rec["kind"] == 2:
cat["escaped"] += 1
if not rec["pi_match"]:
counts["pi_mismatch"] += 1
if not rec["lcam_match"]:
counts["lcam_mismatch"] += 1
summary["variants"][v] = {"counts": counts, "max_F_over_tol": fmax,
"by_category": by_cat}
return summary
def precision_consistency(cases, ids, prec_a, prec_b):
"""Compare reference classification and nearest-double root at two Decimal
precisions. Coefficients/discriminant are exact rationals, so only the
square-root precision can differ. Returns a list of mismatches."""
mismatches = []
for cid in sorted(ids):
ra = kernel_reference(cases[cid], prec_a)
rb = kernel_reference(cases[cid], prec_b)
reason = None
if ra["status"] != rb["status"]:
reason = "status"
elif ra["disc_sign"] != rb["disc_sign"]:
reason = "disc_sign"
elif ra["status"] == "ENTER":
try:
fa = float(ra["root"])
fb = float(rb["root"])
except (OverflowError, ValueError):
reason = "root_unrepresentable"
else:
if not (fa == fb or (math.isnan(fa) and math.isnan(fb))):
reason = "nearest_double_root"
if reason is not None:
mismatches.append({"id": cid, "reason": reason,
"status_a": ra["status"], "status_b": rb["status"]})
return mismatches
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""End-to-end driver for the quadratic precision/performance benchmark.
Self-contained: reads the current working-tree ``src/asymptotic.c``, generates
and builds four variants, runs the accuracy probe, evaluates the exact-decimal
reference, and times the kernel and the public pre-route. All raw artifacts
are written under ``--output-dir`` (default /tmp/opencode/quadratic-comparison).
No production source, Makefile or git state is modified.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import subprocess
import sys
# Do not leave __pycache__ inside the repository benchmark directory: the
# imported local modules (build/cases/oracle) must not be cached here.
sys.dont_write_bytecode = True
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import build as buildmod # noqa: E402
import cases as casesmod # noqa: E402
import oracle # noqa: E402
VARIANTS = list(buildmod.VARIANTS)
def run_cmd(cmd, log_path: Path, env=None):
with open(log_path, "a") as fh:
fh.write("$ " + " ".join(str(c) for c in cmd) + "\n")
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
with open(log_path, "a") as fh:
if proc.stdout:
fh.write(proc.stdout)
if proc.stderr:
fh.write(proc.stderr)
fh.write(f"exit={proc.returncode}\n")
return proc
def read_csv(path: Path):
with open(path, newline="") as fh:
return list(csv.DictReader(fh))
def run_timing(cmd, outp: Path, log_path: Path):
# Never ingest output left by a previous invocation, even after a failure.
outp.unlink(missing_ok=True)
proc = run_cmd(cmd, log_path)
if proc.returncode != 0 or not outp.is_file():
raise RuntimeError(f"timing failed or produced no fresh output: {log_path}")
return json.loads(outp.read_text())
def write_metrics_csv(path: Path, records, columns):
with open(path, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
w.writeheader()
for r in records:
w.writerow(r)
def write_curated(path: Path, rows, columns):
with open(path, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
w.writeheader()
for r in rows:
w.writerow(r)
def build_curated_kernel(kernel_records):
ref_ids = [r["id"] for r in kernel_records[VARIANTS[0]] if r["id"] < 1000]
by = {v: {r["id"]: r for r in kernel_records[v]} for v in VARIANTS}
rows = []
for cid in sorted(ref_ids):
row = {"id": cid, "category": by[VARIANTS[0]][cid]["category"],
"ref": by[VARIANTS[0]][cid]["ref_status"]}
for v in VARIANTS:
row[f"{v}_status"] = by[v][cid]["variant_status"]
row[f"{v}_ulp"] = by[v][cid]["root_err_ulps"]
row[f"{v}_rel"] = by[v][cid]["root_rel_err"]
rows.append(row)
return rows
def build_curated_route(route_records):
ref_ids = [r["id"] for r in route_records[VARIANTS[0]] if r["id"] < 100]
by = {v: {r["id"]: r for r in route_records[v]} for v in VARIANTS}
rows = []
for cid in sorted(ref_ids):
row = {"id": cid, "category": by[VARIANTS[0]][cid]["category"],
"ref": by[VARIANTS[0]][cid]["ref_status"]}
for v in VARIANTS:
row[f"{v}_kind"] = by[v][cid]["kind"]
row[f"{v}_fallback"] = by[v][cid]["fallback"]
row[f"{v}_F_over_tol"] = by[v][cid]["F_over_tol"]
rows.append(row)
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", default=str(HERE.parents[1]))
ap.add_argument("--output-dir", default="/tmp/opencode/quadratic-comparison")
ap.add_argument("--cc", default=os.environ.get("CC", "cc"))
ap.add_argument("--threads", type=int, default=4)
ap.add_argument("--precision", type=int, default=160)
ap.add_argument("--rounds", type=int, default=3)
ap.add_argument("--kernel-target-calls", type=int, default=10_000_000)
ap.add_argument("--route-target-calls", type=int, default=1_000_000)
ap.add_argument("--skip-build", action="store_true")
args = ap.parse_args()
repo = Path(args.repo).resolve()
out = Path(args.output_dir).resolve()
(out / "raw").mkdir(parents=True, exist_ok=True)
(out / "cases").mkdir(parents=True, exist_ok=True)
(out / "logs").mkdir(parents=True, exist_ok=True)
import time as _time
t_start = _time.time()
phase_times = {}
# ---------------------------------------------------------------- cases
t0 = _time.time()
kernel_cases, route_cases = casesmod.build()
casesmod.write_header(kernel_cases, route_cases, out / "cases" / "quadratic_cases.h")
casesmod.write_json(kernel_cases, route_cases, out / "cases" / "cases.json")
phase_times["case_gen"] = _time.time() - t0
print(f"cases: kernel={len(kernel_cases)} route={len(route_cases)}")
# ------------------------------------------------------------ environment
env_info = buildmod.environment(repo, args.cc, args.threads)
env_info["python_version"] = sys.version.split()[0]
env_info["invocation"] = " ".join([sys.executable, *sys.argv])
(out / "environment.json").write_text(json.dumps(env_info, indent=2) + "\n")
(out / "raw" / "invocation.txt").write_text(
" ".join([sys.executable, *sys.argv]) + "\n"
)
print(f"source sha256: {env_info['source_sha256'][:16]} cc: {env_info['cc_version']}")
# ---------------------------------------------------------------- build
t0 = _time.time()
if not args.skip_build:
info = buildmod.build_all(repo, out, args.cc, buildmod.BASE_FLAGS, args.threads)
exes = {k: Path(v) for k, v in info["executables"].items()}
else:
manifest, reasons = buildmod.verify_manifest(repo, out, buildmod.BASE_FLAGS, args.cc)
if reasons:
print("refusing --skip-build: existing build does not match the "
f"current source/flags/fixtures ({', '.join(reasons)}); "
"rerun without --skip-build", file=sys.stderr)
sys.exit(1)
exes = {k: Path(v) for k, v in manifest["executables"].items()}
phase_times["build"] = _time.time() - t0
for name, exe in exes.items():
if not exe.exists():
print(f"missing executable for {name}: {exe}", file=sys.stderr)
sys.exit(1)
# -------------------------------------------------------------- accuracy
t0 = _time.time()
env_log = out / "logs" / "probe_environment.log"
env_log.write_text("")
for name in VARIANTS:
kout = out / "raw" / f"kernel_{name}.csv"
rout = out / "raw" / f"route_{name}.csv"
proc = run_cmd(
[str(exes[name]), "accuracy", "--kernel-out", str(kout),
"--route-out", str(rout)],
out / "logs" / f"accuracy_{name}.log",
)
with open(env_log, "a") as fh:
fh.write(proc.stdout)
if proc.returncode != 0:
print(f"accuracy probe failed for {name}", file=sys.stderr)
sys.exit(1)
phase_times["accuracy"] = _time.time() - t0
# ---------------------------------------------------------------- oracle
t0 = _time.time()
kernel_ref_cases, route_cases_json = oracle.load_cases(out / "cases" / "cases.json")
refs = {
cid: oracle.kernel_reference(case, args.precision)
for cid, case in kernel_ref_cases.items()
}
kernel_records = {}
route_records = {}
for name in VARIANTS:
krows = read_csv(out / "raw" / f"kernel_{name}.csv")
kernel_records[name] = oracle.analyze_kernel_variant(krows, refs, args.precision)
rrows = read_csv(out / "raw" / f"route_{name}.csv")
route_records[name] = oracle.analyze_route_variant(rrows, args.precision)
kcols = ["id", "category", "ref_status", "variant_status", "root_err_ulps",
"root_rel_err",
"resid_kernel_rel", "resid_ideal_rel", "baseline_resid_rel",
"a_zero_ref", "a_zero_kernel", "a_sign_ref", "a_sign_kernel",
"disc_sign_ref", "near_linear", "near_boundary",
"ill_conditioned", "domain_clipped", "late"]
for name in VARIANTS:
write_metrics_csv(out / "raw" / f"kernel_metrics_{name}.csv",
kernel_records[name], kcols)
rcols = ["id", "category", "ref_status", "ref_domain_clipped", "status",
"kind", "kind_name", "failure_reason", "fallback", "pi_match",
"lcam_match", "kern_status", "kern_sigma", "F_over_tol",
"inside_mismatch", "false_miss", "false_candidate", "unconfirmed",
"kernel_false_miss", "kernel_false_miss_escaped"]
for name in VARIANTS:
write_metrics_csv(out / "raw" / f"route_metrics_{name}.csv",
route_records[name], rcols)
ksummary = oracle.summarize_kernel(kernel_records, refs)
rsummary = oracle.summarize_route(route_records)
consistency_ids = {
rec["id"] for rec in kernel_records[VARIANTS[0]]
if rec["id"] < 1000 or rec["near_linear"] or rec["late"]
or rec["near_boundary"] or rec["domain_clipped"]
}
precision_mismatches = oracle.precision_consistency(
kernel_ref_cases, consistency_ids, args.precision, 240
)
curated_kernel = build_curated_kernel(kernel_records)
curated_route = build_curated_route(route_records)
write_curated(out / "raw" / "curated_kernel.csv", curated_kernel,
["id", "category", "ref"] + [f"{v}_{f}" for v in VARIANTS
for f in ("status", "ulp", "rel")])
write_curated(out / "raw" / "curated_route.csv", curated_route,
["id", "category", "ref"] + [f"{v}_{f}" for v in VARIANTS
for f in ("kind", "fallback",
"F_over_tol")])
# ------------------------------------------------------------- hardware FMA
phase_times["oracle"] = _time.time() - t0
fma_info = verify_fma(out, args.threads)
# ---------------------------------------------------------------- timing
t0 = _time.time()
timings = timed_runs(exes, out, args, kernel_target_calls=args.kernel_target_calls,
route_target_calls=args.route_target_calls)
lin_target = max(100_000, args.kernel_target_calls // 20)
timings["linearity"] = linearity_check(exes, out, lin_target)
phase_times["timing"] = _time.time() - t0
# ---------------------------------------------------------------- summary
summary = {
"source_sha256": env_info["source_sha256"],
"flags": buildmod.BASE_FLAGS,
"precision": args.precision,
"precision_consistency": {
"ids_checked": len(consistency_ids),
"mismatches": precision_mismatches,
},
"threads_timing": args.threads,
"cases": {"kernel": len(kernel_cases), "route": len(route_cases)},
"kernel_accuracy": ksummary,
"route_accuracy": rsummary,
"curated_kernel": curated_kernel,
"fma": fma_info,
"timing": timings,
"phase_seconds": phase_times,
"total_seconds": _time.time() - t_start,
}
(out / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
print_summary(summary)
print(f"\nartifacts under {out}")
def verify_fma(out: Path, threads: int):
info = {"objects": {}}
for name in VARIANTS:
obj = out / "build" / f"probe_{name}.o"
if not obj.exists():
continue
proc = subprocess.run(["objdump", "-d", str(obj)], capture_output=True,
text=True, check=False)
n = sum(1 for line in proc.stdout.splitlines()
if "vfmadd" in line or "vfmsub" in line or "fmadd" in line)
nm = subprocess.run(["nm", "-u", str(obj)], capture_output=True,
text=True, check=False)
undef = sorted({tok for line in nm.stdout.splitlines()
for tok in line.split()
if tok.startswith("fma")})
kd = subprocess.run(["objdump", "-dr", str(obj)], capture_output=True,
text=True, check=False)
kb_lines = []
inside = False
for line in kd.stdout.splitlines():
if re.match(r"^[0-9a-f]+ <entry_solve", line):
inside = True
kb_lines.append(line)
continue
if inside:
if re.match(r"^[0-9a-f]+ <", line):
break
kb_lines.append(line)
kb = "\n".join(kb_lines)
x87 = sum(1 for line in kb.splitlines()
if re.search(r"\b(fld|fstp|fmul|fadd|fsub|fdiv|fcom)\b", line))
vfma = sum(1 for line in kb.splitlines()
if "vfmadd" in line or "vfmsub" in line)
calls_fmal = sum(1 for line in kb.splitlines() if "fmal" in line)
info["objects"][name] = {
"fma_instructions": n,
"lowered_hardware": n > 0,
"undefined_fma_symbols": undef,
"entry_solve_x87": x87,
"entry_solve_vfmadd": vfma,
"entry_solve_fmal_calls": calls_fmal,
}
print(f"arith verify {name}: fused={n} undef={undef} "
f"entry_solve[x87={x87} vfmadd={vfma} fmal_calls={calls_fmal}]")
(out / "raw" / "fma_verify.json").write_text(json.dumps(info, indent=2) + "\n")
return info
def linearity_check(exes, out: Path, target_calls: int):
"""Confirm the microbench time scales with the call count (no hoisting)."""
res = {}
for name in ("ld_fma", "double_fma"):
secs = []
for mult in (1, 2):
outp = out / "raw" / f"linearity_{name}_{mult}.json"
d = run_timing([str(exes[name]), "microbench", "--out", str(outp),
"--target-calls", str(target_calls * mult)],
outp, out / "logs" / f"linearity_{name}_{mult}.log")
secs.append(d["seconds"])
res[name] = {"t1": secs[0], "t2": secs[1],
"ratio": secs[1] / secs[0] if secs[0] > 0 else None}
return res
def timed_runs(exes, out: Path, args, kernel_target_calls, route_target_calls):
schedules = []
fwd = list(VARIANTS)
schedules.append(fwd)
schedules.append(list(reversed(fwd)))
for i in range(2, args.rounds):
schedules.append(fwd if i % 2 == 0 else list(reversed(fwd)))
schedules = schedules[: args.rounds]
results = {"microbench": [], "routebench": []}
def order(seq):
return [exes[n] for n in seq]
for rnd, seq in enumerate(schedules):
for name in seq:
outp = out / "raw" / f"microbench_{name}_r{rnd}.json"
d = run_timing([str(exes[name]), "microbench", "--out", str(outp),
"--target-calls", str(kernel_target_calls)],
outp, out / "logs" / f"microbench_{name}_r{rnd}.log")
d["round"] = rnd
results["microbench"].append(d)
for rnd, seq in enumerate(schedules):
for name in seq:
outp = out / "raw" / f"routebench_{name}_r{rnd}.json"
d = run_timing([str(exes[name]), "routebench", "--out", str(outp),
"--target-calls", str(route_target_calls),
"--threads", str(args.threads), "--max-seconds", "20"],
outp, out / "logs" / f"routebench_{name}_r{rnd}.log")
d["round"] = rnd
results["routebench"].append(d)
return results
def print_summary(summary):
print("\n=== kernel accuracy (per variant) ===")
print(f"{'variant':<18}{'ENTER':>7}{'MISS':>7}{'UNC':>6}{'falseMiss':>10}"
f"{'falseCand':>10}{'domClipCand':>12}{'a0ref':>7}{'a0k':>6}{'asign':>6}"
f"{'illcond':>8}{'nearBnd':>8}{'medULP':>10}{'p95ULP':>11}{'maxULP':>11}")
for v, s in summary["kernel_accuracy"]["variants"].items():
c = s["counts"]
d = s["root_ulp_all_ref_enter"]
print(f"{v:<18}{c['enter']:>7}{c['miss']:>7}{c['uncertain']:>6}"
f"{c['false_miss']:>10}{c['false_candidate']:>10}"
f"{c['domain_clipped_candidate']:>12}"
f"{c['a_zero_ref']:>7}{c['a_zero_kernel']:>6}"
f"{c['a_sign_mismatch']:>6}{c['ill_conditioned_enter']:>8}"
f"{c['near_boundary_enter']:>8}"
f"{d.get('median', float('nan')):>10.4g}{d.get('p95', float('nan')):>11.4g}"
f"{d.get('max', float('nan')):>11.4g}")
print("well-conditioned common-ENTER ULP (excludes late / near-linear /"
" growing-out / shrinking):")
for v, s in summary["kernel_accuracy"]["variants"].items():
d = s["root_ulp_common_enter_wellcond"]
r = s["root_rel_common_enter_wellcond"]
print(f" {v:<18}n={d.get('n',0):>5} median={d.get('median', float('nan')):>9.3g}"
f" p95={d.get('p95', float('nan')):>9.3g}"
f" max={d.get('max', float('nan')):>9.3g}"
f" |rel err median={r.get('median', float('nan')):>9.3g}"
f" p95={r.get('p95', float('nan')):>9.3g}")
print(f"common reference-ENTER ids solved ENTRY by all variants: "
f"{summary['kernel_accuracy']['common_enter_ids']}")
print("\n=== curated kernel cases (status/ULP) ===")
print(f"{'id':>4} {'category':<20} {'ref':<6} "
+ " ".join(f"{v:>16}" for v in VARIANTS))
for row in summary.get("curated_kernel", []):
cells = []
for v in VARIANTS:
st = row.get(f"{v}_status")
u = row.get(f"{v}_ulp")
cells.append(f"{st}/{float(u):.3g}" if u not in (None, "") else f"{st}/-")
print(f"{row['id']:>4} {row['category']:<20} {row.get('ref',''):<6} "
+ " ".join(f"{c:>16}" for c in cells))
print("\n=== route accuracy (per variant) ===")
print(f"{'variant':<18}{'insideOK':>9}{'insideBad':>10}{'falseMiss':>10}"
f"{'falseCand':>10}{'unconf':>8}{'kernFM':>8}{'kernFMesc':>10}"
f"{'fallback':>9}{'F>tol':>7}{'maxF/tol':>10}")
for v, s in summary["route_accuracy"]["variants"].items():
c = s["counts"]
print(f"{v:<18}{c['inside_ok']:>9}{c['inside_mismatch']:>10}"
f"{c['false_miss']:>10}{c['false_candidate']:>10}"
f"{c['unconfirmed']:>8}{c['kernel_false_miss']:>8}"
f"{c['kernel_false_miss_escaped']:>10}"
f"{c['fallback_used']:>9}"
f"{c['F_over_tol_gt1']:>7}{s['max_F_over_tol']:>10.3g}")
pc = summary.get("precision_consistency", {})
print(f"reference precision {summary['precision']} vs 240: checked "
f"{pc.get('ids_checked')} curated/near-linear/late ids, "
f"mismatches={len(pc.get('mismatches', []))}")
print("\nroute fallback / entry / escaped by category "
"(ld_plain | ld_fma | double_fma):")
ra = summary["route_accuracy"]["variants"]
cats = sorted({c for s in ra.values() for c in s.get("by_category", {})})
for cat in cats:
cells = []
for v in ("ld_plain", "ld_fma", "double_fma"):
d = ra[v].get("by_category", {}).get(cat, {})
cells.append(f"fb={d.get('fallback',0)} e={d.get('entry',0)} "
f"x={d.get('escaped',0)}")
print(f" {cat:<20} " + " | ".join(cells))
print("\n=== timing (median ns/call over rounds) ===")
for mode in ("microbench", "routebench"):
per = {}
for rec in summary["timing"][mode]:
per.setdefault(rec["variant"], []).append(rec["ns_per_call"])
for v, vals in per.items():
vals.sort()
med = vals[len(vals) // 2]
print(f"{mode:<12}{v:<18}median={med:>10.2f} ns/call "
f"rounds={len(vals)}")
lin = summary["timing"].get("linearity", {})
for v, d in lin.items():
print(f"linearity {v:<18}t1={d['t1']:.4f}s t2={d['t2']:.4f}s "
f"ratio={d['ratio']:.3f} (expect ~2)")
if __name__ == "__main__":
main()
+63 -5
View File
@@ -381,7 +381,9 @@ typedef struct {
单张与电影共享 `ObserverState` 和 ray 初始化。单张不是只指定三维位置:
由事件、坐标速度、指向与 roll 生成完整四速度和 tetrad。
目前解析单张事件取 $t=0$;此构造不要求静态时空或静止观测者。
`--observer-time T` 指定单张相机事件的坐标时(默认 $t=0$,接受任意有限值,
包括负值);metric 求值、ray 初始化与 lens-map 帧元数据使用同一事件时刻。
此构造不要求静态时空或静止观测者;瞬时相机的 proper time 仍以零为参考。
`--observer-position X Y Z` 与 `--look-ra-deg` / `--look-dec-deg` 独立。
只有位置时取朝原点的坐标方向;只有指向时令 $\mathbf x=-R\mathbf d$。
@@ -777,6 +779,26 @@ witness 提升为正式 midpoint 时原地复用同一 vertex id,只保留一
provenance 保存 coordinate-time step 与初始 step 预算,使实际积分来源与成本可
replay。
诊断输出所有权:所有 ray 失败报告只在 CLI/main 的串行后处理阶段发出,
ray-tracing 的 OpenMP worker 与 geodesic 热循环不写任何日志(既有 PSF splat
诊断不属于此范围)。稳定的 `ray_reason_name` 覆盖全部
`RayReason`;默认(非 verbose)也按 frame/phase 输出 `INCOMPLETE` 的 reason
直方图与真实 frame id、相机坐标时间,verbose 或 GR_DEBUG 才追加每 reason 有界
数量的代表性样本(generation/phase、sample id/kind、持久 vertex id、film 坐标、
可信 endpoint 的 stop 状态与 accepted/rejected/RHS 计数)并报告被抑制数量。
`UNRESOLVED/BUDGET_EXHAUSTED` 只在帧边界统计确认存在 blocking triangle 时按帧
报告,与数值 `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 协议
@@ -985,6 +1007,35 @@ residual、Chebyshev 表或解析主项;运行期不得建表。
- Minkowski entry quadratic 用稳定根公式($q=-\tfrac12(b+\mathrm{copysign}
(\sqrt\Delta,b))$,取最小正根);$c=0$(相机在边界)时按 $b=\mathrm dF/
\mathrm ds$ 分类。worldtube sample 必须有限且 $R>0$,否则 `INVALID`。
Alcubierre 等平直外区共用此解法;相对位置/速度、系数、判别式和根采用
`long double` 中间量,最后才转换为 backend 的 double 状态。某些平台的
`long double` 不提供额外有效位,因此精度提升不能替代入口验证。
送入根公式前,以系数最大绝对值的二进制指数共同缩放 $a,b,c$(最大值进入
$[1/2,1)$),避免判别式乘积溢出;不改变传播参数及根,也不覆盖系数构造前
已发生的误差。非有限系数或缩放后非零系数落入 subnormal 范围,转入不确定
路径而非当作退化线性式。判别式 $b^2-4ac$ 和入口斜率采用普通 `long double`
运算,不使用显式 FMA;精度/成本对照见 `benchmarks/quadratic_precision/`。
本机软件 `fmal` 成本明显,测试未显示足以抵偿成本的精度收益;不确定带、
几何验证及后备定位仍保留,不能把此样本结论当作全参数可靠性保证。
- **入口快路径与后备定位分离**:解析相交只提供候选入口;采用前须在实际 backend
坐标、实际入口时间重新检查 worldtube 残差。通过原有几何舍入容差的候选沿快路径
激活;未通过的候选沿同一外区 geodesic,从原相机事件重新求值并用确定在外/
严格在内的区间定位首次入口。公共 localizer 不依赖具体 metric backend,
各已支持的外区提供轨迹求值与首次入口 bracket;不能把离散采样无交点当作 miss。
miss 判定必须排除真实入口;擦边或求根病态造成的数值不确定性不能作为
`ESCAPED` 的依据,允许保守地生成候选入口并进行后备验证。
浮点判别式等于零不构成精确擦边证明;重建最小点的单次正残差也不构成
miss 证明(大时间/坐标的舍入会移动该最小点)。精确擦边可由独立几何证据
排除入口,例如固定半径段内某个不变坐标的精确距离已不小于球半径;
无法获得这种证据且无法构造可信 bracket 时仍返回 `ENTRY_UNCONFIRMED`。
后备路径返回 bracket 收敛后的可表示内侧轨迹状态,不做径向位置投影,也不放宽
原 worldtube 检查容差。未能确认首次入口须报告 `INCOMPLETE/ENTRY_UNCONFIRMED`,
callback、历史及几何错误仍传播各自具体 reason,不能改写为 capture 或 escape。
定位在 pre-route 中完成,不在 slab sweep 中倒回相机时间;不重置相机 `L0`,
内区 accepted-step/lookback 预算仍从最终激活事件起算。成功的后备入口由
`entry_fallback_evaluations` 记录 localizer 的轨迹求值次数(不含构造 bracket
的探测),不混入内区 RHS 计数;临时状态由
调用者独占,不引入共享可变缓存。此标记不改变 lens-map 二进制布局。
- **构造期验证优先**:`spacetime_create_*()` 成功即承诺该 source 已可安全光追。
每个 constructor 在安装 ops/context 后调用公共 `spacetime_source_finalize()`:
检查 ops/context 完整、`end_id` 唯一且非 `NONE`、exterior kind 受支持、
@@ -1312,11 +1363,18 @@ immutable 的 cache,不再逐帧做 tile 扫描、prefetch OpenMP 区域或打
multi-frame map 与 observer movie 走同一 union 路径。
movie PNG 编码/写盘由一个单 producer、单 writer 的有界队列(默认容量 2)承担,
与下一帧渲染重叠。producer 在 enqueue 前完成 sensor bloom、clean RGB8 与可选
mesh overlay RGB8 转换,job 只持有 8-bit buffer,HDR 在 submit 后即可释放。
与下一帧渲染重叠。producer 完成 sensor bloom、tone mapping 和 sRGB 转换,
生成 clean RGB8;可选诊断网格由去重边光栅化为预乘 alpha 的 sRGB RGBA8 层。
HDR 与临时线段由 producer 释放;job 独立拥有这两个输出 buffer,成功 enqueue
后 ownership 转交队列。writer 先写 clean 图,再以 source-over 原地合成诊断层、写出
mesh sibling,最后释放 job buffer。单帧、movie 与 lens-map replay 共用此合成规则。
writer 的首个错误持久保存,使后续 submit 立即失败;`finish()` drain 已接受 job
后 join writer,所有退出路径都必须 join,绝不为求重叠而提前打印 `Rendered ... ok`。
诊断网格是最终 mesh 的只读可视化:一像素抗锯齿边按端点终态分别着色相邻半边,
在中点切换颜色。合成位于 tone mapping 与 sRGB transfer 之后,clean 图与 HDR
保持独立。调色与透明度配置见 [`usage.md`](usage.md)。
fast mode 的单星精度由 deposit 模式与 `N` 决定:`nearest` 的格点间距是
每轴 `1/N` 个输出像素,单帧瞬时舍入误差至多是 `1/(2N)`;在
`--psf-fwhm-pixels` 不变时它与图像分辨率无关,只有增大 `N`(或使用保持
@@ -1936,8 +1994,8 @@ map 共用同一入口。每个 RGB 通道独立、各向同性地把超过有
\(E\) 使用 exposure 之后的 renderer-scale 线性 HDR;\(e\) 同时决定每轮保留传播的
比例和有效传播距离;模型允许信号损失,不守恒。原始 `--hdr-output` FITS 在模型
运行前写出,因此始终是 bloom 前的 PSF HDR;tone-mapped PNG/PPM 与视频帧在模型
之后写出。视觉式多尺度 bloom 不在当前范围内。mesh overlay 在模型之后绘制,
不参与溢出传播。
之后写出。视觉式多尺度 bloom 不在当前范围内。mesh 诊断层在 tone mapping 与
sRGB transfer 后合成。
---
+608 -68
View File
@@ -1,5 +1,6 @@
#include "asymptotic.h"
#include "asymptotic_entry.h"
#include "asymptotic_schwarzschild.h"
#include <float.h>
@@ -188,73 +189,190 @@ int asymptotic_backend_from_canonical(const SpacetimeSource *source,
return 0;
}
/* Solve |d + q s|^2 = (R0 - rr s)^2 for the smallest s >= 0 with outside ->
* inside crossing. Returns 1 on entry (sets s), 0 on miss, -1 on error. */
static int solve_entry_quadratic(const double d[3], const double q[3],
double R0, double rr, double *s_out) {
const double qq = dot3(q, q);
const double a = qq - rr * rr;
const double b = 2.0 * (dot3(d, q) + R0 * rr);
const double c = dot3(d, d) - R0 * R0;
if (c < 0.0) {
/* Long-double coefficients of the relative-distance quadratic
* F(sigma) = |d + q sigma|^2 - (R0 - rr sigma)^2 = a sigma^2 + b sigma + c,
* where d = x_cur - c_frame and q = w_frame + v_frame. Keeping them in long
* double preserves the cancellation-prone grazing entries; the caller hands
* the coefficients to both the root solve and the conservative fallback. */
typedef struct {
long double a, b, c;
} EntryQuadratic;
static EntryQuadratic entry_quadratic_coeffs(
const double x_cur[3], const double c_frame[3], const double w_frame[3],
const double v_frame[3], double R0, double rr) {
long double d[3], q[3];
for (int i = 0; i < 3; ++i) {
d[i] = (long double)x_cur[i] - (long double)c_frame[i];
q[i] = (long double)w_frame[i] + (long double)v_frame[i];
}
long double qq = 0.0L, dot_dq = 0.0L, dot_dd = 0.0L;
for (int i = 0; i < 3; ++i) {
qq += q[i] * q[i];
dot_dq += d[i] * q[i];
dot_dd += d[i] * d[i];
}
const long double R0_ld = (long double)R0;
const long double rr_ld = (long double)rr;
EntryQuadratic k;
k.a = qq - rr_ld * rr_ld;
k.b = 2.0L * (dot_dq + R0_ld * rr_ld);
k.c = dot_dd - R0_ld * R0_ld;
return k;
}
typedef enum {
ENTRY_SOLVE_MISS = 0, /* proven no outside->inside crossing */
ENTRY_SOLVE_ENTRY = 1, /* a first-crossing candidate parameter was found */
ENTRY_SOLVE_UNCERTAIN = 2 /* discriminant/degeneracy at the resolution floor */
} EntrySolveResult;
/* For normalized coefficients, compute b*b - 4*a*c without overflowing.
* Near-cancellation is handled conservatively by the uncertainty band. */
static long double entry_discriminant(const EntryQuadratic *k,
long double *scale) {
const long double four_a = 4.0L * k->a;
const long double ac = four_a * k->c;
*scale = k->b * k->b + fabsl(ac);
return k->b * k->b - ac;
}
/* Solve the quadratic for the smallest sigma >= 0 with an outside -> inside
* crossing. The stable roots are evaluated in long double. A discriminant
* that is negative but within its own rounding bound, an exact double root, or
* a root that does not move inward is not a proof: it is reported as
* ENTRY_SOLVE_UNCERTAIN so the caller attempts a strict-inside bracket instead
* of fabricating a miss. Exact algebra (a == 0) stays the only linear branch;
* a tiny-but-nonzero `a` always goes through the discriminant, so a future
* entry at a huge parameter is never discarded by an approximate threshold. */
static EntrySolveResult entry_solve(const EntryQuadratic *k, double *s_out) {
if (!isfinite(k->a) || !isfinite(k->b) || !isfinite(k->c))
return ENTRY_SOLVE_UNCERTAIN;
/* A common power-of-two scale preserves roots and coefficient signs without
* adding division rounding. Normalize before squaring/products, retaining
* the original coefficients in the caller for geometric fallback. Do not
* silently turn an underflowed coefficient into a linear/boundary case. */
EntryQuadratic scaled = *k;
const long double magnitude = fmaxl(fabsl(k->a),
fmaxl(fabsl(k->b), fabsl(k->c)));
if (magnitude > 0.0L) {
int exponent;
(void)frexpl(magnitude, &exponent);
scaled.a = scalbnl(k->a, -exponent);
scaled.b = scalbnl(k->b, -exponent);
scaled.c = scalbnl(k->c, -exponent);
if ((k->a != 0.0L && fabsl(scaled.a) < LDBL_MIN) ||
(k->b != 0.0L && fabsl(scaled.b) < LDBL_MIN) ||
(k->c != 0.0L && fabsl(scaled.c) < LDBL_MIN))
return ENTRY_SOLVE_UNCERTAIN;
}
k = &scaled;
if (k->c < 0.0L) {
/* Strictly inside; the lifecycle normally handles this as INSIDE. */
*s_out = 0.0;
return 1;
return ENTRY_SOLVE_ENTRY;
}
if (c == 0.0) {
if (k->c == 0.0L) {
/* On the boundary: classify by dF/ds = b. Past-inward enters at once;
* outward/tangent rays may still re-enter later when the sphere shrinks
* (a < 0), so do not declare a permanent miss on the zero root. */
if (b < 0.0) {
if (k->b < 0.0L) {
*s_out = 0.0;
return 1;
return ENTRY_SOLVE_ENTRY;
}
if (b == 0.0) {
if (a < 0.0) {
if (k->b == 0.0L) {
if (k->a < 0.0L) {
*s_out = 0.0;
return 1;
return ENTRY_SOLVE_ENTRY;
}
return 0;
return ENTRY_SOLVE_MISS;
}
if (a < 0.0) {
*s_out = -b / a;
return 1;
if (k->a < 0.0L) {
*s_out = (double)(-k->b / k->a);
return ENTRY_SOLVE_ENTRY;
}
return 0;
return ENTRY_SOLVE_MISS;
}
/* Compare the quadratic coefficient against the velocity-squared scale it
* is built from; mixing in R0^2 would let a large radius misclassify a
* genuinely quadratic entry as linear. */
const double scale = qq + rr * rr;
if (fabs(a) <= 32.0 * DBL_EPSILON * scale) {
if (!isfinite(b) || b >= 0.0)
return 0;
const double s = -c / b;
if (s <= 0.0)
return 0;
*s_out = s;
return 1;
/* c > 0: the ray starts outside. */
if (k->a == 0.0L) {
/* Exact linear branch only. b >= 0 never crosses for sigma > 0. */
if (!(k->b < 0.0L))
return ENTRY_SOLVE_MISS;
const long double s = -k->c / k->b;
if (!(s > 0.0L))
return ENTRY_SOLVE_MISS;
*s_out = (double)s;
return ENTRY_SOLVE_ENTRY;
}
const double disc = b * b - 4.0 * a * c;
if (!isfinite(disc) || disc <= 0.0)
return 0;
/* Numerically stable quadratic roots: q avoids cancellation in the root
/* Ordinary long-double products; near-zero differences need fallback. */
long double disc_scale;
const long double disc = entry_discriminant(k, &disc_scale);
/* Conservative discriminant resolution: the long-double evaluation error
* plus the rounding the double inputs already carry through the frame
* rotation/translation into d and q. The double term dominates and keeps a
* near-tangent discriminant from being read as a proven miss. */
const long double disc_err =
64.0L * ((long double)DBL_EPSILON + (long double)LDBL_EPSILON) *
disc_scale;
if (!isfinite(disc))
return ENTRY_SOLVE_UNCERTAIN;
if (disc < -disc_err)
return ENTRY_SOLVE_MISS;
if (fabsl(disc) <= disc_err)
return ENTRY_SOLVE_UNCERTAIN;
/* Numerically stable quadratic roots: qq2 avoids cancellation in the root
* with the same sign as b, which is exactly the small entry root when the
* camera sits just outside a large sphere. */
const double root = sqrt(disc);
const double qq2 = -0.5 * (b + copysign(root, b));
const double r1 = qq2 / a;
const double r2 = c / qq2;
const long double root = sqrtl(disc);
const long double qq2 = -0.5L * (k->b + copysignl(root, k->b));
if (qq2 == 0.0L)
return ENTRY_SOLVE_UNCERTAIN;
const long double r1 = qq2 / k->a;
const long double r2 = k->c / qq2;
/* The first outside->inside crossing is the smallest positive root. */
double s = INFINITY;
if (r1 > 0.0)
long double s = INFINITY;
if (r1 > 0.0L)
s = r1;
if (r2 > 0.0 && r2 < s)
if (r2 > 0.0L && r2 < s)
s = r2;
if (!(s < INFINITY))
return 0;
*s_out = s;
return 1;
return (r1 > 0.0L || r2 > 0.0L) ? ENTRY_SOLVE_UNCERTAIN : ENTRY_SOLVE_MISS;
/* First crossing must move inward (dF/dsigma < 0). A nonnegative slope
* means the stable-root selection picked the exit root or the roots merged;
* that is uncertain, not a proof of a miss. */
const long double slope = 2.0L * k->a * s + k->b;
if (!(slope < 0.0L))
return ENTRY_SOLVE_UNCERTAIN;
*s_out = (double)s;
return ENTRY_SOLVE_ENTRY;
}
static void minkowski_route_entry(const SpacetimeAsymptoticEnd *end, double t0,
const double x_frame[3],
const double w_frame[3], double s_entry,
SpacetimeEndId end_id,
AsymptoticRoute *route);
/* Opaque evaluator context: repropagate constant-velocity motion from the
* ORIGINAL camera state to a total past parameter, never from a nearby root. */
typedef struct {
const SpacetimeAsymptoticEnd *end;
double t0;
const double *x_frame;
const double *w_frame;
double log_alpha_p0;
SpacetimeEndId end_id;
} MinkowskiEntryContext;
static AsymptoticStatus minkowski_entry_evaluate(void *opaque, double parameter,
AsymptoticRoute *state) {
const MinkowskiEntryContext *ctx = opaque;
*state = (AsymptoticRoute){0};
minkowski_route_entry(ctx->end, ctx->t0, ctx->x_frame, ctx->w_frame,
parameter, ctx->end_id, state);
state->log_alpha_p0 = ctx->log_alpha_p0;
state->log_alpha_p0_camera = ctx->log_alpha_p0;
return ASYMPTOTIC_OK;
}
static void minkowski_route_escaped(const SpacetimeAsymptoticEnd *end,
@@ -287,13 +405,248 @@ static void minkowski_route_entry(const SpacetimeAsymptoticEnd *end, double t0,
route->Pi[i] = -w_backend[i];
}
/* Recover a first-entry bracket inside the CURRENT constant-motion segment
* when the closed-form candidate fails geometric validation or the
* discriminant is uncertain. The outside endpoint is the segment start (the
* previous segments produced no entry), and the inside endpoint is either the
* convex minimum or a modest, geometrically grown step past the candidate
* root. Both are confirmed with the actual worldtube callback. On success
* `route` carries the localized ENTRY and a nonzero fallback evaluation count.
* Any failure leaves `route->failure_reason` set and never reports an escape;
* one reconstructed probe cannot certify a miss. */
static AsymptoticStatus minkowski_fallback_entry(
const SpacetimeSource *source, const SpacetimeAsymptoticEnd *end, double t0,
const double x_frame[3], const double w_frame[3], double log_alpha_p0,
SpacetimeEndId end_id, double s_base, double s_segment,
const EntryQuadratic *k, EntrySolveResult solve, double candidate_sigma,
AsymptoticRoute *route) {
MinkowskiEntryContext ctx = {.end = end,
.t0 = t0,
.x_frame = x_frame,
.w_frame = w_frame,
.log_alpha_p0 = log_alpha_p0,
.end_id = end_id};
/* Outside endpoint: the segment start must still be outside. */
AsymptoticRoute outside_state;
AsymptoticStatus status =
minkowski_entry_evaluate(&ctx, s_base, &outside_state);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return status;
}
RayReason why = RAY_REASON_NONE;
double f_out = 0.0, tol_out = 0.0;
status = asymptotic_entry_geometry(source, end_id, outside_state.activate_t,
outside_state.x, &f_out, &tol_out, &why);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end_id;
return status;
}
if (!(f_out >= 0.0)) {
/* Already inside at the segment start: an earlier segment missed the
* crossing. Do not fabricate a bracket from it. */
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
int have_inside = 0;
double inside_sigma = 0.0;
if (k->a > 0.0L) {
/* Convex: only the quadratic minimum can be strictly inside, and F is
* monotonically decreasing from the segment start to that minimum, so the
* bracket still straddles the first crossing. */
const long double sigma_min_ld = -k->b / (2.0L * k->a);
if (!(sigma_min_ld > 0.0L)) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
double probe = (double)sigma_min_ld;
if (probe > s_segment)
probe = s_segment;
if (!(probe > 0.0)) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
AsymptoticRoute probe_state;
status = minkowski_entry_evaluate(&ctx, s_base + probe, &probe_state);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return status;
}
double f_probe = 0.0, tol_probe = 0.0;
status = asymptotic_entry_geometry(source, end_id, probe_state.activate_t,
probe_state.x, &f_probe, &tol_probe,
&why);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end_id;
return status;
}
if (f_probe < 0.0) {
have_inside = 1;
inside_sigma = probe;
} else {
/* One reconstructed probe is not a miss proof: coordinate-time rounding
* can shift the minimum and hide an inside point at a nearby parameter. */
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
} else {
/* Monotone (a == 0 linear) or concave after the first crossing: step
* modestly past the candidate root and grow geometrically, staying inside
* the declared segment and the positive-radius domain. */
if (solve != ENTRY_SOLVE_ENTRY || !(candidate_sigma >= 0.0)) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
double sigma = candidate_sigma;
if (sigma > s_segment)
sigma = s_segment;
AsymptoticRoute probe_state;
status = minkowski_entry_evaluate(&ctx, s_base + sigma, &probe_state);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return status;
}
double f_sigma = 0.0, tol_sigma = 0.0;
status = asymptotic_entry_geometry(source, end_id, probe_state.activate_t,
probe_state.x, &f_sigma, &tol_sigma,
&why);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end_id;
return status;
}
if (f_sigma < 0.0) {
have_inside = 1;
inside_sigma = sigma;
} else {
const long double slope =
2.0L * k->a * (long double)sigma + k->b; /* < 0 for an entry */
double delta = 0.0;
if (slope < 0.0L)
delta = 2.0 * fabs(f_sigma) / fabs((double)slope);
const double ulp_term =
16.0 * DBL_EPSILON * fmax(1.0, fabs(s_base + sigma));
if (!(delta > ulp_term))
delta = ulp_term;
for (int attempt = 0; attempt < 64 && !have_inside; ++attempt) {
const double probe = sigma + delta;
if (!(probe > sigma) || probe > s_segment)
break;
AsymptoticRoute grown_state;
status = minkowski_entry_evaluate(&ctx, s_base + probe, &grown_state);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return status;
}
double f_grown = 0.0, tol_grown = 0.0;
status = asymptotic_entry_geometry(source, end_id,
grown_state.activate_t,
grown_state.x, &f_grown, &tol_grown,
&why);
if (status != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end_id;
return status;
}
if (f_grown < 0.0) {
have_inside = 1;
inside_sigma = probe;
} else {
delta *= 2.0;
}
}
if (!have_inside) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
}
}
/* Bracket confirmed: hand it to the common, exterior-independent driver. */
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason localize_reason = RAY_REASON_NONE;
status = asymptotic_entry_localize(
source, end_id, minkowski_entry_evaluate, &ctx, s_base,
s_base + inside_sigma, &out, &evaluations, &localize_reason);
if (status == ASYMPTOTIC_OK) {
*route = out;
route->failure_reason = RAY_REASON_NONE;
route->entry_fallback_evaluations = evaluations;
return ASYMPTOTIC_OK;
}
if (status == ASYMPTOTIC_TIME_RANGE_EXHAUSTED ||
status == ASYMPTOTIC_UNSUPPORTED) {
route->failure_reason = localize_reason;
route->end_id = end_id;
return status;
}
route->failure_reason =
(localize_reason == RAY_REASON_ESCAPE_LOCALIZATION_FAILED ||
localize_reason == RAY_REASON_NONE ||
localize_reason == RAY_REASON_PROTOCOL_ERROR)
? RAY_REASON_ENTRY_UNCONFIRMED
: localize_reason;
route->end_id = end_id;
return ASYMPTOTIC_INVALID;
}
/* A fixed coordinate outside the sphere is an independent algebraic miss
* certificate, including exact tangency. Restrict this cheap certificate to
* identity axes and zero origin so frame reconstruction cannot change the
* original camera component. Sterbenz's lemma certifies the subtraction when
* both nonzero operands have the same sign and are within a factor of two. */
static int minkowski_coordinate_miss(
const SpacetimeAsymptoticEnd *end, const double x_cur[3],
const double w_frame[3], const SpacetimeEscapeWorldtubeSample *sample) {
if (sample->radius_rate != 0.0)
return 0;
for (int i = 0; i < 3; ++i)
if (end->frame_origin[i] != 0.0)
return 0;
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
if (end->frame_axes[i][j] != (i == j ? 1.0 : 0.0))
return 0;
for (int i = 0; i < 3; ++i) {
if (w_frame[i] != 0.0 || sample->velocity[i] != 0.0)
continue;
const double x = x_cur[i] + end->frame_origin[i];
const double c = sample->center[i];
const int exact = x == 0.0 || c == 0.0 ||
(signbit(x) == signbit(c) && fabs(x) * 0.5 <= fabs(c) &&
fabs(c) * 0.5 <= fabs(x));
const double d = x - c;
if (isfinite(x) && isfinite(d) && exact && fabs(d) >= sample->radius)
return 1;
}
return 0;
}
static AsymptoticStatus minkowski_preroute(
const SpacetimeSource *source, const SpacetimeAsymptoticEnd *end, double t0,
const double x_frame[3], const double w_frame[3], SpacetimeEndId end_id,
AsymptoticRoute *route) {
const double x_frame[3], const double w_frame[3], double log_alpha_p0,
SpacetimeEndId end_id, AsymptoticRoute *route) {
/* Walk constant-velocity motion segments. A quadratic root is only valid
* inside the current segment and while the radius stays positive; otherwise
* advance to the next segment boundary and re-sample. */
route->end_id = end_id;
route->failure_reason = RAY_REASON_NONE;
double s = 0.0;
for (int segment = 0; segment < 1000000; ++segment) {
const double t = t0 - s;
@@ -313,32 +666,68 @@ static AsymptoticStatus minkowski_preroute(
* path below. */
return ASYMPTOTIC_UNSUPPORTED;
}
double c_frame[3], v_frame[3], d[3], q[3];
double c_frame[3], v_frame[3];
backend_position_to_frame(end, sample.center, c_frame);
backend_vector_to_frame(end, sample.velocity, v_frame);
for (int i = 0; i < 3; ++i) {
d[i] = x_cur[i] - c_frame[i];
q[i] = w_frame[i] + v_frame[i];
}
const double boundary = spacetime_escape_worldtube_next_segment(
source, end->end_id, t);
const double s_segment = isfinite(boundary) ? (t - boundary) : INFINITY;
if (!(s_segment >= 0.0))
return ASYMPTOTIC_INVALID;
double sigma;
const int hit = solve_entry_quadratic(d, q, sample.radius,
sample.radius_rate, &sigma);
const EntryQuadratic k = entry_quadratic_coeffs(
x_cur, c_frame, w_frame, v_frame, sample.radius, sample.radius_rate);
double sigma = 0.0;
EntrySolveResult solve = entry_solve(&k, &sigma);
if (solve == ENTRY_SOLVE_UNCERTAIN &&
minkowski_coordinate_miss(end, x_cur, w_frame, &sample))
solve = ENTRY_SOLVE_MISS;
/* The backend constructor guarantees R > 0 throughout every segment, so
* a root inside the segment is a real entry. A root past the segment
* boundary is not adopted here; the next segment is sampled instead.
* The cheap R > 0 test at the root guards against a backend that
* bypasses its constructor. */
if (hit && sigma >= 0.0 && sigma <= s_segment) {
if (solve == ENTRY_SOLVE_ENTRY && sigma >= 0.0 && sigma <= s_segment) {
if (sample.radius - sample.radius_rate * sigma <= 0.0)
return ASYMPTOTIC_INVALID;
AsymptoticRoute candidate = {.kind = ASYMPTOTIC_ROUTE_INVALID};
minkowski_route_entry(end, t0, x_frame, w_frame, s + sigma, end_id,
route);
return ASYMPTOTIC_OK;
&candidate);
candidate.log_alpha_p0 = log_alpha_p0;
candidate.log_alpha_p0_camera = log_alpha_p0;
candidate.failure_reason = RAY_REASON_NONE;
int valid = 0;
RayReason why = RAY_REASON_NONE;
status = asymptotic_entry_validate(source, end_id, &candidate, &valid,
&why);
if (status == ASYMPTOTIC_TIME_RANGE_EXHAUSTED) {
route->failure_reason = why;
return status;
}
if (status != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end_id;
return status;
}
if (valid) {
/* Fast path: the reconstructed entry is already on the boundary
* within the geometric ULP band. */
*route = candidate;
return ASYMPTOTIC_OK;
}
/* The closed-form candidate lands off the reconstructed boundary:
* fall back to the common numerical localizer inside this segment. */
status = minkowski_fallback_entry(source, end, t0, x_frame, w_frame,
log_alpha_p0, end_id, s, s_segment, &k,
solve, sigma, route);
return status;
} else if (solve == ENTRY_SOLVE_UNCERTAIN) {
/* Near-tangent/degenerate discriminant: not a proof of a miss. Attempt
* a strict-inside bracket; a positive reconstructed probe cannot prove
* that the continuous trajectory misses. */
status = minkowski_fallback_entry(source, end, t0, x_frame, w_frame,
log_alpha_p0, end_id, s, s_segment, &k,
solve, 0.0, route);
return status;
}
if (!isfinite(s_segment)) {
/* Open final segment with no entry: a genuine miss. */
@@ -355,6 +744,127 @@ static AsymptoticStatus minkowski_preroute(
return ASYMPTOTIC_INVALID;
}
/* Evaluate the exact Schwarzschild inward orbit from the original camera at
* the radius parameter p = -rho. Recomputed per call (rotate + integral), not
* projected from an earlier state, and using no backend metric. */
typedef struct {
const SpacetimeAsymptoticEnd *end;
const SchwarzschildCanonical *camera;
double log_alpha_p0_camera;
SpacetimeEndId end_id;
} SchwarzschildEntryContext;
static AsymptoticStatus schwarzschild_entry_evaluate(void *opaque,
double parameter,
AsymptoticRoute *state) {
const SchwarzschildEntryContext *ctx = opaque;
const double rho = -parameter;
double x[3], Pi[3], log_alpha_p0 = 0.0, activate_t = 0.0;
if (asymptotic_schwarzschild_inward_state_at_radius(
ctx->end, ctx->camera, rho, x, Pi, &log_alpha_p0, &activate_t))
return ASYMPTOTIC_INVALID;
*state = (AsymptoticRoute){0};
state->kind = ASYMPTOTIC_ROUTE_ENTRY;
state->end_id = ctx->end_id;
state->activate_t = activate_t;
for (int i = 0; i < 3; ++i) {
state->x[i] = x[i];
state->Pi[i] = Pi[i];
}
state->log_alpha_p0 = log_alpha_p0;
state->log_alpha_p0_camera = ctx->log_alpha_p0_camera;
state->failure_reason = RAY_REASON_NONE;
state->entry_fallback_evaluations = 0;
return ASYMPTOTIC_OK;
}
/* Bracket the first inward radius crossing when the closed-form entry state
* lands off the reconstructed worldtube boundary. The parameter p = -rho
* increases inward from the original camera radius; the inside end is nudged
* just below the worldtube radius, and expanded inward only while staying
* above the turning radius and the rho > 2 state domain. Failure is an
* explicit unconfirmed entry, never an escape. */
static AsymptoticStatus schwarzschild_fallback_entry(
const SpacetimeSource *source, const SpacetimeAsymptoticEnd *end,
const SchwarzschildCanonical *camera, double worldtube_radius,
double log_alpha_p0_camera, AsymptoticRoute *route) {
SchwarzschildEntryContext ctx = {.end = end,
.camera = camera,
.log_alpha_p0_camera = log_alpha_p0_camera,
.end_id = end->end_id};
double floor = 2.0 + 1e-12 * fmax(1.0, worldtube_radius);
const double rho_turn = asymptotic_schwarzschild_turning_rho(camera->beta);
if (isfinite(rho_turn) && rho_turn > floor)
floor = rho_turn;
if (!(floor < worldtube_radius)) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end->end_id;
return ASYMPTOTIC_INVALID;
}
double rho_inside = nextafter(worldtube_radius, -INFINITY);
if (!(rho_inside > floor))
rho_inside = 0.5 * (worldtube_radius + floor);
double decrement = 0.0;
int found = 0;
for (int attempt = 0; attempt < 64; ++attempt) {
if (!(rho_inside > floor))
break;
double x[3], Pi[3], log_alpha_p0 = 0.0, activate_t = 0.0;
if (asymptotic_schwarzschild_inward_state_at_radius(
end, camera, rho_inside, x, Pi, &log_alpha_p0, &activate_t))
break;
double F = 0.0, tol = 0.0;
RayReason why = RAY_REASON_NONE;
const AsymptoticStatus st = asymptotic_entry_geometry(
source, end->end_id, activate_t, x, &F, &tol, &why);
if (st != ASYMPTOTIC_OK) {
route->failure_reason = why;
route->end_id = end->end_id;
return st;
}
if (F < 0.0) {
found = 1;
break;
}
if (decrement == 0.0)
decrement = (worldtube_radius - rho_inside) * 2.0;
else
decrement *= 2.0;
rho_inside = worldtube_radius - decrement;
}
if (!found) {
route->failure_reason = RAY_REASON_ENTRY_UNCONFIRMED;
route->end_id = end->end_id;
return ASYMPTOTIC_INVALID;
}
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason localize_reason = RAY_REASON_NONE;
const AsymptoticStatus status = asymptotic_entry_localize(
source, end->end_id, schwarzschild_entry_evaluate, &ctx, -camera->rho,
-rho_inside, &out, &evaluations, &localize_reason);
if (status == ASYMPTOTIC_OK) {
*route = out;
route->failure_reason = RAY_REASON_NONE;
route->entry_fallback_evaluations = evaluations;
return ASYMPTOTIC_OK;
}
if (status == ASYMPTOTIC_TIME_RANGE_EXHAUSTED ||
status == ASYMPTOTIC_UNSUPPORTED) {
route->failure_reason = localize_reason;
route->end_id = end->end_id;
return status;
}
route->failure_reason =
(localize_reason == RAY_REASON_ESCAPE_LOCALIZATION_FAILED ||
localize_reason == RAY_REASON_NONE ||
localize_reason == RAY_REASON_PROTOCOL_ERROR)
? RAY_REASON_ENTRY_UNCONFIRMED
: localize_reason;
route->end_id = end->end_id;
return ASYMPTOTIC_INVALID;
}
static AsymptoticStatus schwarzschild_route(
const SpacetimeSource *source, const SpacetimeAsymptoticEnd *end,
const MetricData *metric, const GeodesicRayState *state,
@@ -397,14 +907,38 @@ static AsymptoticStatus schwarzschild_route(
}
route->end_id = end->end_id;
if (kind == SCH_ROUTE_ENTRY) {
route->kind = ASYMPTOTIC_ROUTE_ENTRY;
route->activate_t = activate_t;
AsymptoticRoute candidate = {.kind = ASYMPTOTIC_ROUTE_ENTRY,
.end_id = end->end_id,
.activate_t = activate_t,
.log_alpha_p0 = log_alpha_p0,
.log_alpha_p0_camera = state->log_alpha_p0,
.failure_reason = RAY_REASON_NONE};
for (int i = 0; i < 3; ++i) {
route->x[i] = x[i];
route->Pi[i] = Pi[i];
candidate.x[i] = x[i];
candidate.Pi[i] = Pi[i];
}
route->log_alpha_p0 = log_alpha_p0;
return ASYMPTOTIC_OK;
int valid = 0;
RayReason why = RAY_REASON_NONE;
const AsymptoticStatus validate_status = asymptotic_entry_validate(
source, end->end_id, &candidate, &valid, &why);
if (validate_status == ASYMPTOTIC_TIME_RANGE_EXHAUSTED) {
route->failure_reason = why;
route->kind = ASYMPTOTIC_ROUTE_TIME_RANGE_EXHAUSTED;
return validate_status;
}
if (validate_status != ASYMPTOTIC_OK) {
route->failure_reason = why;
return validate_status;
}
if (valid) {
*route = candidate;
return ASYMPTOTIC_OK;
}
/* The closed-form entry is off the reconstructed boundary: localize it in
* the radius parameter against the same exact inward transfer. */
return schwarzschild_fallback_entry(source, end, &camera,
sample.radius / end->mass,
state->log_alpha_p0, route);
}
route->kind = ASYMPTOTIC_ROUTE_ESCAPED;
for (int i = 0; i < 3; ++i)
@@ -521,14 +1055,20 @@ AsymptoticStatus asymptotic_route_camera(const SpacetimeSource *source,
AsymptoticRoute candidate = {.kind = ASYMPTOTIC_ROUTE_INVALID};
const AsymptoticStatus status = minkowski_preroute(
source, &end, state.coordinate_time, canonical.x, canonical.w,
end.end_id, &candidate);
state.log_alpha_p0, end.end_id, &candidate);
if (status == ASYMPTOTIC_TIME_RANGE_EXHAUSTED) {
route->kind = ASYMPTOTIC_ROUTE_TIME_RANGE_EXHAUSTED;
route->end_id = end.end_id;
route->failure_reason = candidate.failure_reason;
return ASYMPTOTIC_TIME_RANGE_EXHAUSTED;
}
if (status != ASYMPTOTIC_OK)
if (status != ASYMPTOTIC_OK) {
/* Propagate the specific validation/fallback failure the segment walk
* refused with, not a generic preroute error. */
route->failure_reason = candidate.failure_reason;
route->end_id = candidate.end_id;
return status;
}
if (candidate.kind == ASYMPTOTIC_ROUTE_ENTRY) {
const double s = state.coordinate_time - candidate.activate_t;
if (!have_entry || s < best_s) {
+10
View File
@@ -48,6 +48,16 @@ typedef struct {
/* Terminal infinity endpoint for ESCAPED. */
double n_infinity[3];
double frequency_ratio;
/* Diagnostic reason for an ASYMPTOTIC_INVALID return: set specifically by the
* validation/fallback failure that refused the route, so the lifecycle can
* report the concrete cause instead of a generic preroute failure. NONE on
* success. */
RayReason failure_reason;
/* Nonzero when this route was produced by the generic bracketed first-entry
* localizer rather than a closed-form/fast entry solve. It records the
* number of evaluator calls the localizer spent; zero on the fast path. This
* is private in-memory provenance only and is not serialized. */
unsigned int entry_fallback_evaluations;
} AsymptoticRoute;
/* Pre-route one camera ray against every declared end's worldtube. */
+326
View File
@@ -0,0 +1,326 @@
#include "asymptotic_entry.h"
#include <float.h>
#include <math.h>
#include <stddef.h>
/* See asymptotic_entry.h for the contract. This module deliberately keeps no
* global mutable state: every cache/scratch value lives on the stack of the
* calling trace, so it stays thread-safe under the coarse-grained OpenMP ray
* parallelism of the renderer. */
static void set_failure(RayReason *failure, RayReason reason) {
if (failure != NULL)
*failure = reason;
}
/* Left-to-right double accumulation, matching the geodesic event layer's
* worldtube F. */
static double dot3(const double a[3], const double b[3]) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
static int entry_state_finite(const AsymptoticRoute *state) {
if (!isfinite(state->activate_t) || !isfinite(state->log_alpha_p0) ||
!isfinite(state->log_alpha_p0_camera))
return 0;
for (int i = 0; i < 3; ++i)
if (!isfinite(state->x[i]) || !isfinite(state->Pi[i]))
return 0;
return 1;
}
AsymptoticStatus asymptotic_entry_geometry(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
const double x[3], double *F,
double *tol, RayReason *failure) {
set_failure(failure, RAY_REASON_PROTOCOL_ERROR);
if (source == NULL || x == NULL || F == NULL || tol == NULL) {
set_failure(failure, RAY_REASON_INVALID_ARGUMENT);
return ASYMPTOTIC_INVALID;
}
if (!isfinite(t)) {
set_failure(failure, RAY_REASON_WORLDTUBE_GEOMETRY_INVALID);
return ASYMPTOTIC_INVALID;
}
SpacetimeEscapeWorldtubeSample sample;
if (spacetime_escape_worldtube_sample(source, end_id, t, &sample)) {
set_failure(failure, RAY_REASON_WORLDTUBE_SAMPLE_FAILED);
return ASYMPTOTIC_INVALID;
}
if (!sample.valid) {
/* The backend cannot describe the worldtube at this time; this is history
* exhaustion, never a miss. */
set_failure(failure, RAY_REASON_TIME_RANGE_EXHAUSTED);
return ASYMPTOTIC_TIME_RANGE_EXHAUSTED;
}
if (!(sample.radius > 0.0) || !isfinite(sample.radius) ||
!isfinite(sample.radius_rate)) {
set_failure(failure, RAY_REASON_WORLDTUBE_GEOMETRY_INVALID);
return ASYMPTOTIC_INVALID;
}
for (int i = 0; i < 3; ++i) {
if (!isfinite(sample.center[i]) || !isfinite(sample.velocity[i])) {
set_failure(failure, RAY_REASON_WORLDTUBE_GEOMETRY_INVALID);
return ASYMPTOTIC_INVALID;
}
}
double d[3];
for (int i = 0; i < 3; ++i) {
d[i] = x[i] - sample.center[i];
if (!isfinite(d[i])) {
set_failure(failure, RAY_REASON_WORLDTUBE_GEOMETRY_INVALID);
return ASYMPTOTIC_INVALID;
}
}
/* Exact same grouping as the geodesic event layer: F uses
* dot3(d,d) - radius^2, while the geometric ULP band accumulates d2 with an
* explicit left-to-right loop. Keeping both identical means a candidate that
* passes this validator at the tolerance threshold is grouped exactly like
* the geodesic's own f_before. */
const double value = dot3(d, d) - sample.radius * sample.radius;
const double r2 = sample.radius * sample.radius;
double d2 = 0.0;
for (int i = 0; i < 3; ++i)
d2 += d[i] * d[i];
const double geometry_tol = 128.0 * DBL_EPSILON * fmax(r2, d2);
/* Overflow to inf and NaN propagation both land here. */
if (!isfinite(value) || !isfinite(geometry_tol)) {
set_failure(failure, RAY_REASON_WORLDTUBE_GEOMETRY_INVALID);
return ASYMPTOTIC_INVALID;
}
*F = value;
*tol = geometry_tol;
set_failure(failure, RAY_REASON_NONE);
return ASYMPTOTIC_OK;
}
AsymptoticStatus asymptotic_entry_validate(const SpacetimeSource *source,
SpacetimeEndId end_id,
const AsymptoticRoute *candidate,
int *valid, RayReason *failure) {
set_failure(failure, RAY_REASON_PROTOCOL_ERROR);
if (source == NULL || candidate == NULL || valid == NULL) {
set_failure(failure, RAY_REASON_INVALID_ARGUMENT);
return ASYMPTOTIC_INVALID;
}
*valid = 0;
if (candidate->kind != ASYMPTOTIC_ROUTE_ENTRY) {
set_failure(failure, RAY_REASON_PROTOCOL_ERROR);
return ASYMPTOTIC_INVALID;
}
if (!entry_state_finite(candidate)) {
set_failure(failure, RAY_REASON_PROTOCOL_ERROR);
return ASYMPTOTIC_INVALID;
}
double value = 0.0, tol = 0.0;
const AsymptoticStatus status = asymptotic_entry_geometry(
source, end_id, candidate->activate_t, candidate->x, &value, &tol,
failure);
if (status != ASYMPTOTIC_OK)
return status;
/* Boundary-near means within the geometric ULP band on either side. Outside
* and arbitrary-deep-inside are both non-candidates, not protocol errors. */
*valid = fabs(value) <= tol;
set_failure(failure, RAY_REASON_NONE);
return ASYMPTOTIC_OK;
}
/* Map an evaluator's own failure to a diagnostic reason. The worldtube
* callback/history/geometry reasons come from asymptotic_entry_geometry; this
* only covers the case where the evaluator itself refuses to produce a state. */
static AsymptoticStatus entry_evaluator_failure(AsymptoticStatus status,
RayReason *failure) {
switch (status) {
case ASYMPTOTIC_TIME_RANGE_EXHAUSTED:
set_failure(failure, RAY_REASON_TIME_RANGE_EXHAUSTED);
break;
case ASYMPTOTIC_UNSUPPORTED:
set_failure(failure, RAY_REASON_UNSUPPORTED);
break;
default:
/* The evaluator refused to produce a state, so the first entry is not
* confirmed. */
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
break;
}
return status;
}
AsymptoticStatus asymptotic_entry_localize(
const SpacetimeSource *source, SpacetimeEndId end_id,
AsymptoticEntryEvaluator evaluate, void *context,
double outside_parameter, double inside_parameter, AsymptoticRoute *out,
unsigned int *evaluations, RayReason *failure) {
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = 0;
if (source == NULL || evaluate == NULL || out == NULL) {
set_failure(failure, RAY_REASON_INVALID_ARGUMENT);
return ASYMPTOTIC_INVALID;
}
if (!isfinite(outside_parameter) || !isfinite(inside_parameter) ||
!(outside_parameter < inside_parameter)) {
set_failure(failure, RAY_REASON_INVALID_ARGUMENT);
return ASYMPTOTIC_INVALID;
}
unsigned int count = 0;
AsymptoticRoute lo_state, hi_state;
double f_lo = 0.0, f_hi = 0.0, tol_lo = 0.0, tol_hi = 0.0;
AsymptoticStatus status = evaluate(context, outside_parameter, &lo_state);
++count;
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return entry_evaluator_failure(status, failure);
}
if (!entry_state_finite(&lo_state)) {
if (evaluations != NULL)
*evaluations = count;
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
return ASYMPTOTIC_INVALID;
}
status = asymptotic_entry_geometry(source, end_id, lo_state.activate_t,
lo_state.x, &f_lo, &tol_lo, failure);
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return status;
}
status = evaluate(context, inside_parameter, &hi_state);
++count;
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return entry_evaluator_failure(status, failure);
}
if (!entry_state_finite(&hi_state)) {
if (evaluations != NULL)
*evaluations = count;
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
return ASYMPTOTIC_INVALID;
}
status = asymptotic_entry_geometry(source, end_id, hi_state.activate_t,
hi_state.x, &f_hi, &tol_hi, failure);
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return status;
}
if (!(f_lo >= 0.0) || !(f_hi < 0.0)) {
/* The caller owns the first-entry bracket; a bracket that does not straddle
* the boundary is an unconfirmed entry, never an escape. */
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = count;
return ASYMPTOTIC_INVALID;
}
/* Parameter increases backward, so the inside end must not be later in
* coordinate time than the outside end. */
if (!(hi_state.activate_t <= lo_state.activate_t)) {
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = count;
return ASYMPTOTIC_INVALID;
}
if (f_lo == 0.0) {
/* Exact boundary at the outside end with a strictly inside other end: this
* is the legitimate inward first-entry state already on the worldtube, so
* no bisection is needed. */
*out = lo_state;
out->kind = ASYMPTOTIC_ROUTE_ENTRY;
out->end_id = end_id;
if (evaluations != NULL)
*evaluations = count;
set_failure(failure, RAY_REASON_NONE);
return ASYMPTOTIC_OK;
}
double lo = outside_parameter;
double hi = inside_parameter;
unsigned int iteration = 0;
for (; iteration < ASYMPTOTIC_ENTRY_BISECTION_LIMIT; ++iteration) {
const double span = hi - lo;
const double mid = isfinite(span) ? lo + span * 0.5
: lo * 0.5 + hi * 0.5;
if (!(mid > lo && mid < hi))
break; /* Parameter midpoint cannot be represented; bracket is adjacent. */
AsymptoticRoute mid_state;
status = evaluate(context, mid, &mid_state);
++count;
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return entry_evaluator_failure(status, failure);
}
if (!entry_state_finite(&mid_state) ||
!(hi_state.activate_t <= mid_state.activate_t &&
mid_state.activate_t <= lo_state.activate_t)) {
if (evaluations != NULL)
*evaluations = count;
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
return ASYMPTOTIC_INVALID;
}
double f_mid = 0.0, tol_mid = 0.0;
status = asymptotic_entry_geometry(source, end_id, mid_state.activate_t,
mid_state.x, &f_mid, &tol_mid, failure);
if (status != ASYMPTOTIC_OK) {
if (evaluations != NULL)
*evaluations = count;
return status;
}
if (f_mid >= 0.0) {
lo = mid;
lo_state = mid_state;
} else {
hi = mid;
hi_state = mid_state;
f_hi = f_mid;
tol_hi = tol_mid;
}
}
if (iteration >= ASYMPTOTIC_ENTRY_BISECTION_LIMIT) {
/* The parameter interval never contracted to adjacent doubles within the
* implementation guard; do not fabricate an entry. */
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = count;
return ASYMPTOTIC_INVALID;
}
/* Final consistency: keep the strict-inside adjacent endpoint. Its negative
* residual need not fit the fast-path band at coarse coordinate resolution;
* the bracket, rather than a radial displacement, establishes the entry. */
if (!(f_hi < 0.0) ||
!(hi_state.activate_t <= lo_state.activate_t) ||
!isfinite(hi_state.activate_t) || !isfinite(hi_state.log_alpha_p0) ||
!isfinite(hi_state.log_alpha_p0_camera)) {
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = count;
return ASYMPTOTIC_INVALID;
}
for (int i = 0; i < 3; ++i) {
if (!isfinite(hi_state.x[i]) || !isfinite(hi_state.Pi[i])) {
set_failure(failure, RAY_REASON_ENTRY_UNCONFIRMED);
if (evaluations != NULL)
*evaluations = count;
return ASYMPTOTIC_INVALID;
}
}
*out = hi_state;
out->kind = ASYMPTOTIC_ROUTE_ENTRY;
out->end_id = end_id;
if (evaluations != NULL)
*evaluations = count;
set_failure(failure, RAY_REASON_NONE);
return ASYMPTOTIC_OK;
}
+136
View File
@@ -0,0 +1,136 @@
#ifndef ASYMPTOTIC_ENTRY_H
#define ASYMPTOTIC_ENTRY_H
#include "asymptotic.h"
/* Backend-independent numerical entry localizer for the common asymptotic
* exterior.
*
* The camera pre-route must decide, without touching any backend metric outside
* a worldtube, whether a past-directed camera ray crosses an escape worldtube
* from outside to inside and where the FIRST such entry lies. A supported
* exterior model may provide a closed-form (quadratic/analytic) entry; when it
* cannot, the caller supplies a path-parameter bracket that is already known to
* straddle the first entry and this module refines it numerically.
*
* The module never evaluates a metric, never loads a slab, never sweeps the
* movie in reverse and never snaps a position onto a radial shell. It only
* consumes the worldtube `escape_worldtube_sample` callback through
* `asymptotic_entry_geometry`, and an opaque evaluator callback that
* repropagates the exact supported exterior geodesic from its camera state to a
* path parameter. The driver is therefore independent of the exterior model
* and does not solve the entry equation itself.
*
* Parameter and time convention (18A.5/18A.6): the evaluator parameter
* increases along the renderer's backward propagation; the returned state's
* `activate_t` is the coordinate time at that parameter, so it is
* nonincreasing as the parameter increases. `outside_parameter` is the
* smaller-parameter end (worldtube outside, or exactly on the boundary) and
* `inside_parameter` is the larger-parameter end that is strictly inside.
*
* The caller is responsible for providing a correct first-entry bracket. This
* driver does not search arbitrary samples for a crossing; a bracket that does
* not straddle the boundary is reported as an unconfirmed entry
* (RAY_REASON_ENTRY_UNCONFIRMED), never as an escape. */
typedef AsymptoticStatus (*AsymptoticEntryEvaluator)(void *context,
double parameter,
AsymptoticRoute *state);
/* Convenience bundle for callers that want to keep the callback and its opaque
* context together. Not required by any entry point. */
typedef struct {
AsymptoticEntryEvaluator evaluate;
void *context;
} AsymptoticEntryPropagator;
/* Hard implementation guard on the number of bisection refinements. This
* bounds the double-parameter bisection; it is not a physical parameter. Any
* bracket near a finite nonzero entry contracts in roughly 60 halvings;
* very wide exponent ranges may instead exhaust the guard explicitly. */
#define ASYMPTOTIC_ENTRY_BISECTION_LIMIT 256u
/* Evaluate the worldtube function
*
* F(t, x) = |x - center(t)|^2 - radius(t)^2
*
* at one state through the worldtube sample callback alone (no metric
* evaluation). `*F` is accumulated with the same left-to-right double
* arithmetic as the geodesic event layer, and `*tol` is the matching geometric
* ULP band 128 * DBL_EPSILON * max(radius^2, |x-center|^2).
*
* Status / failure reason:
* ASYMPTOTIC_OK -> *failure = RAY_REASON_NONE
* ASYMPTOTIC_TIME_RANGE_EXHAUSTED -> RAY_REASON_TIME_RANGE_EXHAUSTED
* ASYMPTOTIC_INVALID (callback failed)-> RAY_REASON_WORLDTUBE_SAMPLE_FAILED
* ASYMPTOTIC_INVALID (bad geometry) -> RAY_REASON_WORLDTUBE_GEOMETRY_INVALID
*
* A non-positive/non-finite radius, non-finite radius_rate, centers or
* velocities, and any non-finite (overflow/NaN) F or tolerance are geometry
* failures. `*failure` may be NULL. */
AsymptoticStatus asymptotic_entry_geometry(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
const double x[3], double *F,
double *tol, RayReason *failure);
/* Validate one caller-produced entry candidate. The candidate must be finite
* (activate_t, x, Pi, log_alpha_p0, log_alpha_p0_camera), carry kind
* ASYMPTOTIC_ROUTE_ENTRY, and sit
* on the worldtube boundary within the geometric ULP band:
*
* |F| <= tol -> *valid = 1
*
* A candidate outside the worldtube (F > tol) or arbitrarily deep inside
* (F < -tol) is NOT an entry candidate: it yields *valid = 0 but still returns
* ASYMPTOTIC_OK, because the caller owns the exterior solve and a non-candidate
* is not a protocol error. A wrong kind or non-finite state is a protocol
* error. Worldtube callback/history/geometry failures propagate with the same
* reasons as asymptotic_entry_geometry.
*
* This function does not fabricate a velocity or re-derive the entry: the
* caller already guarantees that its exterior solve produced an inward entry. */
AsymptoticStatus asymptotic_entry_validate(const SpacetimeSource *source,
SpacetimeEndId end_id,
const AsymptoticRoute *candidate,
int *valid, RayReason *failure);
/* Numerically locate the first outside -> inside entry inside a known bracket.
*
* `evaluate(context, parameter, state)` repropagates the exact exterior geodesic
* from its camera state to `parameter`; it must fill `state->activate_t` (the
* coordinate time at that parameter), `state->x`, `state->Pi`,
* `state->log_alpha_p0`, `state->log_alpha_p0_camera` and `state->end_id`.
* Every call is counted into `*evaluations` (may be NULL).
*
* The two bracket endpoints are evaluated first. The driver requires
* F(outside_parameter) >= 0 and F(inside_parameter) < 0; a bracket with an
* exact boundary at the outside end (F == 0) and a strictly inside other end is
* returned directly as the legitimate inward entry. Otherwise it bisects the
* parameter. Each midpoint reconstructs a fresh state through the evaluator
* (no propagation from a prior midpoint, so no accumulated rounding or
* projection error), keeps the low end outside (F >= 0) and the high end
* strictly inside (F < 0), and never turns an F >= 0 midpoint into an escape.
*
* Bisection stops only when the parameter midpoint can no longer be represented
* strictly between the two ends (adjacent doubles), not on an F tolerance, so a
* curved trajectory is not stopped early by a coarser coordinate-time
* resolution. On success the strictly-inside endpoint adjacent to the entry in
* path parameter is returned: its Pi/L/L_camera are copied from the evaluator
* state unchanged, with kind forced to ENTRY and end_id set to `end_id`. The
* result is a representable bracketing of the entry (the true entry lies
* between the final outside and inside endpoints), not an absolute positional
* error claim; the returned inside state may legitimately have F < -tol.
*
* `ASYMPTOTIC_ENTRY_BISECTION_LIMIT` is an implementation guard on parameter
* representability, not a physical parameter. If the bracket never contracts,
* if the endpoints do not straddle the boundary, or if the endpoint coordinate
* times are not ordered (inside time <= outside time), the result is
* ASYMPTOTIC_INVALID with RAY_REASON_ENTRY_UNCONFIRMED. Callback, history and
* geometry failures from the evaluator/geometry propagate their exact status
* and reason; nothing is silently reported as a successful entry or escape. */
AsymptoticStatus asymptotic_entry_localize(
const SpacetimeSource *source, SpacetimeEndId end_id,
AsymptoticEntryEvaluator evaluate, void *context,
double outside_parameter, double inside_parameter, AsymptoticRoute *out,
unsigned int *evaluations, RayReason *failure);
#endif
+44 -18
View File
@@ -408,6 +408,47 @@ int asymptotic_schwarzschild_state_from_canonical(
return 0;
}
int asymptotic_schwarzschild_inward_state_at_radius(
const SpacetimeAsymptoticEnd *end, const SchwarzschildCanonical *camera,
double rho, double x[3], double Pi[3], double *log_alpha_p0,
double *activate_t) {
if (end == NULL || camera == NULL || x == NULL || Pi == NULL)
return -1;
if (!(rho > 2.0) || !(rho <= camera->rho))
return -1;
/* The inward branch only exists while the orbit has not turned before the
* requested radius. Allow a few ULP at the grazing limit so rounding in the
* turning root does not reject a legitimate boundary radius; a genuine
* inside-the-turning radius still fails through the Q >= 0 check below. */
const double rho_turn = asymptotic_schwarzschild_turning_rho(camera->beta);
if (isfinite(rho_turn) &&
rho < rho_turn - 16.0 * DBL_EPSILON * fmax(1.0, rho_turn))
return -1;
const double dphi = asymptotic_schwarzschild_phi(rho, camera->beta) -
asymptotic_schwarzschild_phi(camera->rho, camera->beta);
if (!isfinite(dphi))
return -1;
double rhat_rho[3];
if (camera->beta > 0.0)
rotate_axis(camera->rhat, camera->Lhat, -dphi, rhat_rho);
else
for (int i = 0; i < 3; ++i)
rhat_rho[i] = camera->rhat[i];
SchwarzschildCanonical state = *camera;
state.rho = rho;
for (int i = 0; i < 3; ++i)
state.rhat[i] = rhat_rho[i];
state.radial_sign = -1;
if (asymptotic_schwarzschild_state_from_canonical(end, &state, x, Pi,
log_alpha_p0))
return -1;
if (activate_t != NULL) {
const double T = sch_time_transfer(camera->rho, rho, camera->beta);
*activate_t = camera->t - end->mass * T;
}
return 0;
}
int asymptotic_schwarzschild_finish(const SpacetimeAsymptoticEnd *end,
const SchwarzschildCanonical *canonical,
double n_infinity[3],
@@ -476,25 +517,10 @@ int asymptotic_schwarzschild_preroute(
}
if (camera->beta < beta_R) {
const double dphi = asymptotic_schwarzschild_phi(R, camera->beta) -
asymptotic_schwarzschild_phi(camera->rho, camera->beta);
double rhat_entry[3];
if (camera->beta > 0.0)
rotate_axis(camera->rhat, camera->Lhat, -dphi, rhat_entry);
else
for (int i = 0; i < 3; ++i)
rhat_entry[i] = camera->rhat[i];
SchwarzschildCanonical entry = *camera;
entry.rho = R;
for (int i = 0; i < 3; ++i)
entry.rhat[i] = rhat_entry[i];
entry.radial_sign = -1;
if (asymptotic_schwarzschild_state_from_canonical(end, &entry, x, Pi,
log_alpha_p0))
/* Shared exact inward transfer, also used by the common entry fallback. */
if (asymptotic_schwarzschild_inward_state_at_radius(
end, camera, R, x, Pi, log_alpha_p0, activate_t))
return -1;
const double T =
sch_time_transfer(camera->rho, R, camera->beta);
*activate_t = camera->t - end->mass * T;
*kind = SCH_ROUTE_ENTRY;
return 0;
}
+13
View File
@@ -42,6 +42,19 @@ int asymptotic_schwarzschild_state_from_canonical(
const SpacetimeAsymptoticEnd *end, const SchwarzschildCanonical *canonical,
double x[3], double Pi[3], double *log_alpha_p0);
/* Repropagate the exact inward orbit from the ORIGINAL camera canonical state
* to `rho >= turning_rho` (and > 2) by recomputing the swept angle and the
* coordinate-time integral -- never by projecting a nearby state. Fills the
* backend entry state, its local L = ln(alpha p^0), and the activation
* coordinate time. Returns -1 when `rho` lies outside the reachable inward
* domain. This is the radius-parameter evaluator used by the common first
* entry localizer; `R` may dip slightly below the worldtube radius because the
* common driver only needs a strictly-inside bracket. */
int asymptotic_schwarzschild_inward_state_at_radius(
const SpacetimeAsymptoticEnd *end, const SchwarzschildCanonical *camera,
double rho, double x[3], double Pi[3], double *log_alpha_p0,
double *activate_t);
/* Infinity endpoint for an outward crossing at the canonical radius. */
int asymptotic_schwarzschild_finish(const SpacetimeAsymptoticEnd *end,
const SchwarzschildCanonical *canonical,
+7 -7
View File
@@ -301,7 +301,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
if (!full_only || sink->samples[i].events == sink->event_capacity)
++count;
if (count == 0) {
fprintf(stderr, "Dummy PSF %s chunks: none\n", label);
fprintf(stdout, "Dummy PSF %s chunks: none\n", label);
return;
}
double *events = count > SIZE_MAX / (6 * sizeof *events)
@@ -338,7 +338,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
"maximum_successive_triangle_jump_px"};
for (size_t field = 0; field < 6; ++field) {
qsort(series[field], count, sizeof **series, compare_double);
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s %s: min=%.3f p10=%.3f p25=%.3f p50=%.3f "
"p75=%.3f p90=%.3f p99=%.3f max=%.3f\n",
label, names[field], series[field][0],
@@ -349,7 +349,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
percentile(series[field], count, 0.90),
percentile(series[field], count, 0.99), series[field][count - 1]);
}
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s adaptive criterion: %zu/%zu chunks tile16-eligible "
"(events>=8192 and events/occupied_32px_tiles>=32)\n",
label, adaptive, count);
@@ -386,7 +386,7 @@ static void report_support_group(const DummyPsfSink *sink, int full_only,
for (size_t field = 0; field < 5; ++field) {
double *series = values + field * count;
qsort(series, count, sizeof *series, compare_double);
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s %s: min=%.3f p10=%.3f p25=%.3f p50=%.3f "
"p75=%.3f p90=%.3f p99=%.3f max=%.3f\n",
label, names[field], series[0],
@@ -395,7 +395,7 @@ static void report_support_group(const DummyPsfSink *sink, int full_only,
percentile(series, count, 0.90), percentile(series, count, 0.99),
series[count - 1]);
}
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s support totals: references=%zu tasks=%zu summed_first_pass=%.3f s\n",
label, total_refs, total_tasks, total_pass);
free(values);
@@ -407,7 +407,7 @@ void dummy_psf_sink_report(const DummyPsfSink *sink) {
size_t full = 0;
for (size_t i = 0; i < sink->count; ++i)
full += sink->samples[i].events == sink->event_capacity;
fprintf(stderr,
fprintf(stdout,
"Dummy PSF diagnostic only: no HDR or image was accumulated/written.\n"
"Dummy PSF chunks: total=%zu full=%zu partial=%zu events=%zu "
"capacity=%zu selector_tile=%dpx\n",
@@ -416,7 +416,7 @@ void dummy_psf_sink_report(const DummyPsfSink *sink) {
report_group(sink, 0, "all");
report_group(sink, 1, "full");
if (sink->support_stats) {
fputs("Dummy PSF support diagnostic: 16px rectangular cache-limited first pass only; no references were materialized or uploaded.\n", stderr);
fputs("Dummy PSF support diagnostic: 16px rectangular cache-limited first pass only; no references were materialized or uploaded.\n", stdout);
report_support_group(sink, 0, "all");
report_support_group(sink, 1, "full");
}
+5 -99
View File
@@ -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 {
@@ -2260,7 +2260,7 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
(size_t)dummy_workers, completed, 1);
}
if (psf_event_sink_destroy(&owner)) dummy_failed = 1;
fprintf(stderr,
fprintf(stdout,
"Dummy PSF producers: %d workers; classification/chunk wall %.3f s\n",
dummy_workers, omp_get_wtime() - dummy_start);
copy_psf_splat_stats(psf_stats, (CatalogSplatStats){
@@ -2349,14 +2349,14 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
}
omp_destroy_lock(&submit_lock);
if (psf_event_sink_destroy(&owner)) hip_failed = 1;
fprintf(stderr, "HIP producers: %d workers; summed generation %.3f s, submission/fallback %.3f s; wall %.3f s\n",
fprintf(stdout, "HIP producers: %d workers; summed generation %.3f s, submission/fallback %.3f s; wall %.3f s\n",
hip_workers, hip_generate_seconds, hip_submit_seconds, omp_get_wtime() - hip_start);
fprintf(stderr,
fprintf(stdout,
"HIP PSF accumulation: atomic %zu batches, tile16 %zu batches; selector %.3f s, binning %.3f s\n",
owner.hip_timing.atomic_batch_count, owner.hip_timing.tile_batch_count,
owner.hip_timing.selection_seconds, owner.hip_timing.bin_seconds);
if (owner.hip_timing.tile_batch_count)
fprintf(stderr,
fprintf(stdout,
"HIP PSF tile workload: references %zu, tasks %zu, merges %zu; peak references/chunk %zu\n",
owner.hip_timing.tile_reference_count, owner.hip_timing.tile_task_count,
owner.hip_timing.tile_merge_count,
@@ -2543,100 +2543,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
return images;
}
static void blend_gray(double *hdr, int width, int height, int x, int y,
double gray, double alpha) {
if (x < 0 || x >= width || y < 0 || y >= height)
return;
double *pixel = &hdr[3 * (y * width + x)];
for (int channel = 0; channel < 3; ++channel)
pixel[channel] = (1.0 - alpha) * pixel[channel] + alpha * gray;
}
static double fractional_part(double value) { return value - floor(value); }
static void plot_aa(double *hdr, int width, int height, int steep, int x, int y,
double coverage, double gray, double opacity) {
if (coverage > 0.0)
blend_gray(hdr, width, height, steep ? y : x, steep ? x : y, gray,
coverage * opacity);
}
/* Xiaolin Wu line rasterization: a one-pixel line with coverage-based alpha. */
static void draw_line(double *hdr, int width, int height,
const LensVertex *from, const LensVertex *to, double gray,
double opacity) {
double x0 = from->image_x, y0 = from->image_y;
double x1 = to->image_x, y1 = to->image_y;
const int steep = fabs(y1 - y0) > fabs(x1 - x0);
if (steep) {
double swap = x0;
x0 = y0;
y0 = swap;
swap = x1;
x1 = y1;
y1 = swap;
}
if (x0 > x1) {
double swap = x0;
x0 = x1;
x1 = swap;
swap = y0;
y0 = y1;
y1 = swap;
}
const double dx = x1 - x0;
if (dx == 0.0) {
plot_aa(hdr, width, height, steep, (int)lround(x0), (int)floor(y0), 1.0,
gray, opacity);
return;
}
const double gradient = (y1 - y0) / dx;
double x_end = round(x0);
double y_end = y0 + gradient * (x_end - x0);
double x_gap = 1.0 - fractional_part(x0 + 0.5);
int x_pixel_start = (int)x_end;
int y_pixel = (int)floor(y_end);
plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel,
(1.0 - fractional_part(y_end)) * x_gap, gray, opacity);
plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel + 1,
fractional_part(y_end) * x_gap, gray, opacity);
double inter_y = y_end + gradient;
x_end = round(x1);
y_end = y1 + gradient * (x_end - x1);
x_gap = fractional_part(x1 + 0.5);
const int x_pixel_end = (int)x_end;
y_pixel = (int)floor(y_end);
plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel,
(1.0 - fractional_part(y_end)) * x_gap, gray, opacity);
plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel + 1,
fractional_part(y_end) * x_gap, gray, opacity);
for (int x = x_pixel_start + 1; x < x_pixel_end; ++x) {
y_pixel = (int)floor(inter_y);
plot_aa(hdr, width, height, steep, x, y_pixel,
1.0 - fractional_part(inter_y), gray, opacity);
plot_aa(hdr, width, height, steep, x, y_pixel + 1, fractional_part(inter_y),
gray, opacity);
inter_y += gradient;
}
}
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
int height, double gray, double opacity) {
if (mesh == NULL || hdr == NULL || width <= 0 || height <= 0 || gray < 0.0 ||
opacity < 0.0 || opacity > 1.0)
return;
for (size_t i = 0; i < mesh->triangle_count; ++i) {
const LensTriangle *triangle = &mesh->triangles[i];
for (int edge = 0; edge < 3; ++edge) {
const size_t from_id = triangle->vertex[edge];
const size_t to_id = triangle->vertex[(edge + 1) % 3];
if (from_id < to_id)
draw_line(hdr, width, height, &mesh->vertices[from_id],
&mesh->vertices[to_id], gray, opacity);
}
}
}
void frame_lens_mesh_destroy(FrameLensMesh *mesh) {
if (mesh == NULL)
return;
-2
View File
@@ -303,8 +303,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
const FrameSplatProgress *progress,
FastPsfAccumulator *fast,
MovieFrameTiming *timing);
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
int height, double gray, double opacity);
void frame_lens_mesh_destroy(FrameLensMesh *mesh);
#endif
+252 -54
View File
@@ -9,6 +9,145 @@ typedef struct {
double x[3], Pi[3], log_alpha_p0;
} Derivative;
const char *ray_reason_name(RayReason reason) {
switch (reason) {
case RAY_REASON_NONE:
return "NONE";
case RAY_REASON_REDSHIFT_LIMIT:
return "REDSHIFT_LIMIT";
case RAY_REASON_BUDGET_EXHAUSTED:
return "BUDGET_EXHAUSTED";
case RAY_REASON_TIME_RANGE_EXHAUSTED:
return "TIME_RANGE_EXHAUSTED";
case RAY_REASON_OUT_OF_DOMAIN:
return "OUT_OF_DOMAIN";
case RAY_REASON_INVALID_METRIC:
return "INVALID_METRIC";
case RAY_REASON_INTEGRATION_ERROR:
return "INTEGRATION_ERROR";
case RAY_REASON_UNSUPPORTED:
return "UNSUPPORTED";
case RAY_REASON_PROTOCOL_ERROR:
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_ENTRY_UNCONFIRMED:
return "ENTRY_UNCONFIRMED";
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:
case RAY_REASON_ENTRY_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];
}
@@ -451,25 +590,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;
@@ -533,9 +673,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)
@@ -545,12 +693,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;
}
}
@@ -627,17 +775,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;
}
@@ -646,12 +800,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);
@@ -662,8 +822,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
@@ -706,7 +869,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;
@@ -729,7 +892,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;
@@ -763,7 +926,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;
@@ -1197,25 +1360,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 =
@@ -1258,7 +1430,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;
}
@@ -1344,11 +1516,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;
@@ -1366,7 +1539,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;
}
@@ -1375,7 +1548,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;
@@ -1386,16 +1559,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;
@@ -1441,9 +1616,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;
@@ -1454,7 +1630,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;
}
}
@@ -1480,7 +1656,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;
@@ -1498,14 +1674,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) {
@@ -1516,7 +1694,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(
@@ -1541,7 +1719,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;
@@ -1589,7 +1770,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;
@@ -1632,7 +1813,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;
@@ -1706,14 +1887,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);
@@ -1750,7 +1931,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)
@@ -1823,7 +2004,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;
@@ -1844,7 +2025,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;
@@ -1906,7 +2087,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;
}
}
@@ -1945,21 +2129,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;
@@ -1980,7 +2172,10 @@ 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_valid(route.failure_reason) &&
route.failure_reason != RAY_REASON_NONE
? route.failure_reason : RAY_REASON_CAMERA_PREROUTE_FAILED;
out.end_id = route.end_id;
return out;
}
if (route.kind == ASYMPTOTIC_ROUTE_ESCAPED) {
@@ -2001,7 +2196,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,
@@ -2029,7 +2224,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) ==
@@ -2055,21 +2250,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;
@@ -2089,7 +2287,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) ==
+61 -2
View File
@@ -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,9 +33,60 @@ 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_ENTRY_UNCONFIRMED, /* no trustworthy first-entry bracket */
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
+2 -2
View File
@@ -605,7 +605,7 @@ static int submit_prepared(HipPsfSink *sink, HipPsfPreparedChunk *prepared,
sink->timing.event_count += event_count;
const auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - sink->last_report).count() >= 5.0) {
std::fprintf(stderr,
std::fprintf(stdout,
"HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
sink->timing.event_count, sink->completed_events,
sink->timing.timed_batch_count, sink->timing.batch_count,
@@ -641,7 +641,7 @@ static int submit_prepared(HipPsfSink *sink, HipPsfPreparedChunk *prepared,
sink->timing.event_count += event_count;
const auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - sink->last_report).count() >= 5.0) {
std::fprintf(stderr, "HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
std::fprintf(stdout, "HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
sink->timing.event_count, sink->completed_events, sink->timing.timed_batch_count,
sink->timing.batch_count, sink->timing.kernel_seconds);
sink->last_report = now;
+3 -2
View File
@@ -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,8 @@ 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);
if (failed) break;
v->end_id = (SpacetimeEndId)end_id;
v->outcome = (RayOutcome)outcome;
v->reason = (RayReason)reason;
+814 -123
View File
File diff suppressed because it is too large. Load diff
+506
View File
@@ -0,0 +1,506 @@
#include "mesh_overlay.h"
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
/* ------------------------------------------------------------------------- */
/* Settings */
/* ------------------------------------------------------------------------- */
MeshOverlaySettings mesh_overlay_default_settings(void) {
static const unsigned char defaults[MESH_OVERLAY_CATEGORY_COUNT][3] = {
{0x7F, 0x84, 0x9C}, /* ESCAPE Catppuccin Mocha overlay1 */
{0xCB, 0xA6, 0xF7}, /* DARK mauve */
{0xF9, 0xE2, 0xAF}, /* UNRESOLVED yellow */
{0xF3, 0x8B, 0xA8}, /* INCOMPLETE red */
{0x89, 0xB4, 0xFA}, /* UNTRACED blue */
};
MeshOverlaySettings settings;
memcpy(settings.colors, defaults, sizeof settings.colors);
settings.opacity = 0.5;
return settings;
}
static int overlay_hex_nibble(char digit, unsigned char *value) {
if (digit >= '0' && digit <= '9') {
*value = (unsigned char)(digit - '0');
return 0;
}
if (digit >= 'a' && digit <= 'f') {
*value = (unsigned char)(digit - 'a' + 10);
return 0;
}
if (digit >= 'A' && digit <= 'F') {
*value = (unsigned char)(digit - 'A' + 10);
return 0;
}
return -1;
}
int mesh_overlay_parse_color(const char *text, unsigned char rgb[3]) {
if (text == NULL || rgb == NULL)
return -1;
if (strlen(text) != 7 || text[0] != '#')
return -1;
unsigned char parsed[3];
for (int channel = 0; channel < 3; ++channel) {
unsigned char high, low;
if (overlay_hex_nibble(text[1 + 2 * channel], &high) ||
overlay_hex_nibble(text[2 + 2 * channel], &low))
return -1;
parsed[channel] = (unsigned char)((high << 4) | low);
}
rgb[0] = parsed[0];
rgb[1] = parsed[1];
rgb[2] = parsed[2];
return 0;
}
/* ------------------------------------------------------------------------- */
/* Edge extraction */
/* ------------------------------------------------------------------------- */
typedef struct {
size_t low;
size_t high;
} OverlayEdge;
static int overlay_edge_compare(const void *lhs, const void *rhs) {
const OverlayEdge *a = lhs;
const OverlayEdge *b = rhs;
if (a->low != b->low)
return a->low < b->low ? -1 : 1;
if (a->high != b->high)
return a->high < b->high ? -1 : 1;
return 0;
}
static unsigned char overlay_vertex_category(const LensVertex *vertex) {
if (!vertex->traced)
return (unsigned char)MESH_OVERLAY_CATEGORY_UNTRACED;
switch (vertex->outcome) {
case RAY_OUTCOME_ESCAPED:
return (unsigned char)MESH_OVERLAY_CATEGORY_ESCAPE;
case RAY_OUTCOME_DARK:
return (unsigned char)MESH_OVERLAY_CATEGORY_DARK;
case RAY_OUTCOME_UNRESOLVED:
return (unsigned char)MESH_OVERLAY_CATEGORY_UNRESOLVED;
case RAY_OUTCOME_INCOMPLETE:
default:
return (unsigned char)MESH_OVERLAY_CATEGORY_INCOMPLETE;
}
}
int mesh_overlay_prepare(const FrameLensMesh *mesh, MeshOverlayLines *lines) {
if (lines == NULL)
return -1;
lines->lines = NULL;
lines->count = 0;
if (mesh == NULL)
return -1;
/* An overflowing triangle count is rejected before any pointer is
* dereferenced so a corrupt mesh cannot drive an out-of-bounds read. */
if (mesh->triangle_count > SIZE_MAX / 3)
return -1;
if (mesh->triangle_count == 0)
return 0;
const size_t raw_count = mesh->triangle_count * 3;
if (raw_count > SIZE_MAX / sizeof(OverlayEdge))
return -1;
if (mesh->triangles == NULL || mesh->vertices == NULL)
return -1;
OverlayEdge *raw = malloc(raw_count * sizeof *raw);
if (raw == NULL)
return -1;
for (size_t triangle = 0; triangle < mesh->triangle_count; ++triangle) {
const LensTriangle *leaf = &mesh->triangles[triangle];
for (int edge = 0; edge < 3; ++edge) {
const size_t from = leaf->vertex[edge];
const size_t to = leaf->vertex[(edge + 1) % 3];
if (from >= mesh->vertex_count || to >= mesh->vertex_count) {
free(raw);
return -1;
}
const LensVertex *a = &mesh->vertices[from];
const LensVertex *b = &mesh->vertices[to];
if (!isfinite(a->image_x) || !isfinite(a->image_y) ||
!isfinite(b->image_x) || !isfinite(b->image_y)) {
free(raw);
return -1;
}
OverlayEdge *slot = &raw[3 * triangle + (size_t)edge];
slot->low = from < to ? from : to;
slot->high = from < to ? to : from;
}
}
qsort(raw, raw_count, sizeof *raw, overlay_edge_compare);
size_t unique = 0;
for (size_t i = 0; i < raw_count; ++i) {
if (unique == 0 || raw[unique - 1].low != raw[i].low ||
raw[unique - 1].high != raw[i].high)
raw[unique++] = raw[i];
}
if (unique > SIZE_MAX / sizeof(MeshOverlayLine)) {
free(raw);
return -1;
}
MeshOverlayLine *out = NULL;
if (unique != 0) {
out = malloc(unique * sizeof *out);
if (out == NULL) {
free(raw);
return -1;
}
}
for (size_t i = 0; i < unique; ++i) {
const LensVertex *a = &mesh->vertices[raw[i].low];
const LensVertex *b = &mesh->vertices[raw[i].high];
out[i].x0 = a->image_x;
out[i].y0 = a->image_y;
out[i].x1 = b->image_x;
out[i].y1 = b->image_y;
out[i].category0 = overlay_vertex_category(a);
out[i].category1 = overlay_vertex_category(b);
}
free(raw);
lines->lines = out;
lines->count = unique;
return 0;
}
void mesh_overlay_lines_destroy(MeshOverlayLines *lines) {
if (lines == NULL)
return;
free(lines->lines);
lines->lines = NULL;
lines->count = 0;
}
/* ------------------------------------------------------------------------- */
/* Rasterization */
/* ------------------------------------------------------------------------- */
static double overlay_fractional_part(double value) { return value - floor(value); }
static void blend_overlay(unsigned char *pixels, int width, int height, int x,
int y, const unsigned char rgb[3], double alpha,
int rgba) {
if (alpha <= 0.0 || x < 0 || x >= width || y < 0 || y >= height)
return;
if (alpha > 1.0)
alpha = 1.0;
const int stride = rgba ? 4 : 3;
unsigned char *pixel = pixels + stride * ((size_t)y * (size_t)width + (size_t)x);
for (int channel = 0; channel < stride; ++channel) {
const double source = channel == 3 ? 255.0 : (double)rgb[channel];
const double mixed =
(double)pixel[channel] * (1.0 - alpha) + source * alpha;
long value = lround(mixed);
if (value < 0)
value = 0;
if (value > 255)
value = 255;
pixel[channel] = (unsigned char)value;
}
}
/* Liang-Barsky clip of the (major, minor) segment to the inclusive box. Keeps
* every subsequent cast and loop bounded even for huge finite coordinates.
* Returns 1 when a nonempty clipped segment remains, 0 when fully outside. The
* clipped outputs are guaranteed finite and inside the box before the caller
* casts them: a nonfinite interpolation result (cancellation) is skipped, and a
* finite roundoff overshoot is clamped back into the box. The segment direction
* keeps x0 <= x1 and the box clamp is monotone, so the order is preserved. */
static int clip_overlay_segment(double *x0, double *y0, double *x1, double *y1,
double xmin, double xmax, double ymin,
double ymax) {
const double dx = *x1 - *x0;
const double dy = *y1 - *y0;
if (!isfinite(dx) || !isfinite(dy))
return 0;
double t0 = 0.0, t1 = 1.0;
const double p[4] = {-dx, dx, -dy, dy};
const double q[4] = {*x0 - xmin, xmax - *x0, *y0 - ymin, ymax - *y0};
for (int i = 0; i < 4; ++i) {
if (p[i] == 0.0) {
if (q[i] < 0.0)
return 0;
} else {
const double r = q[i] / p[i];
if (p[i] < 0.0) {
if (r > t1)
return 0;
if (r > t0)
t0 = r;
} else {
if (r < t0)
return 0;
if (r < t1)
t1 = r;
}
}
}
double nx0 = *x0 + t0 * dx;
double ny0 = *y0 + t0 * dy;
double nx1 = *x0 + t1 * dx;
double ny1 = *y0 + t1 * dy;
if (!isfinite(nx0) || !isfinite(ny0) || !isfinite(nx1) || !isfinite(ny1))
return 0;
if (nx0 < xmin)
nx0 = xmin;
if (nx0 > xmax)
nx0 = xmax;
if (ny0 < ymin)
ny0 = ymin;
if (ny0 > ymax)
ny0 = ymax;
if (nx1 < xmin)
nx1 = xmin;
if (nx1 > xmax)
nx1 = xmax;
if (ny1 < ymin)
ny1 = ymin;
if (ny1 > ymax)
ny1 = ymax;
*x0 = nx0;
*y0 = ny0;
*x1 = nx1;
*y1 = ny1;
return 1;
}
static void plot_overlay_aa(unsigned char *pixels, int width, int height,
int steep, int x, int y, double coverage,
const unsigned char rgb[3], double opacity,
int rgba) {
if (coverage > 0.0)
blend_overlay(pixels, width, height, steep ? y : x, steep ? x : y, rgb,
coverage * opacity, rgba);
}
/* Xiaolin Wu line rasterization, one pixel wide, with a color that switches to
* the second endpoint category at the major-axis midpoint. A single pass
* colors the whole edge, so a midpoint pixel is never blended from both halves. */
static void draw_overlay_line(unsigned char *pixels, int width, int height,
const MeshOverlayLine *line,
const MeshOverlaySettings *settings, int rgba) {
double x0 = line->x0, y0 = line->y0;
double x1 = line->x1, y1 = line->y1;
const unsigned char *first = settings->colors[line->category0];
const unsigned char *second = settings->colors[line->category1];
const int steep = fabs(y1 - y0) > fabs(x1 - x0);
if (steep) {
double swap = x0;
x0 = y0;
y0 = swap;
swap = x1;
x1 = y1;
y1 = swap;
}
if (x0 > x1) {
double swap = x0;
x0 = x1;
x1 = swap;
swap = y0;
y0 = y1;
y1 = swap;
const unsigned char *color_swap = first;
first = second;
second = color_swap;
}
/* The switch is fixed to the true midpoint of the unclipped edge. Halving
* each endpoint separately cannot overflow for finite same-sign endpoints. */
const double midpoint = 0.5 * x0 + 0.5 * x1;
const int major_limit = steep ? height : width;
const int minor_limit = steep ? width : height;
double cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1;
if (!clip_overlay_segment(&cx0, &cy0, &cx1, &cy1, -1.0, (double)major_limit,
-1.0, (double)minor_limit))
return;
x0 = cx0;
y0 = cy0;
x1 = cx1;
y1 = cy1;
const double dx = x1 - x0;
/* A zero-length edge has no coverage; do not turn it into a vertex dot. */
if (!(dx > 0.0))
return;
const double gradient = (y1 - y0) / dx;
/* After the steep/orientation normalization |gradient| <= 1, so it is finite
* for a finite nonzero dx; this guard keeps a pathological subnormal dx from
* ever reaching a float-to-int cast. */
if (!isfinite(gradient))
return;
const int first_column = (int)round(x0);
const int last_column = (int)round(x1);
if (first_column == last_column) {
/* Wu's two endpoint formulas overlap in the same column for a subpixel
* segment. Paint its length-weighted coverage once, rather than applying
* two alpha blends that make tiny edges brighter than full-length ones. */
const double center_y = 0.5 * y0 + 0.5 * y1;
const int row = (int)floor(center_y);
const double fraction = overlay_fractional_part(center_y);
const unsigned char *color = first_column < midpoint ? first : second;
plot_overlay_aa(pixels, width, height, steep, first_column, row,
dx * (1.0 - fraction), color, settings->opacity, rgba);
plot_overlay_aa(pixels, width, height, steep, first_column, row + 1,
dx * fraction, color, settings->opacity, rgba);
return;
}
double x_end = (double)first_column;
double y_end = y0 + gradient * (x_end - x0);
if (!isfinite(y_end))
return;
double x_gap = 1.0 - overlay_fractional_part(x0 + 0.5);
const int x_pixel_start = (int)x_end;
int y_pixel = (int)floor(y_end);
const unsigned char *start_color = (x_pixel_start < midpoint) ? first : second;
plot_overlay_aa(pixels, width, height, steep, x_pixel_start, y_pixel,
(1.0 - overlay_fractional_part(y_end)) * x_gap, start_color,
settings->opacity, rgba);
plot_overlay_aa(pixels, width, height, steep, x_pixel_start, y_pixel + 1,
overlay_fractional_part(y_end) * x_gap, start_color,
settings->opacity, rgba);
double inter_y = y_end + gradient;
x_end = (double)last_column;
y_end = y1 + gradient * (x_end - x1);
if (!isfinite(inter_y) || !isfinite(y_end))
return;
x_gap = overlay_fractional_part(x1 + 0.5);
const int x_pixel_end = (int)x_end;
y_pixel = (int)floor(y_end);
const unsigned char *end_color = (x_pixel_end < midpoint) ? first : second;
plot_overlay_aa(pixels, width, height, steep, x_pixel_end, y_pixel,
(1.0 - overlay_fractional_part(y_end)) * x_gap, end_color,
settings->opacity, rgba);
plot_overlay_aa(pixels, width, height, steep, x_pixel_end, y_pixel + 1,
overlay_fractional_part(y_end) * x_gap, end_color,
settings->opacity, rgba);
for (int x = x_pixel_start + 1; x < x_pixel_end; ++x) {
y_pixel = (int)floor(inter_y);
const unsigned char *color = (x < midpoint) ? first : second;
plot_overlay_aa(pixels, width, height, steep, x, y_pixel,
1.0 - overlay_fractional_part(inter_y), color,
settings->opacity, rgba);
plot_overlay_aa(pixels, width, height, steep, x, y_pixel + 1,
overlay_fractional_part(inter_y), color,
settings->opacity, rgba);
inter_y += gradient;
}
}
static int draw_overlay(const MeshOverlayLines *lines, unsigned char *pixels,
int width, int height,
const MeshOverlaySettings *settings, int rgba) {
if (lines == NULL || pixels == NULL || settings == NULL)
return -1;
if (width <= 0 || height <= 0)
return -1;
/* Keep the raster's y+1 / x+1 and clipped-box endpoint casts strictly inside
* `int`, and keep the RGB8/RGBA8 byte count inside `size_t`. */
if (width > INT_MAX - 2 || height > INT_MAX - 2)
return -1;
const size_t dim_width = (size_t)width;
const size_t dim_height = (size_t)height;
if (dim_width > SIZE_MAX / dim_height)
return -1;
const size_t pixel_count = dim_width * dim_height;
if (pixel_count > SIZE_MAX / (rgba ? 4 : 3))
return -1;
if (!isfinite(settings->opacity) || settings->opacity < 0.0 ||
settings->opacity > 1.0)
return -1;
/* A line batch larger than any allocatable MeshOverlayLine array cannot be
* real; reject it before dereferencing the array. */
if (lines->count > SIZE_MAX / sizeof(MeshOverlayLine))
return -1;
if (lines->count != 0 && lines->lines == NULL)
return -1;
/* Validate the whole batch before drawing so an invalid line cannot leave a
* partially painted image behind. Endpoint differences too large to
* represent (e.g. -DBL_MAX..+DBL_MAX) are rejected up front, before the
* midpoint or clipping math can produce a nonfinite value. */
for (size_t i = 0; i < lines->count; ++i) {
const MeshOverlayLine *line = &lines->lines[i];
if (line->category0 >= MESH_OVERLAY_CATEGORY_COUNT ||
line->category1 >= MESH_OVERLAY_CATEGORY_COUNT)
return -1;
if (!isfinite(line->x0) || !isfinite(line->y0) || !isfinite(line->x1) ||
!isfinite(line->y1))
return -1;
if (!isfinite(line->x1 - line->x0) || !isfinite(line->y1 - line->y0))
return -1;
}
for (size_t i = 0; i < lines->count; ++i)
draw_overlay_line(pixels, width, height, &lines->lines[i], settings, rgba);
return 0;
}
int mesh_overlay_draw_rgb8(const MeshOverlayLines *lines, unsigned char *pixels,
int width, int height,
const MeshOverlaySettings *settings) {
return draw_overlay(lines, pixels, width, height, settings, 0);
}
void mesh_overlay_layer_destroy(MeshOverlayLayer *layer) {
if (layer == NULL)
return;
free(layer->rgba);
*layer = (MeshOverlayLayer){0};
}
int mesh_overlay_build_layer(const FrameLensMesh *mesh, int width, int height,
const MeshOverlaySettings *settings,
MeshOverlayLayer *layer) {
if (layer == NULL)
return -1;
*layer = (MeshOverlayLayer){0};
if (width <= 0 || height <= 0 || width > INT_MAX - 2 ||
height > INT_MAX - 2 || (size_t)width > SIZE_MAX / (size_t)height ||
(size_t)width * height > SIZE_MAX / 4 || settings == NULL ||
!isfinite(settings->opacity) || settings->opacity < 0.0 ||
settings->opacity > 1.0)
return -1;
MeshOverlayLines lines = {0};
if (mesh_overlay_prepare(mesh, &lines))
return -1;
unsigned char *pixels = calloc((size_t)width * height, 4);
const int result = pixels == NULL ? -1 :
draw_overlay(&lines, pixels, width, height, settings, 1);
mesh_overlay_lines_destroy(&lines);
if (result) {
free(pixels);
return -1;
}
layer->rgba = pixels;
layer->width = width;
layer->height = height;
return 0;
}
int mesh_overlay_composite_rgb8(const MeshOverlayLayer *layer,
unsigned char *rgb8, int width, int height) {
if (layer == NULL || layer->rgba == NULL || rgb8 == NULL || width <= 0 ||
height <= 0 || width != layer->width || height != layer->height ||
(size_t)width > SIZE_MAX / (size_t)height ||
(size_t)width * height > SIZE_MAX / 4)
return -1;
const size_t count = (size_t)width * height;
for (size_t pixel = 0; pixel < count; ++pixel) {
const unsigned char *source = &layer->rgba[4 * pixel];
const unsigned int alpha = source[3];
if (alpha == 0)
continue;
for (int channel = 0; channel < 3; ++channel) {
const unsigned int value = source[channel] +
(rgb8[3 * pixel + channel] * (255 - alpha) + 127) / 255;
rgb8[3 * pixel + channel] = value > 255 ? 255 : (unsigned char)value;
}
}
return 0;
}
+103
View File
@@ -0,0 +1,103 @@
#ifndef MESH_OVERLAY_H
#define MESH_OVERLAY_H
#include "frame.h"
#include <stddef.h>
/* Diagnostic overlay for a finalized lens mesh. It is a pure consumer of
* FrameLensMesh: it copies the coordinates it needs and never aliases the
* mutable mesh, so it can run alongside a writer without sharing state.
*
* Categories mirror the rendering terminal classes plus UNTRACED for a vertex
* that never received an endpoint (traced == 0). UNRESOLVED and INCOMPLETE are
* kept distinct so the overlay does not hide a retryable/completion shortfall
* behind the normal dark terminal. */
enum MeshOverlayCategory {
MESH_OVERLAY_CATEGORY_ESCAPE = 0,
MESH_OVERLAY_CATEGORY_DARK,
MESH_OVERLAY_CATEGORY_UNRESOLVED,
MESH_OVERLAY_CATEGORY_INCOMPLETE,
MESH_OVERLAY_CATEGORY_UNTRACED,
MESH_OVERLAY_CATEGORY_COUNT
};
typedef struct {
/* Display-sRGB #RRGGBB color per category. Index by MeshOverlayCategory. */
unsigned char colors[MESH_OVERLAY_CATEGORY_COUNT][3];
/* Coverage multiplier in [0, 1] applied on top of the one-pixel AA weight. */
double opacity;
} MeshOverlaySettings;
/* One undirected mesh edge. (x0, y0) and (x1, y1) are the two endpoints in
* image-plane pixel coordinates; category0 belongs to endpoint 0 and
* category1 to endpoint 1. Each half of the edge carries the color of its
* adjacent vertex with an abrupt switch at the major-axis midpoint. */
typedef struct {
double x0, y0, x1, y1;
unsigned char category0, category1;
} MeshOverlayLine;
typedef struct {
MeshOverlayLine *lines;
size_t count;
} MeshOverlayLines;
/* Owned display-sRGB overlay, byte order R,G,B,A with premultiplied RGB.
* Transparent pixels are all zero. No live mesh or palette is retained. */
typedef struct {
unsigned char *rgba;
int width, height;
} MeshOverlayLayer;
/* Allocate/rasterize a transparent layer from the unique mesh edges. The output
* must be empty; failures leave it empty. Temporary lines are freed before
* returning. The caller owns the layer until destroyed or submitted. */
int mesh_overlay_build_layer(const FrameLensMesh *mesh, int width, int height,
const MeshOverlaySettings *settings,
MeshOverlayLayer *layer);
void mesh_overlay_layer_destroy(MeshOverlayLayer *layer);
/* Composite a valid premultiplied layer onto RGB8 in place. Layer dimensions
* must match. Zero alpha leaves RGB bytes unchanged; the layer is read-only. */
int mesh_overlay_composite_rgb8(const MeshOverlayLayer *layer,
unsigned char *rgb8, int width, int height);
/* Catppuccin Mocha diagnostics palette with opacity 0.5:
* ESCAPE #7F849C, DARK #CBA6F7, UNRESOLVED #F9E2AF, INCOMPLETE #F38BA8,
* UNTRACED #89B4FA. */
MeshOverlaySettings mesh_overlay_default_settings(void);
/* Parse a strict `#RRGGBB` color into rgb[3]. Returns 0 on success and -1 for
* NULL arguments, a wrong length/prefix, or a non-hex digit. Both upper- and
* lower-case hex digits are accepted. */
int mesh_overlay_parse_color(const char *text, unsigned char rgb[3]);
/* Build the unique undirected edge set of the mesh's leaf triangles. Edges are
* canonicalized to (min vertex id, max vertex id), sorted and deduplicated so
* shared and boundary edges are emitted exactly once regardless of winding.
* Off-mesh probe witnesses have no triangle edge and are never emitted.
* Returns 0 on success (count may be 0) and -1 on invalid arguments, an
* out-of-range vertex index, a nonfinite coordinate, or allocation overflow.
* The output handle must be empty (zero-initialized or previously destroyed).
* On success the caller owns lines->lines and must release it with
* mesh_overlay_lines_destroy. */
int mesh_overlay_prepare(const FrameLensMesh *mesh, MeshOverlayLines *lines);
/* Release the edge array and reset the handle to empty. Safe on NULL. */
void mesh_overlay_lines_destroy(MeshOverlayLines *lines);
/* Rasterize the edges onto an interleaved RGB8 image (width*height*3 bytes),
* one pixel wide with Xiaolin Wu coverage AA, mixing directly in display sRGB
* with alpha = coverage * settings->opacity. Endpoint categories are chosen by
* position along the major axis relative to the midpoint; edges are clipped
* safely to the image so offscreen or huge coordinates cannot loop unbounded or
* overflow an integer conversion. Returns 0 on success and -1 on NULL
* arguments, a nonpositive size, an opacity outside [0, 1] or nonfinite, an
* out-of-range category, a nonfinite line coordinate/difference, or an
* overflowing image/batch size. Invalid batches are rejected before painting. */
int mesh_overlay_draw_rgb8(const MeshOverlayLines *lines, unsigned char *pixels,
int width, int height,
const MeshOverlaySettings *settings);
#endif
+14 -10
View File
@@ -16,23 +16,27 @@ static int movie_output_default_write(void *context, const MovieOutputJob *job,
if (write_rgb8_image(job->output_path, job->clean_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
fprintf(stdout, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
job->images, job->catalog_stars, job->output_path, job->note);
if (job->draw_mesh && job->mesh_rgb8 != NULL) {
if (write_rgb8_image(job->mesh_path, job->mesh_rgb8, job->width,
/* The clean file is already written, so drawing in place on clean_rgb8 can
* never alter it; mesh_path receives the augmented buffer. */
if (job->draw_mesh) {
if (mesh_overlay_composite_rgb8(&job->mesh_layer, job->clean_rgb8, job->width,
job->height) ||
write_rgb8_image(job->mesh_path, job->clean_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Wrote mesh overlay image: %s\n", job->mesh_path);
fprintf(stdout, "Wrote mesh overlay image: %s\n", job->mesh_path);
}
if (job->has_psf_stats) {
if (job->fast_mode)
fprintf(stderr,
fprintf(stdout,
"Fast PSF splats: deposited %zu, wing-clipped %zu, discarded "
"below min-Y %zu\n",
job->psf_stats.cached_splats, job->psf_stats.cached_wing_clipped,
job->psf_stats.discarded_below_min_y);
else
psf_kernel_cache_report(NULL, &job->psf_stats, stderr);
psf_kernel_cache_report(NULL, &job->psf_stats, stdout);
}
return 0;
}
@@ -46,11 +50,11 @@ static void *movie_output_writer_main(void *opaque) {
if (queue->count == 0 && queue->producer_done)
break;
const size_t slot = queue->head;
const MovieOutputJob job = queue->jobs[slot];
MovieOutputJob job = queue->jobs[slot];
/* Ownership moved into the local copy; clear the slot so destroy() cannot
* free the same buffers a second time. */
queue->jobs[slot].clean_rgb8 = NULL;
queue->jobs[slot].mesh_rgb8 = NULL;
queue->jobs[slot].mesh_layer = (MeshOverlayLayer){0};
queue->head = (queue->head + 1) % queue->capacity;
--queue->count;
pthread_cond_signal(&queue->not_full);
@@ -72,7 +76,7 @@ static void *movie_output_writer_main(void *opaque) {
}
pthread_mutex_unlock(&queue->mutex);
free(job.clean_rgb8);
free(job.mesh_rgb8);
mesh_overlay_layer_destroy(&job.mesh_layer);
pthread_mutex_lock(&queue->mutex);
}
pthread_mutex_unlock(&queue->mutex);
@@ -213,7 +217,7 @@ void movie_output_queue_destroy(MovieOutputQueue *queue) {
if (queue->jobs != NULL)
for (size_t i = 0; i < queue->capacity; ++i) {
free(queue->jobs[i].clean_rgb8);
free(queue->jobs[i].mesh_rgb8);
mesh_overlay_layer_destroy(&queue->jobs[i].mesh_layer);
}
pthread_cond_destroy(&queue->not_empty);
pthread_cond_destroy(&queue->not_full);
+19 -10
View File
@@ -1,6 +1,7 @@
#ifndef MOVIE_OUTPUT_H
#define MOVIE_OUTPUT_H
#include "mesh_overlay.h"
#include "optics.h"
#include <limits.h>
@@ -10,13 +11,16 @@
/* Bounded, single-producer/single-writer movie output queue.
*
* The producer (the render loop) performs all HDR work and the tone map before
* submitting; a job therefore carries finished 8-bit RGB buffers, never a
* double HDR framebuffer. The writer thread encodes/writes them in submit
* order while the producer renders the next frame.
* submitting, and rasterizes the independent premultiplied RGBA8 mesh overlay.
* A job carries finished RGB8 and optional immutable RGBA8, never a double HDR
* framebuffer or a live mesh reference. The
* writer thread encodes/writes them in submit order while the producer renders
* the next frame.
*
* Ownership contract for submit(): on success the queue owns clean_rgb8 and
* mesh_rgb8 and frees them after writing; on failure they remain owned by the
* caller.
* Ownership contract for submit(): on success the queue owns clean_rgb8 and the
* mesh_layer buffer and releases them after writing; on failure they remain
* owned by the caller, who must free(clean_rgb8) and call
* mesh_overlay_layer_destroy(&mesh_layer).
*
* `capacity` bounds the queued jobs only; the writer may additionally hold one
* already-popped job, so the true in-memory bound is capacity + 1 jobs. With
@@ -29,10 +33,14 @@ typedef struct {
int draw_mesh;
unsigned char *clean_rgb8;
unsigned char *mesh_rgb8; /* NULL when draw_mesh is false */
int width;
int height;
/* Producer-rasterized, premultiplied display-sRGB RGBA8. The writer only
* composites it after writing the clean image; no mesh/palette is retained.
* Empty when draw_mesh is false. */
MeshOverlayLayer mesh_layer;
size_t images;
size_t catalog_stars;
PsfSplatStats psf_stats;
@@ -47,9 +55,10 @@ typedef struct {
} MovieOutputJob;
/* Optional custom writer. Returns 0 on success; the default writer writes
* clean_rgb8 to output_path and, when draw_mesh is set, mesh_rgb8 to
* mesh_path, then prints the "Rendered ... (<note>)" and PSF lines. The queue
* owns and frees clean_rgb8/mesh_rgb8 after the writer returns. */
* clean_rgb8 to output_path and, when draw_mesh is set, composites mesh_layer in
* place on clean_rgb8 before writing the augmented buffer to mesh_path. It
* then prints the "Rendered ... (<note>)" and PSF lines. The queue owns and
* releases clean_rgb8 and mesh_layer after the writer returns. */
typedef int (*MovieOutputWriteFn)(void *context, const MovieOutputJob *job,
const PngWriteSettings *settings);
+2 -2
View File
@@ -251,7 +251,7 @@ void psf_kernel_cache_report(const PsfKernelCache *cache,
stats->gpu_upload_seconds, stats->gpu_kernel_seconds,
stats->gpu_download_seconds);
if (stats != NULL && stats->discarded_below_min_y != 0)
fputs("Warning: --psf-min-y discarded one or more PSF events.\n", stream);
fputs("Warning: --psf-min-y discarded one or more PSF events.\n", stderr);
#ifdef GR_DEBUG
if (stats != NULL)
fprintf(stream, "Debug: max raw magnification %.6g; magnification-clamped "
@@ -705,7 +705,7 @@ int fast_psf_accumulator_resolve(FastPsfAccumulator *accumulator,
accumulator->fftw_last_timing = timing;
accumulator->fftw_frame_seconds = omp_get_wtime() - start;
if (accumulator->verbose)
fprintf(stderr,
fprintf(stdout,
"Fast FFTW frame: zero_pack=%.6f forward=%.6f "
"multiply=%.6f inverse=%.6f crop_downsample=%.6f "
"total=%.6f\n",
+6 -3
View File
@@ -149,8 +149,11 @@ void ray_pool_preroute(RayPool *p, const SpacetimeSource *source) {
if (status == ASYMPTOTIC_UNSUPPORTED || status == ASYMPTOTIC_INVALID) {
p->endpoint[i].outcome = RAY_OUTCOME_INCOMPLETE;
p->endpoint[i].reason = status == ASYMPTOTIC_UNSUPPORTED
? RAY_REASON_UNSUPPORTED
: RAY_REASON_PROTOCOL_ERROR;
? RAY_REASON_UNSUPPORTED
: (ray_reason_valid(route.failure_reason) &&
route.failure_reason != RAY_REASON_NONE
? route.failure_reason
: RAY_REASON_CAMERA_PREROUTE_FAILED);
p->endpoint[i].end_id = route.end_id;
p->status[i] = RAY_POOL_FAILED;
continue;
@@ -182,7 +185,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;
}
+4 -4
View File
@@ -130,9 +130,9 @@ def endpoint_deviation(a, b):
return mismatches, worst
def trace_cost(stderr, label):
def trace_cost(text, label):
match = re.search(label + r' trace cost: accepted=(\d+) rejected=(\d+) '
r'rhs=(\d+)', stderr)
r'rhs=(\d+)', text)
return None if match is None else match.groups()
@@ -263,8 +263,8 @@ with tempfile.TemporaryDirectory(prefix='gr-adaptive-cli-',
replay_hdr = replay_out.with_name(replay_out.stem + '_HDR.fits')
assert dflt_out.with_name(dflt_out.stem + '_HDR.fits').read_bytes() \
== replay_hdr.read_bytes()
live_cost = trace_cost(dflt_run.stderr, 'Frame 0')
replay_cost = trace_cost(replay_run.stderr, 'Imported map')
live_cost = trace_cost(dflt_run.stdout, 'Frame 0')
replay_cost = trace_cost(replay_run.stdout, 'Imported map')
assert live_cost is not None and replay_cost is not None
assert live_cost == replay_cost, (live_cost, replay_cost)
+310 -4
View File
@@ -3,6 +3,7 @@
#include "ray.h"
#include "spacetime.h"
#include <float.h>
#include <math.h>
#include <stdio.h>
@@ -50,6 +51,7 @@ typedef struct {
int sample_nonpositive_radius;
int fail_on_sample_call; /* 1-based callback invocation to fail. */
int sample_call_count;
double frame_origin[3];
} SyntheticContext;
static SpacetimePointStatus synthetic_eval(const SpacetimeSource *source,
@@ -95,7 +97,8 @@ static int synthetic_end(const SpacetimeSource *source, size_t index,
.end_id = 0,
.exterior_kind = kind,
.mass = mass,
.frame_origin = {0.0, 0.0, 0.0},
.frame_origin = {context->frame_origin[0], context->frame_origin[1],
context->frame_origin[2]},
.frame_axes = {{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}};
return 0;
}
@@ -134,12 +137,17 @@ static int synthetic_worldtube(const SpacetimeSource *source,
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
/* The sample contract is the worldtube value at this coordinate time, so the
* reported radius must carry its own time dependence: R(t) = R0 + rr t, with
* dR/dt = radius_rate. A constant `radius` with a nonzero rate would make
* the closed-form segment model and the callback geometry disagree. */
const double radius_t = context->radius + context->radius_rate * t;
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 = radius_t,
.radius_rate = context->radius_rate,
.velocity_constant = context->constant,
.valid = 1};
@@ -148,7 +156,7 @@ static int synthetic_worldtube(const SpacetimeSource *source,
*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 = radius_t,
.radius_rate = context->radius_rate,
.velocity_constant = context->constant,
.valid = 1};
@@ -499,11 +507,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
@@ -660,6 +718,166 @@ static void test_moving_sphere(void) {
"co-moving ray misses");
}
/* Fixed-observer tetrad used by the production Alcubierre observer-track rows
* 63/64, with spatial axes (e1, e2, e3) = (y-hat, z-hat, x-hat). The literal
* values are embedded here so this regression does not depend on the
* untracked observer CSV. */
static ObserverState track_observer(double coordinate_time) {
ObserverState o = {0};
o.coordinate_time = coordinate_time;
o.coordinate_position[0] = 0.0;
o.coordinate_position[1] = -24.0;
o.coordinate_position[2] = 0.0;
o.tetrad[0][0] = 1.0;
o.tetrad[1][2] = 1.0;
o.tetrad[2][3] = 1.0;
o.tetrad[3][1] = 1.0;
return o;
}
/* Two exact production RayPool pre-route samples (frame 63 sample 12315 and
* frame 64 sample 3994). They are grazing (disc/b^2 ~ 1e-4), so the plain
* double root solve left the reconstructed entry state at F ~ 1.0-1.2 x
* geom_tol, which the event layer rejected as OUTSIDE_WORLDTUBE. The moving
* sphere fixture reproduces the Alcubierre worldtube (center = 2 t, radius 5);
* the observer time/position/tetrad and the camera direction are the exact raw
* production values. The route must land inside the geometric tolerance with
* the entry direction (Pi) unchanged. */
static void test_grazing_production_entries(void) {
SyntheticContext context = {.vx = 2.0,
.accel = 0.0,
.radius = 5.0,
.radius_rate = 0.0,
.valid_t_min = -1.0e30,
.constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
struct {
double time;
double direction[3];
double pi[3];
} cases[2] = {
{0x1.fa8f5c28f5c29p+3,
{0x1.c7378f8e872d1p-1, 0x1.15bad4e30e8ddp-8, -0x1.d4afba4704cap-2},
{0x1.d4afba4704cap-2, -0x1.c7378f8e872d1p-1, -0x1.15bad4e30e8ddp-8}},
{0x1.fb17e4b17e4b1p+3,
{0x1.bd3bb364ac492p-1, 0x1.102d2a1c6ac74p-7, -0x1.f98ae1a782104p-2},
{0x1.f98ae1a782104p-2, -0x1.bd3bb364ac492p-1, -0x1.102d2a1c6ac74p-7}},
};
for (int c = 0; c < 2; ++c) {
ObserverState observer = track_observer(cases[c].time);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer, cases[c].direction,
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"grazing production entry found");
CHECK(route.entry_fallback_evaluations == 0,
"grazing production entry stays on the fast path");
for (int i = 0; i < 3; ++i)
CHECK(route.Pi[i] == cases[c].pi[i],
"grazing entry direction is unchanged");
SpacetimeEscapeWorldtubeSample sample;
CHECK(spacetime_escape_worldtube_sample(&source, route.end_id,
route.activate_t, &sample) == 0 &&
sample.valid && sample.radius > 0.0,
"grazing entry sample valid");
double d2 = 0.0;
for (int k = 0; k < 3; ++k) {
const double dk = route.x[k] - sample.center[k];
d2 += dk * dk;
}
const double r2 = sample.radius * sample.radius;
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0,
"grazing entry worldtube value");
const double geom_tol = 128.0 * DBL_EPSILON * fmax(r2, d2);
/* The event layer rejects the entry (OUTSIDE_WORLDTUBE) exactly when
* F > geom_tol; require the residual to sit inside the tolerance band
* rather than accepting an arbitrary sign. */
CHECK(value <= geom_tol && value >= -geom_tol,
"grazing entry F within geometric tolerance");
}
}
/* Linear (a == 0) entry: a growing sphere whose radius rate cancels the
* relative closing speed, so qq == rr^2 and the quadratic degenerates. The
* stable solver must still take the smallest positive root. The fixture's
* sample() reports the consistent radius R(t) = 10 - t, i.e. R(s) = 10 + s
* along the past parameter s = -t, so the contact point is on the true
* ruled-surface boundary. */
static void test_linear_a_zero_entry(void) {
SyntheticContext context = {.vx = 0.0,
.accel = 0.0,
.radius = 10.0,
.radius_rate = -1.0, /* R(t) = 10 - t */
.valid_t_min = -1.0e30,
.constant = 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,
"linear a~0 entry");
CHECK(fabs(route.activate_t + 45.0) < 1e-12, "linear entry time");
CHECK(fabs(route.x[0] - 55.0) < 1e-12 && fabs(route.x[1]) < 1e-12 &&
fabs(route.x[2]) < 1e-12,
"linear entry position");
}
/* Large-coordinate-time cancellation: the long-double closed-form root is
* accurate in the frame, but the entry state reconstructed in double at a huge
* t0 loses the sub-ULP part of the event and lands far outside the geometric
* ULP band (F ~ 0.3 >> tol). The common fallback must repropagate from the
* original camera, localize the first entry numerically, and return a
* strict-inside endpoint (F < 0) while preserving the camera direction Pi and
* reference L exactly. The fixture is a legitimate constant-velocity
* worldtube, not a nonlinearity injection. */
static void test_fallback_reconstruction_cancellation(void) {
SyntheticContext context = {.vx = 0x1.999999999999ap-4, /* 0.1 */
.accel = 0.0,
.radius = 10.0,
.radius_rate = 0.0,
.valid_t_min = -1.0e30,
.constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const double t0 = 1.0e15;
/* Exactly 0.1 * 1e15 + 100.123456789, pinned as a hex literal. The offset
* is not aligned to the t0 ULP, so activate_t = t0 - s rounds and the
* reconstructed boundary residual exceeds the tolerance band. */
const double camera_x = 0x1.6bcc41e901908p+46;
ObserverState observer = flat_observer(camera_x, 0.0, 0.0);
observer.coordinate_time = t0;
const double direction[3] = {-1.0, 0.0, 0.0};
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer, direction, &route) ==
ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"cancellation fallback still produces an entry");
CHECK(route.entry_fallback_evaluations > 0,
"cancellation entry used the common fallback");
CHECK(route.failure_reason == RAY_REASON_NONE,
"successful fallback has no failure reason");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
value <= 0.0,
"fallback entry state is inside the worldtube");
MetricData metric;
GeodesicRayState camera_state;
CHECK(spacetime_eval(&source, t0, observer.coordinate_position, &metric) ==
0 &&
geodesic_initialize_past_ray_metric(&metric, &observer, direction,
&camera_state) == 0,
"cancellation camera state");
for (int i = 0; i < 3; ++i)
CHECK(route.Pi[i] == camera_state.Pi[i],
"fallback preserves the entry direction exactly");
CHECK(route.log_alpha_p0_camera == camera_state.log_alpha_p0,
"fallback preserves the camera reference L exactly");
}
static void test_accelerated_worldtube_unsupported(void) {
/* A genuinely accelerating (non-constant velocity) worldtube has no strict
* relative-motion interval bound, so the route is explicitly unsupported.
@@ -739,11 +957,98 @@ static void test_ray_pool_lifecycle(void) {
spacetime_destroy(&source);
}
static void test_zero_discriminant_is_not_miss(void) {
SpacetimeSource source;
CHECK(spacetime_create_minkowski(&source, 1.0) == 0,
"zero-discriminant source");
const ObserverState observer = flat_observer(1e10, 0.5, 0.0);
AsymptoticRoute route;
/* Forming c = 1e20 + 0.25 - 1 loses the transverse contribution even in
* 80-bit arithmetic; b*b - 4*a*c then rounds to zero despite a real entry. */
CHECK(asymptotic_route_camera(&source, &observer,
(double[]){-1.0, 0.0, 0.0}, &route) ==
ASYMPTOTIC_OK && route.kind == ASYMPTOTIC_ROUTE_ENTRY &&
route.entry_fallback_evaluations > 0,
"rounded zero discriminant uses entry fallback, not escape");
double value = 0.0;
CHECK(asymptotic_worldtube_value(&source, 0, route.activate_t, route.x,
&value) == ASYMPTOTIC_OK && value < 0.0,
"zero-discriminant fallback produces actual inside state");
const ObserverState tangent = flat_observer(1e10, 1.0, 0.0);
CHECK(asymptotic_route_camera(&source, &tangent,
(double[]){-1.0, 0.0, 0.0}, &route) ==
ASYMPTOTIC_OK && route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"fixed transverse coordinate independently certifies exact tangency");
spacetime_destroy(&source);
}
static void test_positive_reconstructed_minimum_is_not_miss(void) {
SyntheticContext ctx = {.vx = 0.1, .radius = 10.0, .constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &ctx};
ObserverState observer = flat_observer(0x1.6bcc41e901904p+46,
0x1.3ffffde7210bfp+3, 0.0);
observer.coordinate_time = 1e15;
const double parameter = 0x1.bca8814065f1ep+6;
const double witness[3] = {observer.coordinate_position[0] - parameter,
observer.coordinate_position[1], 0.0};
double value = 0.0;
CHECK(asymptotic_worldtube_value(&source, 0,
observer.coordinate_time - parameter,
witness, &value) == ASYMPTOTIC_OK && value < 0,
"strict-inside witness exists despite positive reconstructed minimum");
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer,
(double[]){-1.0, 0.0, 0.0}, &route) ==
ASYMPTOTIC_INVALID &&
route.failure_reason == RAY_REASON_ENTRY_UNCONFIRMED,
"positive probe without a miss certificate fails explicitly");
RayPool pool;
CHECK(ray_pool_init(&pool, 1) == 0, "ambiguous entry pool");
CHECK(ray_pool_append(&pool, &observer, (double[]){-1.0, 0.0, 0.0},
0, 0) == 0, "ambiguous entry ray");
ray_pool_preroute(&pool, &source);
CHECK(pool.status[0] == RAY_POOL_FAILED &&
pool.endpoint[0].outcome == RAY_OUTCOME_INCOMPLETE &&
pool.endpoint[0].reason == RAY_REASON_ENTRY_UNCONFIRMED,
"pool propagates unconfirmed entry instead of fabricating escape");
ray_pool_destroy(&pool);
}
static void test_translated_frame_cannot_certify_miss(void) {
SyntheticContext ctx = {.radius = 1.0, .constant = 1,
.valid_t_min = -1e100,
.frame_origin = {0.0, 1e10, 0.0}};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &ctx};
const ObserverState observer = flat_observer(1e10, 1.0 - 0x1p-22, 0.0);
double value = 0.0;
const double witness[3] = {0.0, observer.coordinate_position[1], 0.0};
CHECK(asymptotic_worldtube_value(&source, 0, -1e10, witness, &value) ==
ASYMPTOTIC_OK && value < 0.0,
"original untranslated trajectory has an inside witness");
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer,
(double[]){-1.0, 0.0, 0.0}, &route) ==
ASYMPTOTIC_INVALID &&
route.failure_reason == RAY_REASON_ENTRY_UNCONFIRMED,
"lossy translated frame must not certify a miss");
ctx.frame_origin[1] = 0.0;
CHECK(asymptotic_route_camera(&source, &observer,
(double[]){-1.0, 0.0, 0.0}, &route) ==
ASYMPTOTIC_OK && route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"same unshifted trajectory confirms an entry");
}
int main(void) {
test_fixed_sphere();
test_large_radius_quadratic();
test_round_trip();
test_moving_sphere();
test_grazing_production_entries();
test_linear_a_zero_entry();
test_fallback_reconstruction_cancellation();
test_zero_discriminant_is_not_miss();
test_positive_reconstructed_minimum_is_not_miss();
test_translated_frame_cannot_certify_miss();
test_accelerated_worldtube_unsupported();
test_piecewise_segment_entry();
test_boundary_semantics_minkowski();
@@ -753,6 +1058,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();
+781
View File
@@ -0,0 +1,781 @@
#include "asymptotic_entry.h"
#include "spacetime.h"
#include <float.h>
#include <math.h>
#include <stdio.h>
/* Backend-independent core regression for the numerical entry localizer. It
* deliberately links no analytic backend and no geodesic integrator: the fake
* SpacetimeSource exposes only `escape_worldtube_sample` (plus a deliberately
* trapped `eval`), and the evaluator is an analytic path-parameter callback.
*
* Nothing here depends on an untracked production track, CSV or binary. */
static int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, message); \
++failures; \
} \
} while (0)
#ifndef TEST_PI
#define TEST_PI 3.14159265358979323846
#endif
/* ------------------------------------------------------------------ */
/* Fake worldtube source */
/* ------------------------------------------------------------------ */
typedef struct {
double center0[3];
double center_vel[3]; /* dc/dt */
double radius0;
double radius_rate; /* dR/dt */
double valid_t_min; /* sample is valid for t >= valid_t_min */
int callback_fails; /* always return -1 */
int fail_at_call; /* 1-based sample-call index to fail, 0 disabled */
int nan_radius;
int zero_radius;
double hole_center; /* isolated invalid time window */
double hole_halfwidth; /* 0 disables the window */
int call_count;
int eval_calls; /* trap: how often the metric eval callback ran */
} EntryWorldtube;
static SpacetimePointStatus entry_eval_trap(const SpacetimeSource *source,
double t, const double x[3],
MetricData *metric) {
EntryWorldtube *wt = source->context;
++wt->eval_calls;
(void)t;
(void)x;
(void)metric;
/* This source is deliberately outside the metric domain. The localizer must
* never reach here because it does no metric evaluation. */
return SPACETIME_POINT_OUT_OF_DOMAIN;
}
static int entry_worldtube_cb(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
SpacetimeEscapeWorldtubeSample *out) {
EntryWorldtube *wt = source->context;
if (end_id != 0)
return -1;
++wt->call_count;
if (wt->fail_at_call > 0 && wt->call_count == wt->fail_at_call)
return -1;
if (wt->callback_fails)
return -1;
if (!isfinite(t) || t < wt->valid_t_min) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
if (wt->hole_halfwidth > 0.0 &&
fabs(t - wt->hole_center) <= wt->hole_halfwidth) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
if (wt->nan_radius) {
*out = (SpacetimeEscapeWorldtubeSample){.radius = NAN, .valid = 1};
return 0;
}
if (wt->zero_radius) {
*out = (SpacetimeEscapeWorldtubeSample){.radius = 0.0, .valid = 1};
return 0;
}
*out = (SpacetimeEscapeWorldtubeSample){
.center = {wt->center0[0] + wt->center_vel[0] * t,
wt->center0[1] + wt->center_vel[1] * t,
wt->center0[2] + wt->center_vel[2] * t},
.velocity = {wt->center_vel[0], wt->center_vel[1], wt->center_vel[2]},
.radius = wt->radius0 + wt->radius_rate * t,
.radius_rate = wt->radius_rate,
.velocity_constant = 1,
.valid = 1};
return 0;
}
static const SpacetimeOps entry_ops = {
.eval = entry_eval_trap,
.escape_worldtube_sample = entry_worldtube_cb,
};
static SpacetimeSource entry_source(EntryWorldtube *wt) {
return (SpacetimeSource){.ops = &entry_ops, .context = wt};
}
/* ------------------------------------------------------------------ */
/* Analytic path-parameter evaluator */
/* ------------------------------------------------------------------ */
typedef struct {
double camera_t;
double camera_x[3];
double w[3]; /* unit past direction (straight mode) */
int arc_mode;
double arc_center[3];
double arc_radius;
double arc_theta0;
double L0;
double L0camera;
int evaluator_fails_at;
AsymptoticStatus fail_status;
int evaluator_call_count;
int nonfinite_at;
int reversed_time_at;
} EntryEvaluator;
static void entry_trajectory(const EntryEvaluator *c, double parameter,
double x[3], double w[3], double *t) {
if (c->arc_mode) {
/* Circular analytic arc: parameter is arc length. Not a physical
* geodesic, but a generic curved callback that exercises the driver beyond
* straight lines. */
const double theta = c->arc_theta0 + parameter / c->arc_radius;
x[0] = c->arc_center[0] + c->arc_radius * cos(theta);
x[1] = c->arc_center[1] + c->arc_radius * sin(theta);
x[2] = c->arc_center[2];
w[0] = -sin(theta);
w[1] = cos(theta);
w[2] = 0.0;
} else {
for (int i = 0; i < 3; ++i) {
x[i] = c->camera_x[i] + parameter * c->w[i];
w[i] = c->w[i];
}
}
*t = c->camera_t - parameter;
}
static AsymptoticStatus entry_evaluator_cb(void *context, double parameter,
AsymptoticRoute *state) {
EntryEvaluator *c = context;
++c->evaluator_call_count;
if (c->evaluator_fails_at > 0 &&
c->evaluator_call_count == c->evaluator_fails_at)
return c->fail_status;
double x[3], w[3], t;
entry_trajectory(c, parameter, x, w, &t);
*state = (AsymptoticRoute){0};
state->kind = ASYMPTOTIC_ROUTE_ENTRY;
state->end_id = 0;
state->activate_t = t;
for (int i = 0; i < 3; ++i) {
state->x[i] = x[i];
state->Pi[i] = -w[i];
}
state->log_alpha_p0 = c->L0;
state->log_alpha_p0_camera = c->L0camera;
if (c->evaluator_call_count == c->nonfinite_at)
state->log_alpha_p0_camera = NAN;
if (c->evaluator_call_count == c->reversed_time_at)
state->activate_t = c->camera_t + 1.0;
return ASYMPTOTIC_OK;
}
/* Independent test-side oracle: the same worldtube F the driver sees, but
* computed directly from the analytic trajectory. Used only to find the true
* first entry for comparison. */
typedef struct {
const EntryEvaluator *ev;
const EntryWorldtube *wt;
} EntryOracle;
static double entry_oracle_F(void *context, double parameter) {
const EntryOracle *o = context;
double x[3], w[3], t;
entry_trajectory(o->ev, parameter, x, w, &t);
double d[3];
for (int i = 0; i < 3; ++i)
d[i] = x[i] - (o->wt->center0[i] + o->wt->center_vel[i] * t);
const double d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
const double radius = o->wt->radius0 + o->wt->radius_rate * t;
return d2 - radius * radius;
}
static double entry_oracle_root(const EntryEvaluator *ev,
const EntryWorldtube *wt, double lo,
double hi) {
EntryOracle o = {.ev = ev, .wt = wt};
if (!(entry_oracle_F(&o, lo) >= 0.0 && entry_oracle_F(&o, hi) < 0.0))
return NAN;
for (int i = 0; i < 200; ++i) {
const double mid = 0.5 * (lo + hi);
if (!(mid > lo && mid < hi))
break;
if (entry_oracle_F(&o, mid) >= 0.0)
lo = mid;
else
hi = mid;
}
return 0.5 * (lo + hi);
}
static double path_parameter(const EntryEvaluator *ev,
const AsymptoticRoute *state) {
return ev->camera_t - state->activate_t;
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
static void test_geometry_contract(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
double F = NAN, tol = NAN;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_OK,
"boundary geometry status");
CHECK(F == 0.0, "boundary F is exactly zero");
CHECK(tol > 0.0 && isfinite(tol), "boundary tolerance finite positive");
CHECK(reason == RAY_REASON_NONE, "boundary reason none");
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){20.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_OK &&
F == 300.0,
"outside F is positive 300");
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){5.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_OK &&
F == -75.0,
"inside F is negative 75");
/* History exhaustion beats a miss. */
EntryWorldtube hole = {.radius0 = 10.0, .valid_t_min = 0.0};
source = entry_source(&hole);
CHECK(asymptotic_entry_geometry(&source, 0, -1.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) ==
ASYMPTOTIC_TIME_RANGE_EXHAUSTED &&
reason == RAY_REASON_TIME_RANGE_EXHAUSTED,
"valid=0 is history exhaustion");
/* Callback failure is distinct from an invalid geometry. */
EntryWorldtube fail = {.radius0 = 10.0, .callback_fails = 1};
source = entry_source(&fail);
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_SAMPLE_FAILED,
"callback failure reason");
EntryWorldtube nanr = {.radius0 = 10.0, .nan_radius = 1};
source = entry_source(&nanr);
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_GEOMETRY_INVALID,
"NaN radius is invalid geometry");
EntryWorldtube zeror = {.radius0 = 10.0, .zero_radius = 1};
source = entry_source(&zeror);
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_GEOMETRY_INVALID,
"non-positive radius is invalid geometry");
source = entry_source(&wt);
CHECK(asymptotic_entry_geometry(&source, 0, NAN, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_GEOMETRY_INVALID,
"NaN time is invalid geometry");
CHECK(asymptotic_entry_geometry(NULL, 0, 0.0, (double[]){10.0, 0.0, 0.0},
&F, &tol, &reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_INVALID_ARGUMENT,
"NULL source rejected");
CHECK(asymptotic_entry_geometry(&source, 0, 0.0, NULL, &F, &tol,
&reason) == ASYMPTOTIC_INVALID,
"NULL position rejected");
}
static void test_validate_contract(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
int valid = -1;
RayReason reason = RAY_REASON_COUNT;
AsymptoticRoute candidate = {.kind = ASYMPTOTIC_ROUTE_ENTRY,
.end_id = 0,
.activate_t = 0.0,
.x = {10.0, 0.0, 0.0},
.Pi = {-1.0, 0.0, 0.0},
.log_alpha_p0 = 0.5,
.log_alpha_p0_camera = 0.25};
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_OK &&
valid == 1,
"boundary candidate is valid");
candidate.x[0] = 11.0; /* F = 21 > tol */
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_OK &&
valid == 0 && reason == RAY_REASON_NONE,
"outside candidate is valid=0 with OK status");
candidate.x[0] = 5.0; /* F = -75, far inside */
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_OK &&
valid == 0,
"deep-inside candidate is valid=0 with OK status");
candidate.x[0] = 10.0;
candidate.kind = ASYMPTOTIC_ROUTE_ESCAPED;
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_INVALID &&
valid == 0 && reason == RAY_REASON_PROTOCOL_ERROR,
"wrong candidate kind is a protocol error");
candidate.kind = ASYMPTOTIC_ROUTE_ENTRY;
candidate.x[0] = NAN;
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_INVALID &&
reason == RAY_REASON_PROTOCOL_ERROR,
"non-finite candidate is a protocol error");
/* History exhaustion propagates through validation. */
candidate.x[0] = 10.0;
candidate.activate_t = -1.0;
EntryWorldtube hole = {.radius0 = 10.0, .valid_t_min = 0.0};
source = entry_source(&hole);
CHECK(asymptotic_entry_validate(&source, 0, &candidate, &valid, &reason) ==
ASYMPTOTIC_TIME_RANGE_EXHAUSTED &&
reason == RAY_REASON_TIME_RANGE_EXHAUSTED,
"validation propagates history exhaustion");
}
static void test_fixed_sphere_localize(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.75,
.L0camera = 0.5};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"fixed-sphere localize succeeds");
CHECK(out.kind == ASYMPTOTIC_ROUTE_ENTRY && out.end_id == 0,
"localized kind and end");
const double s = path_parameter(&ev, &out);
const double s_true = entry_oracle_root(&ev, &wt, 30.0, 45.0);
CHECK(isfinite(s_true), "oracle found the same bracket");
CHECK(s >= s_true && s - s_true <= 1e-9,
"localized just past first entry");
CHECK(fabs(s - 40.0) <= 1e-9, "fixed-sphere entry at s=40");
CHECK(out.Pi[0] == 1.0 && out.Pi[1] == 0.0 && out.Pi[2] == 0.0,
"direction preserved exactly");
CHECK(out.log_alpha_p0 == 0.75 && out.log_alpha_p0_camera == 0.5,
"L and camera L preserved exactly");
CHECK(evaluations >= 2 && evaluations <= 260, "evaluation count bounded");
CHECK(ev.evaluator_call_count == (int)evaluations,
"evaluator calls counted once each");
CHECK(wt.eval_calls == 0, "no metric evaluation outside the worldtube");
}
static void test_too_early_hint(void) {
/* Outside endpoint is the camera (a deliberately too-early, corrupted
* bracket); the localizer still returns the true first entry, not the
* inside hint and not the camera. */
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.1,
.L0camera = 0.2};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 0.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"too-early hint still localizes");
const double s = path_parameter(&ev, &out);
CHECK(fabs(s - 40.0) <= 1e-9, "returns actual first entry, not the hint");
CHECK(s > 1.0 && s < 45.0, "not the camera and not the inside hint");
CHECK(evaluations <= 260, "hint evaluation budget");
}
static void test_moving_sphere_localize(void) {
EntryWorldtube wt = {.radius0 = 10.0,
.center_vel = {0.5, 0.0, 0.0},
.valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {100.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.3,
.L0camera = 0.4};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 150.0,
200.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"translated+ moving sphere localize");
const double s = path_parameter(&ev, &out);
const double s_true = entry_oracle_root(&ev, &wt, 150.0, 200.0);
CHECK(fabs(s - s_true) <= 1e-8 && fabs(s - 180.0) <= 1e-8,
"moving-sphere entry at s=180");
CHECK(evaluations <= 260, "moving-sphere evaluation budget");
}
static void test_radius_rate_localize(void) {
/* radius(t) = radius0 + radius_rate * t with radius_rate = -1 and t = -s, so
* R grows as 10 + s; the entry is at s = 45. */
EntryWorldtube wt = {.radius0 = 10.0,
.radius_rate = -1.0,
.valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {100.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.6,
.L0camera = 0.6};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
60.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"linear radius-rate localize");
const double s = path_parameter(&ev, &out);
const double s_true = entry_oracle_root(&ev, &wt, 30.0, 60.0);
CHECK(fabs(s - s_true) <= 1e-8 && fabs(s - 45.0) <= 1e-8,
"linear radius-rate entry at s=45");
CHECK(evaluations <= 260, "radius-rate evaluation budget");
}
static void test_rotated_frame_localize(void) {
/* Camera on a rotated axis: (40,30,0), past direction toward the origin. */
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {40.0, 30.0, 0.0},
.w = {-0.8, -0.6, 0.0},
.L0 = 0.2,
.L0camera = 0.1};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"rotated flat frame localize");
const double s = path_parameter(&ev, &out);
const double s_true = entry_oracle_root(&ev, &wt, 30.0, 45.0);
CHECK(fabs(s - s_true) <= 1e-9 && fabs(s - 40.0) <= 1e-9,
"rotated-frame entry at s=40");
CHECK(fabs(out.x[1] - 6.0) <= 1e-6, "rotated entry position on sphere");
}
static void test_grazing_first_entry(void) {
/* Grazing pass: the camera is offset by 9.9 from the sphere axis. The first
* entry at s ~ 48.589 is inside the bracket; the exit at s ~ 51.410 is not.
* Bisection must return the first entry, not the later exit. */
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 9.9, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.0,
.L0camera = 0.0};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 48.0,
50.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"grazing first entry localizes");
const double s = path_parameter(&ev, &out);
const double first = 50.0 - sqrt(100.0 - 9.9 * 9.9);
CHECK(fabs(s - first) <= 1e-8, "grazing entry is the first crossing");
CHECK(s < 51.4, "not the later exit crossing");
CHECK(fabs(out.x[1] - 9.9) <= 1e-9, "grazing impact parameter preserved");
CHECK(evaluations <= 260, "grazing evaluation budget");
}
static void test_curved_arc_localize(void) {
/* Circular analytic arc of radius 30 and worldtube centered at (25,0,0)
* radius 8; entry at arc length ~ 87.40. */
EntryWorldtube wt = {.radius0 = 8.0,
.center0 = {25.0, 0.0, 0.0},
.valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.arc_mode = 1,
.arc_center = {0.0, 0.0, 0.0},
.arc_radius = 30.0,
.arc_theta0 = TEST_PI,
.L0 = 0.9,
.L0camera = 0.8};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
const double lo = 30.0 * (5.9 - TEST_PI);
const double hi = 30.0 * (6.2 - TEST_PI);
const double s_true = entry_oracle_root(&ev, &wt, lo, hi);
CHECK(isfinite(s_true), "curved oracle bracket");
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, lo, hi,
&out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"curved arc localize");
const double s = path_parameter(&ev, &out);
CHECK(s >= s_true - 1e-9 && s - s_true <= 1e-8,
"curved arc entry matches the oracle");
/* d^2(theta) = 1525 - 1500 cos(theta) = 8^2 on the arc. */
const double expected =
30.0 * (2.0 * TEST_PI - acos((1525.0 - 64.0) / 1500.0) - TEST_PI);
CHECK(fabs(s - expected) <= 1e-8, "curved arc entry matches analytic root");
CHECK(evaluations <= 260, "curved arc evaluation budget");
}
static void test_boundary_entry_exact(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.L0 = 0.4,
.L0camera = 0.4};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
/* F == 0 exactly at the outside endpoint and strictly inside at 45. */
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 40.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"exact boundary entry localize");
CHECK(out.kind == ASYMPTOTIC_ROUTE_ENTRY && out.activate_t == -40.0 &&
out.x[0] == 10.0,
"boundary endpoint returned directly");
CHECK(evaluations == 2, "boundary path needs no bisection");
}
static void test_unconfirmed_bracket(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0}};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
/* Both endpoints outside: no strict-inside bracket. */
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 10.0,
20.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_ENTRY_UNCONFIRMED,
"false candidate outside bracket is unconfirmed, not escaped");
/* Both endpoints strictly inside: also no entry bracket. */
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 45.0,
50.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_ENTRY_UNCONFIRMED,
"both-inside bracket is unconfirmed");
/* Reversed bracket ordering. */
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 45.0,
30.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_INVALID_ARGUMENT,
"reversed bracket rejected");
}
static void test_callback_failure_propagation(void) {
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0}};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
EntryWorldtube fail = {.radius0 = 10.0,
.valid_t_min = -1.0e300,
.callback_fails = 1};
SpacetimeSource source = entry_source(&fail);
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_SAMPLE_FAILED,
"endpoint callback failure propagates");
fail = (EntryWorldtube){.radius0 = 10.0,
.valid_t_min = -1.0e300,
.fail_at_call = 2};
source = entry_source(&fail);
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_SAMPLE_FAILED,
"inside endpoint callback failure propagates");
fail = (EntryWorldtube){.radius0 = 10.0,
.valid_t_min = -1.0e300,
.fail_at_call = 3};
source = entry_source(&fail);
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_WORLDTUBE_SAMPLE_FAILED,
"midpoint callback failure propagates");
}
static void test_history_hole_propagation(void) {
/* The inside endpoint falls past the valid history: the driver must report
* TIME_RANGE_EXHAUSTED, never a miss or a fabricated entry. */
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -40.0};
SpacetimeSource source = entry_source(&wt);
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0}};
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED &&
reason == RAY_REASON_TIME_RANGE_EXHAUSTED,
"endpoint history hole propagates");
/* A midpoint-only history hole: both endpoints are valid, but the first
* bisection midpoint (t = -37.5) falls in an isolated invalid window. */
wt = (EntryWorldtube){.radius0 = 10.0,
.valid_t_min = -1.0e300,
.hole_center = -37.5,
.hole_halfwidth = 0.5};
source = entry_source(&wt);
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED &&
reason == RAY_REASON_TIME_RANGE_EXHAUSTED,
"midpoint history hole propagates, never a miss");
}
static void test_evaluator_failure_propagation(void) {
EntryWorldtube wt = {.radius0 = 10.0, .valid_t_min = -1.0e300};
SpacetimeSource source = entry_source(&wt);
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
EntryEvaluator ev = {.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.evaluator_fails_at = 1,
.fail_status = ASYMPTOTIC_TIME_RANGE_EXHAUSTED};
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED &&
reason == RAY_REASON_TIME_RANGE_EXHAUSTED,
"evaluator history failure propagates");
ev = (EntryEvaluator){.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.evaluator_fails_at = 1,
.fail_status = ASYMPTOTIC_INVALID};
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID &&
reason == RAY_REASON_ENTRY_UNCONFIRMED,
"evaluator invalid failure maps to unconfirmed");
ev = (EntryEvaluator){.camera_t = 0.0,
.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0},
.evaluator_fails_at = 2,
.fail_status = ASYMPTOTIC_INVALID};
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 30.0,
45.0, &out, &evaluations,
&reason) == ASYMPTOTIC_INVALID,
"inside endpoint evaluator failure propagates");
}
static AsymptoticStatus wide_parameter_path(void *context, double parameter,
AsymptoticRoute *state) {
(void)context;
*state = (AsymptoticRoute){.kind = ASYMPTOTIC_ROUTE_ENTRY,
.end_id = 0,
.activate_t = -parameter,
.x = {2.0 - parameter * 1e-308, 0.0, 0.0},
.Pi = {1.0, 0.0, 0.0}};
return ASYMPTOTIC_OK;
}
static void test_representability_and_state_checks(void) {
EntryWorldtube wt = {.radius0 = 1.0, .valid_t_min = -DBL_MAX};
SpacetimeSource source = entry_source(&wt);
AsymptoticRoute out;
unsigned int evaluations = 0;
RayReason reason = RAY_REASON_COUNT;
/* Both endpoints are finite, but subtracting them overflows. This must not
* be mistaken for an adjacent bracket and return the far-inside endpoint. */
CHECK(asymptotic_entry_localize(&source, 0, wide_parameter_path, NULL,
-1.6e308, 1.6e308, &out, &evaluations,
&reason) == ASYMPTOTIC_OK,
"overflow-safe parameter midpoint");
CHECK(fabs(out.x[0] - 1.0) < 1e-14 && evaluations > 2,
"wide bracket contracts to entry, not initial inside endpoint");
wt.radius0 = 10.0;
EntryEvaluator ev = {.camera_x = {50.0, 0.0, 0.0},
.w = {-1.0, 0.0, 0.0}, .nonfinite_at = 1};
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 40.0,
45.0, &out, &evaluations, &reason) ==
ASYMPTOTIC_INVALID && reason == RAY_REASON_ENTRY_UNCONFIRMED,
"boundary shortcut rejects nonfinite camera energy reference");
ev.evaluator_call_count = 0;
ev.nonfinite_at = 3;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 35.0,
45.0, &out, &evaluations, &reason) ==
ASYMPTOTIC_INVALID && reason == RAY_REASON_ENTRY_UNCONFIRMED,
"midpoint rejects nonfinite camera energy reference");
ev.evaluator_call_count = 0;
ev.nonfinite_at = 0;
ev.reversed_time_at = 3;
CHECK(asymptotic_entry_localize(&source, 0, entry_evaluator_cb, &ev, 35.0,
45.0, &out, &evaluations, &reason) ==
ASYMPTOTIC_INVALID && reason == RAY_REASON_ENTRY_UNCONFIRMED,
"midpoint cannot reverse coordinate time");
}
int main(void) {
test_geometry_contract();
test_validate_contract();
test_fixed_sphere_localize();
test_too_early_hint();
test_moving_sphere_localize();
test_radius_rate_localize();
test_rotated_frame_localize();
test_grazing_first_entry();
test_curved_arc_localize();
test_boundary_entry_exact();
test_unconfirmed_bracket();
test_callback_failure_propagation();
test_history_hole_propagation();
test_evaluator_failure_propagation();
test_representability_and_state_checks();
if (failures == 0)
puts("asymptotic entry regression passed");
else
fprintf(stderr, "%d asymptotic entry regression failures\n", failures);
return failures == 0 ? 0 : 1;
}
+65
View File
@@ -0,0 +1,65 @@
/* Exercise the private numerical kernel directly, including coefficient
* ranges that cannot be represented by a public double worldtube fixture.
* The build rule omits the separately compiled asymptotic.c. */
#include "../src/asymptotic.c"
#include <stdio.h>
static int failures;
#define CHECK(condition, message) do { \
if (!(condition)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, message); \
++failures; \
} \
} while (0)
static void check_scaled(int exponent) {
const long double scale = scalbnl(1.0L, exponent);
double root = -1.0;
EntryQuadratic k = {scale, -3.0L * scale, 2.0L * scale};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_ENTRY && root == 1.0,
"common scale preserves smallest inward root");
k = (EntryQuadratic){scale, -scale, scale};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_MISS,
"common scale preserves a clear miss");
k = (EntryQuadratic){0.0L, -scale, 2.0L * scale};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_ENTRY && root == 2.0,
"common scale preserves linear entry");
k = (EntryQuadratic){scale, -scale, 0.0L};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_ENTRY && root == 0.0,
"common scale preserves boundary entry");
k = (EntryQuadratic){-scale, scale, 2.0L * scale};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_ENTRY && root == 2.0,
"common scale preserves concave entry");
}
static void test_product_cancellation(void) {
const long double u = scalbnl(1.0L, 1 - LDBL_MANT_DIG);
const EntryQuadratic k = {1.0L + u, -2.0L, 1.0L - 0.5L * u};
long double scale;
(void)entry_discriminant(&k, &scale);
/* Exact dyadic oracle: 4 - 4(1+u)(1-u/2) = -2u + 2u^2.
* A separately rounded 4*a*c is 4 and loses this nonzero discriminant. */
double root;
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_UNCERTAIN,
"product cancellation must remain uncertain, not a proven miss");
}
int main(void) {
check_scaled(0);
check_scaled(LDBL_MAX_EXP - 4);
check_scaled(LDBL_MIN_EXP + 4);
check_scaled(LDBL_MIN_EXP - LDBL_MANT_DIG + 2);
test_product_cancellation();
double root;
EntryQuadratic k = {LDBL_MIN, LDBL_MAX / 8.0L, 1.0L};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_UNCERTAIN,
"scaling cannot silently erase a nonzero coefficient");
k = (EntryQuadratic){1.0L, 2.0L, -INFINITY};
CHECK(entry_solve(&k, &root) == ENTRY_SOLVE_UNCERTAIN,
"nonfinite coefficient is not a normal entry");
if (!failures)
puts("asymptotic quadratic regression passed");
return failures ? 1 : 0;
}
+120
View File
@@ -6,6 +6,7 @@
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
static int failures = 0;
#define CHECK(condition, message) \
@@ -189,6 +190,8 @@ static void test_preroute_entry(void) {
ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"outside camera enters");
CHECK(route.entry_fallback_evaluations == 0,
"analytic entry stays on the fast path");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
@@ -521,6 +524,122 @@ static void test_preroute_branches(void) {
spacetime_destroy(&source);
}
/* Translated-origin wrapper around the analytic Schwarzschild KS source: the
* inner metric is evaluated at x - origin and the worldtube/end are shifted by
* the same origin. This models a black hole at a large coordinate offset; the
* double reconstruction of the boundary state loses the sub-ULP offset and
* trips the common entry fallback, while the exact inward orbit transfer stays
* valid. It exercises the production fallback path, not a synthetic
* nonlinearity. */
typedef struct {
SpacetimeSource inner;
double origin[3];
} ShiftedOriginContext;
static SpacetimePointStatus shifted_origin_eval(const SpacetimeSource *source,
double t, const double x[3],
MetricData *metric) {
const ShiftedOriginContext *ctx = source->context;
const double local[3] = {x[0] - ctx->origin[0], x[1] - ctx->origin[1],
x[2] - ctx->origin[2]};
return spacetime_eval(&ctx->inner, t, local, metric);
}
static SpacetimeRayStatus shifted_origin_classify(const SpacetimeSource *source,
double t,
const double x[3]) {
const ShiftedOriginContext *ctx = source->context;
const double local[3] = {x[0] - ctx->origin[0], x[1] - ctx->origin[1],
x[2] - ctx->origin[2]};
return spacetime_classify(&ctx->inner, t, local);
}
static size_t shifted_origin_end_count(const SpacetimeSource *source) {
const ShiftedOriginContext *ctx = source->context;
return spacetime_asymptotic_end_count(&ctx->inner);
}
static int shifted_origin_end(const SpacetimeSource *source, size_t index,
SpacetimeAsymptoticEnd *out) {
const ShiftedOriginContext *ctx = source->context;
if (spacetime_asymptotic_end(&ctx->inner, index, out))
return -1;
for (int i = 0; i < 3; ++i)
out->frame_origin[i] = ctx->origin[i];
return 0;
}
static int shifted_origin_worldtube(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
SpacetimeEscapeWorldtubeSample *out) {
const ShiftedOriginContext *ctx = source->context;
if (spacetime_escape_worldtube_sample(&ctx->inner, end_id, t, out))
return -1;
for (int i = 0; i < 3; ++i)
out->center[i] += ctx->origin[i];
return 0;
}
static void shifted_origin_destroy(SpacetimeSource *source) {
ShiftedOriginContext *ctx = source->context;
if (ctx != NULL) {
spacetime_destroy(&ctx->inner);
free(ctx);
}
source->context = NULL;
source->ops = NULL;
}
static const SpacetimeOps shifted_origin_ops = {
.eval = shifted_origin_eval,
.classify = shifted_origin_classify,
.asymptotic_end_count = shifted_origin_end_count,
.asymptotic_end = shifted_origin_end,
.escape_worldtube_sample = shifted_origin_worldtube,
.destroy = shifted_origin_destroy};
static void test_translated_origin_fallback(void) {
ShiftedOriginContext *ctx = malloc(sizeof *ctx);
CHECK(ctx != NULL, "shifted-origin context");
if (ctx == NULL)
return;
ctx->origin[0] = 1.0e6;
ctx->origin[1] = 2.0e6;
ctx->origin[2] = -3.0e6;
CHECK(spacetime_create_schwarzschild_ks(&ctx->inner, 1.0, 256.0) == 0,
"shifted-origin inner source");
SpacetimeSource source = {.ops = &shifted_origin_ops, .context = ctx};
ObserverCamera cam = {.look_ra_deg = 180.0, .look_dec_deg = 0.0};
for (int i = 0; i < 3; ++i)
cam.position[i] = ctx->origin[i];
cam.position[0] += 500.0;
MetricData metric;
CHECK(spacetime_eval(&source, 0.0, cam.position, &metric) == 0,
"shifted-origin camera metric");
ObserverState observer;
CHECK(observer_from_coordinate_camera(&metric, &cam, &observer, NULL) ==
OBSERVER_BUILD_OK,
"shifted-origin camera observer");
const double direction[3] = {cos(0.3), sin(0.3), 0.0};
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer, direction, &route) ==
ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"shifted-origin entry found");
CHECK(route.entry_fallback_evaluations > 0,
"shifted-origin entry used the common fallback");
CHECK(route.failure_reason == RAY_REASON_NONE,
"shifted-origin fallback has no failure reason");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
value <= 0.0,
"shifted-origin fallback state is inside the worldtube");
CHECK(route.activate_t < 0.0, "shifted-origin entry is in the past");
spacetime_destroy(&source);
}
int main(void) {
test_round_trip();
test_finish_matches_integration();
@@ -532,6 +651,7 @@ int main(void) {
test_time_reference();
test_grazing_reference();
test_preroute_branches();
test_translated_origin_fallback();
if (failures == 0)
puts("asymptotic schwarzschild regression passed");
else
+57 -11
View File
@@ -115,14 +115,15 @@ def map_vertices(path):
return values, data[offset:offset + triangles * MAP_TRIANGLE_SIZE]
with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') as directory:
tmp = Path(directory)
for backend in ('minkowski', 'schwarzschild'):
binary = BUILD / f'{backend}_sky'
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
hdr_available = '--hdr-output' in help_text
for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'):
hdr_available = any(line.startswith(' --hdr-output ')
for line in help_text.splitlines())
for option in ('--observer-time', '--observer-position', '--observer-velocity', '--camera-roll-deg'):
assert option in help_text
assert '--tone-map' in help_text and '--tone-map-p' in help_text
assert '--sensor-bloom-limit' in help_text
@@ -149,7 +150,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
fast_path = tmp / f'minkowski_fast.{ext}'
fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2,
'--output', fast_path)
assert 'Fast FFTW:' in fast.stderr, fast.stderr
assert 'Fast FFTW:' in fast.stdout, fast.stdout
assert image_payload(fast_path)
# Equivalent independently specified and inferred camera geometry.
@@ -162,6 +163,15 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert pole == render('pole_explicit', '--observer-position', 0, 0, 30,
'--look-ra-deg', 0, '--look-dec-deg', -90)
default = render('default')
# Stationary backends are time-translation invariant; the event time
# must nevertheless survive in lens-map metadata, including negatives.
for time in (0, 12.5, -12.5):
timed_map = tmp / f'{backend}_time_{time}.grlens'
assert default == render('timed', '--observer-time', time,
'--lens-map-output', timed_map)
saved_time, proper_time = struct.unpack_from(
'<dd', timed_map.read_bytes(), MAP_FRAME_HEADER_START + 8)
assert saved_time == time and proper_time == 0
pos = (0, 0, 0) if backend == 'minkowski' else (0, 0, 30)
assert default == render('default_explicit', '--observer-position', *pos,
'--look-ra-deg', 90, '--look-dec-deg', -90)
@@ -217,10 +227,19 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
tmp / 'missing_catalog.csv', '--output', long_path,
ok=False)
assert 'Mesh overlay output path is too long' in too_long.stderr, too_long.stderr
assert 'Blackbody backend' not in too_long.stderr, too_long.stderr
assert 'PSF cache ready' not in too_long.stderr
assert 'Blackbody backend' not in (too_long.stdout + too_long.stderr), too_long.stderr
assert 'PSF cache ready' not in (too_long.stdout + too_long.stderr)
errors = [
(['--observer-time'], None),
(['--observer-time', ''], None),
(['--observer-time', 'bad'], None),
(['--observer-time', 'nan'], None),
(['--observer-time', 'inf'], None),
(['--observer-time', '-inf'], None),
(['--observer-track', 'missing.csv', '--observer-time', 0], 'cannot be combined'),
(['--frames-dir', tmp, '--observer-time', 0], 'cannot be combined'),
(['--lens-map-input', 'missing.grlens', '--observer-time', 0], 'cannot be combined'),
(['--observer-position', 1, 2], None),
(['--observer-position', 1, 2, 'nan'], None),
(['--observer-velocity', 0, 0, 'inf'], None),
@@ -275,7 +294,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
if message:
assert message in result.stderr, result.stderr
assert not missing_catalog.exists(), result.stderr
assert 'PSF cache ready' not in result.stderr
assert 'PSF cache ready' not in (result.stdout + result.stderr)
track = tmp / f'{backend}.csv'
run(TESTDIR / f'test_observer_{backend}', track)
@@ -378,8 +397,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
'--sensor-bloom-transfer', 0.5,
'--output', bloom_output)
assert image_payload(bloom_output) != baseline
assert 'Sensor bloom:' in bloom_run.stderr, bloom_run.stderr
report = bloom_run.stderr.split('Sensor bloom:', 1)[1].splitlines()[0]
assert 'Sensor bloom:' in bloom_run.stdout, bloom_run.stdout
report = bloom_run.stdout.split('Sensor bloom:', 1)[1].splitlines()[0]
fields = dict(token.split('=', 1) for token in report.split() if '=' in token)
assert int(fields['saturated']) > 0, report
assert int(fields['iterations'].split('/')[0]) >= 1, report
@@ -424,8 +443,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
'--movie-track-samples', '--frames-dir', tmp,
'--frames-prefix', 'mixed', '--verbose',
'--lens-map-output', parallel_map)
assert 'Ray trace generation 1: frame 0 added' in result.stderr
assert 'Ray trace generation 0: frame 1 added' not in result.stderr
assert 'Ray trace generation 1: frame 0 added' in result.stdout
assert 'Ray trace generation 0: frame 1 added' not in result.stdout
for frame in range(2):
image_payload(tmp / f'mixed_{frame:06d}.{ext}',
dimensions=(64, 36), allow_black=True)
@@ -443,3 +462,30 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
f'movie lens map changed with {threads} threads'
print('schwarzschild: movie lens map identical with 1, 4 and 16 threads', flush=True)
print(f'{backend}: CLI checks passed; single/movie image identical, map max error {max_error:.3g}', flush=True)
# In the moving bubble, (t, x) -> (t+T, x+v_s*T) preserves the
# metric and physical ray endpoints. This detects a stale t=0 in either
# camera metric evaluation or ray initialization, not just map metadata.
alc = BUILD / 'alcubierre_sky'
if alc.exists():
reference = None
for time in (0, 12.5, -12.5):
path = tmp / f'alcubierre_time_{time}.grlens'
run(alc, *common, '--alcubierre-vs', 0.3,
'--alcubierre-radius', 1, '--observer-time', time,
'--observer-position', 0.3 * time, 0, 0,
'--observer-velocity', 0.3, 0, 0,
'--look-ra-deg', 0, '--look-dec-deg', 0,
'--lens-map-output', path, '--output', tmp / f'alcubierre.{ext}')
assert struct.unpack_from('<d', path.read_bytes(),
MAP_FRAME_HEADER_START + 8)[0] == time
vertices, triangles = map_vertices(path)
if reference is None:
reference = vertices, triangles
continue
expected, expected_triangles = reference
assert len(vertices) == len(expected) and triangles == expected_triangles
for actual, baseline in zip(vertices, expected):
assert actual[9:12] == baseline[9:12]
assert max(abs(a - b) for a, b in zip(actual[:9], baseline[:9])) < 1e-8
print('alcubierre: nonzero camera-time translation checks passed', flush=True)
+88 -4
View File
@@ -1,5 +1,6 @@
#include "frame.h"
#include "lens_map.h"
#include "mesh_overlay.h"
#include "optics.h"
#include <math.h>
@@ -1086,10 +1087,27 @@ int main(void) {
if (!fast_ok)
goto done;
}
frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5);
if (hdr[3 * (10 * width + 20)] != 0.25) {
fputs("mesh diagnostic overlay regression failed\n", stderr);
goto done;
/* Diagnostic overlay draws the finalized mesh as an sRGB8 edge map: the
* vertical coarse edge crossing (20, 10) must be painted, while an interior
* pixel away from every edge must stay at the background value. */
{
MeshOverlayLines overlay_lines = {0};
const MeshOverlaySettings overlay_settings = mesh_overlay_default_settings();
unsigned char *overlay_rgb = calloc((size_t)width * height * 3, 1);
const int overlay_ok =
overlay_rgb != NULL &&
mesh_overlay_prepare(&mesh, &overlay_lines) == 0 &&
overlay_lines.count != 0 &&
mesh_overlay_draw_rgb8(&overlay_lines, overlay_rgb, width, height,
&overlay_settings) == 0 &&
overlay_rgb[3 * (10 * width + 20)] != 0 &&
overlay_rgb[3 * (12 * width + 5)] == 0;
mesh_overlay_lines_destroy(&overlay_lines);
free(overlay_rgb);
if (!overlay_ok) {
fputs("mesh diagnostic overlay regression failed\n", stderr);
goto done;
}
}
/* A fixed absolute edge tolerance used to make tiny source triangles claim
* sources far outside their field. */
@@ -1418,6 +1436,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,11 +1480,38 @@ 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;
}
lens_map_destroy(&dloaded);
/* Truncate within the first v3 vertex, including each terminal field.
* Failed reads must reject the map and release its partially read mesh. */
{
unsigned char prefix[316];
FILE *fixture = fopen(dp_path, "rb");
int fixture_failed = fixture == NULL ||
fread(prefix, 1, sizeof prefix, fixture) != sizeof prefix;
if (fixture != NULL && fclose(fixture)) fixture_failed = 1;
if (fixture_failed) {
fputs("lens-map truncation fixture read failed\n", stderr);
unlink(dp_path); goto done;
}
const size_t cuts[] = {232, 303, 304, 307, 308, 311, 312, 315};
for (size_t c = 0; c < sizeof cuts / sizeof cuts[0]; ++c) {
FILE *short_file = fopen(dp_path, "wb");
int short_failed = short_file == NULL ||
fwrite(prefix, 1, cuts[c], short_file) != cuts[c];
if (short_file != NULL && fclose(short_file)) short_failed = 1;
if (short_failed || !lens_map_read(dp_path, NULL, &dloaded) ||
dloaded.frames != NULL || dloaded.frame_count != 0) {
fputs("lens-map truncated vertex rejection regression failed\n", stderr);
lens_map_destroy(&dloaded); unlink(dp_path); goto done;
}
}
}
/* Unknown wire code and non-finite/out-of-bounds DP fields must be rejected
* by the shared schema validator, not accepted as a usable map. */
{
@@ -1497,6 +1546,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)
+109 -3
View File
@@ -3,14 +3,118 @@
#include <math.h>
#include <stdio.h>
#include <string.h>
static int nearly_equal(double a, double b) { return fabs(a - b) < 1e-12; }
/* 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) {
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 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;
}
}
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_ENTRY_UNCONFIRMED) !=
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;
}
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);
@@ -36,7 +140,9 @@ int main(void) {
metric.alpha != 1.0 ||
!spacetime_slab_eval(slab, 0.25, (double[]){0.0, 0.0, 0.0}, &metric))
return 1;
int result = check_ray(&source, &observer, (double[]){1.0, 0.0, 0.0},
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},
(double[]){1.0, 0.0, 0.0});
+82 -14
View File
@@ -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);
+740
View File
@@ -0,0 +1,740 @@
/* Standalone regression for the diagnostic mesh overlay (src/mesh_overlay.c).
* It links only the overlay module, so it needs neither a catalog, ray tracing,
* FFTW, nor an output writer. Every assertion targets observable behavior:
* terminal-category colors, half-edge switching, AA coverage, deduplication,
* deterministic ordering, clipping safety and clean failure on bad input. */
#include "mesh_overlay.h"
#include <float.h>
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int fail(const char *message) {
fprintf(stderr, "%s\n", message);
return -1;
}
static LensVertex make_vertex(double x, double y, int traced,
RayOutcome outcome) {
LensVertex vertex;
memset(&vertex, 0, sizeof vertex);
vertex.image_x = x;
vertex.image_y = y;
vertex.traced = traced;
vertex.outcome = outcome;
return vertex;
}
static int channel_at(const unsigned char *rgb, int width, int x, int y,
int channel) {
return rgb[3 * ((size_t)y * (size_t)width + (size_t)x) + (size_t)channel];
}
static int test_default_settings(void) {
const MeshOverlaySettings settings = mesh_overlay_default_settings();
static const unsigned char expected[MESH_OVERLAY_CATEGORY_COUNT][3] = {
{0x7F, 0x84, 0x9C}, {0xCB, 0xA6, 0xF7}, {0xF9, 0xE2, 0xAF},
{0xF3, 0x8B, 0xA8}, {0x89, 0xB4, 0xFA}};
if (settings.opacity != 0.5)
return fail("default opacity is not 0.5");
for (int category = 0; category < MESH_OVERLAY_CATEGORY_COUNT; ++category)
for (int channel = 0; channel < 3; ++channel)
if (settings.colors[category][channel] != expected[category][channel])
return fail("default palette mismatch");
return 0;
}
static int test_parse_color(void) {
unsigned char rgb[3] = {1, 2, 3};
if (mesh_overlay_parse_color("#7F849C", rgb) != 0 || rgb[0] != 0x7F ||
rgb[1] != 0x84 || rgb[2] != 0x9C)
return fail("parse uppercase failed");
if (mesh_overlay_parse_color("#7f849c", rgb) != 0 || rgb[0] != 0x7F ||
rgb[1] != 0x84 || rgb[2] != 0x9C)
return fail("parse lowercase failed");
if (mesh_overlay_parse_color("#000000", rgb) != 0 || rgb[0] || rgb[1] ||
rgb[2])
return fail("parse black failed");
static const char *const bad[] = {"", "#", "7F849C",
"#7F849", "#7F849C0", "#GG849C",
"#7F84 9C", "#7F849c ", " #7F849C",
"#12345g", "#12345G0"};
for (size_t i = 0; i < sizeof bad / sizeof *bad; ++i)
if (mesh_overlay_parse_color(bad[i], rgb) != -1)
return fail("accepted an invalid color string");
if (mesh_overlay_parse_color(NULL, rgb) != -1)
return fail("accepted NULL text");
if (mesh_overlay_parse_color("#7F849C", NULL) != -1)
return fail("accepted NULL output");
return 0;
}
/* Deduplication, canonical ordering and per-vertex category assignment. */
static int test_prepare_dedup_categories(void) {
LensVertex vertices[4] = {
make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED),
make_vertex(30, 10, 1, RAY_OUTCOME_DARK),
make_vertex(10, 30, 1, RAY_OUTCOME_UNRESOLVED),
make_vertex(30, 30, 1, RAY_OUTCOME_INCOMPLETE)};
LensTriangle triangles[2] = {{{0, 1, 2}, 0, 0, 0}, {{0, 2, 3}, 0, 0, 0}};
FrameLensMesh mesh = {.vertices = vertices,
.vertex_count = 4,
.triangles = triangles,
.triangle_count = 2};
MeshOverlayLines lines = {0};
if (mesh_overlay_prepare(&mesh, &lines) != 0)
return fail("prepare failed on a valid quad");
/* The shared diagonal (0,2) must appear exactly once. */
if (lines.count != 5) {
mesh_overlay_lines_destroy(&lines);
return fail("unique edge count is not 5");
}
static const struct {
unsigned char c0, c1;
} expected[5] = {
{MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_DARK},
{MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_UNRESOLVED},
{MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_INCOMPLETE},
{MESH_OVERLAY_CATEGORY_DARK, MESH_OVERLAY_CATEGORY_UNRESOLVED},
{MESH_OVERLAY_CATEGORY_UNRESOLVED, MESH_OVERLAY_CATEGORY_INCOMPLETE}};
for (size_t i = 0; i < lines.count; ++i)
if (lines.lines[i].category0 != expected[i].c0 ||
lines.lines[i].category1 != expected[i].c1) {
mesh_overlay_lines_destroy(&lines);
return fail("edge category or deterministic order mismatch");
}
if (lines.lines[0].x0 != 10 || lines.lines[0].y0 != 10 ||
lines.lines[0].x1 != 30 || lines.lines[0].y1 != 10) {
mesh_overlay_lines_destroy(&lines);
return fail("edge coordinates mismatch");
}
/* Lines must copy coordinates, never alias the mutable mesh. */
vertices[0].image_x = 999;
if (lines.lines[0].x0 != 10) {
mesh_overlay_lines_destroy(&lines);
return fail("overlay lines alias the live mesh");
}
mesh_overlay_lines_destroy(&lines);
if (lines.lines != NULL || lines.count != 0)
return fail("destroy did not reset the handle");
mesh_overlay_lines_destroy(NULL);
return 0;
}
static int test_untraced_category(void) {
LensVertex vertices[3] = {
make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED),
/* traced == 0 must win over the stale outcome value. */
make_vertex(30, 10, 0, RAY_OUTCOME_ESCAPED),
make_vertex(10, 30, 1, RAY_OUTCOME_INCOMPLETE)};
LensTriangle triangle = {{0, 1, 2}, 0, 0, 0};
FrameLensMesh mesh = {.vertices = vertices,
.vertex_count = 3,
.triangles = &triangle,
.triangle_count = 1};
MeshOverlayLines lines = {0};
if (mesh_overlay_prepare(&mesh, &lines) != 0)
return fail("prepare failed for untraced mesh");
int saw_untraced = 0;
for (size_t i = 0; i < lines.count; ++i)
if (lines.lines[i].category0 == MESH_OVERLAY_CATEGORY_UNTRACED ||
lines.lines[i].category1 == MESH_OVERLAY_CATEGORY_UNTRACED)
saw_untraced = 1;
mesh_overlay_lines_destroy(&lines);
return saw_untraced ? 0 : fail("untraced vertex category missing");
}
/* Boundary edges are emitted regardless of winding, and reversing the winding
* cannot change the deterministic output. */
static int test_boundary_and_winding(void) {
LensVertex vertices[3] = {
make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED),
make_vertex(30, 10, 1, RAY_OUTCOME_DARK),
make_vertex(10, 30, 1, RAY_OUTCOME_UNRESOLVED)};
LensTriangle forward[1] = {{{0, 1, 2}, 0, 0, 0}};
LensTriangle reversed[1] = {{{2, 1, 0}, 0, 0, 0}};
FrameLensMesh mesh_a = {.vertices = vertices,
.vertex_count = 3,
.triangles = forward,
.triangle_count = 1};
FrameLensMesh mesh_b = {.vertices = vertices,
.vertex_count = 3,
.triangles = reversed,
.triangle_count = 1};
MeshOverlayLines a = {0}, b = {0};
if (mesh_overlay_prepare(&mesh_a, &a) != 0 ||
mesh_overlay_prepare(&mesh_b, &b) != 0) {
mesh_overlay_lines_destroy(&a);
mesh_overlay_lines_destroy(&b);
return fail("prepare failed for single triangle");
}
int ok = a.count == 3 && b.count == 3 &&
memcmp(a.lines, b.lines, a.count * sizeof *a.lines) == 0;
mesh_overlay_lines_destroy(&a);
mesh_overlay_lines_destroy(&b);
return ok ? 0 : fail("boundary/winding determinism failed");
}
/* Off-mesh probe witnesses have no triangle edge and must never be emitted or
* drawn as a vertex dot. */
static int test_isolated_witness_not_drawn(void) {
LensVertex vertices[4] = {
make_vertex(10, 50, 1, RAY_OUTCOME_ESCAPED),
make_vertex(30, 50, 1, RAY_OUTCOME_ESCAPED),
make_vertex(20, 30, 1, RAY_OUTCOME_ESCAPED),
make_vertex(90, 90, 1, RAY_OUTCOME_ESCAPED)};
vertices[3].diagnostic_probe = 1;
vertices[3].probe_edge[0] = 0;
vertices[3].probe_edge[1] = 1;
LensTriangle triangle = {{0, 1, 2}, 0, 0, 0};
FrameLensMesh mesh = {.vertices = vertices,
.vertex_count = 4,
.triangles = &triangle,
.triangle_count = 1};
const int width = 100, height = 100;
MeshOverlayLines lines = {0};
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
const MeshOverlaySettings settings = mesh_overlay_default_settings();
if (rgb == NULL || mesh_overlay_prepare(&mesh, &lines) != 0) {
free(rgb);
mesh_overlay_lines_destroy(&lines);
return fail("prepare failed for witness mesh");
}
if (lines.count != 3) {
free(rgb);
mesh_overlay_lines_destroy(&lines);
return fail("isolated witness added an edge");
}
if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) {
free(rgb);
mesh_overlay_lines_destroy(&lines);
return fail("draw failed for witness mesh");
}
const int witness_painted =
channel_at(rgb, width, 90, 90, 0) != 0 ||
channel_at(rgb, width, 90, 90, 1) != 0 ||
channel_at(rgb, width, 90, 90, 2) != 0;
free(rgb);
mesh_overlay_lines_destroy(&lines);
return witness_painted ? fail("isolated witness vertex was drawn as a dot")
: 0;
}
static int test_draw_category_colors(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 1.0;
const int width = 80, height = 40;
for (int category = 0; category < MESH_OVERLAY_CATEGORY_COUNT; ++category) {
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
if (rgb == NULL)
return fail("allocation failed");
MeshOverlayLine line = {.x0 = 10,
.y0 = 20,
.x1 = 70,
.y1 = 20,
.category0 = (unsigned char)category,
.category1 = (unsigned char)category};
MeshOverlayLines lines = {.lines = &line, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
for (int channel = 0; ok && channel < 3; ++channel)
if (channel_at(rgb, width, 40, 20, channel) !=
settings.colors[category][channel])
ok = 0;
free(rgb);
if (!ok)
return fail("category color mismatch");
}
return 0;
}
static int test_halves_and_switch(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 1.0;
const int width = 60, height = 40;
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
if (rgb == NULL)
return fail("allocation failed");
MeshOverlayLine line = {.x0 = 10,
.y0 = 20,
.x1 = 50,
.y1 = 20,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_DARK};
MeshOverlayLines lines = {.lines = &line, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
/* The switch sits at the major-axis midpoint x = 30. Interior pixels are
* fully covered, so each must equal exactly one endpoint color: the whole
* edge is rasterized once, never as two blends that would smear the switch. */
for (int channel = 0; ok && channel < 3; ++channel) {
if (channel_at(rgb, width, 11, 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] ||
channel_at(rgb, width, 29, 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] ||
channel_at(rgb, width, 30, 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel] ||
channel_at(rgb, width, 49, 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel])
ok = 0;
}
free(rgb);
return ok ? 0 : fail("half-edge color switch failed");
}
static int test_antialiasing(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 1.0;
const int width = 60, height = 60;
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
if (rgb == NULL)
return fail("allocation failed");
MeshOverlayLine line = {.x0 = 10,
.y0 = 10,
.x1 = 50,
.y1 = 20,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines lines = {.lines = &line, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
const int full = settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][0];
int partial = 0;
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x) {
const int value = channel_at(rgb, width, x, y, 0);
if (value > 0 && value < full)
++partial;
}
free(rgb);
return ok && partial > 0 ? 0 : fail("no antialiased partial coverage");
}
static int test_subpixel_and_zero_length(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 1.0;
unsigned char pixels[8 * 8 * 3] = {0};
MeshOverlayLine line = {.x0 = 2.1, .y0 = 3.0, .x1 = 2.4, .y1 = 3.0,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines lines = {.lines = &line, .count = 1};
if (mesh_overlay_draw_rgb8(&lines, pixels, 8, 8, &settings))
return fail("subpixel draw failed");
for (int channel = 0; channel < 3; ++channel)
if (channel_at(pixels, 8, 2, 3, channel) !=
lround((line.x1 - line.x0) * settings.colors[0][channel]))
return fail("subpixel edge applied overlapping endpoint blends");
memset(pixels, 0, sizeof pixels);
line.x1 = line.x0;
if (mesh_overlay_draw_rgb8(&lines, pixels, 8, 8, &settings))
return fail("zero-length draw failed");
for (size_t i = 0; i < sizeof pixels; ++i)
if (pixels[i])
return fail("zero-length edge became a vertex dot");
return 0;
}
static int test_high_white_background(void) {
const MeshOverlaySettings settings = mesh_overlay_default_settings();
const int width = 80, height = 40;
unsigned char *rgb = malloc((size_t)width * height * 3);
if (rgb == NULL)
return fail("allocation failed");
memset(rgb, 255, (size_t)width * height * 3);
MeshOverlayLine line = {.x0 = 10,
.y0 = 20,
.x1 = 60,
.y1 = 20,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines lines = {.lines = &line, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
for (int channel = 0; ok && channel < 3; ++channel) {
const long expected =
lround(255.0 * (1.0 - settings.opacity) +
settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] *
settings.opacity);
if (channel_at(rgb, width, 40, 20, channel) != expected)
ok = 0;
}
/* Still clearly visible against white. */
if (ok && channel_at(rgb, width, 40, 20, 0) == 255)
ok = 0;
free(rgb);
return ok ? 0 : fail("overlay not visible on a high-white background");
}
static int test_opacity_extremes(void) {
const int width = 60, height = 40;
MeshOverlayLine line = {.x0 = 10,
.y0 = 20,
.x1 = 50,
.y1 = 20,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines lines = {.lines = &line, .count = 1};
unsigned char *rgb = malloc((size_t)width * height * 3);
if (rgb == NULL)
return fail("allocation failed");
MeshOverlaySettings settings = mesh_overlay_default_settings();
memset(rgb, 0x33, (size_t)width * height * 3);
settings.opacity = 0.0;
if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) {
free(rgb);
return fail("draw failed at opacity 0");
}
for (int i = 0; i < width * height * 3; ++i)
if (rgb[i] != 0x33) {
free(rgb);
return fail("opacity 0 changed the image");
}
memset(rgb, 0x00, (size_t)width * height * 3);
settings.opacity = 1.0;
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
for (int channel = 0; ok && channel < 3; ++channel)
if (channel_at(rgb, width, 30, 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel])
ok = 0;
free(rgb);
return ok ? 0 : fail("opacity 1 did not apply the full color");
}
static int test_clipping_and_huge_coordinates(void) {
const MeshOverlaySettings settings = mesh_overlay_default_settings();
const int width = 64, height = 64;
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
if (rgb == NULL)
return fail("allocation failed");
/* A horizontal line far beyond both image edges must still paint row 30 and
* terminate in bounded time. */
MeshOverlayLine huge = {.x0 = -1e15,
.y0 = 30,
.x1 = 1e15,
.y1 = 30,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_DARK};
MeshOverlayLines lines = {.lines = &huge, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
int painted = 0;
for (int x = 0; x < width; ++x)
if (channel_at(rgb, width, x, 30, 0) != 0)
painted = 1;
if (!ok || !painted) {
free(rgb);
return fail("huge coordinate line was not clipped into view");
}
/* A fully offscreen line leaves the buffer untouched. */
memset(rgb, 0, (size_t)width * height * 3);
MeshOverlayLine offscreen = {.x0 = 1000,
.y0 = 1000,
.x1 = 2000,
.y1 = 1000,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
lines.lines = &offscreen;
if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) {
free(rgb);
return fail("offscreen line returned an error");
}
for (int i = 0; i < width * height * 3; ++i)
if (rgb[i] != 0) {
free(rgb);
return fail("offscreen line painted the image");
}
/* A huge diagonal must not overflow the integer conversions. */
MeshOverlayLine diagonal = {.x0 = -1e12,
.y0 = -1e12,
.x1 = 1e12,
.y1 = 1e12,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_DARK};
lines.lines = &diagonal;
if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) {
free(rgb);
return fail("huge diagonal returned an error");
}
free(rgb);
return 0;
}
static int test_extreme_magnitudes(void) {
const MeshOverlaySettings settings = mesh_overlay_default_settings();
const int width = 64, height = 64;
const size_t bytes = (size_t)width * height * 3;
unsigned char *rgb = malloc(bytes);
if (rgb == NULL)
return fail("allocation failed");
/* -DBL_MAX..+DBL_MAX overflows the endpoint difference to infinity: the whole
* batch must be rejected before painting and the buffer left untouched. */
MeshOverlayLine bad = {.x0 = -DBL_MAX,
.y0 = 10,
.x1 = DBL_MAX,
.y1 = 10,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines lines = {.lines = &bad, .count = 1};
memset(rgb, 0x5A, bytes);
int rejected =
mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == -1;
int unchanged = 1;
for (size_t i = 0; i < bytes; ++i)
if (rgb[i] != 0x5A)
unchanged = 0;
if (!rejected || !unchanged) {
free(rgb);
return fail("+-DBL_MAX x-delta not rejected unchanged");
}
bad.x0 = 10;
bad.x1 = 10;
bad.y0 = -DBL_MAX;
bad.y1 = DBL_MAX;
memset(rgb, 0x5A, bytes);
rejected = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == -1;
unchanged = 1;
for (size_t i = 0; i < bytes; ++i)
if (rgb[i] != 0x5A)
unchanged = 0;
if (!rejected || !unchanged) {
free(rgb);
return fail("+-DBL_MAX y-delta not rejected unchanged");
}
/* Same-sign DBL_MAX endpoints have a finite difference and a finite (non
* overflowing) midpoint; the segment is entirely offscreen, so it is skipped
* safely without painting. */
MeshOverlayLine same = {.x0 = DBL_MAX,
.y0 = 10,
.x1 = DBL_MAX,
.y1 = 30,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_DARK};
lines.lines = &same;
memset(rgb, 0x5A, bytes);
if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) {
free(rgb);
return fail("same-sign DBL_MAX segment was not handled safely");
}
for (size_t i = 0; i < bytes; ++i)
if (rgb[i] != 0x5A) {
free(rgb);
return fail("offscreen same-sign DBL_MAX segment painted the image");
}
free(rgb);
return 0;
}
static int test_clipped_midpoint_uses_original(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 1.0;
const int width = 64, height = 40;
unsigned char *rgb = calloc((size_t)width * height * 3, 1);
if (rgb == NULL)
return fail("allocation failed");
/* The original midpoint is -40, so the entire visible span [0, 19] lies in
* the second half and every visible pixel must use category1. A midpoint
* recomputed from the clipped endpoints would wrongly color the left half. */
MeshOverlayLine line = {.x0 = -100,
.y0 = 20,
.x1 = 20,
.y1 = 20,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_DARK};
MeshOverlayLines lines = {.lines = &line, .count = 1};
int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0;
static const int probes[] = {0, 1, 5, 10, 19};
for (size_t p = 0; ok && p < sizeof probes / sizeof *probes; ++p)
for (int channel = 0; channel < 3; ++channel)
if (channel_at(rgb, width, probes[p], 20, channel) !=
settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel])
ok = 0;
free(rgb);
return ok ? 0
: fail("clipped edge did not use the original midpoint category");
}
static int test_invalid_arguments(void) {
MeshOverlaySettings settings = mesh_overlay_default_settings();
unsigned char pixels[4 * 4 * 3] = {0};
MeshOverlayLines lines = {0};
if (mesh_overlay_prepare(NULL, &lines) != -1)
return fail("prepare accepted NULL mesh");
if (mesh_overlay_prepare(NULL, NULL) != -1)
return fail("prepare accepted NULL lines");
FrameLensMesh empty = {0};
if (mesh_overlay_prepare(&empty, &lines) != 0 || lines.count != 0 ||
lines.lines != NULL)
return fail("empty mesh did not produce an empty edge set");
LensVertex vertices[3] = {
make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED),
make_vertex(30, 10, 1, RAY_OUTCOME_ESCAPED),
make_vertex(10, 30, 1, RAY_OUTCOME_ESCAPED)};
LensTriangle triangle = {{0, 1, 2}, 0, 0, 0};
FrameLensMesh mesh = {.vertices = vertices,
.vertex_count = 2, /* vertex 2 is out of range */
.triangles = &triangle,
.triangle_count = 1};
if (mesh_overlay_prepare(&mesh, &lines) != -1)
return fail("prepare accepted an out-of-range vertex");
mesh.vertex_count = 3;
vertices[2].image_x = NAN;
if (mesh_overlay_prepare(&mesh, &lines) != -1)
return fail("prepare accepted a nonfinite coordinate");
vertices[2].image_x = 10;
mesh.triangles = NULL;
if (mesh_overlay_prepare(&mesh, &lines) != -1)
return fail("prepare accepted NULL triangles");
FrameLensMesh overflow = {0};
overflow.triangle_count = SIZE_MAX; /* > SIZE_MAX / 3, rejected before use */
if (mesh_overlay_prepare(&overflow, &lines) != -1)
return fail("prepare accepted an overflowing triangle count");
MeshOverlayLine line = {.x0 = 1,
.y0 = 1,
.x1 = 3,
.y1 = 1,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines one = {.lines = &line, .count = 1};
if (mesh_overlay_draw_rgb8(NULL, pixels, 4, 4, &settings) != -1)
return fail("draw accepted NULL lines");
if (mesh_overlay_draw_rgb8(&one, NULL, 4, 4, &settings) != -1)
return fail("draw accepted NULL pixels");
if (mesh_overlay_draw_rgb8(&one, pixels, 0, 4, &settings) != -1)
return fail("draw accepted zero width");
if (mesh_overlay_draw_rgb8(&one, pixels, 4, -1, &settings) != -1)
return fail("draw accepted negative height");
if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, NULL) != -1)
return fail("draw accepted NULL settings");
MeshOverlayLines null_lines = {.lines = NULL, .count = 1};
if (mesh_overlay_draw_rgb8(&null_lines, pixels, 4, 4, &settings) != -1)
return fail("draw accepted a NULL line array with a nonzero count");
/* Dimensions too near INT_MAX would overflow the raster's y+1/x+1 casts. */
if (mesh_overlay_draw_rgb8(&one, pixels, INT_MAX, 1, &settings) != -1)
return fail("draw accepted a width near INT_MAX");
if (mesh_overlay_draw_rgb8(&one, pixels, 1, INT_MAX, &settings) != -1)
return fail("draw accepted a height near INT_MAX");
/* An unallocatable line count must be rejected before the array dereference. */
MeshOverlayLines overflow_lines = {.lines = &line, .count = SIZE_MAX};
if (mesh_overlay_draw_rgb8(&overflow_lines, pixels, 4, 4, &settings) != -1)
return fail("draw accepted an overflowing line count");
MeshOverlaySettings bad = settings;
bad.opacity = NAN;
if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1)
return fail("draw accepted NaN opacity");
bad.opacity = 1.5;
if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1)
return fail("draw accepted opacity above 1");
bad.opacity = -0.1;
if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1)
return fail("draw accepted negative opacity");
MeshOverlayLine bad_category = {.x0 = 1,
.y0 = 1,
.x1 = 3,
.y1 = 1,
.category0 = 99,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
MeshOverlayLines bad_lines = {.lines = &bad_category, .count = 1};
if (mesh_overlay_draw_rgb8(&bad_lines, pixels, 4, 4, &settings) != -1)
return fail("draw accepted an out-of-range category");
MeshOverlayLine bad_coord = {.x0 = NAN,
.y0 = 1,
.x1 = 3,
.y1 = 1,
.category0 = MESH_OVERLAY_CATEGORY_ESCAPE,
.category1 = MESH_OVERLAY_CATEGORY_ESCAPE};
bad_lines.lines = &bad_coord;
if (mesh_overlay_draw_rgb8(&bad_lines, pixels, 4, 4, &settings) != -1)
return fail("draw accepted a nonfinite coordinate");
MeshOverlayLines none = {.lines = NULL, .count = 0};
if (mesh_overlay_draw_rgb8(&none, pixels, 4, 4, &settings) != 0)
return fail("draw failed on an empty edge set");
return 0;
}
static int test_rgba_layer(void) {
LensVertex vertices[3] = {
make_vertex(2, 4, 1, RAY_OUTCOME_ESCAPED),
make_vertex(12, 4, 1, RAY_OUTCOME_ESCAPED),
make_vertex(2, 12, 1, RAY_OUTCOME_ESCAPED)};
LensTriangle triangles[2] = {{{0, 1, 2}, 0, 0, 0},
{{2, 1, 0}, 0, 0, 0}};
FrameLensMesh mesh = {.vertices = vertices, .vertex_count = 3,
.triangles = triangles, .triangle_count = 1};
MeshOverlaySettings settings = mesh_overlay_default_settings();
settings.opacity = 0.5;
MeshOverlayLayer layer = {0}, duplicate = {0};
if (mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer))
return fail("RGBA layer build failed");
int ok = layer.width == 16 && layer.height == 16;
const unsigned char *on_edge = &layer.rgba[4 * (4 * 16 + 5)];
for (int c = 0; c < 3; ++c)
ok &= on_edge[c] == lround(settings.colors[0][c] * settings.opacity);
ok &= on_edge[3] == 128;
for (size_t p = 0; p < 16 * 16; ++p)
for (int c = 0; c < 3; ++c)
ok &= layer.rgba[4 * p + c] <= layer.rgba[4 * p + 3];
mesh.triangle_count = 2;
ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &duplicate) == 0;
if (duplicate.rgba != NULL)
ok &= memcmp(layer.rgba, duplicate.rgba, 16 * 16 * 4) == 0;
unsigned char image[16 * 16 * 3];
memset(image, 255, sizeof image);
ok &= mesh_overlay_composite_rgb8(&layer, image, 16, 16) == 0;
for (int c = 0; c < 3; ++c)
ok &= image[3 * (4 * 16 + 5) + c] == on_edge[c] + 127;
ok &= image[0] == 255; /* no overlay coverage, not a full-image gray tint */
mesh_overlay_layer_destroy(&layer);
mesh_overlay_layer_destroy(&duplicate);
ok &= layer.rgba == NULL && layer.width == 0 && layer.height == 0;
settings.opacity = 0.0;
ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer) == 0;
if (layer.rgba != NULL)
for (size_t i = 0; i < 16 * 16 * 4; ++i)
ok &= layer.rgba[i] == 0;
unsigned char before[sizeof image];
memcpy(before, image, sizeof image);
ok &= mesh_overlay_composite_rgb8(&layer, image, 16, 16) == 0;
ok &= memcmp(before, image, sizeof image) == 0;
ok &= mesh_overlay_composite_rgb8(&layer, image, 15, 16) == -1;
mesh_overlay_layer_destroy(&layer);
ok &= mesh_overlay_build_layer(&mesh, 0, 16, &settings, &layer) == -1;
ok &= mesh_overlay_build_layer(&mesh, INT_MAX, INT_MAX, &settings, &layer) == -1;
vertices[0].image_x = NAN;
ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer) == -1;
ok &= layer.rgba == NULL;
return ok ? 0 : fail("RGBA premultiplication/composition/ownership regression");
}
static int test_rgba_composition_extremes(void) {
unsigned char rgba[] = {0, 0, 0, 0, 20, 40, 60, 255, 10, 20, 30, 128};
const MeshOverlayLayer layer = {.rgba = rgba, .width = 3, .height = 1};
unsigned char rgb[] = {100, 110, 120, 100, 100, 100, 100, 100, 100};
const unsigned char expected[] = {100, 110, 120, 20, 40, 60, 60, 70, 80};
return mesh_overlay_composite_rgb8(&layer, rgb, 3, 1) == 0 &&
memcmp(rgb, expected, sizeof rgb) == 0
? 0 : fail("RGBA transparent/opaque/partial-alpha composition");
}
int main(void) {
if (test_default_settings() || test_parse_color() ||
test_prepare_dedup_categories() || test_untraced_category() ||
test_boundary_and_winding() || test_isolated_witness_not_drawn() ||
test_draw_category_colors() || test_halves_and_switch() ||
test_antialiasing() || test_subpixel_and_zero_length() ||
test_high_white_background() ||
test_opacity_extremes() || test_clipping_and_huge_coordinates() ||
test_extreme_magnitudes() || test_clipped_midpoint_uses_original() ||
test_invalid_arguments() || test_rgba_layer() ||
test_rgba_composition_extremes())
return 1;
return 0;
}
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Production CLI regression for post-tone-map mesh settings and replay."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
BINARY = BUILD / 'minkowski_sky'
ENV = dict(os.environ, OMP_NUM_THREADS='4')
TMP = Path('/tmp/opencode')
TMP.mkdir(parents=True, exist_ok=True)
COLORS = ['escape', 'dark', 'unresolved', 'incomplete', 'untraced']
COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', '32', '--height',
'24', '--coarse-cell-pixels', '8', '--refine-max-level', '0',
'--exposure', '1e-3', '--psf-relative-tail', '1e-4']
def run(*args, ok=True):
result = subprocess.run([str(BINARY), *map(str, args)], env=ENV,
capture_output=True, text=True)
assert (result.returncode == 0) == ok, (args, result.stdout, result.stderr)
return result
help_text = run('--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
for category in COLORS:
assert f'--mesh-color-{category}' in help_text
for value in ['red', '123456', '#12345', '#1234567', '#GG1122', '']:
result = run(f'--mesh-color-{category}', value, ok=False)
assert result.stderr and 'Rendered' not in result.stdout
run(f'--mesh-color-{category}', ok=False)
for value in ['nan', 'inf', '-inf', '-0.1', '1.1', 'junk']:
run('--mesh-opacity', value, ok=False)
run('--mesh-opacity', ok=False)
custom = []
for category, color in zip(COLORS, ['#123ABC', '#ABC123', '#AA5533',
'#1122EE', '#112233']):
custom += [f'--mesh-color-{category}', color]
custom += ['--mesh-opacity', '0.85']
with tempfile.TemporaryDirectory(prefix='mesh-cli-', dir=TMP) as directory:
tmp = Path(directory)
baseline = tmp / f'baseline.{ext}'
run(*COMMON, '--output', baseline)
configured = tmp / f'configured.{ext}'
run(*COMMON, *custom, '--output', configured)
assert baseline.read_bytes() == configured.read_bytes()
assert not (tmp / f'configured_mesh.{ext}').exists()
for opacity in [0, 1]:
output = tmp / f'opacity{opacity}.{ext}'
run(*COMMON, *custom, '--mesh-opacity', opacity, '--draw-mesh',
'--output', output)
assert output.read_bytes() == baseline.read_bytes()
mesh = tmp / f'opacity{opacity}_mesh.{ext}'
assert (mesh.read_bytes() == baseline.read_bytes()) == (opacity == 0)
single_map = tmp / 'single.grlens'
single = tmp / f'single.{ext}'
run(*COMMON, *custom, '--draw-mesh', '--lens-map-output', single_map,
'--output', single)
replay = tmp / f'replay.{ext}'
run('--catalog', 'assets/sky_grid_5deg.csv', '--exposure', '1e-3',
'--psf-relative-tail', '1e-4', *custom, '--draw-mesh',
'--lens-map-input', single_map, '--output', replay)
assert single.read_bytes() == replay.read_bytes()
assert (tmp / f'single_mesh.{ext}').read_bytes() == (tmp / f'replay_mesh.{ext}').read_bytes()
track = tmp / 'track.csv'
run('--write-minkowski-accel-track', track, '--duration', 2, '--fps', 4,
'--proper-acceleration', 0.1)
movie_dir, replay_dir = tmp / 'movie', tmp / 'movie_replay'
movie_dir.mkdir()
replay_dir.mkdir()
movie_map = tmp / 'movie.grlens'
run(*COMMON, *custom, '--draw-mesh', '--observer-track', track,
'--duration', 1, '--fps', 1, '--frames-dir', movie_dir,
'--frames-prefix', 'frame', '--lens-map-output', movie_map)
run('--catalog', 'assets/sky_grid_5deg.csv', '--exposure', '1e-3',
'--psf-relative-tail', '1e-4', *custom, '--draw-mesh',
'--lens-map-input', movie_map, '--frames-dir', replay_dir,
'--frames-prefix', 'frame')
expected_names = {f'frame_{i:06d}{suffix}.{ext}'
for i in range(2) for suffix in ['', '_mesh']}
assert {p.name for p in movie_dir.iterdir()} == expected_names
assert {p.name for p in replay_dir.iterdir()} == expected_names
for name in expected_names:
assert (movie_dir / name).read_bytes() == (replay_dir / name).read_bytes(), name
print('mesh-overlay CLI checks passed: colors/opacity, clean fidelity, single/movie replay')
+213 -29
View File
@@ -2,12 +2,14 @@
#include "movie_output.h"
#include <errno.h>
#include <math.h>
#include <omp.h>
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
@@ -24,6 +26,15 @@ static void check(int condition, const char *message) {
}
}
/* Scratch data lives in the pre-approved OpenCode scratch directory rather
* than directly under /tmp. */
#define TEST_TMP_ROOT "/tmp/opencode"
#ifdef ENABLE_PNG
#define TEST_IMAGE_EXT "png"
#else
#define TEST_IMAGE_EXT "ppm"
#endif
#define MAX_JOBS 64
#define JOB_WIDTH 16
#define JOB_HEIGHT 16
@@ -35,6 +46,9 @@ typedef struct {
size_t count;
size_t order[MAX_JOBS];
unsigned char pixels[MAX_JOBS][JOB_BYTES];
int mesh_draw_flags[MAX_JOBS];
size_t mesh_pixel_counts[MAX_JOBS];
unsigned char layers[MAX_JOBS][JOB_WIDTH * JOB_HEIGHT * 4];
} MockWriter;
static int mock_write(void *context, const MovieOutputJob *job,
@@ -44,6 +58,12 @@ static int mock_write(void *context, const MovieOutputJob *job,
if (mock->count < MAX_JOBS) {
mock->order[mock->count] = job->frame_id;
memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES);
mock->mesh_draw_flags[mock->count] = job->draw_mesh;
mock->mesh_pixel_counts[mock->count] = job->mesh_layer.rgba == NULL ? 0 :
(size_t)job->mesh_layer.width * job->mesh_layer.height;
if (job->mesh_layer.rgba != NULL)
memcpy(mock->layers[mock->count], job->mesh_layer.rgba,
sizeof mock->layers[mock->count]);
}
++mock->count;
if (mock->delay_ms > 0) {
@@ -76,6 +96,22 @@ static MovieOutputJob make_job(size_t frame_id) {
return job;
}
/* An independent premultiplied RGBA8 fixture with sparse nonzero pixels. */
static int make_layer(MeshOverlayLayer *layer, size_t count) {
*layer = (MeshOverlayLayer){0};
layer->rgba = calloc(JOB_WIDTH * JOB_HEIGHT, 4);
if (layer->rgba == NULL)
return -1;
layer->width = JOB_WIDTH;
layer->height = JOB_HEIGHT;
for (size_t i = 0; i < count; ++i) {
for (int channel = 0; channel < 3; ++channel)
layer->rgba[4 * i + channel] = (unsigned char)((i * 19 + channel * 3) % 129);
layer->rgba[4 * i + 3] = 128;
}
return 0;
}
/* Order and pixel fidelity for capacity 1 and 2. */
static void test_order_and_pixels(size_t capacity) {
MovieOutputQueue queue;
@@ -146,7 +182,9 @@ static void test_backpressure(void) {
}
/* Writer failure at frame N must propagate, unblock the producer, and join
* cleanly without losing the ownership contract. */
* cleanly. RGBA layers are attached to every job so both ownership paths
* are exercised: the queue releases accepted jobs, while the caller releases
* the job rejected after the failure is recorded. */
static void test_writer_failure(void) {
MovieOutputQueue queue;
MockWriter mock;
@@ -161,9 +199,16 @@ static void test_writer_failure(void) {
int saw_failure = 0;
for (size_t f = 0; f < 8; ++f) {
MovieOutputJob job = make_job(f);
if (movie_output_queue_submit(&queue, &job, NULL)) {
/* The queue no longer owns these buffers. */
job.draw_mesh = 1;
if (make_layer(&job.mesh_layer, 3)) {
free(job.clean_rgb8);
check(0, "failure: layer allocation");
break;
}
if (movie_output_queue_submit(&queue, &job, NULL)) {
/* The queue no longer owns these resources. */
free(job.clean_rgb8);
mesh_overlay_layer_destroy(&job.mesh_layer);
saw_failure = 1;
break;
}
@@ -193,6 +238,76 @@ static void test_empty_paths(void) {
movie_output_queue_destroy(&queue);
}
/* The queued job owns an independent RGBA layer: rasterizing a heap mesh, then
* releasing that source mesh before the writer runs, must not disturb the
* pixels the writer observes. */
static void test_lines_survive_source_release(void) {
LensVertex *vertices = calloc(3, sizeof *vertices);
LensTriangle *triangles = calloc(1, sizeof *triangles);
if (vertices == NULL || triangles == NULL) {
free(vertices);
free(triangles);
check(0, "source release: allocation");
return;
}
vertices[0] = (LensVertex){.image_x = 1.5,
.image_y = 1.5,
.outcome = RAY_OUTCOME_ESCAPED,
.traced = 1};
vertices[1] = (LensVertex){.image_x = JOB_WIDTH - 2.5,
.image_y = 2.5,
.outcome = RAY_OUTCOME_DARK,
.traced = 1};
vertices[2] = (LensVertex){.image_x = 5.5,
.image_y = JOB_HEIGHT - 2.5,
.outcome = RAY_OUTCOME_INCOMPLETE,
.traced = 0};
triangles[0].vertex[0] = 0;
triangles[0].vertex[1] = 1;
triangles[0].vertex[2] = 2;
FrameLensMesh mesh = {.vertices = vertices,
.vertex_count = 3,
.triangles = triangles,
.triangle_count = 1};
MeshOverlayLayer layer = {0};
const MeshOverlaySettings overlay = mesh_overlay_default_settings();
const int prepared = mesh_overlay_build_layer(&mesh, JOB_WIDTH, JOB_HEIGHT,
&overlay, &layer) == 0;
/* Release the source mesh (and its vertex/triangle arrays) before submit. */
free(vertices);
free(triangles);
check(prepared, "source release: overlay extraction");
MovieOutputQueue queue;
MockWriter mock;
memset(&mock, 0, sizeof mock);
mock.fail_at = -1;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "source release: queue init");
mesh_overlay_layer_destroy(&layer);
return;
}
movie_output_queue_set_writer(&queue, mock_write, &mock);
MovieOutputJob job = make_job(0);
job.draw_mesh = 1;
job.mesh_layer = layer; /* ownership transferred to the job/queue */
int submitted = job.clean_rgb8 != NULL &&
movie_output_queue_submit(&queue, &job, NULL) == 0;
if (!submitted) {
free(job.clean_rgb8);
mesh_overlay_layer_destroy(&job.mesh_layer);
}
check(submitted && movie_output_queue_finish(&queue) == 0,
"source release: submit/finish");
check(mock.count == 1 && mock.mesh_draw_flags[0] == 1 &&
mock.mesh_pixel_counts[0] == JOB_WIDTH * JOB_HEIGHT &&
memcmp(mock.layers[0], (unsigned char[JOB_WIDTH * JOB_HEIGHT * 4]){0},
sizeof mock.layers[0]) != 0,
"source release: writer saw the intact RGBA overlay");
movie_output_queue_destroy(&queue);
}
#ifdef ENABLE_PNG
static int decode_png_rgb8(const char *path, unsigned char *out, int width,
int height) {
@@ -216,12 +331,12 @@ static int decode_png_rgb8(const char *path, unsigned char *out, int width,
return ok ? 0 : -1;
}
/* The default writer's clean and mesh files must decode to the submitted
* payloads. */
static void test_default_writer_success(void) {
char directory[] = "/tmp/movie_output_XXXXXX";
/* The default writer's clean file must decode unchanged and its mesh sibling
* must equal the core overlay drawn in place on that same clean payload. */
static void test_default_writer_mesh(void) {
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
if (mkdtemp(directory) == NULL) {
check(0, "default success: mkdtemp");
check(0, "default mesh: mkdtemp");
return;
}
char clean_path[PATH_MAX];
@@ -231,7 +346,7 @@ static void test_default_writer_success(void) {
const PngWriteSettings settings = {-1};
MovieOutputQueue queue;
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default success: queue init");
check(0, "default mesh: queue init");
rmdir(directory);
return;
}
@@ -239,29 +354,35 @@ static void test_default_writer_success(void) {
job.draw_mesh = 1;
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path);
job.mesh_rgb8 = make_rgb8(99);
int submitted = job.clean_rgb8 != NULL && job.mesh_rgb8 != NULL &&
int layer_ok = make_layer(&job.mesh_layer, 5) == 0;
unsigned char *clean_expected = make_rgb8(0);
unsigned char mesh_expected[JOB_BYTES];
int expected_ok = layer_ok && clean_expected != NULL;
if (expected_ok) {
memcpy(mesh_expected, clean_expected, JOB_BYTES);
expected_ok = mesh_overlay_composite_rgb8(&job.mesh_layer, mesh_expected,
JOB_WIDTH, JOB_HEIGHT) == 0;
}
int submitted = expected_ok && job.clean_rgb8 != NULL &&
movie_output_queue_submit(&queue, &job, NULL) == 0;
if (!submitted) {
free(job.clean_rgb8);
free(job.mesh_rgb8);
mesh_overlay_layer_destroy(&job.mesh_layer);
}
check(submitted && movie_output_queue_finish(&queue) == 0,
"default success: submit/finish");
"default mesh: submit/finish");
unsigned char clean_decoded[JOB_BYTES];
unsigned char mesh_decoded[JOB_BYTES];
unsigned char *clean_expected = make_rgb8(0);
unsigned char *mesh_expected = make_rgb8(99);
const int decoded_ok =
clean_expected != NULL && mesh_expected != NULL &&
decode_png_rgb8(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 &&
decode_png_rgb8(mesh_path, mesh_decoded, JOB_WIDTH, JOB_HEIGHT) == 0;
check(decoded_ok && memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
"default success: clean pixels");
check(decoded_ok && memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
"default success: mesh pixels");
check(decoded_ok && clean_expected != NULL &&
memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
"default mesh: clean image unchanged");
check(decoded_ok && expected_ok &&
memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
"default mesh: mesh matches core overlay result");
free(clean_expected);
free(mesh_expected);
unlink(clean_path);
unlink(mesh_path);
rmdir(directory);
@@ -269,38 +390,101 @@ static void test_default_writer_success(void) {
}
#endif
/* The default writer must fail under a real filesystem error. */
static void test_default_writer_failure(void) {
/* Failure while writing the clean image happens before any overlay work; the
* queue must still release the job's resources. */
static void test_default_writer_failure_before_overlay(void) {
MovieOutputQueue queue;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default failure: queue init");
check(0, "failure before overlay: queue init");
return;
}
MovieOutputJob job = make_job(0);
job.draw_mesh = 1;
if (make_layer(&job.mesh_layer, 4)) {
free(job.clean_rgb8);
check(0, "failure before overlay: layer allocation");
movie_output_queue_destroy(&queue);
return;
}
snprintf(job.output_path, sizeof job.output_path,
"/nonexistent-directory-xyz/frame.png");
"/nonexistent-directory-xyz/frame." TEST_IMAGE_EXT);
snprintf(job.mesh_path, sizeof job.mesh_path,
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
free(job.clean_rgb8);
check(0, "default failure: submit");
mesh_overlay_layer_destroy(&job.mesh_layer);
check(0, "failure before overlay: submit");
movie_output_queue_destroy(&queue);
return;
}
check(movie_output_queue_finish(&queue) != 0,
"default failure: finish reports unwritable path");
"failure before overlay: clean write error propagates");
movie_output_queue_destroy(&queue);
}
/* Failure while writing the mesh image happens after the clean file is written
* and the overlay is drawn; that later error must also propagate. */
static void test_default_writer_failure_after_overlay(void) {
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
if (mkdtemp(directory) == NULL) {
check(0, "failure after overlay: mkdtemp");
return;
}
char clean_path[PATH_MAX];
snprintf(clean_path, sizeof clean_path, "%s/frame_000000." TEST_IMAGE_EXT,
directory);
MovieOutputQueue queue;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "failure after overlay: queue init");
rmdir(directory);
return;
}
MovieOutputJob job = make_job(0);
job.draw_mesh = 1;
if (make_layer(&job.mesh_layer, 4)) {
free(job.clean_rgb8);
check(0, "failure after overlay: layer allocation");
rmdir(directory);
movie_output_queue_destroy(&queue);
return;
}
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
snprintf(job.mesh_path, sizeof job.mesh_path,
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
free(job.clean_rgb8);
mesh_overlay_layer_destroy(&job.mesh_layer);
check(0, "failure after overlay: submit");
rmdir(directory);
movie_output_queue_destroy(&queue);
return;
}
check(movie_output_queue_finish(&queue) != 0,
"failure after overlay: mesh write error propagates");
unlink(clean_path);
rmdir(directory);
movie_output_queue_destroy(&queue);
}
int main(void) {
/* Standalone/CI runs need not have an OpenCode-created scratch directory. */
if (mkdir(TEST_TMP_ROOT, 0700) != 0 && errno != EEXIST) {
perror("create movie-output test scratch directory");
return 1;
}
test_order_and_pixels(1);
test_order_and_pixels(2);
test_backpressure();
test_writer_failure();
test_empty_paths();
test_lines_survive_source_release();
#ifdef ENABLE_PNG
test_default_writer_success();
test_default_writer_mesh();
#endif
test_default_writer_failure();
test_default_writer_failure_before_overlay();
test_default_writer_failure_after_overlay();
if (failures != 0) {
fprintf(stderr, "%d movie-output failure(s)\n", failures);
return 1;
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Regression test for the stdout/stderr split of the production CLI.
Normal progress, startup/backend configuration, successful file outputs,
timings and cache summaries are normal informational output and must go to
stdout. Genuine warnings, errors and the Debug per-event direct-fallback
diagnostic go to stderr. A mixed success/error line such as
``Rendered ... (ok|write failed)`` must follow its outcome.
The checks capture the two streams separately so that a future change which
silently moves a normal message to stderr (or an error to stdout) is caught.
Small deterministic Minkowski scenes keep the runtime short.
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
# Keep scratch data inside the pre-approved OpenCode scratch directory.
TMP_ROOT = Path('/tmp/opencode')
TMP_ROOT.mkdir(parents=True, exist_ok=True)
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
ENV = dict(os.environ, OMP_NUM_THREADS='4')
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]
def run(binary, *args, env=ENV):
return subprocess.run([str(binary), *map(str, args)], env=env,
capture_output=True, text=True)
def stderr_errors(result):
"""Non-advisory stderr lines.
A Debug build deliberately logs each direct PSF fallback from the active
splat worker; those ``Debug:`` lines are real diagnostics, not a misplaced
normal message, so they are excluded when the Debug startup banner is
present on stdout.
"""
lines = [line for line in result.stderr.splitlines() if line.strip()]
if 'Debug build:' in result.stdout:
lines = [line for line in lines if not line.startswith('Debug:')]
return lines
binary = BUILD / 'minkowski_sky'
assert binary.exists(), f'missing {binary}'
# The image extension follows the build's compiled writer, exactly as
# tests/test_camera_cli.py detects it from --help (a libpng build advertises
# `.png`, a PNG-less build advertises `.ppm`).
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
with tempfile.TemporaryDirectory(prefix='gr-output-streams-',
dir=str(TMP_ROOT)) as directory:
tmp = Path(directory)
# 1) --help is informational: complete usage on stdout, nothing on stderr.
help_result = run(binary, '--help')
assert help_result.returncode == 0, help_result.stderr
assert 'Usage:' in help_result.stdout, help_result.stdout
assert help_result.stderr == '', help_result.stderr
# 2) An unknown option is an error: usage diagnostic on stderr only.
bad_result = run(binary, '--not-an-option')
assert bad_result.returncode != 0, bad_result.stdout
assert bad_result.stdout == '', bad_result.stdout
assert bad_result.stderr != '', 'missing error diagnostic on stderr'
# 3) A successful non-verbose single-frame render puts every startup,
# statistics and success line on stdout with an empty stderr.
single_out = tmp / f'single.{ext}'
single = run(binary, *COMMON, '--output', single_out)
assert single.returncode == 0, single.stderr
assert single_out.exists(), single.stderr
assert 'Blackbody backend:' in single.stdout, single.stdout
assert 'PSF cache ready:' in single.stdout, single.stdout
assert 'Rendered' in single.stdout and '(ok)' in single.stdout, single.stdout
assert '(write failed)' not in single.stdout, single.stdout
assert not stderr_errors(single), single.stderr
# 4) Verbose progress (including the trace-cost line) is still stdout.
verbose_out = tmp / f'verbose.{ext}'
verbose = run(binary, *COMMON, '--verbose', '--output', verbose_out)
assert verbose.returncode == 0, verbose.stderr
assert 'Frame 0: tracing' in verbose.stdout, verbose.stdout
assert 'Frame 0 trace cost:' in verbose.stdout, verbose.stdout
assert not stderr_errors(verbose), verbose.stderr
# 5) A short movie exercises the asynchronous writer; its per-frame logs,
# timing summary and writer summary are stdout, stderr stays empty. The
# 2 s fixture track plus --duration 1 --fps 1 yields two frames, so the
# async producer/writer overlap is actually exercised.
track = tmp / 'track.csv'
track_run = run(binary, '--write-minkowski-accel-track', track,
'--duration', 2, '--fps', 30, '--proper-acceleration', 1.52)
assert track_run.returncode == 0, track_run.stderr
assert track.exists(), track_run.stderr
frames_dir = tmp / 'frames'
frames_dir.mkdir()
movie = run(binary, *COMMON, '--observer-track', track, '--frames-dir',
frames_dir, '--frames-prefix', 'frame', '--duration', 1,
'--fps', 1, '--verbose', '--output', tmp / f'movie.{ext}')
assert movie.returncode == 0, movie.stderr
assert (frames_dir / f'frame_000000.{ext}').exists(), movie.stderr
assert (frames_dir / f'frame_000001.{ext}').exists(), movie.stderr
assert 'Ray trace generation' in movie.stdout, movie.stdout
assert movie.stdout.count('Rendered') == 2, movie.stdout
assert movie.stdout.count('(ok)') == 2, movie.stdout
assert 'Movie timing total' in movie.stdout, movie.stdout
assert 'Movie writer summary:' in movie.stdout, movie.stdout
assert 'Movie end-to-end wall:' in movie.stdout, movie.stdout
assert not stderr_errors(movie), movie.stderr
# 6) A genuine warning goes to stderr and does not disturb the success line
# on stdout. The fast-mode preview advisory is deterministic in the CPU
# build.
fast_out = tmp / f'fast.{ext}'
fast = run(binary, *COMMON, '--fast-mode', '--output', fast_out)
assert fast.returncode == 0, fast.stderr
assert 'Fast mode is a preview approximation' in fast.stderr, fast.stderr
assert fast.stdout.count('Rendered') == 1, fast.stdout
assert '(ok)' in fast.stdout, fast.stdout
# The only stderr content is the advisory: no normal line leaked across.
assert len(stderr_errors(fast)) == 1, fast.stderr
# 7) A failed write must route the mixed success/error line to stderr and
# leave stdout free of the success wording.
missing_dir = tmp / 'missing_subdir' / f'out.{ext}'
failed = run(binary, *COMMON, '--output', missing_dir)
assert failed.returncode != 0, failed.stdout
assert '(write failed)' in failed.stderr, failed.stderr
assert '(write failed)' not in failed.stdout, failed.stdout
# 8) A rejected camera velocity is an error on stderr, not stdout.
velocity = run(binary, *COMMON, '--observer-velocity', 10, 0, 0,
'--output', tmp / f'velocity.{ext}')
assert velocity.returncode != 0, velocity.stdout
assert 'not timelike' in velocity.stderr, velocity.stderr
# stdout may hold only the Debug startup banner; no render ran.
assert 'Rendered' not in velocity.stdout, velocity.stdout
print('output-stream checks passed: normal success stdout / diagnostics '
'stderr', flush=True)
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Regression test for always-on ray failure diagnostics.
Every INCOMPLETE ray endpoint must be reported on stderr with its reason and
the affected frame id / camera coordinate time even without ``--verbose``, so a
long movie never needs a rerun to be diagnosed. ``--verbose`` (or any Debug
build) adds a bounded set of representative samples with film/cost
localization. Budget-incomplete ``UNRESOLVED/BUDGET_EXHAUSTED`` frames are
reported separately from numerical ``INCOMPLETE`` failures, and the publication
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. 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_<backend>`` 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
all time slabs.
"""
import os
import re
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
# Keep scratch data inside the pre-approved OpenCode scratch directory.
TMP_ROOT = Path('/tmp/opencode')
TMP_ROOT.mkdir(parents=True, exist_ok=True)
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD
ENV = dict(os.environ, OMP_NUM_THREADS='4')
# v3 lens-map layout (see src/lens_map.c); only enough to count stored
# INCOMPLETE vertices. The first frame header follows the 40..176 v3
# provenance block.
VERSION_OFFSET = 8
FRAME_COUNT_OFFSET = 32
FRAME_HEADER_START = 176
VERTEX_SIZE = 108
OUTCOME_INDEX = 10
INCOMPLETE_OUTCOME = 3
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 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',
'--ode-initial-step', '0.5', '--ode-min-step', '0.5',
'--ode-max-step', '0.5', '--ode-max-rejections', '1']
def run(binary, *args, ok=True, env=ENV):
result = subprocess.run([str(binary), *map(str, args)], env=env,
capture_output=True, text=True)
if (result.returncode == 0) != ok:
raise AssertionError(
f'{binary.name} {args}: rc={result.returncode}\n'
f'stdout:\n{result.stdout}\nstderr:\n{result.stderr}')
return result
def is_debug(result):
return 'Debug build:' in result.stdout
def incomplete_total(stderr):
return sum(int(m) for m in re.findall(r'INCOMPLETE=(\d+)', stderr))
def frame_reported(stderr, frame_id):
return re.search(rf'Ray failures: frame {frame_id} camera_t=', stderr) is not None
def map_incomplete_vertices(path):
data = path.read_bytes()
assert data[:8] == b'GRLENS\x01\x00', f'not a lens map: {path}'
version = struct.unpack_from('<I', data, VERSION_OFFSET)[0]
assert version == 3, f'expected v3 map, got v{version}'
frame_count = struct.unpack_from('<Q', data, FRAME_COUNT_OFFSET)[0]
offset = FRAME_HEADER_START
incomplete = 0
for _ in range(frame_count):
vertices, triangles = struct.unpack_from('<QQ', data, offset + 24)
offset += 48
for _ in range(vertices):
fields = struct.unpack_from('<9dIIIQQQ', data, offset)
offset += VERTEX_SIZE
if fields[OUTCOME_INDEX] == INCOMPLETE_OUTCOME:
incomplete += 1
offset += triangles * 32 + 4 # triangles plus the frame payload CRC
return incomplete
# A required renderer or fixture must not be skipped silently under make test.
binary = BUILD / 'schwarzschild_sky'
assert binary.exists(), f'missing {binary}'
observer_test = TESTDIR / 'test_observer_schwarzschild'
assert observer_test.exists(), f'missing {observer_test}'
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
for option in ('--verbose', '--allow-incomplete', '--observer-track',
'--movie-track-samples', '--lens-map-output', '--lens-map-input',
'--refine-max-level'):
assert option in help_text, (option, help_text)
with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-',
dir=str(TMP_ROOT)) as directory:
tmp = Path(directory)
# 1) A normal scene emits no ray-failure or budget diagnostics at all, so a
# clean run is not polluted by the always-on summary. The refinement
# variant exercises the always-installed diagnostics callback across
# finished generations without producing any failure.
normal = tmp / f'normal.{ext}'
normal_run = run(binary, *COMMON, '--output', normal)
assert normal_run.returncode == 0
assert 'Ray failures:' not in normal_run.stderr, normal_run.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' not in normal_run.stderr, normal_run.stderr
refined = tmp / f'refined.{ext}'
refined_run = run(binary, *COMMON, '--refine-max-level', '2',
'--refine-angle-abs-deg', '0.1', '--output', refined)
assert refined_run.returncode == 0
assert 'Ray failures:' not in refined_run.stderr, refined_run.stderr
# 2) Non-verbose INCOMPLETE: the reason histogram, frame id, and camera time
# are on stderr even though --verbose was not passed.
single = tmp / f'single.{ext}'
single_run = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--output', single)
err = single_run.stderr
assert 'Ray failures:' 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
assert 'film=' not in err, err
else:
# A Debug build prints representative detail without --verbose.
assert 'ray failure:' in err and 'film=' in err, err
# 3) Verbose adds bounded representative samples with localization.
verbose = tmp / f'verbose.{ext}'
verbose_run = run(binary, *COMMON, *INJECT, '--allow-incomplete', '--verbose',
'--output', verbose)
err = verbose_run.stderr
assert 'ray failure:' 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
# reported as suppressed.
reps = sum(1 for line in err.splitlines()
if 'ray failure: frame=0 reason=' in line
and 'suppressed=' not in line)
assert reps <= 3, err
assert 'suppressed=' in err, err
# 4) Refinement enabled with the failing trace: the always-installed
# diagnostics callback is part of the build, but the uniform strict
# injection fails every initial vertex, so no probe generation runs and
# only the initial scan reports (documented limit: probe/refinement
# witness failures are not reachable from this deterministic scene).
refine_map = tmp / 'refine_inc.grlens'
refine_run = run(binary, *COMMON, *INJECT, '--refine-max-level', '1',
'--refine-angle-abs-deg', '0.1', '--allow-incomplete',
'--lens-map-output', refine_map,
'--output', tmp / f'refine_inc.{ext}')
err = refine_run.stderr
assert frame_reported(err, 0), err
stored = map_incomplete_vertices(refine_map)
assert stored > 0
assert incomplete_total(err) == stored, \
f'refine histogram {incomplete_total(err)} != stored {stored}'
# 5) Two-frame movie. (a) A default, non-verbose run must identify both
# frames and count each failing sample exactly once across the time
# slabs, matching the saved map.
track_single = tmp / 'track_single.csv'
run(observer_test, track_single)
rows = [line for line in track_single.read_text().splitlines()
if line.strip() and not line.startswith('#')
and not line[0].isalpha()]
assert len(rows) == 1, rows
fields = rows[0].split(',')
fields[0], fields[1] = '1', '1'
header = next(line for line in track_single.read_text().splitlines()
if line.startswith('t,'))
track = tmp / 'track2.csv'
track.write_text(header + '\n' + rows[0] + '\n' + ','.join(fields) + '\n')
frames_dir = tmp / 'frames'
frames_dir.mkdir()
movie_map = tmp / 'movie.grlens'
movie_run = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--slab-duration', '0.4', '--observer-track', track,
'--movie-track-samples', '--frames-dir', frames_dir,
'--lens-map-output', movie_map)
err = movie_run.stderr
assert frame_reported(err, 0) and frame_reported(err, 1), err
stored = map_incomplete_vertices(movie_map)
assert stored > 0, 'movie map has no INCOMPLETE vertices'
assert incomplete_total(err) == stored, \
f'histogram {incomplete_total(err)} != stored {stored}; ' \
'a sample was re-counted across slabs'
if not is_debug(movie_run):
assert 'ray failure:' not in err, err
# (b) Verbose movie adds request kind, persistent vertex id, film position
# and cost, bounded per reason.
movie_verbose_dir = tmp / 'frames_verbose'
movie_verbose_dir.mkdir()
movie_verbose = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--verbose', '--slab-duration', '0.4',
'--observer-track', track, '--movie-track-samples',
'--frames-dir', movie_verbose_dir)
err = movie_verbose.stderr
assert 'sample=' in err and 'kind=vertex' in err and 'vertex=' in err, err
assert 'film=(' in err, err
assert 'accepted=' in err and 'rejected=' in err and 'rhs=' in err, err
reps = sum(1 for line in err.splitlines()
if 'ray failure:' in line and 'suppressed=' not in line)
assert reps <= 6, err # two frames, one reason each, <=3 reps per reason
# (c) Render-only replay of the two-frame map without --allow-incomplete
# reports both frames' reasons before the publication gate refuses, and
# never invents an unpersisted trusted stop state.
map_frames_dir = tmp / 'map_frames'
map_frames_dir.mkdir()
refused = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', movie_map, '--frames-dir', map_frames_dir,
'--output', tmp / f'map_refused.{ext}', ok=False)
err = refused.stderr
assert frame_reported(err, 0) and frame_reported(err, 1), 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
single_map = tmp / 'single.grlens'
run(binary, *COMMON, *INJECT, '--allow-incomplete', '--lens-map-output',
single_map, '--output', tmp / f'maplive.{ext}')
replay = tmp / f'replay.{ext}'
replay_run = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', single_map, '--allow-incomplete',
'--verbose', '--output', replay)
err = replay_run.stderr
assert 'Ray failures:' in err and frame_reported(err, 0), 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
# still points at the retry budget. A replay of an allowed budget map
# must not present the unpersisted continuation time as observed.
budget_map = tmp / 'budget.grlens'
run(binary, *COMMON, '--integrator', 'dp54',
'--trace-lookback-time', '1e-6', '--retry-lookback-increment', '0',
'--max-total-lookback-time', '1e-6', '--allow-incomplete',
'--lens-map-output', budget_map, '--output', tmp / f'budget_allow.{ext}')
replay_budget = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', budget_map, '--allow-incomplete',
'--verbose', '--output', tmp / f'budget_replay.{ext}')
err = replay_budget.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' in err, err
assert 'continuation_t=-' in err, err
assert 'continuation_t=-1' not in err, \
'replay invented an unpersisted continuation time'
budget = tmp / f'budget.{ext}'
budget_run = run(binary, *COMMON, '--integrator', 'dp54',
'--trace-lookback-time', '1e-6',
'--retry-lookback-increment', '0',
'--max-total-lookback-time', '1e-6',
'--output', budget, ok=False)
err = budget_run.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' in err, err
assert re.search(r'UNRESOLVED/BUDGET_EXHAUSTED: frame 0 camera_t=', err), err
assert 'blocking_triangles=' in err and 'unresolved_samples=' in err, err
assert 'Incomplete render refused' in err, err
assert 'budget' in err.lower(), err
assert not budget.exists()
print('ray diagnostics checks passed: always-on reasons + frame/time, '
'bounded verbose samples, movie slab de-duplication and replay',
flush=True)
+47 -4
View File
@@ -18,10 +18,14 @@ for the field and camera.
Both backends accept the same instantaneous camera parameters. Position and
velocity use the backend's coordinates; velocity means `dx/dt, dy/dt, dz/dt`,
not a local physical speed. The analytic single-frame event is at `t=0`.
not a local physical speed. The single-frame event defaults to `t=0`; use
`--observer-time T` to select another coordinate time (any finite value,
including negative times). Metric evaluation and past-directed ray tracing
start at that event, and saved lens maps retain its coordinate time.
| Option | Meaning / default |
| --- | --- |
| `--observer-time T` | Single-frame camera coordinate time, default `0`; cannot be combined with movie/track or lens-map input |
| `--observer-position X Y Z` | Coordinate position; if look is omitted, point toward the origin |
| `--look-ra-deg RA`, `--look-dec-deg DEC` | Coordinate look direction; missing angle defaults to RA=90°, Dec=-90° |
| `--observer-radius R` | Positive radius used only to infer position, default 30; conflicts with explicit position |
@@ -610,13 +614,52 @@ the sum of producer frame times only (it excludes tracing, prefetch, and the
final queue drain). The all-sky `Movie catalog prefetch:` line reports mark,
load+commit, and total tile time. Timing uses one clock read per bulk phase,
never inside the per-star or per-pixel hot loops.
With `--draw-mesh`, tone mapping runs only once per frame. The async writer
writes the clean RGB8 image first, then composites the producer-rasterized
premultiplied RGBA8 layer in place and writes the mesh sibling. Mesh preparation
and rasterization are included in the producer's frame total; composition and
image output are included in the writer summary.
Pass `--draw-mesh` to also write the final image-plane triangle mesh as a
`<output-stem>_mesh.png` sibling (`.ppm` in non-PNG builds). The main
tone-mapped image and any `--hdr-output` FITS file remain mesh-free. The
overlay alpha-composites image-plane triangle edges as one-pixel-wide 0.5
linear-gray diagnostic lines at 0.5 opacity. The line rasterizer uses
coverage-based antialiasing.
overlay alpha-composites one-pixel-wide, coverage-antialiased triangle edges
onto the final sRGB8 image **after** sensor bloom, tone mapping, and the sRGB
transfer, preserving mesh contrast on saturated highlights. Each vertex colors
its incident half-edges; differently classified endpoints switch color at the
edge midpoint. Shared edges are drawn once. The premultiplied sRGB RGBA8 layer
uses source-over accumulation and composition, with the same rules for
single-frame, movie, and replay output.
The default palette is Catppuccin Mocha, with opacity `0.5`:
| Vertex category | Default color | CLI override |
| --- | --- | --- |
| `ESCAPED` | Overlay1 `#7F849C` (gray) | `--mesh-color-escape` |
| `DARK` | Mauve `#CBA6F7` (purple) | `--mesh-color-dark` |
| `UNRESOLVED` | Yellow `#F9E2AF` | `--mesh-color-unresolved` |
| `INCOMPLETE` | Red `#F38BA8` | `--mesh-color-incomplete` |
| Untraced | Blue `#89B4FA` | `--mesh-color-untraced` |
Color arguments are strict sRGB `#RRGGBB` values; quote them in the shell.
`--mesh-opacity` accepts a finite number in `[0,1]`. These settings do not
implicitly enable `--draw-mesh`. For example:
```sh
--draw-mesh --mesh-color-dark '#CBA6F7' --mesh-color-unresolved '#F9E2AF' --mesh-opacity 0.8
```
`UNRESOLVED` denotes trustworthy trajectories with exhausted compute budgets
(not just accepted-step limits), while `INCOMPLETE` denotes actual history,
domain, metric, integration, I/O, or protocol failures. Different dark reasons
share one color. Coloring is a read-only visualization of the finalized mesh.
Normal progress and summaries go to stdout; warnings, errors, and Debug
diagnostics go to stderr. Successful runs exit `0` even if warnings are emitted.
Incomplete ray failures and budget-exhausted frames report their reasons and
affected frames on stderr even without `--verbose`; `--verbose` and Debug builds
add bounded per-sample localization (film position and integration cost).
## HDR output