/* Regression tests for the standalone sensor-bloom model in * src/sensor_bloom.c. * * These link only the model: no catalog, ray tracing, image writer, FFTW, or * GPU is involved, so they build in every ENABLE_HDR/PSF_BACKEND * configuration. They call the production entry point and compare it against * an independently written dense synchronous reference where a closed form is * not simpler. */ #include "sensor_bloom.h" #include #include #include #include #include #include #include #include #include /* The scratch malloc-failure path is covered by the ordinary non-ASan run of * `allocation_failure_is_reported()` below. AddressSanitizer intercepts * allocation and its runtime cannot mmap under a forced RLIMIT_AS, so that * probe is skipped in ASan builds rather than being made to pass; the * dimension-overflow validation is a separate check and does not substitute * for it. */ #if defined(__SANITIZE_ADDRESS__) #define SB_HAVE_ASAN 1 #elif defined(__has_feature) #if __has_feature(address_sanitizer) #define SB_HAVE_ASAN 1 #endif #endif static int failures = 0; static void check(int condition, const char *message) { if (!condition) { fprintf(stderr, "FAIL: %s\n", message); ++failures; } } static int close_to(double value, double expected, double tolerance) { return fabs(value - expected) <= tolerance; } static double *alloc_rgb(int width, int height) { return calloc((size_t)width * height * 3, sizeof(double)); } static size_t at(int width, int x, int y, int c) { return ((size_t)y * width + x) * 3 + (size_t)c; } static double sample(const double *rgb, int width, int x, int y, int c) { return rgb[at(width, x, y, c)]; } static void set(double *rgb, int width, int x, int y, int c, double value) { rgb[at(width, x, y, c)] = value; } static int all_finite_and_bounded(const double *rgb, size_t count, double limit) { for (size_t i = 0; i < count; ++i) if (!isfinite(rgb[i]) || rgb[i] > limit) return 0; return 1; } /* Independently written dense synchronous reference: every round reads the * whole previous state and writes the whole next state, with the same 9-point * weights. Only used to cross-check cascades. */ static double reference_overflow(const double *src, size_t index, double limit) { const double difference = src[index] - limit; return difference > 0.0 ? difference : 0.0; } static void reference_bloom(double *hdr, int width, int height, double limit, double transfer) { const double axial = 4.0 / 20.0, diagonal = 1.0 / 20.0; const double tolerance = 1e-9 * limit; const size_t count = (size_t)width * height * 3; double *first = malloc(count * sizeof *first); double *second = malloc(count * sizeof *second); memcpy(first, hdr, count * sizeof *first); memcpy(second, hdr, count * sizeof *second); double *src = first, *dst = second; for (int round = 0; round < 20000; ++round) { double next_max = 0.0; for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { for (int c = 0; c < 3; ++c) { const size_t index = at(width, x, y, c); const double value = src[index]; double incoming = 0.0; if (x > 0) incoming += axial * reference_overflow(src, at(width, x - 1, y, c), limit); if (x + 1 < width) incoming += axial * reference_overflow(src, at(width, x + 1, y, c), limit); if (y > 0) incoming += axial * reference_overflow(src, at(width, x, y - 1, c), limit); if (y + 1 < height) incoming += axial * reference_overflow(src, at(width, x, y + 1, c), limit); if (x > 0 && y > 0) incoming += diagonal * reference_overflow(src, at(width, x - 1, y - 1, c), limit); if (x + 1 < width && y > 0) incoming += diagonal * reference_overflow(src, at(width, x + 1, y - 1, c), limit); if (x > 0 && y + 1 < height) incoming += diagonal * reference_overflow(src, at(width, x - 1, y + 1, c), limit); if (x + 1 < width && y + 1 < height) incoming += diagonal * reference_overflow(src, at(width, x + 1, y + 1, c), limit); const double next = (value < limit ? value : limit) + transfer * incoming; dst[index] = next; const double overflow = next - limit; if (overflow > next_max) next_max = overflow; } } } double *swap = src; src = dst; dst = swap; if (next_max <= tolerance) break; } for (size_t i = 0; i < count; ++i) if (src[i] > limit) src[i] = limit; memcpy(hdr, src, count * sizeof *hdr); free(first); free(second); } static void test_no_saturation(void) { const int width = 8, height = 6; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); for (size_t i = 0; i < count; ++i) hdr[i] = 0.25 * (double)(i % 5); double *original = malloc(count * sizeof *original); memcpy(original, hdr, count * sizeof *original); const SensorBloomSettings settings = {1.0, 0.5}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "no-saturation apply succeeds"); check(memcmp(hdr, original, count * sizeof *hdr) == 0, "no-saturation buffer is byte-for-byte unchanged"); check(stats.iterations == 0, "no-saturation uses zero iterations"); check(stats.predicted_iterations == 0, "no-saturation predicts zero iterations"); check(stats.initially_saturated_channels == 0, "no-saturation reports no saturated channel"); check(stats.final_clamped_channels == 0, "no-saturation clamps nothing"); free(original); free(hdr); } static void test_zero_transfer(void) { const int width = 5, height = 5; double *hdr = alloc_rgb(width, height); set(hdr, width, 2, 2, 0, 1.7); const SensorBloomSettings settings = {1.0, 0.0}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "zero-transfer apply succeeds"); check(close_to(sample(hdr, width, 2, 2, 0), 1.0, 1e-15), "zero-transfer clamps the saturated channel to E"); check(sample(hdr, width, 1, 2, 0) == 0.0 && sample(hdr, width, 3, 2, 0) == 0.0, "zero-transfer leaves axial neighbours unchanged"); check(sample(hdr, width, 1, 1, 0) == 0.0 && sample(hdr, width, 3, 3, 0) == 0.0, "zero-transfer leaves diagonal neighbours unchanged"); check(close_to(stats.absorbed_signal, 0.7, 1e-15), "zero-transfer absorbs the full overflow"); check(stats.boundary_loss == 0.0, "zero-transfer loses nothing at the boundary"); check(stats.iterations >= 1, "zero-transfer iterates at least once"); free(hdr); } static void test_single_impulse_stencil(void) { const int width = 5, height = 5; const double limit = 1.0, transfer = 0.5; double *hdr = alloc_rgb(width, height); set(hdr, width, 2, 2, 0, 1.8); set(hdr, width, 2, 2, 1, 1.5); set(hdr, width, 2, 2, 2, 1.2); const SensorBloomSettings settings = {limit, transfer}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "single-impulse apply succeeds"); check(sample(hdr, width, 2, 2, 0) == limit && sample(hdr, width, 2, 2, 1) == limit && sample(hdr, width, 2, 2, 2) == limit, "single-impulse centre sits exactly at E"); const double red_axis = transfer * 0.8 * (4.0 / 20.0); const double red_diagonal = transfer * 0.8 * (1.0 / 20.0); const double green_axis = transfer * 0.5 * (4.0 / 20.0); const double blue_axis = transfer * 0.2 * (4.0 / 20.0); check(close_to(sample(hdr, width, 1, 2, 0), red_axis, 1e-15) && close_to(sample(hdr, width, 3, 2, 0), red_axis, 1e-15) && close_to(sample(hdr, width, 2, 1, 0), red_axis, 1e-15) && close_to(sample(hdr, width, 2, 3, 0), red_axis, 1e-15), "axial neighbours receive e * D * 4/20"); check(close_to(sample(hdr, width, 1, 1, 0), red_diagonal, 1e-15) && close_to(sample(hdr, width, 3, 3, 0), red_diagonal, 1e-15), "diagonal neighbours receive e * D * 1/20"); check(close_to(sample(hdr, width, 1, 2, 1), green_axis, 1e-15) && close_to(sample(hdr, width, 1, 2, 2), blue_axis, 1e-15), "RGB channels transfer independently"); check(sample(hdr, width, 0, 0, 0) == 0.0 && sample(hdr, width, 4, 4, 2) == 0.0, "far pixels stay untouched"); free(hdr); } static void test_boundary_loss(void) { const int width = 5, height = 5; const double limit = 1.0, transfer = 0.5; double *hdr = alloc_rgb(width, height); set(hdr, width, 0, 0, 0, 1.8); const SensorBloomSettings settings = {limit, transfer}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "corner apply succeeds"); const double overflow = 0.8; check(close_to(sample(hdr, width, 1, 0, 0), transfer * overflow * 0.2, 1e-15) && close_to(sample(hdr, width, 0, 1, 0), transfer * overflow * 0.2, 1e-15), "in-image axial propagation at a corner uses the fixed weight"); check(close_to(sample(hdr, width, 1, 1, 0), transfer * overflow * 0.05, 1e-15), "in-image diagonal propagation at a corner uses the fixed weight"); check(close_to(stats.boundary_loss, transfer * overflow * (2.0 * 0.2 + 3.0 * 0.05), 1e-15), "off-image weight is counted as boundary loss without renormalization"); check(close_to(stats.absorbed_signal, (1.0 - transfer) * overflow, 1e-15), "absorbed signal is (1 - e) * D"); free(hdr); } static void test_cascade_against_reference(void) { const int width = 9, height = 9; const double limit = 0.5, transfer = 0.6; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); set(hdr, width, 4, 4, 0, 10.0); set(hdr, width, 4, 4, 1, 4.0); set(hdr, width, 2, 6, 2, 3.0); set(hdr, width, 7, 1, 0, 1.5); double *expected = malloc(count * sizeof *expected); memcpy(expected, hdr, count * sizeof *expected); reference_bloom(expected, width, height, limit, transfer); const SensorBloomSettings settings = {limit, transfer}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "cascade apply succeeds"); check(stats.iterations >= 2, "cascade needs at least two rounds"); double max_difference = 0.0; for (size_t i = 0; i < count; ++i) max_difference = fmax(max_difference, fabs(hdr[i] - expected[i])); check(max_difference < 1e-12, "cascade matches the dense synchronous reference"); check(all_finite_and_bounded(hdr, count, limit), "cascade output is finite and at most E"); free(expected); free(hdr); } static void rotate_90(const double *src, double *dst, int size) { for (int y = 0; y < size; ++y) for (int x = 0; x < size; ++x) for (int c = 0; c < 3; ++c) set(dst, size, size - 1 - y, x, c, sample(src, size, x, y, c)); } static void test_symmetry(void) { const int size = 9; const double limit = 1.0, transfer = 0.5; double *hdr = alloc_rgb(size, size); set(hdr, size, 4, 4, 0, 10.0); const SensorBloomSettings settings = {limit, transfer}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, size, size, &settings, &stats) == 0, "symmetry apply succeeds"); check(sample(hdr, size, 3, 4, 0) == sample(hdr, size, 5, 4, 0) && sample(hdr, size, 3, 4, 0) == sample(hdr, size, 4, 3, 0) && sample(hdr, size, 3, 4, 0) == sample(hdr, size, 4, 5, 0), "four axial neighbours of a centred point are equal"); check(sample(hdr, size, 3, 3, 0) == sample(hdr, size, 5, 3, 0) && sample(hdr, size, 3, 3, 0) == sample(hdr, size, 3, 5, 0) && sample(hdr, size, 3, 3, 0) == sample(hdr, size, 5, 5, 0), "four diagonal neighbours of a centred point are equal"); /* 90-degree rotational covariance: rotating the input then blooming must * equal blooming then rotating the output. This starts from a fresh, * asymmetric and still-saturated input; the already-bloomed centre-impulse * buffer above has no overflow and would only exercise the no-op path. */ const size_t count = (size_t)size * size * 3; double *input = alloc_rgb(size, size); set(input, size, 2, 5, 0, 30.0); set(input, size, 6, 3, 1, 8.0); set(input, size, 4, 1, 2, 5.0); set(input, size, 7, 7, 0, 4.0); double *bloomed_input = malloc(count * sizeof *bloomed_input); double *rotated_input = alloc_rgb(size, size); rotate_90(input, rotated_input, size); double *bloomed_rotated = malloc(count * sizeof *bloomed_rotated); memcpy(bloomed_input, input, count * sizeof *bloomed_input); memcpy(bloomed_rotated, rotated_input, count * sizeof *bloomed_rotated); check(sensor_bloom_apply(bloomed_input, size, size, &settings, &stats) == 0 && stats.initially_saturated_channels > 0, "covariance input is saturated and blooms"); check(sensor_bloom_apply(bloomed_rotated, size, size, &settings, &stats) == 0, "rotated covariance input blooms"); double *expected = alloc_rgb(size, size); rotate_90(bloomed_input, expected, size); double max_difference = 0.0; for (size_t i = 0; i < count; ++i) max_difference = fmax(max_difference, fabs(bloomed_rotated[i] - expected[i])); check(max_difference < 1e-9, "model is 90-degree rotation covariant"); free(expected); free(bloomed_rotated); free(rotated_input); free(bloomed_input); free(input); free(hdr); } static uint32_t next_random(uint32_t *state) { *state = *state * 1664525u + 1013904223u; return *state; } static void test_thread_determinism(void) { const int width = 40, height = 30; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); uint32_t state = 12345u; for (size_t i = 0; i < count; ++i) hdr[i] = (double)(next_random(&state) % 3000u) / 1000.0; double *first = malloc(count * sizeof *first); double *second = malloc(count * sizeof *second); memcpy(first, hdr, count * sizeof *first); memcpy(second, hdr, count * sizeof *second); const SensorBloomSettings settings = {1.5, 0.5}; SensorBloomStats stats_one, stats_four; omp_set_num_threads(1); check(sensor_bloom_apply(first, width, height, &settings, &stats_one) == 0, "single-thread apply succeeds"); omp_set_num_threads(4); check(sensor_bloom_apply(second, width, height, &settings, &stats_four) == 0, "four-thread apply succeeds"); check(memcmp(first, second, count * sizeof *first) == 0, "final HDR is byte-identical for 1 and 4 threads"); check(stats_one.absorbed_signal == stats_four.absorbed_signal && stats_one.boundary_loss == stats_four.boundary_loss && stats_one.iterations == stats_four.iterations && stats_one.residual_clamp_loss == stats_four.residual_clamp_loss, "reported statistics are identical for 1 and 4 threads"); free(first); free(second); free(hdr); } static void test_convergence_and_hard_limit(void) { const int width = 9, height = 9; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); set(hdr, width, 4, 4, 0, 100.0); const SensorBloomSettings settings = {1.0, 0.5}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "converging apply succeeds"); check(all_finite_and_bounded(hdr, count, 1.0), "converged output is finite and at most E"); check(stats.iterations <= stats.predicted_iterations, "actual iterations do not exceed the conservative bound"); /* A round bound just inside the hard limit must still converge. The reported * cap includes the fp guard but must never exceed 4096, and it must bound the * actual number of rounds. */ const int near_width = 5, near_height = 5; const size_t near_count = (size_t)near_width * near_height * 3; double *near = alloc_rgb(near_width, near_height); set(near, near_width, 2, 2, 0, 1.0 + 7.4e-9); const SensorBloomSettings near_settings = {1.0, 0.9995}; SensorBloomStats near_stats; check(sensor_bloom_apply(near, near_width, near_height, &near_settings, &near_stats) == 0, "near-limit round bound still converges"); check(near_stats.predicted_iterations <= (size_t)4096, "reported round cap never exceeds the 4096 hard limit"); check(near_stats.iterations <= near_stats.predicted_iterations, "actual rounds never exceed the reported guard-inclusive cap"); check(all_finite_and_bounded(near, near_count, 1.0), "near-limit output is finite and at most E"); free(near); /* A bound one round above the hard limit (4097 with these parameters) must * fail in the pre-check, before any HDR sample is touched. */ double *near_fail = alloc_rgb(near_width, near_height); set(near_fail, near_width, 2, 2, 0, 1.0 + 7.758e-9); double *near_original = malloc(near_count * sizeof *near_original); memcpy(near_original, near_fail, near_count * sizeof *near_original); const SensorBloomSettings near_hard = {1.0, 0.9995}; SensorBloomStats near_hard_stats; check(sensor_bloom_apply(near_fail, near_width, near_height, &near_hard, &near_hard_stats) == -1, "round bound just above the hard limit fails"); check(memcmp(near_fail, near_original, near_count * sizeof *near_fail) == 0, "near-limit failure leaves the buffer byte-for-byte unchanged"); free(near_original); free(near_fail); /* An unbounded-overflow parameter set (e = 1 - 1e-6) needs far more than the * 4096-round hard limit and must fail before modifying the input. */ double *failing = alloc_rgb(width, height); set(failing, width, 4, 4, 0, 2.0); double *original = malloc(count * sizeof *original); memcpy(original, failing, count * sizeof *original); const SensorBloomSettings hard = {1.0, 0.999999}; SensorBloomStats hard_stats; check(sensor_bloom_apply(failing, width, height, &hard, &hard_stats) == -1, "round bound above the hard limit fails"); check(memcmp(failing, original, count * sizeof *failing) == 0, "hard-limit failure leaves the buffer byte-for-byte unchanged"); check(hard_stats.iterations == 0, "hard-limit failure reports no iterations"); check(hard_stats.predicted_iterations == 0 && hard_stats.final_clamped_channels == 0, "hard-limit failure reports no predicted rounds or clamps"); free(original); free(failing); free(hdr); } static void test_input_validation(void) { const int width = 4, height = 3; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); double *original = malloc(count * sizeof *original); const SensorBloomSettings valid = {1.0, 0.5}; SensorBloomStats stats; check(sensor_bloom_apply(NULL, width, height, &valid, &stats) == -1, "NULL framebuffer is rejected"); check(sensor_bloom_apply(hdr, width, height, NULL, &stats) == -1, "NULL settings are rejected"); check(sensor_bloom_apply(hdr, 0, height, &valid, &stats) == -1, "zero width is rejected"); check(sensor_bloom_apply(hdr, width, -1, &valid, &stats) == -1, "negative height is rejected"); check(sensor_bloom_apply(hdr, width, height, &valid, NULL) == 0, "NULL stats are permitted for an unsaturated buffer"); double dummy = 0.0; check(sensor_bloom_apply(&dummy, INT32_MAX, INT32_MAX, &valid, &stats) == -1, "multiplication-overflowing dimensions are rejected before scanning"); const double bad_limits[] = {0.0, -1.0, NAN, INFINITY, -INFINITY}; for (size_t i = 0; i < sizeof bad_limits / sizeof bad_limits[0]; ++i) { const SensorBloomSettings bad = {bad_limits[i], 0.5}; check(sensor_bloom_apply(hdr, width, height, &bad, &stats) == -1, "invalid response limit is rejected"); } const double bad_transfers[] = {-0.1, 1.0, 1.5, NAN, INFINITY, -INFINITY}; for (size_t i = 0; i < sizeof bad_transfers / sizeof bad_transfers[0]; ++i) { const SensorBloomSettings bad = {1.0, bad_transfers[i]}; check(sensor_bloom_apply(hdr, width, height, &bad, &stats) == -1, "invalid transfer is rejected"); } const double non_finite[] = {NAN, INFINITY, -INFINITY}; for (size_t i = 0; i < sizeof non_finite / sizeof non_finite[0]; ++i) { for (size_t j = 0; j < count; ++j) hdr[j] = 0.5; hdr[count / 2] = non_finite[i]; memcpy(original, hdr, count * sizeof *original); check(sensor_bloom_apply(hdr, width, height, &valid, &stats) == -1, "non-finite HDR sample is rejected"); check(memcmp(hdr, original, count * sizeof *hdr) == 0, "non-finite rejection leaves the buffer unchanged"); } free(original); free(hdr); } #ifndef SB_HAVE_ASAN /* Forces the scratch allocation to fail by lowering RLIMIT_AS in a child after * the input buffer is already mapped, then checks that the model reports the * allocation failure instead of touching the framebuffer. */ static int allocation_failure_is_reported(void) { const pid_t pid = fork(); if (pid < 0) return 0; if (pid == 0) { const int width = 64, height = 64; const size_t count = (size_t)width * height * 3; double *hdr = calloc(count, sizeof *hdr); if (hdr == NULL) _exit(2); hdr[0] = 2.0; struct rlimit existing; if (getrlimit(RLIMIT_AS, &existing)) _exit(2); const struct rlimit tiny = {.rlim_cur = 1, .rlim_max = existing.rlim_max}; if (setrlimit(RLIMIT_AS, &tiny)) _exit(2); const SensorBloomSettings settings = {1.0, 0.5}; SensorBloomStats stats; _exit(sensor_bloom_apply(hdr, width, height, &settings, &stats) == -1 ? 0 : 1); } int status = 0; if (waitpid(pid, &status, 0) < 0) return 0; return WIFEXITED(status) && WEXITSTATUS(status) == 0; } #endif static void test_nonnegative_output(void) { const int width = 12, height = 7; const size_t count = (size_t)width * height * 3; double *hdr = alloc_rgb(width, height); uint32_t state = 99u; for (size_t i = 0; i < count; ++i) hdr[i] = (double)(next_random(&state) % 4000u) / 1000.0; const SensorBloomSettings settings = {1.2, 0.5}; SensorBloomStats stats; check(sensor_bloom_apply(hdr, width, height, &settings, &stats) == 0, "nonnegative apply succeeds"); int nonnegative = 1; for (size_t i = 0; i < count; ++i) if (!(hdr[i] >= 0.0) || !isfinite(hdr[i])) nonnegative = 0; check(nonnegative, "nonnegative input produces nonnegative finite output"); check(all_finite_and_bounded(hdr, count, 1.2), "nonnegative output respects the response limit"); free(hdr); } int main(void) { #ifndef SB_HAVE_ASAN /* Run the fork-based allocation-failure check before any OpenMP region has * been created in the parent. */ check(allocation_failure_is_reported(), "scratch allocation failure is reported without touching the input"); #endif test_no_saturation(); test_zero_transfer(); test_single_impulse_stencil(); test_boundary_loss(); test_cascade_against_reference(); test_symmetry(); test_thread_determinism(); test_convergence_and_hard_limit(); test_input_validation(); test_nonnegative_output(); if (failures != 0) { fprintf(stderr, "%d sensor-bloom assertion(s) failed\n", failures); return EXIT_FAILURE; } puts("sensor-bloom tests passed"); return EXIT_SUCCESS; }