Files
GR-raytracing/tests/test_sensor_bloom.c
T
wyj 9cd933d1f8 Feat: Add optional three-channel sensor bloom model
Add an opt-in, post-processing limited-response model applied to the
finished linear HDR before tone mapping. Each RGB channel is processed
independently and isotropically: overflow above E spreads to the eight
neighbours with a fixed 9-point stencil, while the rest is absorbed or
lost at the image boundary. The synchronous ping-pong update uses a
monotonic bounding box and a row-parallel, deterministic reduction; the
conservative round bound reserves fp guard rounds inside a 4096 hard
limit and fails before touching HDR when exceeded.

Expose --sensor-bloom-limit E and --sensor-bloom-transfer e (both
required together, default disabled), validate them before expensive
initialization, and route every output path through the same hook in
write_frame_outputs: raw FITS first, bloom, tone-mapped PNG/PPM, then the
mesh overlay. The raw --hdr-output FITS therefore stays pre-bloom.

Add a standalone unit test (stencil, boundary loss, cascade reference,
symmetry, thread determinism, convergence limits, validation, allocation
failure), CLI integration and regression coverage, an isolated
sensor-bloom-bench target, and document the model in the design, usage,
README, and build docs.
2026-09-27 04:39:38 -04:00

569 lines
23 KiB
C

/* 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 <math.h>
#include <omp.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
/* 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;
}