Repository navigation
feat(inversion): certified active-set positive solver on the JAX path, opt-in, mapper-only dispatch (phase A) #566
Description
Overview
Promote the certified active-set positive-only solver from the autolens_profiling harness into PyAutoArray — phase A of the certified-positive-solver epic: the library implementation, opt-in, with structure-aware dispatch. The fixed-lens-light and HST-GPU-residue campaigns measured this solver returning the library's own constrained optimum (pins ≤ 5.6e-10 on every leg) at 1.2–2.6× the speed of the JAX PDIP on source-only inversions (A100 whole call 31.7 vs 50.8 ms), but every gain is still a harness monkeypatch. Phase A ships it behind Settings/config (positive_only_solver: pdip remains the default), selected only on the JAX backend for mapper-only inversions (MGE-inclusive systems never certified and keep PDIP; the NumPy path is untouched because the NumPy certified scheme measured slower than fnnls). The active set is searched under stop_gradient and the final masked Cholesky solve is autodiffed, giving the exact implicit active-set gradient. Phase B — the production-composition jit(vmap) benchmark and the default flip — is filed as PyAutoMind/draft/research/autolens_profiling/certified_solver_production_default.md and is not this issue.
Plan
- Add a JAX certified active-set positive solver to PyAutoArray as a new util module: budgeted
lax.while_loopthat stops at certification (no wasted factorizations), primal + dual (KKT) certification, permanent fixed set, and alax.condfallback to the existing PDIP when the budget is exhausted. - Make it differentiable the right way: the active set is found under
stop_gradient, then one final masked Cholesky solve on that set is differentiated by autodiff — the exact implicit active-set derivative — and tested against finite differences and against PDIP'scustom_vjpgradient. - Wire it behind Settings/config as opt-in (
positive_only_solver: pdipstays the default):certifiedis selected only on the JAX backend for mapper-only inversions (no linear light-profile/MGE coefficients); MGE-inclusive systems and the whole NumPy path keep today's solvers unchanged. Solver observability (certified flag, passes) is exposed without changing return types. - Tests: numerics vs a SciPy NNLS reference and vs PDIP, forced fallback bit-exact, edge-zero subsetting preserved, jit and vmap execution, gradient parity, dispatch by composition and backend, settings/config round trip, NumPy path untouched.
- Validate downstream on the JAX parity scripts with the setting flipped on through a workspace config override (no workspace commits), and record scalar-jit timings on the RTX for the PR body. The production default and the batched (vmap) policy are phase B, filed as a new draft prompt referencing this issue.
Detailed implementation plan
Affected Repositories
- PyAutoArray (primary, only)
Work Classification
Library (source + tests + config). Additive: new optional kwargs and settings; no API break.
Branch Survey
| Repository | Current Branch | Dirty? |
|---|---|---|
| ./PyAutoArray | main (22e6d60) | clean |
Suggested branch: feature/certified-positive-solver
Worktree root: ~/Code/PyAutoLabs-wt/certified-positive-solver/ (conflict guard: no active claim on PyAutoArray; the unregistered delaunay-area-magnification-audit worktree is legacy, not a claim)
Health at planning time
Heart RED (release validation FAILED (stage integrate)) plus YELLOW workspace-validation and manifest-drift reasons — unrelated to this change. Shipping a library PR will require the development-only human Heart-RED override; merge stays /prm behind the library freeze gate. PyAutoArray #553–#556 are merged but unreleased (pending-release chain).
Classification, branch, worktree
- Work type: library (PyAutoArray only). No API break: new optional kwargs and settings only.
- Branch
feature/certified-positive-solver; worktree~/Code/PyAutoLabs-wt/certified-positive-solver/PyAutoArray(via/start_library). Conflict guard: no conflict (no active.md claim on PyAutoArray; the other PyAutoArray worktreedelaunay-area-magnification-auditis unregistered — legacy, note only). - Mind: rescope the existing prompt with a dated "Scoped 2026-09-23" block (phase A here, phase B filed separately), issue in PyAutoArray, row
certified-positive-solver,status: library-dev; new draftdraft/research/autolens_profiling/certified_solver_production_default.md(phase B).
Step 1 — autoarray/util/jax_active_set.py (new; never imports jax at module level, like jax_nnls.py)
masked_solve(Q, q, fixed): port of_masked_solve(identity on fixed rows/cols, zeroed RHS,cholesky+cho_solve), static shapes.certify(Q, q, x, fixed, permanent, tau_rel)→(certified, primal_violations, dual_violations)per the harness tests (g = Qx − q;tau_x = tau_rel·max|x|,tau_g = tau_rel·max|q|; permanent never freeable).active_set_search(Q, q, permanent, pass_budget, tau_rel): pass 0 unconstrained, seedZ = permanent | (x0 < 0), thenlax.while_loop(cond=(~certified) & (passes < budget), body=one masked solve + certify + free_all update); returns(Z_final, certified, passes). Wrapped instop_gradienton its inputs so no reverse-mode path goes through the loop (while_loopis not reverse-differentiable).solve_certified(Q, q, permanent=None, pass_budget=16, tau_rel=1e-9)→(x, certified, passes): runs the search, thenx = masked_solve(Q, q, Z_final)outside the stop_gradient so autodiff of the final Cholesky solve yields the implicit active-set gradient;xis exactly zero on Z. Everything computed in float64 (the NNLS stays fp64 underuse_mixed_precision, as the Settings docstring already states).solve_certified_with_fallback(Q, q, pdip_fn, fallback: bool, ...):lax.cond(certified, lambda: x, pdip_fn)whenfallback, else returns the last iterate withcertified=Falseflagged. Document the vmap both-branches cost in the docstring and point at phase B.
Step 2 — inversion_util.reconstruction_positive_only_from (JAX branch, :328-393)
- New kwarg
solver: str = "pdip"("pdip" | "certified"), read by the caller from settings. In the JAX branch, after the existing Jacobi scaling, branch:pdip→ today'ssolve_nnls_primal(byte-identical behaviour);certified→jax_active_set.solve_certified_with_fallback(Q_pc, q_pc, pdip_fn=lambda: solve_nnls_primal(Q_pc, q_pc, ...), fallback=settings.certified_fallback == "pdip", pass_budget=settings.certified_pass_budget, tau_rel=settings.certified_tau_rel), then unscaleresult * Das today.permanent=None(the caller already subsets tosolve_ids_to_keep; keep the kwarg for callers that do not). - Observability: a module-level
last_certified_statsis not jit-safe; instead return path unchanged and exposecertified/passesvia an optionalstats: dictout-parameter populated with traced arrays (the caller mayjax.debug.callbackthem), mirroring the existing NumPystatsconvention. No change to the return type. - NumPy branch: untouched (
solverignored with a one-line comment citing the measured no-win).
Step 3 — AbstractInversion.reconstruction (abstract.py:599-691) and Settings
- Resolve
solver = self._positive_only_solveronce:"certified"iffsettings.positive_only_solver == "certified"andself.use_jaxandself.has(cls=Mapper)andnot self.has(cls=AbstractLinearObjFuncList); else"pdip". Passsolver=in both the subset and full branches. ImportAbstractLinearObjFuncList(the module imports onlyLinearObj/Mappertoday). Record the decision on the instance (self.positive_only_solver_used) for tests/observability. Settings(autoarray/settings.py): constructor kwargspositive_only_solver=None,certified_pass_budget=None,certified_fallback=None,certified_tau_rel=None; properties with thetry/except KeyErrorfallback pattern; defaultspdip,16,pdip,1.0e-9. Budget 16 is the measured every-draw-certifies bound (rect worst 11, Euclid rect 11) with margin — cheap because the loop exits at certification. Add the keys underinversion:inautoarray/config/general.yamlwith one-line comments (what each does, "opt-in until phase B", the vmap caveat). Extend the settings dict round-trip.use_positive_only_solver=Falsepath unchanged;solve_ids_to_keepsemantics unchanged (edge zeroing preserved).
Step 4 — tests (test_autoarray/)
test_autoarray/util/test_jax_active_set.py(pytest.importorskip("jax"),jax_enable_x64): (a) on seeded random SPD QPs (n = 20, 60; some with negative unconstrained solutions)solve_certifiedmatchesscipy.optimize.nnlstoatol 1e-10and PDIP (solve_nnls_primal) tortol 1e-8; (b)passes≤ budget and the loop stops early (assertpassesequals the harness's NumPy referenceactive_set_certifiedcount when ported into the test as a small helper, or simply < budget on easy systems); (c) permanent fixed indices stay exactly zero and are never released; (d) exhausted budget (pass_budget=1on a hard system) with fallback returns PDIP bit-exactly (np.array_equal) and without fallback returnscertified=False; (e)jax.jitandjax.vmapover B=4 distinct systems produce the per-system scalar results; (f) gradient:jax.grad(lambda q: w @ solve_certified(Q, q)[0])equals central finite differences on the free set tortol 1e-6and agrees withjax.gradthroughsolve_nnls_primaltortol 1e-4(PDIP's gradient is a relaxed approximation; tolerance declared before running, recorded in the test docstring); (g) module never imports jax at import time (mirrortest_jax_nnls.py:10).test_inversion_util.py:reconstruction_positive_only_from(..., solver="certified", xp=jnp)equalssolver="pdip"within 1e-8 on the existing fixtures;solveris ignored on NumPy (xp=npresult unchanged, fnnls still called — monkeypatch spy).test_factory.py/ a newtest_positive_only_dispatch.py: withSettings(positive_only_solver="certified")andxp=jnp: mapper-only inversion (MockMapper) recordspositive_only_solver_used == "certified"and matches PDIP; an inversion containingMockLinearObjFuncListrecords"pdip";xp=nprecords"pdip"; edge-zeroed subset branch still zeroes edge pixels (compare withuse_edge_zeroed_pixelsPDIP result).test_settings_dict.py: the four new keys round-trip;test_jax_nnls.py-style default test: knobs default topdip/16/pdip/1e-9from the packaged config.
Step 5 — downstream validation (evidence for the PR body; no workspace commits)
- In the task worktree's
autolens_workspace_test(attach viaworktree_add_repoor use its config override): setgeneral.yamlinversion.positive_only_solver: certifiedin a scratch config dir passed by--config/WORKSPACE-config mechanism the scripts already use, runscripts/imaging/jax_likelihood/{lp,rectangular_mge,potential_correction}.py,multi_dataset/jax_likelihood/delaunay.py,weak/jax_grad.pyand one Delaunay pixelization cell; assert each printed likelihood/gradient matches the run withpdip(record the relative differences) and prove the JAX path was taken (e.g.positive_only_solver_usedprinted or ajax.debug.callbackcounter).rectangular_mgemust dispatch topdip(MGE present) — assert it. - RTX 2060 scalar-jit timing on the fixed-light system through the library setting (not the monkeypatch): reuse
autolens_profilingfixed_light_trace.pyroute b with a config override selectingcertified, N=1500 Delaunay: expect the route-d-like whole-call number and a 1e-9 pin against route b/pdip. A100 timing and the vmap policy are phase B.
Step 6 — docs
- Docstrings in the new module and in
reconstruction_positive_only_from(algorithm, certification, budgets, fallback, gradient contract, the vmap caveat, why NumPy is unchanged, phase-B pointer).autoarray/config/general.yamlcomments. A short entry in PyAutoArray's changelog/release notes file if one exists (checkdocs/—release_notesheading convention per Mind memory: breaking changes need an "API changes" heading; this is additive).
Delegation
- Steps 1-4 + 6: one Opus subagent (worktree, progress file
~/Code/PyAutoLabs-wt/certified-positive-solver/.progress_cps.md, Monitor). Gates:pytest test_autoarray -q(record baseline count first),black --check/ruff per repo convention, the new JAX tests pass under~/venv/PyAuto. - Step 5: a second Opus subagent after I review the diff (RTX via
~/venv/PyAutoGPUwithJAX_PLATFORM_NAME=cuda). - Ship:
/ship_library(Heart RED → development-only human override will be needed again; merge stays/prmwith the library freeze gate).
Verification
- PyAutoArray suite green with the new tests (count reported vs baseline); new module import-safe without jax.
- Numerics: certified == SciPy NNLS (1e-10) and == PDIP (1e-8) on seeded systems; exhausted budget == PDIP bit-exact; edge-zero subsetting preserved; jit + vmap OK; gradient FD parity.
- Dispatch: certified only on (JAX, mapper-only, setting on); default config leaves every existing test byte-identical (run the suite once with the default and once with
positive_only_solver: certifiedforced — the latter may only differ on JAX mapper-only tests). - Downstream parity scripts equal under both solvers with the JAX path proven; RTX scalar timing recorded.
Out of scope (phase B and later)
Production default flip and the vmap/batched policy (PDIP-under-vmap baseline never measured; lax.cond both-branch cost), any PyAutoFit Fitness._vmap change, a cond-free batched fallback, NumPy certified/factor-reuse solvers (measured no-win), MGE-inclusive certified solves (never certify), mixed-precision solves, Euclid rectangular budget sweeps, the fixed-lens-light workflow (S3 conversion of MGE to regular profiles) as a production stage.
Original Prompt
Click to expand starting prompt
Implement and optimize certified positive solver with structure-aware CPU and JAX dispatch
Type: feature
Target: PyAutoArray
Repos:
- PyAutoArray
Difficulty: large
Autonomy: supervised
Priority: normal
Status: scoped — phase A issued 2026-09-23 (Fable start_dev); phase B filed as draft/research/autolens_profiling/certified_solver_production_default.md
Epic: certified-positive-solver
Phase: A
Consequence: judge
Witness: Representative mapper-only and MGE-inclusive fits preserve constrained reconstruction/evidence within declared tolerances, including forced fallback and JAX jit/vmap; benchmark records justify every automatically selected solver against its backend's current baseline.
Review-minutes: 20
Unattended: needs-slicing
Filed: 2026-09-15
Scoped 2026-09-23 (Fable start_dev) — phase A is THIS issue, phase B is filed separately
Phase A (PyAutoArray only, opt-in): a JAX certified active-set positive solver in a new
autoarray/util/jax_active_set.py (budgeted lax.while_loop that stops at certification; primal +
dual KKT certification; permanent fixed set; lax.cond PDIP fallback on an exhausted budget); the
active set is searched under stop_gradient and the final masked Cholesky solve is autodiffed, so
the gradient is the exact implicit active-set derivative (tested against finite differences and
PDIP's custom_vjp). Wired behind Settings / general.yaml keys positive_only_solver
(pdip default | certified), certified_pass_budget (16), certified_fallback (pdip | none),
certified_tau_rel (1e-9); selected only on the JAX backend for mapper-only inversions
(has(Mapper) and not has(AbstractLinearObjFuncList)) — MGE-inclusive systems never certified
(fixed_light_probe: 40 passes, cond 4e10) and keep PDIP; the NumPy path is untouched (the NumPy
certified scheme measured 3-7 % slower than fnnls, factor-reuse lost to the memo).
Phase B (autolens_profiling, maybe PyAutoFit): measure the production composition
jax.jit(jax.vmap(fn)) with PDIP (the never-measured baseline) against the shipped certified solver
with fallback pdip and none, decide the production default and the batched policy (the
lax.cond fallback runs both branches under vmap: 44.6 vs 31.1 ms/lane at B=16 in residue phase 2).
Only phase B may flip the default.
Evidence added since filing: residue phases 1-3 (autolens_profiling#268/#273/#295) — the certified
solve at budget 7 is 10.2 ms of a 31.6 ms A100 call; whole-call 31.7 vs 50.8 ms PDIP; the harness
kernel is active_set_steps.active_set_masked_jax (static lax.scan, no gradient rule) and
library_solver_injection.certified_reconstruction_from (Jacobi scaling + lax.cond fallback).
Original request
Ok, first intake an issue which is to implement the cerified solve in the source code, have a final stab at speeding it up and optimizing it, and also doing this for CPU. That is, I guess the method should ask if the system it is solving is a Mapper only (e.g. sparse struture) or has dense MGE like structures, and thus chooses the solver which is most efficient for the task. Is it true that the cerified solver works less well than the original code when MGE is included?
Scope
Promote the experimental certified active-set positive solver from the profiling harness into @PyAutoArray, with a bounded final optimization pass on CPU and JAX/GPU. One production-library task and one PR; other repositories below are evidence and validation dependencies only.
Choose the efficient supported solver using backend and actual inversion composition: mapper-only versus systems containing linear-light/MGE coefficients. Use existing inversion metadata (linear_obj_list / has(cls)) before the numeric utility, without lens-profile imports into PyAutoArray. Mapper-only does not imply sparse assembled matrices: the cited benchmarks are dense. Preserve existing sparse paths/fallback unless explicitly supported.
Preserve positivity, fixed/edge-zero constraints, API/return behavior, NumPy fingerprint/memo warm starts, and existing backend/differentiation contracts. Certify primal and dual/KKT conditions, including inactive coefficients with invalid negative dual values. Fall back to the existing robust solver on non-certification, exhausted budgets or numerical failure. Fit settings/override and solver observability into existing APIs.
Use conservative bounded budgets: measured HST 7 Delaunay / 11 rectangular are empirical, not universal; Euclid rectangular already reaches 11 in a fiducial case. Ensure correct, efficient jit/vmap operation: lax.cond inside vmap can execute both solvers, so provide an explicit batching strategy and measure its actual cost, including failed cases.
Make a finite profiling-guided optimization pass. For native CPU, compare against production NumPy/SciPy fnnls separately from JAX-CPU versus PDIP. The existing NumPy certified prototype did not reliably beat fnnls: retain fnnls or keep the new CPU route opt-in unless measurements justify automatic selection. Likewise retain the existing MGE-inclusive solver until matched evidence supports changing it. No speedup is promised on every backend.
Acceptance
Reuse existing fixtures for a finite representative subset: mapper-only rectangular/Delaunay and MGE-inclusive systems, good/poor fits, small/large source sizes (around 500/1500/2500/4000 where useful), available CPU/GPU, fp64 reference. Record solve-only and whole-likelihood times separately, fallback frequency/cost, correctness tolerances and dispatch decisions. Automatically selected routes must preserve constrained reconstruction/evidence and avoid material runtime regression against their production baseline; unproven cases retain the baseline.
Cover positivity/KKT, forced fallback, edge/failure cases and existing derivative contracts. Run appropriate source tests and downstream JAX jit/vmap parity in autogalaxy_workspace_test/autolens_workspace_test; PyAutoArray's own suite is NumPy-only. Document configuration, backend/composition rules, measurements and limitations.
Evidence and boundaries
Formalizes the production-solver seed in PyAutoMind/ideas.md from autolens_profiling#259. In autolens_profiling:
- results/notes/fixed_lens_light_source_only_2026_09.md
- results/notes/fixed_lens_light_library_path_2026_09.md
- results/notes/fixed_lens_light_hardware_2026_09.md
- results/notes/fixed_lens_light_low_likelihood_draws_2026_09.md
- results/notes/fixed_lens_light_source_pixel_scaling_2026_09.md
- results/notes/fixed_lens_light_verdict_2026_09.md
- results/misc/fixed_light_probe/{delaunay_1500,rectangular_1521}.{md,json}
Joint 60-MGE+Delaunay1500 free_all failed certification after 40 passes (41 factorizations), while free_one certified at 32; PDIP converged in 22 iterations. Joint rectangular1521 variants failed after 40; PDIP took 21. Source-only free_all took 2 Delaunay / 7 rectangular passes. These demonstrate poorer convergence with MGE, not a matched joint-GPU timing comparison.
Reuse the completed CPU decomposition records PyAutoMind/complete/2026/09/fixed-light-numba-phase1.md and complete/2026/09/fixed-light-numba-solver.md, with the final campaign verdict in complete/2026/09/fixed-lens-light-numba-cpu.md; do not duplicate that evidence or the separate hst_gpu_non_solver_residue_programme.md.
Fixed-light measurements use pre-solved intensities and exclude preparation; they do not prove that freezing one estimate throughout a search preserves every likelihood. Out of scope: new sparse operators, changes to statistical modeling/positivity, and broad profiling campaigns.
Activity
Jammy2211 commentedon Sep 23, 2026
Library PR Created
pending-release)API changes (additive, default byte-identical):
reconstruction_positive_only_from(..., solver="pdip", stats=None);Settingskwargs/propertiespositive_only_solver,certified_pass_budget,certified_fallback,certified_tau_rel(+general.yamlkeys);AbstractInversion.positive_only_solver_used; new moduleautoarray.util.jax_active_set. Default stayspdip;certifiedis opt-in and dispatched only to mapper-only JAX inversions.Workspace impact — option (ii) API migration: the only downstream consumer that breaks is the autolens_profiling solver-injection harness (strict signature-coverage check on
reconstruction_positive_only_from); it is migrated in the linked autolens_profiling PR. Workspace scripts that call the function (autolens/autogalaxylikelihood_function.py, autolens_workspace_testjax_assertions/nnls.py) use the defaults and are unaffected; the autolens_workspace_test JAX parity scripts were validated identical under both solvers.Next: autolens_profiling PR (library-first merge gate applies).
Jammy2211 commentedon Sep 23, 2026
Heart RED override — development shipping only
pyauto-heart readinessre-read before the PR):release validation FAILED (stage integrate)workspace validation not passing (4 failed, cloud#35579888156: autolens notebooks/cluster/modeling.ipynb, autolens notebooks/weak/a2744.ipynb, autolens scripts/cluster/modeling.py, +1 more)manifest drift: hub organism blurb (organs present) — 7 mismatch(es) vs PyAutoMind/repos.yamli authorize,to the named issue-feat(inversion): certified active-set positive solver on the JAX path, opt-in, mapper-only dispatch (phase A) #566 (+ linked autolens_profiling fix) development override (AUTONOMY.md "Human override for Heart RED (development only)")./prmwith required checks green.Jammy2211 commentedon Sep 23, 2026
Workspace PR: PyAutoLabs/autolens_profiling#299 (autolens_profiling harness migration; library-first gate applies)
Merge pull request #567 from PyAutoLabs/feature/certified-positive-so…
Jammy2211 commentedon Sep 23, 2026
Shipped — 2026-09-23
PRs
11b93476b)043518479)Summary
Phase A shipped the certified active-set positive solver into PyAutoArray:
autoarray/util/jax_active_set.py; newsolver/statskwargs onreconstruction_positive_only_from.positive_only_solver(defaultpdip),certified_pass_budget(16),certified_fallback,certified_tau_rel;AbstractInversion.positive_only_solver_used.stop_gradientactive-set search + an autodiffed final solve.Evidence: 1616 tests (+32); vs SciPy nnls 2e-16, vs PDIP 8e-12, fallback bit-exact, gradient FD 3e-9; downstream JAX parity identical (Delaunay 2.3e-14, dispatch proven); GPU tests 24 pass; RTX 734.9 → 566.1 ms via the library setting (harness monkeypatch was 618.2 ms). Review CLEAN at 233cfc0 (one minor finding fixed: no-jax import guard test moved to its own file).
Merged after the human's
/prmon green checks, under the human's Heart-RED development override; Heart remains RED for release purposes. Merged, not released — PyAutoArray#567 stays pending-release until/review_releaseclears it.Session Notes
lax.while_loopis not reverse-differentiable — hence the stop_gradient-search + autodiffed-final-solve gradient design.lax.condfallback runs both branches under vmap (phase B concern).draft/research/autolens_profiling/certified_solver_production_default.md(default flip + batched policy; blocked on the release shipping feat(inversion): certified active-set positive solver on the JAX path, opt-in, mapper-only dispatch (#566) #567); optionalcustom_jvpto reuse the loop's final Cholesky factor (one extra factorization per solve today); pre-existingmulti_dataset/jax_likelihood/delaunay.pyfailure filed asdraft/bug/autolens_workspace_test/multi_dataset_jax_likelihood_delaunay_wrong_likelihood.md; the fixed_light_trace cell's pin label still says "library PDIP" for route b even when the library runs certified (cosmetic, phase B).