|
| 1 | +""" |
| 2 | +JAX tests of the static step-0 lattice on the `PointSolver` path (point-source CPU phase 3, |
| 3 | +PyAutoArray#568). |
| 4 | +
|
| 5 | +On the JAX path `AbstractSolver._initial_triangles` builds its tiling with |
| 6 | +``static_vertices=True``, so step 0 deflects only the geometrically unique lattice vertices |
| 7 | +(11 859 rather than 69 849 rows for the +-9.9" / 0.2" lattice of a 100x100, 0.2" grid) and |
| 8 | +gathers them back to the triangles. These tests pin that shape (the guard is red on the flat |
| 9 | +table), that a change of geometry changes the table, and that positions, image counts, the |
| 10 | +likelihood, `jax.grad` and `jax.vmap` agree with the flat per-triangle table the solver used |
| 11 | +before -- including near-caustic sources. |
| 12 | +
|
| 13 | +Every test skips on the NumPy-only (no ``[optional]`` extras) CI leg. |
| 14 | +""" |
| 15 | + |
| 16 | +import importlib |
| 17 | + |
| 18 | +import numpy as np |
| 19 | +import pytest |
| 20 | + |
| 21 | + |
| 22 | +# Plain data, defined outside the jax guard: module-level @parametrize reads it at collection |
| 23 | +# time, including on the NumPy-only matrix env. |
| 24 | +# Generic, near-caustic (the SIE's tangential caustic is ~0.1" across) and outer sources. |
| 25 | +SOURCES = [ |
| 26 | + (0.07, 0.07), |
| 27 | + (0.0, 0.0), |
| 28 | + (0.012, 0.0), |
| 29 | + (0.0, 0.015), |
| 30 | + (0.03, 0.03), |
| 31 | + (0.3, -0.2), |
| 32 | + (1.2, 0.1), |
| 33 | + (1.58, 0.0), |
| 34 | +] |
| 35 | + |
| 36 | + |
| 37 | +if importlib.util.find_spec("jax") is None: |
| 38 | + pytestmark = pytest.mark.skip(reason="requires jax (the [optional] extras)") |
| 39 | + |
| 40 | + def test__placeholder_requires_jax(): # pragma: no cover |
| 41 | + pass |
| 42 | + |
| 43 | +else: |
| 44 | + import jax |
| 45 | + import jax.numpy as jnp |
| 46 | + |
| 47 | + jax.config.update("jax_enable_x64", True) |
| 48 | + |
| 49 | + import autolens as al |
| 50 | + from autolens.jax.registration import register_tracer_classes |
| 51 | + from autolens.point.solver.shape_solver import AbstractSolver |
| 52 | + from autoarray.structures.triangles.coordinate_array import ( |
| 53 | + CoordinateArrayTriangles, |
| 54 | + static_vertex_table, |
| 55 | + ) |
| 56 | + |
| 57 | + def _solver(shape_native=(100, 100), pixel_scales=0.2): |
| 58 | + return al.PointSolver.for_grid( |
| 59 | + grid=al.Grid2D.uniform( |
| 60 | + shape_native=shape_native, pixel_scales=pixel_scales |
| 61 | + ), |
| 62 | + pixel_scale_precision=0.001, |
| 63 | + magnification_threshold=0.1, |
| 64 | + use_jax=True, |
| 65 | + ) |
| 66 | + |
| 67 | + def _tracer(einstein_radius=1.6, source=(0.07, 0.07)): |
| 68 | + lens = al.Galaxy( |
| 69 | + redshift=0.5, |
| 70 | + mass=al.mp.Isothermal( |
| 71 | + centre=(0.0, 0.0), |
| 72 | + einstein_radius=einstein_radius, |
| 73 | + ell_comps=(0.05, 0.03), |
| 74 | + ), |
| 75 | + ) |
| 76 | + return al.Tracer( |
| 77 | + galaxies=[ |
| 78 | + lens, |
| 79 | + al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=source)), |
| 80 | + ] |
| 81 | + ) |
| 82 | + |
| 83 | + def _flat_initial_triangles(self, xp): |
| 84 | + """The pre-phase-3 JAX step-0 tiling: the flat (3N, 2) per-triangle vertex table.""" |
| 85 | + return CoordinateArrayTriangles.for_limits_and_scale( |
| 86 | + y_min=self.y_min, |
| 87 | + y_max=self.y_max, |
| 88 | + x_min=self.x_min, |
| 89 | + x_max=self.x_max, |
| 90 | + scale=self.scale, |
| 91 | + ) |
| 92 | + |
| 93 | + def _traced_grid_rows(solver, tracer, source, monkeypatch): |
| 94 | + """Trace one jitted solve and record the row count of every deflected grid.""" |
| 95 | + rows = [] |
| 96 | + plane_grid = AbstractSolver._plane_grid |
| 97 | + |
| 98 | + def recording(self, tracer, grid, xp, plane_redshift=None): |
| 99 | + rows.append(int(np.shape(grid)[0])) |
| 100 | + return plane_grid(self, tracer, grid, xp, plane_redshift) |
| 101 | + |
| 102 | + monkeypatch.setattr(AbstractSolver, "_plane_grid", recording) |
| 103 | + jax.jit( |
| 104 | + lambda: solver.solve(tracer, source_plane_coordinate=source).array |
| 105 | + ).lower() |
| 106 | + monkeypatch.setattr(AbstractSolver, "_plane_grid", plane_grid) |
| 107 | + return rows |
| 108 | + |
| 109 | + def _solve(solver, einstein_radius, source): |
| 110 | + return np.asarray( |
| 111 | + jax.jit( |
| 112 | + lambda e, s: solver.solve( |
| 113 | + _tracer(e, (s[0], s[1])), source_plane_coordinate=(s[0], s[1]) |
| 114 | + ).array |
| 115 | + )(einstein_radius, jnp.array(source)) |
| 116 | + ) |
| 117 | + |
| 118 | + def _finite_sorted(positions): |
| 119 | + positions = positions[np.all(np.isfinite(positions), axis=1)] |
| 120 | + return positions[np.lexsort((positions[:, 1], positions[:, 0]))] |
| 121 | + |
| 122 | + |
| 123 | + |
| 124 | +def test__step_0_deflects_the_unique_lattice_vertices(monkeypatch): |
| 125 | + """ |
| 126 | + The shape guard: the first deflected grid of a jitted solve on the 100x100, 0.2" grid is the |
| 127 | + 11 859-row unique-vertex table, not the 69 849-row flat one. Red before phase 3. |
| 128 | + """ |
| 129 | + solver = _solver() |
| 130 | + tracer = _tracer() |
| 131 | + register_tracer_classes(tracer) |
| 132 | + |
| 133 | + rows = _traced_grid_rows(solver, tracer, (0.07, 0.07), monkeypatch) |
| 134 | + |
| 135 | + assert rows[0] == 11859 |
| 136 | + assert rows[0] != 3 * 23283 |
| 137 | + assert len(rows) == solver.n_steps |
| 138 | + |
| 139 | + |
| 140 | +def test__changing_the_geometry_changes_the_step_0_table(monkeypatch): |
| 141 | + solver = _solver() |
| 142 | + tracer = _tracer() |
| 143 | + register_tracer_classes(tracer) |
| 144 | + |
| 145 | + solver.scale = 0.3 |
| 146 | + rows = _traced_grid_rows(solver, tracer, (0.07, 0.07), monkeypatch) |
| 147 | + |
| 148 | + expected = static_vertex_table( |
| 149 | + float(solver.y_min), |
| 150 | + float(solver.y_max), |
| 151 | + float(solver.x_min), |
| 152 | + float(solver.x_max), |
| 153 | + 0.3, |
| 154 | + )[0].shape[0] |
| 155 | + assert rows[0] == expected |
| 156 | + assert rows[0] != 11859 |
| 157 | + |
| 158 | + |
| 159 | +@pytest.mark.parametrize("source", SOURCES) |
| 160 | +def test__positions_match_the_flat_table(source, monkeypatch): |
| 161 | + """Identical image counts and positions to <= 1e-10 against the pre-phase-3 flat table.""" |
| 162 | + solver = _solver() |
| 163 | + register_tracer_classes(_tracer()) |
| 164 | + |
| 165 | + static = _finite_sorted(_solve(solver, 1.6, source)) |
| 166 | + |
| 167 | + jax.clear_caches() |
| 168 | + monkeypatch.setattr(AbstractSolver, "_initial_triangles", _flat_initial_triangles) |
| 169 | + flat = _finite_sorted(_solve(solver, 1.6, source)) |
| 170 | + |
| 171 | + assert static.shape == flat.shape |
| 172 | + assert static.shape[0] > 0 |
| 173 | + np.testing.assert_allclose(static, flat, rtol=0.0, atol=1e-10) |
| 174 | + |
| 175 | + |
| 176 | +@pytest.mark.parametrize("source", [(0.07, 0.07), (0.0, 0.015), (0.3, -0.2)]) |
| 177 | +def test__positions_match_numpy(source): |
| 178 | + solver = _solver() |
| 179 | + register_tracer_classes(_tracer()) |
| 180 | + |
| 181 | + positions = _finite_sorted(_solve(solver, 1.6, source)) |
| 182 | + |
| 183 | + solver_np = al.PointSolver.for_grid( |
| 184 | + grid=al.Grid2D.uniform(shape_native=(100, 100), pixel_scales=0.2), |
| 185 | + pixel_scale_precision=0.001, |
| 186 | + magnification_threshold=0.1, |
| 187 | + ) |
| 188 | + positions_np = _finite_sorted( |
| 189 | + np.asarray( |
| 190 | + solver_np.solve(_tracer(1.6, source), source_plane_coordinate=source).array |
| 191 | + ) |
| 192 | + ) |
| 193 | + |
| 194 | + assert positions.shape == positions_np.shape |
| 195 | + np.testing.assert_allclose(positions, positions_np, rtol=0.0, atol=1e-10) |
| 196 | + |
| 197 | + |
| 198 | +def _log_likelihood_and_grad(solver): |
| 199 | + positions = al.Grid2DIrregular([(1.53, 0.35), (-1.24, -0.28)]) |
| 200 | + noise_map = al.ArrayIrregular([0.05, 0.05]) |
| 201 | + |
| 202 | + def log_likelihood(einstein_radius): |
| 203 | + fit = al.FitPositionsImagePairAll( |
| 204 | + name="point_0", |
| 205 | + data=positions, |
| 206 | + noise_map=noise_map, |
| 207 | + tracer=_tracer(einstein_radius, (0.07, 0.07)), |
| 208 | + solver=solver, |
| 209 | + xp=jnp, |
| 210 | + ) |
| 211 | + return fit.log_likelihood |
| 212 | + |
| 213 | + value, grad = jax.jit(jax.value_and_grad(log_likelihood))(1.6) |
| 214 | + return float(value), float(grad) |
| 215 | + |
| 216 | + |
| 217 | +def test__log_likelihood_and_grad_match_the_flat_table(monkeypatch): |
| 218 | + solver = _solver() |
| 219 | + register_tracer_classes(_tracer()) |
| 220 | + |
| 221 | + value, grad = _log_likelihood_and_grad(solver) |
| 222 | + |
| 223 | + jax.clear_caches() |
| 224 | + monkeypatch.setattr(AbstractSolver, "_initial_triangles", _flat_initial_triangles) |
| 225 | + value_flat, grad_flat = _log_likelihood_and_grad(solver) |
| 226 | + |
| 227 | + assert np.isfinite(value) |
| 228 | + assert grad != 0.0, "jax.grad through the solver is silently zero" |
| 229 | + assert abs(value - value_flat) <= 1e-12 * abs(value_flat) |
| 230 | + assert grad == pytest.approx(grad_flat, rel=1e-10) |
| 231 | + |
| 232 | + |
| 233 | +def test__vmap_matches_scalar_solves(): |
| 234 | + solver = _solver() |
| 235 | + register_tracer_classes(_tracer()) |
| 236 | + |
| 237 | + sources = jnp.array([(0.07, 0.07), (0.0, 0.015), (0.3, -0.2), (0.03, 0.03)]) |
| 238 | + |
| 239 | + batched = np.asarray( |
| 240 | + jax.jit( |
| 241 | + jax.vmap( |
| 242 | + lambda s: solver.solve( |
| 243 | + _tracer(1.6, (s[0], s[1])), source_plane_coordinate=(s[0], s[1]) |
| 244 | + ).array |
| 245 | + ) |
| 246 | + )(sources) |
| 247 | + ) |
| 248 | + |
| 249 | + for i, source in enumerate(np.asarray(sources)): |
| 250 | + scalar = _finite_sorted(_solve(solver, 1.6, tuple(source))) |
| 251 | + np.testing.assert_allclose( |
| 252 | + _finite_sorted(batched[i]), scalar, rtol=0.0, atol=1e-10 |
| 253 | + ) |
| 254 | + |
| 255 | + |
| 256 | +def test__source_on_a_step_0_vertex_returns_the_two_true_images(): |
| 257 | + """ |
| 258 | + Pinned tie regression (human gate decision, 2026-09-24: PASS). |
| 259 | +
|
| 260 | + The source of a simple SIE is placed bit-exactly on the image of the traced step-0 lattice |
| 261 | + vertex v = (-0.8, -1.99185843) (flat triangle vertex 31 732 of the +-9.9" / 0.2" lattice), |
| 262 | + and solved under ``jit`` with the source a traced input, as in a likelihood. The system has |
| 263 | + exactly two images: the vertex itself and a counter-image at ~(0.41013634, 0.89463539) |
| 264 | + (Newton roots of the lens equation; NumPy's 7 positions collapse onto the same two roots). |
| 265 | +
|
| 266 | + The static step-0 lattice returns exactly those two images. The pre-phase-3 flat table |
| 267 | + (main before PyAutoArray#568 phase 3) returns three here: it keeps two triangles either side |
| 268 | + of the tie vertex, |p - v| ~ 9e-4 each, which both Newton-converge to the vertex root -- it |
| 269 | + double-counts one image. That flat control is not self-consistent at such measure-zero ties |
| 270 | + either: eager and ``jit`` solves differ in the step-0 kept set and often in the image count, |
| 271 | + and a 1e-9 nudge of the source gives two images on both paths. The phase-3 image-count gate |
| 272 | + therefore passes this case as the removal of a duplicate, not the loss of an image. |
| 273 | + """ |
| 274 | + solver = _solver() |
| 275 | + register_tracer_classes(_tracer()) |
| 276 | + |
| 277 | + lattice = CoordinateArrayTriangles.for_limits_and_scale( |
| 278 | + y_min=-9.9, y_max=9.9, x_min=-9.9, x_max=9.9, scale=0.2 |
| 279 | + ) |
| 280 | + vertex = np.asarray(lattice.triangles).reshape(-1, 2)[31732] |
| 281 | + np.testing.assert_allclose(vertex, (-0.8, -1.99185843), rtol=0.0, atol=1e-8) |
| 282 | + |
| 283 | + lens_only = al.Tracer( |
| 284 | + galaxies=[_tracer().galaxies[0], al.Galaxy(redshift=1.0)] |
| 285 | + ) |
| 286 | + deflection = np.asarray( |
| 287 | + lens_only.deflections_yx_2d_from( |
| 288 | + grid=al.Grid2DIrregular([tuple(vertex)]) |
| 289 | + ).array |
| 290 | + )[0] |
| 291 | + source = tuple(float(value) for value in vertex - deflection) |
| 292 | + |
| 293 | + positions = _finite_sorted(_solve(solver, 1.6, source)) |
| 294 | + |
| 295 | + true_images = np.array([vertex, (0.41013634, 0.89463539)]) |
| 296 | + assert positions.shape == (2, 2) |
| 297 | + np.testing.assert_allclose( |
| 298 | + positions, true_images, rtol=0.0, atol=2.0 * solver.pixel_scale_precision |
| 299 | + ) |
0 commit comments