feat(interferometer): array-free Interferometer.from_stream / from_sparse_terms (streaming phase 1, Discussion #13) - #593
Conversation
…arse_terms (streaming phase 1, #592) Build an Interferometer with no data, noise_map, uv_wavelengths or transformer from accumulated SparseTerms, so a sparse (w-tilde) pixelized inversion and its log_evidence run with peak memory set by the image size, not the number of visibilities. SparseTerms carries provenance and refuses mismatched sums; the inversion base reads its mask from the dataset; every array-dependent property raises a typed DatasetException; dirty_image_natural / dirty_beam exist on both dataset kinds. In-memory behaviour is unchanged. Discussion https://github.com/orgs/PyAutoLabs/discussions/13, phase 2 of the proposal; Mind epic streaming-visibilities, phase 1 of 5. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZZksyZ8LTA4LLxoZjQMNF
Independent review (Codex gpt-6-astra) of this branch before PR-open
FINDINGS (4) Disposition: #1, #2, #3 fixed in this branch (tests 🤖 Generated with Claude Code |
Summary
Streaming phase 1 (Mind epic
streaming-visibilities; phase 2 of https://github.com/orgs/PyAutoLabs/discussions/13, HRSAstro). Closes #592.After #589 / PyAutoLabs/PyAutoGalaxy#637 / PyAutoLabs/PyAutoLens#757 the sparse likelihood no longer touches visibilities, but the process still held them:
Interferometer.__init__always built aTransformerNUFFTfromuv_wavelengths(its own ~48 B/vis copy on top of the dataset's 48 B/vis),AbstractDataset.__init__rejectednoise_map=None, andapply_sparse_operator_from_chunksreturned a dataset retaining every array. This PR adds the array-free dataset:Interferometer.from_stream(chunks, real_space_mask, *, transformer_class=TransformerNUFFT, method, eps, chunk_size, chunk_k, use_jax, show_progress, batch_size)accumulates(uv_wavelengths, data, noise_map)chunks intoSparseTermsand builds a dataset whosedata,noise_map,uv_wavelengthsandtransformerare allNone;from_sparse_terms(terms, real_space_mask)does the same from an existing record.is_array_freesays which kind a dataset is;sparse_termsis stored (also byapply_sparse_operator_from_chunks).SparseTermscarries provenance (shape_native,pixel_scales,origin,eps,transformer_class_name);__add__refuses to sum terms from different geometries/accuracies;from_sparse_termsrefuses a mask of the wrong shape.AbstractInversionInterferometer.maskreads the dataset's real-space mask instead oftransformer.real_space_mask(the one transformer read on the sparse likelihood path).exc.DatasetExceptionnaming the missing input andfrom_stream(dataset:amplitudes,phases,uv_distances,dirty_image,dirty_noise_map,signal_to_noise_map,dirty_signal_to_noise_map,psf_precision_operator_from,apply_sparse_operator; fit:mask,transformer,residual_map,normalized_residual_map,chi_squared_map,signal_to_noise_map,chi_squared, everydirty_*);mapped_reconstructed_operated_data_dictraisesexc.InversionException.log_evidence/figure_of_meritof a sparse inversion fit never reach any of them (spy-tested).dirty_image_natural(Re(Fᴴ w d)/Σw) anddirty_beam(Re(Fᴴ w)/Σw) on both dataset kinds (from the terms when present, else from the arrays; agree to rel 1e-12).dirty_image(unweighted adjoint) is unchanged.Design decisions (recorded in the Mind epic ledger):
transformer=Nonewith honest gating rather than a stub transformer; natural-weighted images under new names so in-memory behaviour is untouched; provenance onSparseTerms. Phases 2–5 (fit + save/reload, visualizer, non-linear light profiles via the data-term identity, cubes/phase centre) follow one at a time.Parity: rectangular-mesh sparse inversion on the array-free dataset vs the in-memory
apply_sparse_operatordataset —fast_chi_squared, regularization and log-det terms,reconstruction,log_evidenceall at rel 1e-8 (numpy and jax); on the 5e5-visibility witnesslog_evidence−1577804.7762316698 streamed vs −1577804.7762316696 in memory (rel 1.5e-16).Memory witness (fresh process per N, NUFFT, 400×400 circular mask at 0.05"/pix = 125,676 pixels, 4096-vis chunks read from an npz one chunk at a time; baselines: import-only 171 MB, one-chunk stream 536 MB):
Peak RSS is set by the image size (the NUFFT working set for a 125k-pixel mask), not by N_vis: it moves by ~100 MB over an 8× range where the held arrays would add 168 MB (and the transformer's copy as much again). The discussion's 36 MB figure is "over baseline" for pyuvimage's accumulator on its own image; the absolute level here is the per-chunk NUFFT working set. Not in this phase (by design, review finding #4): an
AnalysisInterferometersearch on an array-free dataset does not yet run end to end —save_attributesreadsdata.in_arrayand the aggregator reads the visibility HDUs; both are phase 2 (streaming_p2_fit_save_reload). Phase 1 supports the fit / inversion /log_evidenceonly. Two things not chased in this phase: the mild +100 MB drift at 4e6 and the super-linear wall time (2× N ≈ 3× time above 1e6 — most likely the witness's per-chunk npz reads, not the accumulator); both are noted on the epic ledger for phase 2.Heart RED override (development only)
Heart verdict at ship: RED (2026-09-30T12:42Z), exact reason:
release validation FAILED (stage integrate)— a release-integrate leg unrelated to this branch. The live human authorized the development-only override for issue #592 in-session on 2026-09-30 ("Authorize override for #592"): commit, push and this pending-release PR only. Branch gates passed before the ask:test_autoarray1783 passed;test_autogalaxy/interferometer43 andtest_autolens/interferometer29 passed unchanged; red-checks on the new tests; independent Codex (gpt-6-astra) review: FINDINGS (4) — #1 geometry guard infrom_sparse_terms(pixel_scales/origin), #2origininSparseTerms.__add__, #3 provenance merge when one operand is unrecorded: all three fixed in-branch with red-checked tests; #4 (AnalysisInterferometer.save_attributes/ aggregator cannot handle an array-free dataset) is phase 2 by design, documented (full record in the comment below). This PR does not claim to repair Heart; Heart remains RED for release purposes and merge needs its own explicit human command with every required check green.API Changes
Additive plus one behaviour change. Added
Interferometer.from_stream,Interferometer.from_sparse_terms,Interferometer.is_array_free,Interferometer.sparse_terms(ctor kwarg + attribute),Interferometer.dirty_image_natural,Interferometer.dirty_beam;SparseTermsgains five optional trailing provenance fields and__add__raises on mismatch.Interferometeracceptsuv_wavelengths=None/transformer_class=None(transformer becomesNone);AbstractDatasetacceptsnoise_map=Nonewhendatais alsoNone;AbstractDataset.shape_slimisNoneon such a dataset.AbstractInversionInterferometer.masknow comes from the dataset (same value everywhere a transformer exists). Array/transformer-dependent properties raiseexc.DatasetException/exc.InversionExceptioninstead ofAttributeErrorwhen the input is absent. In-memory datasets are byte-identical in behaviour.See full details below.
Test Plan
pytest test_autoarray— 1783 passed (1779 before the review fixes)test_autogalaxy/interferometer43 passed;test_autolens/interferometer29 passedNone, terms + provenance +data_termset, mask-shape guard); parity vs in-memory at rel 1e-8 numpy + jax; typed exceptions on every dataset/fit property and onmapped_reconstructed_operated_data_dict; spy thatlog_evidencenever touches visibility properties; provenance sum / mismatch raise;apply_sparse_operator_from_chunksretainssparse_terms;maskregression onInterferometer/DatasetInterfacewith and without transformer;dirty_image_natural/dirty_beamterms-vs-arrays agree (NUFFT + DFT)maskreroute reverted → 3 fail (AttributeError: 'NoneType' object has no attribute 'real_space_mask'); one guard removed → typed-exception test fails; provenance check disabled → 2 failFull API Changes (for automation & release notes)
Added
Interferometer.from_stream(chunks, real_space_mask, *, transformer_class=TransformerNUFFT, method="nufft", eps=None, chunk_size=None, chunk_k=2048, use_jax=False, show_progress=False, batch_size=128) -> Interferometer— array-free dataset accumulated from(uv_wavelengths, data, noise_map)chunksInterferometer.from_sparse_terms(terms, real_space_mask, *, batch_size=128) -> InterferometerInterferometer(..., sparse_terms=None)kwarg and.sparse_termsattribute (also set byapply_sparse_operator_from_chunks)Interferometer.is_array_free -> boolInterferometer.dirty_image_natural -> Array2D,Interferometer.dirty_beam -> Array2D— natural-weighted, normalised; both dataset kindsSparseTerms.shape_native,.pixel_scales,.origin,.eps,.transformer_class_name(optional trailing fields, filled bysparse_terms_from_chunks;__add__keeps the recorded value when one operand is unrecorded)Changed Signature
Interferometer(uv_wavelengths=None, transformer_class=None, ...)permitted (array-free);AbstractDataset(noise_map=None)permitted whendataisNoneChanged Behaviour
AbstractInversionInterferometer.mask— dataset'sreal_space_mask, else transformer's, elsedataset.mask(was transformer only)SparseTerms.__add__— raisesexc.InversionExceptionwhen both sides record differentshape_native/pixel_scales/origin/eps;Interferometer.from_sparse_termsraisesexc.DatasetExceptionwhen the mask's shape / pixel_scales / origin differ from the recorded onesInterferometerproperties needing absent arrays/transformer raiseexc.DatasetException;InversionInterferometerSparse.mapped_reconstructed_operated_data_dictraisesexc.InversionExceptionwithout a transformer;aa.FitInterferometermaps /mask/transformer/chi_squaredraiseexc.DatasetExceptionon an array-free datasetAbstractDataset.shape_slimreturnsNonewhendataisNoneMigration
dataset.is_array_free.Generated by the PyAutoLabs agent workflow.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JZZksyZ8LTA4LLxoZjQMNF