Overview
Phase 4 of the streaming-visibilities epic (Discussion https://github.com/orgs/PyAutoLabs/discussions/13). Phases 1–3 made an array-free Interferometer.from_stream dataset (no data/noise_map/uv_wavelengths/transformer, just SparseTerms + the sparse operator with cached data_term/noise_normalization) fit, save/reload and plot end to end — but only for pixelization and linear-light-profile models. Fits with an ordinary (non-linear) light profile still raise, because profile_visibilities = F i_p is formed over N_vis and subtracted from the data. This task applies the identity Σ|d−p|²/σ² = data_term − 2 i_pᵀd̃ + i_pᵀW̃i_p (one operated_matrix_slim_from product, already computed by sparse_dirty_image_from) so such fits run array-free, with and without an inversion, in numpy and under jax.jit.
Trap: fast_chi_squared reads term 3 from the raw sparse_operator.data_term whenever dataset.data is None; removing the current raise alone would make the inversion silently use the unsubtracted data term. The inversion DatasetInterface therefore gains a per-fit data_term override, and the guard stays until it is wired.
Plan
- Give the inversion
DatasetInterface an optional precomputed data_term; fast_chi_squared prefers it over the operator scalar when data is None.
- In autogalaxy compute the corrected data term next to the sparse dirty image from one
W̃ i_p product and pass both (with data=None) into the inversion for sparse fits with ordinary light.
- Make light-profile-only (no inversion) fits work via a
chi_squared hook on the same identity, so log_likelihood/figure_of_merit work on array-free datasets.
- Replace the typed raise in
profile_visibilities with None on array-free datasets; model_data and the visibility dicts raise a clear typed DatasetException instead of None + array.
- PyAutoLens mirrors autogalaxy (it already imports the ag helpers).
- Tests: parity at rel 1e-8 vs the in-memory dense fit for LP-only, LP+pixelization, LP+linear-light in numpy and under
jax.jit; a transformer spy proves the N_vis path is never formed; autoarray unit tests for the override.
Detailed implementation plan
Affected Repositories
- PyAutoArray (primary)
- PyAutoGalaxy
- PyAutoLens
Branch Survey
| Repository |
Current Branch |
Dirty? |
| array/PyAutoArray |
main |
clean |
| galaxy/PyAutoGalaxy |
main |
clean |
| lens/PyAutoLens |
main |
clean |
Suggested branch: feature/streaming-p4-light-profile-identity (same name in all three repos; worktree ~/Code/PyAutoLabs-wt/streaming-p4-light-profile-identity/)
Implementation Steps
A. PyAutoArray (array/PyAutoArray)
autoarray/inversion/inversion/dataset_interface.py (DatasetInterface.__init__, L2-76): add
data_term: Optional[float] = None (store as self.data_term). Docstring: "χ² data term
Σ|d|²/σ² of this interface's (possibly profile-subtracted) data; used when data is None."
autoarray/inversion/inversion/interferometer/abstract.py fast_chi_squared (L222-241): when
self.dataset.data is None, term 3 = getattr(self.dataset, "data_term", None), falling back to
sparse_operator.data_term, else the existing InversionException. Pure scalar → jit-safe.
autoarray/fit/fit_interferometer.py (aa.FitInterferometer): add an overridable hook used by the
no-inversion path — e.g. property sparse_chi_squared returning None by default; chi_squared (L209)
returns it when the dataset is array-free (self.dataset.data is None) and the hook is not None, before the
_require raise. Keep _require for the maps. log_likelihood (fit_dataset.py:102) and
noise_normalization (fit_interferometer.py:219-241, already returns the operator scalar when
noise_map is dataset.noise_map, i.e. None is None) then work unchanged.
- Optional small helper in
inversion_interferometer_util.py next to operated_matrix_slim_from (L1588):
sparse_profile_terms_from(operator, image_slim, extent_index_for_masked_pixel, xp) -> (operated_image, sparse_dirty_image, data_term) so ag/al share one implementation and autoarray can unit-test it against the
dense Σ|d − F i|²/σ² on interferometer_7. (Alternatively keep it in ag — decide in implementation; prefer
autoarray so the identity is tested where W̃ lives.)
- Tests
test_autoarray/inversion/inversion/interferometer/test_interferometer.py: modelled on
..._sparse_dirty_image_override__used_by_data_vector (L607) and ..._data_none_uses_the_sparse_operator_data_term
(L1250): data_term override used when given, operator scalar used when absent, numpy == jax (template L1349);
helper identity vs dense at rel 1e-10. test_autoarray/fit/test_fit_interferometer.py: chi_squared via the hook on
an array-free fit; maps still raise match="array-free" (L440 contract preserved).
B. PyAutoGalaxy (galaxy/PyAutoGalaxy/autogalaxy/interferometer/fit_interferometer.py)
- Replace
sparse_dirty_image_from (L58-111) internals with the shared helper: new module function
sparse_profile_terms_from(dataset, galaxies, image, xp) → (sparse_dirty_image, data_term) or (None, None)
when no sparse operator / no ordinary light; sparse_dirty_image_from wraps it (keeps the existing callers).
uses_precomputed_data_term_from (L114-157): the "no ordinary light" clause stays for the in-memory path only
when no override is available; simplest rule: sparse operator with data_term present and
data is dataset.data / noise_map is dataset.noise_map → precomputed, with ordinary light handled by the
corrected data_term override. Document the rule in the docstring.
profile_visibilities (L233-269): on dataset.transformer is None return None (no raise).
profile_subtracted_visibilities (L271-285): return None when self.data is None without evaluating
profile_visibilities first.
galaxies_to_inversion (L302-338): compute (sparse_dirty_image, data_term) once; pass data=None,
sparse_dirty_image=…, data_term=… to DatasetInterface when _uses_precomputed_data_term.
- No-inversion: implement the aa hook (
sparse_chi_squared) returning the identity from profile_image
when the dataset is array-free and there is no inversion; None otherwise.
model_data (L384-401) and galaxy_model_visibilities_dict (~L487): raise
aa.exc.DatasetException("… array-free … use model_image_natural / dirty_model_image_natural") when
dataset.transformer is None. inversion_with_data (L352) unchanged.
- Tests
test_autogalaxy/interferometer/test_fit_interferometer.py: delete
test__fit_figure_of_merit__array_free_dataset__light_profile__raises (L947); add, each using
_array_free_dataset_from (L860) + _assert_sparse_fit_matches_dense (L515) at rel 1e-8:
LP-only (figure_of_merit == log_likelihood), LP+pixelization (log_evidence), LP+linear-light; jit variants
modelled on L910 (coefficient-parameterised galaxy); a transformer/profile_visibilities spy (monkeypatch
TransformerNUFFT.visibilities_from to raise) proving the N_vis path is never formed; model_data typed raise;
the natural-dirty-image tests (L1001, L1074) pass unchanged. Also assert the in-memory sparse fit with ordinary
light is byte-unchanged vs today (..._light_profile__unchanged_vs_data_passed L794 stays green).
C. PyAutoLens (lens/PyAutoLens/autolens/interferometer/fit_interferometer.py)
Mirror B using the ag helpers it already imports (L28-32): profile_visibilities (L129-165) returns None
array-free; profile_subtracted_visibilities (L167-182) short-circuits; tracer_to_inversion (L199-236) passes
data_term; sparse_chi_squared hook from tracer.image_2d_from; model_data (L302) and the planes/galaxy
visibility helpers (~L430/452) raise typed. Tests in test_autolens/interferometer/test_fit_interferometer.py:
replace ..._array_free_dataset__lens_light_profile__raises (L816) with lens-light-only, lens-light+pixelized-source
(_pixelized_source_tracer(lens_light=True) L554), lens-light+MGE source parity at rel 1e-8, numpy + jit
(template L779).
Analysis / plotting
No changes expected: ag model/analysis.py:89, al model/visualizer.py:54 and the three fit_interferometer_plots
modules already branch on is_array_free and use model_image_natural (= profile_image + mapped_reconstructed_data,
shipped P3). Implementation verifies no array-free branch touches fit.model_data.
Trade-offs / decisions
- Override lives on
DatasetInterface, not a mutated operator — the operator is a frozen shared dataclass; the
per-fit scalar is fit state. Matches how sparse_dirty_image already flows (decision (a)/(d) lineage).
- Precision: the identity subtracts two large numbers (
data_term ≫ χ² at high S/N); in float64 this is fine at
rel 1e-8 on logL (phase-1 parity was 1e-16 on a comparable identity). JAX tests run with x64 enabled as the
existing sparse tests do; no fp32 path is in scope.
- In-memory sparse + ordinary light now also goes through the identity (phase 1 already did this for the dirty
image). Behaviour change is expected to be at machine precision; the L794 unchanged-test is the guard.
- Out of scope (ledger notes): per-chunk JAX recompile → profiling campaign;
visualize_before_fit switch →
PyAutoFit draft; phase 5 cubes/phase-centre.
Key Files
autoarray/inversion/inversion/dataset_interface.py — DatasetInterface gains data_term
autoarray/inversion/inversion/interferometer/abstract.py — fast_chi_squared term-3 override
autoarray/fit/fit_interferometer.py — array-free chi_squared hook for no-inversion fits
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py — shared identity helper next to operated_matrix_slim_from
autogalaxy/interferometer/fit_interferometer.py — sparse_profile_terms_from, profile_visibilities, galaxies_to_inversion, model_data
autolens/interferometer/fit_interferometer.py — mirror
- tests:
test_autoarray/inversion/inversion/interferometer/test_interferometer.py, test_autoarray/fit/test_fit_interferometer.py, test_autogalaxy/interferometer/test_fit_interferometer.py, test_autolens/interferometer/test_fit_interferometer.py
Verification
pytest test_autoarray/inversion/inversion/interferometer test_autoarray/fit/test_fit_interferometer.py
pytest test_autogalaxy/interferometer test_autogalaxy/interferometer/model and the al mirrors; full suites per repo
(baseline counts P3: aa 1898, ag 1293, al 776+1 xfail).
- Witness (prompt header): LP+pixelization and LP-only fits on a
from_stream dataset match the in-memory fit at
rel 1e-8 in numpy and under jax.jit; the visibilities_from spy is never called. Run also the phase-2/3 analysis
round-trip tests (ag test_analysis_interferometer.py:171/271/318, al :220) to confirm save/reload/visualize
still pass with an ordinary light profile in the model.
Original Prompt
Click to expand starting prompt
Streaming phase 4: non-linear light profiles array-free via the data-term identity
Type: feature
Target: PyAutoArray
Repos:
- PyAutoArray
- PyAutoGalaxy
- PyAutoLens
Themes:
- interferometer
- sparse-operator
- memory
Autonomy: supervised
Priority: medium
Status: draft
Epic: streaming-visibilities
Phase: 4
Difficulty: medium
Consequence: judge
Witness: fits with a non-linear light profile plus a pixelization, and with non-linear light profiles only, run on an array-free dataset with chi-squared computed as data_term − 2 i_pᵀd̃ + i_pᵀW̃i_p (one operated_matrix_slim_from product shared with sparse_dirty_image_from), matching the in-memory fit at rel 1e-8 in numpy and under jax.jit; profile_visibilities is never formed.
Review-minutes: 8
Unattended: ready
Parent: draft/feature/autoarray/interferometer_from_stream_array_free_dataset.md
Blocked-by: none (phase 2 merged 2026-09-30)
Source: https://github.com/orgs/PyAutoLabs/discussions/13 phase 2, sliced 2026-09-30.
What
aa.DatasetInterface gains an optional precomputed data term (e.g. sparse_data_term) that fast_chi_squared term 3 reads when set
(abstract.py ~L218-225), replacing the profile_subtracted_visibilities reduction.
- autogalaxy computes it next to
sparse_dirty_image_from (fit_interferometer.py ~L58-111) from profile_image; extend
uses_precomputed_data_term_from so non-linear light profiles also pass data=None; autolens mirror.
- No-inversion fits: override
chi_squared in ag/al FitInterferometer on the same identity (figure_of_merit → log_likelihood).
- Tests mirror the phase-1 sparse-vs-dense set for LP+pix and LP-only, numpy + jit;
profile_visibilities spy stays empty.
Overview
Phase 4 of the
streaming-visibilitiesepic (Discussion https://github.com/orgs/PyAutoLabs/discussions/13). Phases 1–3 made an array-freeInterferometer.from_streamdataset (nodata/noise_map/uv_wavelengths/transformer, justSparseTerms+ the sparse operator with cacheddata_term/noise_normalization) fit, save/reload and plot end to end — but only for pixelization and linear-light-profile models. Fits with an ordinary (non-linear) light profile still raise, becauseprofile_visibilities = F i_pis formed over N_vis and subtracted from the data. This task applies the identityΣ|d−p|²/σ² = data_term − 2 i_pᵀd̃ + i_pᵀW̃i_p(oneoperated_matrix_slim_fromproduct, already computed bysparse_dirty_image_from) so such fits run array-free, with and without an inversion, in numpy and underjax.jit.Trap:
fast_chi_squaredreads term 3 from the rawsparse_operator.data_termwheneverdataset.data is None; removing the current raise alone would make the inversion silently use the unsubtracted data term. The inversionDatasetInterfacetherefore gains a per-fitdata_termoverride, and the guard stays until it is wired.Plan
DatasetInterfacean optional precomputeddata_term;fast_chi_squaredprefers it over the operator scalar whendata is None.W̃ i_pproduct and pass both (withdata=None) into the inversion for sparse fits with ordinary light.chi_squaredhook on the same identity, solog_likelihood/figure_of_meritwork on array-free datasets.profile_visibilitieswithNoneon array-free datasets;model_dataand the visibility dicts raise a clear typedDatasetExceptioninstead ofNone + array.jax.jit; a transformer spy proves the N_vis path is never formed; autoarray unit tests for the override.Detailed implementation plan
Affected Repositories
Branch Survey
Suggested branch:
feature/streaming-p4-light-profile-identity(same name in all three repos; worktree~/Code/PyAutoLabs-wt/streaming-p4-light-profile-identity/)Implementation Steps
A. PyAutoArray (
array/PyAutoArray)autoarray/inversion/inversion/dataset_interface.py(DatasetInterface.__init__, L2-76): adddata_term: Optional[float] = None(store asself.data_term). Docstring: "χ² data termΣ|d|²/σ² of this interface's (possibly profile-subtracted) data; used when
data is None."autoarray/inversion/inversion/interferometer/abstract.pyfast_chi_squared(L222-241): whenself.dataset.data is None, term 3 =getattr(self.dataset, "data_term", None), falling back tosparse_operator.data_term, else the existingInversionException. Pure scalar → jit-safe.autoarray/fit/fit_interferometer.py(aa.FitInterferometer): add an overridable hook used by theno-inversion path — e.g. property
sparse_chi_squaredreturningNoneby default;chi_squared(L209)returns it when the dataset is array-free (
self.dataset.data is None) and the hook is notNone, before the_requireraise. Keep_requirefor the maps.log_likelihood(fit_dataset.py:102) andnoise_normalization(fit_interferometer.py:219-241, already returns the operator scalar whennoise_map is dataset.noise_map, i.e.None is None) then work unchanged.inversion_interferometer_util.pynext tooperated_matrix_slim_from(L1588):sparse_profile_terms_from(operator, image_slim, extent_index_for_masked_pixel, xp) -> (operated_image, sparse_dirty_image, data_term)so ag/al share one implementation and autoarray can unit-test it against thedense
Σ|d − F i|²/σ²oninterferometer_7. (Alternatively keep it in ag — decide in implementation; preferautoarray so the identity is tested where
W̃lives.)test_autoarray/inversion/inversion/interferometer/test_interferometer.py: modelled on..._sparse_dirty_image_override__used_by_data_vector(L607) and..._data_none_uses_the_sparse_operator_data_term(L1250):
data_termoverride used when given, operator scalar used when absent, numpy == jax (template L1349);helper identity vs dense at rel 1e-10.
test_autoarray/fit/test_fit_interferometer.py:chi_squaredvia the hook onan array-free fit; maps still raise
match="array-free"(L440 contract preserved).B. PyAutoGalaxy (
galaxy/PyAutoGalaxy/autogalaxy/interferometer/fit_interferometer.py)sparse_dirty_image_from(L58-111) internals with the shared helper: new module functionsparse_profile_terms_from(dataset, galaxies, image, xp)→(sparse_dirty_image, data_term)or(None, None)when no sparse operator / no ordinary light;
sparse_dirty_image_fromwraps it (keeps the existing callers).uses_precomputed_data_term_from(L114-157): the "no ordinary light" clause stays for the in-memory path onlywhen no override is available; simplest rule: sparse operator with
data_termpresent anddata is dataset.data/noise_map is dataset.noise_map→ precomputed, with ordinary light handled by thecorrected
data_termoverride. Document the rule in the docstring.profile_visibilities(L233-269): ondataset.transformer is NonereturnNone(no raise).profile_subtracted_visibilities(L271-285): returnNonewhenself.data is Nonewithout evaluatingprofile_visibilitiesfirst.galaxies_to_inversion(L302-338): compute(sparse_dirty_image, data_term)once; passdata=None,sparse_dirty_image=…,data_term=…toDatasetInterfacewhen_uses_precomputed_data_term.sparse_chi_squared) returning the identity fromprofile_imagewhen the dataset is array-free and there is no inversion;
Noneotherwise.model_data(L384-401) andgalaxy_model_visibilities_dict(~L487): raiseaa.exc.DatasetException("… array-free … use model_image_natural / dirty_model_image_natural")whendataset.transformer is None.inversion_with_data(L352) unchanged.test_autogalaxy/interferometer/test_fit_interferometer.py: deletetest__fit_figure_of_merit__array_free_dataset__light_profile__raises(L947); add, each using_array_free_dataset_from(L860) +_assert_sparse_fit_matches_dense(L515) at rel 1e-8:LP-only (
figure_of_merit == log_likelihood), LP+pixelization (log_evidence), LP+linear-light; jit variantsmodelled on L910 (coefficient-parameterised galaxy); a
transformer/profile_visibilitiesspy (monkeypatchTransformerNUFFT.visibilities_fromto raise) proving the N_vis path is never formed;model_datatyped raise;the natural-dirty-image tests (L1001, L1074) pass unchanged. Also assert the in-memory sparse fit with ordinary
light is byte-unchanged vs today (
..._light_profile__unchanged_vs_data_passedL794 stays green).C. PyAutoLens (
lens/PyAutoLens/autolens/interferometer/fit_interferometer.py)Mirror B using the ag helpers it already imports (L28-32):
profile_visibilities(L129-165) returnsNonearray-free;
profile_subtracted_visibilities(L167-182) short-circuits;tracer_to_inversion(L199-236) passesdata_term;sparse_chi_squaredhook fromtracer.image_2d_from;model_data(L302) and the planes/galaxyvisibility helpers (~L430/452) raise typed. Tests in
test_autolens/interferometer/test_fit_interferometer.py:replace
..._array_free_dataset__lens_light_profile__raises(L816) with lens-light-only, lens-light+pixelized-source(
_pixelized_source_tracer(lens_light=True)L554), lens-light+MGE source parity at rel 1e-8, numpy + jit(template L779).
Analysis / plotting
No changes expected: ag
model/analysis.py:89, almodel/visualizer.py:54and the threefit_interferometer_plotsmodules already branch on
is_array_freeand usemodel_image_natural(=profile_image + mapped_reconstructed_data,shipped P3). Implementation verifies no array-free branch touches
fit.model_data.Trade-offs / decisions
DatasetInterface, not a mutated operator — the operator is a frozen shared dataclass; theper-fit scalar is fit state. Matches how
sparse_dirty_imagealready flows (decision (a)/(d) lineage).data_term ≫ χ²at high S/N); in float64 this is fine atrel 1e-8 on logL (phase-1 parity was 1e-16 on a comparable identity). JAX tests run with x64 enabled as the
existing sparse tests do; no fp32 path is in scope.
image). Behaviour change is expected to be at machine precision; the L794 unchanged-test is the guard.
visualize_before_fitswitch →PyAutoFit draft; phase 5 cubes/phase-centre.
Key Files
autoarray/inversion/inversion/dataset_interface.py—DatasetInterfacegainsdata_termautoarray/inversion/inversion/interferometer/abstract.py—fast_chi_squaredterm-3 overrideautoarray/fit/fit_interferometer.py— array-freechi_squaredhook for no-inversion fitsautoarray/inversion/inversion/interferometer/inversion_interferometer_util.py— shared identity helper next tooperated_matrix_slim_fromautogalaxy/interferometer/fit_interferometer.py—sparse_profile_terms_from,profile_visibilities,galaxies_to_inversion,model_dataautolens/interferometer/fit_interferometer.py— mirrortest_autoarray/inversion/inversion/interferometer/test_interferometer.py,test_autoarray/fit/test_fit_interferometer.py,test_autogalaxy/interferometer/test_fit_interferometer.py,test_autolens/interferometer/test_fit_interferometer.pyVerification
pytest test_autoarray/inversion/inversion/interferometer test_autoarray/fit/test_fit_interferometer.pypytest test_autogalaxy/interferometer test_autogalaxy/interferometer/modeland the al mirrors; full suites per repo(baseline counts P3: aa 1898, ag 1293, al 776+1 xfail).
from_streamdataset match the in-memory fit atrel 1e-8 in numpy and under
jax.jit; thevisibilities_fromspy is never called. Run also the phase-2/3 analysisround-trip tests (ag
test_analysis_interferometer.py:171/271/318, al:220) to confirm save/reload/visualizestill pass with an ordinary light profile in the model.
Original Prompt
Click to expand starting prompt
Streaming phase 4: non-linear light profiles array-free via the data-term identity
Type: feature
Target: PyAutoArray
Repos:
Themes:
Autonomy: supervised
Priority: medium
Status: draft
Epic: streaming-visibilities
Phase: 4
Difficulty: medium
Consequence: judge
Witness: fits with a non-linear light profile plus a pixelization, and with non-linear light profiles only, run on an array-free dataset with chi-squared computed as
data_term − 2 i_pᵀd̃ + i_pᵀW̃i_p(oneoperated_matrix_slim_fromproduct shared withsparse_dirty_image_from), matching the in-memory fit at rel 1e-8 in numpy and underjax.jit;profile_visibilitiesis never formed.Review-minutes: 8
Unattended: ready
Parent: draft/feature/autoarray/interferometer_from_stream_array_free_dataset.md
Blocked-by: none (phase 2 merged 2026-09-30)
Source: https://github.com/orgs/PyAutoLabs/discussions/13 phase 2, sliced 2026-09-30.
What
aa.DatasetInterfacegains an optional precomputed data term (e.g.sparse_data_term) thatfast_chi_squaredterm 3 reads when set(
abstract.py~L218-225), replacing theprofile_subtracted_visibilitiesreduction.sparse_dirty_image_from(fit_interferometer.py~L58-111) fromprofile_image; extenduses_precomputed_data_term_fromso non-linear light profiles also passdata=None; autolens mirror.chi_squaredin ag/alFitInterferometeron the same identity (figure_of_merit→log_likelihood).profile_visibilitiesspy stays empty.