Skip to content

Commit 9732581

Browse files
Jammy2211claude
authored andcommitted
fix OOB read in psf_precision_value_from causing NaN sparse-CPU log_evidence
The kernel walk in psf_precision_value_from (used by the sparse-operator CPU inversion path) reads value_native[ip0_y + k0_y + kernel_shift_y, ...] without bounds checking. For mask pixels within `kernel_shift` of the noise-map array boundary, that index lands off the array; @numba.jit() does not bounds-check, so the read returns uninitialized memory. For the HST 28x28 RectangularAdaptDensity pixelization profiled in autolens_workspace_developer/jax_profiling/imaging/pixelization_sparse_cpu.py, this produced 1064 inf entries in the curvature matrix, a NaN log_det, and ultimately NaN log_evidence. log_likelihood was -191493 (vs the correct +28664) because the inf-poisoned F+H made the NNLS solver return all-zeros for s, giving a wildly wrong chi-squared. The fix adds an explicit bounds check around the kernel read. Off-array positions are skipped, which matches the function's existing semantics for masked-but-zeroed interior pixels (`if value > 0.0: ...` already filters those). After the fix, sparse and non-sparse paths agree on the HST workspace_developer model to rtol=1.18e-09. Existing tests are unaffected: the closest util test (test__psf_precision_operator_sparse_from) uses interior pixels in a 4x4 noise map so the bounds check is a no-op there. The integration tests in test_factory.py use a 3x3 no-blur PSF (only center=1) so off-diagonal kernel reads contribute zero regardless. The new test__psf_precision_operator_sparse_from__edge_pixels exercises the fixed path with corner pixels and a non-trivial 3x3 PSF, validating against a pure-numpy reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9976ed commit 9732581

2 files changed

Lines changed: 104 additions & 3 deletions

File tree

autoarray/inversion/inversion/imaging_numba/inversion_imaging_numba_util.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,24 @@ def psf_precision_value_from(
310310

311311
for k0_y in range(kernel_native.shape[0]):
312312
for k0_x in range(kernel_native.shape[1]):
313-
value = value_native[
314-
ip0_y + k0_y + kernel_shift_y, ip0_x + k0_x + kernel_shift_x
315-
]
313+
iy = ip0_y + k0_y + kernel_shift_y
314+
ix = ip0_x + k0_x + kernel_shift_x
315+
316+
# numba @jit() does not bounds-check array reads. Without this
317+
# guard, a kernel position that lands off the noise-map array
318+
# (e.g. a mask pixel within `kernel_shift` of the array edge)
319+
# silently reads uninitialized memory, producing astronomical
320+
# or non-finite contributions that poison the entire
321+
# psf_precision_operator and downstream curvature matrix.
322+
if (
323+
iy < 0
324+
or iy >= value_native.shape[0]
325+
or ix < 0
326+
or ix >= value_native.shape[1]
327+
):
328+
continue
329+
330+
value = value_native[iy, ix]
316331

317332
if value > 0.0:
318333
k1_y = k0_y + ip_y_offset

test_autoarray/inversion/inversion/imaging/test_inversion_imaging_util.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,92 @@ def test__psf_precision_operator_sparse_from():
121121
assert psf_weighted_noise_lengths == pytest.approx(np.array([4, 3, 2, 1]), 1.0e-4)
122122

123123

124+
def test__psf_precision_operator_sparse_from__edge_pixels():
125+
# Regression test: every slim pixel sits at a corner of the 4x4 noise map,
126+
# so the kernel walk in psf_precision_value_from indexes off the array.
127+
# numba.jit() does not bounds-check, so without the explicit guard added
128+
# in the function those reads return uninitialized memory and produce
129+
# astronomically large or non-finite operator entries.
130+
noise_map = np.array(
131+
[
132+
[1.0, 1.0, 1.0, 1.0],
133+
[1.0, 2.0, 2.0, 1.0],
134+
[1.0, 2.0, 2.0, 1.0],
135+
[1.0, 1.0, 1.0, 1.0],
136+
]
137+
)
138+
kernel = np.array([[1.0, 1.0, 0.0], [1.0, 2.0, 1.0], [0.0, 1.0, 1.0]])
139+
native_index_for_slim_index = np.array([[0, 0], [0, 3], [3, 0], [3, 3]])
140+
141+
(
142+
op,
143+
indexes,
144+
lengths,
145+
) = aa.util.inversion_imaging_numba.psf_precision_operator_sparse_from(
146+
noise_map_native=noise_map,
147+
kernel_native=kernel,
148+
native_index_for_slim_index=native_index_for_slim_index,
149+
)
150+
151+
# Sanity: no inf/nan in the operator.
152+
assert np.isfinite(op).all()
153+
assert int(lengths.sum()) == op.shape[0]
154+
155+
# Independent reference: a pure-numpy bounds-checked re-implementation of
156+
# psf_precision_value_from. The numba version with the fix applied must
157+
# match this byte-for-byte.
158+
def _reference_value(ip0_y, ip0_x, ip1_y, ip1_x):
159+
h, w = noise_map.shape
160+
kh, kw = kernel.shape
161+
kernel_shift_y = -(kw // 2)
162+
kernel_shift_x = -(kh // 2)
163+
ip_y_offset = ip0_y - ip1_y
164+
ip_x_offset = ip0_x - ip1_x
165+
if (
166+
ip_y_offset < 2 * kernel_shift_y
167+
or ip_y_offset > -2 * kernel_shift_y
168+
or ip_x_offset < 2 * kernel_shift_x
169+
or ip_x_offset > -2 * kernel_shift_x
170+
):
171+
return 0.0
172+
total = 0.0
173+
for k0_y in range(kh):
174+
for k0_x in range(kw):
175+
iy = ip0_y + k0_y + kernel_shift_y
176+
ix = ip0_x + k0_x + kernel_shift_x
177+
if iy < 0 or iy >= h or ix < 0 or ix >= w:
178+
continue
179+
v = noise_map[iy, ix]
180+
if v > 0.0:
181+
k1_y = k0_y + ip_y_offset
182+
k1_x = k0_x + ip_x_offset
183+
if 0 <= k1_y < kh and 0 <= k1_x < kw:
184+
total += kernel[k0_y, k0_x] * kernel[k1_y, k1_x] / v ** 2
185+
return total
186+
187+
n_pix = native_index_for_slim_index.shape[0]
188+
expected = []
189+
expected_indexes = []
190+
expected_lengths = []
191+
for ip0 in range(n_pix):
192+
ip0_y, ip0_x = native_index_for_slim_index[ip0]
193+
count = 0
194+
for ip1 in range(ip0, n_pix):
195+
ip1_y, ip1_x = native_index_for_slim_index[ip1]
196+
v = _reference_value(ip0_y, ip0_x, ip1_y, ip1_x)
197+
if ip0 == ip1:
198+
v /= 2.0
199+
if v > 0.0:
200+
expected.append(v)
201+
expected_indexes.append(ip1)
202+
count += 1
203+
expected_lengths.append(count)
204+
205+
assert op == pytest.approx(np.array(expected), 1.0e-4)
206+
assert indexes == pytest.approx(np.array(expected_indexes), 1.0e-4)
207+
assert lengths == pytest.approx(np.array(expected_lengths), 1.0e-4)
208+
209+
124210
def test__data_vector_via_blurred_mapping_matrix_from():
125211
blurred_mapping_matrix = np.array(
126212
[

0 commit comments

Comments
 (0)