ADR-008: Make the continuum composition tile-native
| Status | 🟢 Accepted |
| Created | 2026-09-18 |
| Last Updated | 2026-10-11 (every round declares its tasks' memory) |
| Deciders | Gemma Danks |
| Tags | tiling, halos, ownership, reconciliation, memory, invariance |
Context
This section describes Hebog on 18 September 2026, when the decision was taken; the sections after Decision Outcome describe the composition as built, and are amended as it changes.
ADR-005 decided that all image-sized scientific work uses hierarchical haloed
tiles. Hebog implemented that decision only for the first part of the
pipeline. Background and RMS estimation and first-pass compact detection ran
per tile through an executor; tiled multiscale, deblending, measurement,
fitting and compact catalogue stages existed in hebog.stages but only tests
called them. The installed public path,
hebog.public_science.build_configured_continuum_products, received whole
float64 planes and held about ten of them at once. That was why the public
size limit was 1,024 pixels per side.
The arithmetic is unforgiving. One float64 plane is 16 GB at 45,000² and
80 GB at 100,000². The development machine has 18 GiB of RAM, so from 22,500²
upward no image-sized array can exist in one process. Every remaining
milestone depends on removing them: M4 benchmarks Rapthor sector images, M5
bounds the graph for 100,000², and M6 qualifies the LOFAR ladder.
Three properties of the current science make this tractable, and were confirmed while preparing this decision:
- No stage needs a global continuous statistic. Scale signal-to-noise is
calibrated analytically per pixel from a propagated RMS
(
calibrated_scale_snrs), not from a global noise estimate. The only global objects are label equivalences, per-object record aggregates, and the background and RMS grid. - The pixel-domain kernels are already halo-bounded, and their halos are
small. For a 5-pixel beam,
scale_filter_halo_pixelsgives 34 pixels for the matched-filter bank,residual_atrous_scale_halos_pixelsat most 14 for the à trous transform,segment_association_halo_pixels15 for segment association andsegment_refinement_halo_pixels5 for segment refinement. The reviewed noise grids are larger: the 150/50 coarse grid needs about 125 pixels and the adaptive 35/7 grid with its 75-pixel influence radius and 20-pixel transition needs about 120. The noise grid, not the multiscale filters, sets the maximum halo, and at roughly 125 pixels it sits well inside the quarter-core limit that the scalability contract sets for its smallest admitted core of 2,048 pixels. - Several kernels already accept tiled inputs.
assign_seeded_multiscale_supportresolves ties by a caller-supplied global row-major seed reference,reconcile_island_tilesmerges boundary equivalences hierarchically, and the deblending, deferred-completion and extended-measurement stages already shard exact membership.
What is missing is a single stated contract: which pass each public stage
belongs to, what it reads, which task owns each result, what crosses the
executor boundary, and how the pieces merge. Without it the convergence of
public_science.py onto stages/ would decide those questions one commit at
a time, and partition invariance would be discovered rather than designed.
The plan names extended association as the largest risk to M2 for exactly
this reason.
Problem Statement
How should the public continuum composition be decomposed into tiles, so that one composition serves every image size, peak memory scales with a tile rather than an image, and results do not depend on tile geometry, partition origin, worker count, completion order or retry?
Options Considered
| Option | Description | Partition invariance | Bounded memory | Scheduler cost | Small-image overhead | One composition | Reuse of current code | Overall score |
|---|---|---|---|---|---|---|---|---|
| Weight | - | 3 | 3 | 2 | 2 | 2 | 1 | - |
| Tiled passes with an object phase | Haloed pixel passes separated by global reductions, then bounded per-object tasks with an escalation path | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅ | 37 |
| One fused task per core | Every stage for one core evaluated in one task under the maximum halo | ❌ | ⚠️ | ✅ | ✅ | ✅ | ⚠️ | 29 |
| Size-branched composition | Keep the whole-array path below a size threshold and tile only above it | ❌ | ⚠️ | ✅ | ✅ | ❌ | ✅ | 26 |
| Object-parallel only | Keep image-sized filter planes whole and parallelise only islands and sources | ⚠️ | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | 25 |
✅ = 3 (good), ⚠️ = 2 (acceptable), ❌ = 1 (poor)
A fine-grained public Dask-array graph was rejected in ADR-005 and is not rescored here.
The fused single-task option fails on invariance rather than on cost: an island, a support region or an association pair may exceed any fixed halo, and a task that sees only its own read cannot decide whether a component continues beyond it. The size-branched option reintroduces the two scientific paths that this milestone exists to remove, and makes one-tile versus many-tile comparison meaningless because the two sides would run different code. Object-parallel work alone leaves the filter, à trous and support planes image-sized, which is the state M2 must leave behind.
Decision Outcome
Hebog will use tiled passes with an object phase. The public composition becomes four passes over image-anchored geometry, followed by materialisation. A pass boundary exists where, and only where, a global reduction must complete before the next stage can decide anything.
- Pass A — noise. Coarse grid statistics, adaptive candidate discovery, the source-protected fine grid, and the background and RMS planes.
- Pass B — detection. One read per tile produces prepared inputs, the matched-filter bank, the à trous transform, thresholding and tile-local island labels with boundary summaries. These stages are fused because they share one read window.
- Pass C — support. Seeded multiscale support, segment refinement, cross-scale persistence and island admission, using globally reconciled label mappings sharded per tile. Final labels and mask are written here. Pass C is several rounds rather than one, because two of its steps are scoped to an owner and two of its inputs are global reductions, adjacent-scale persistence and the set of owners published anywhere; the rounds are listed under Owner-scoped connectivity below.
- Pass D — objects. Deblending, compact measurement and fitting, extended measurement, source association and catalogue rows, as bounded per-object tasks reduced hierarchically. Like pass C it is several rounds, because three of its steps are global reductions rather than per-object work; the rounds are listed under The object pass's rounds below.
Rules that hold for every stage
- Cores own pixels. A tile writes only its non-overlapping core. Halos are read, never written.
- Canonical pixels own objects. An island, component or source is owned by the tile core containing the row-major minimum pixel of its reconciled global support. Identity is a stable hash of that global pixel, so it does not change with tile geometry, label integers or completion order.
- Derived geometry is anchored to the image, never to the partition.
Noise grid cells, adaptive influence boxes and filter evaluation blocks are
placed from the image origin. Moving
partition_origin_yxchanges which task computes a value, never the value. - Nothing image-sized crosses the executor boundary or reaches the
driver. Tasks exchange records, bounded summaries and Zarr chunk
identities. Label mappings are sharded to the labels present in a tile;
an accepted-label table is never broadcast whole, and every value a task
carries survives serialization exactly: a
WCStravels as the header text it was built from and is rebuilt on the worker, because Astropy re-serializes one through a header it reformats. One exception, bounded by the image rather than the tile, is declared under Objects wider than the read budget. The implementation does not yet meet this rule in aggregate: the records the passes keep from every tile, above all the tile summaries' per-label records, which keep every candidate island, grow the serial traced peak about 1.7 bytes a pixel (measured 29 and 30 September 2026), and the plan's risks carry them. - Reductions are hierarchical and order-independent. Merge operations are associative and commutative, or are applied to a canonically sorted input.
- Read once per round. A task reads its window once and derives every quantity its round needs from it; filter responses are recomputed in a later round rather than stored. Passes B and C fuse their pixel work into one read per tile, but the owner and object rounds of passes C and D each read the windows they decide, so a plane is read once per round that needs it, not once per pass. The measured cost of those reads is recorded under Consequences.
- Store a plane only if a later pass or a product needs it. Background, RMS, publication labels, the mask and the position signal are stored; filter responses, à trous coefficients and normalised planes are not.
Per-stage contract
Halo values are for a 5-pixel beam and the reviewed 150/50 and 35/7 grids.
| Stage | Halo | Ownership | Boundary summary | Merge |
|---|---|---|---|---|
| Input admission | 0 | file | header record, streamed digest | none |
| Coarse noise grid | ⌈150/2⌉ + 50 = 125 | cell owned by the core containing its centre | cell statistics at grid resolution in Zarr | none; cells are disjoint |
| Adaptive candidates | 34 (matched-filter) | candidate owned by the core containing its peak | canonical peak, influence box | deterministic union of overlapping boxes keyed by canonical peak |
| Adaptive fine grid | 75 + 20 + ⌈35/2⌉ + 7 = 120 | as coarse grid, inside candidate regions | fine cells at grid resolution | none |
| Background and RMS planes | one grid cell (50) | pixel core | none | none |
| Matched-filter bank | 34 (4σ at 4 beams) | pixel core | none | none |
| Residual B3 à trous | 14 (frozen cumulative) | pixel core | none | none |
| Island labelling | 0 | pixel core; labels tile-local | edge label runs, per-label pixel count, sum, bounding box, canonical pixel | union–find over boundary equivalences, tree-reduced; aggregates summed |
| Island admission | 0 | reconciled island | accepted-label set | area and pixel-count predicates on merged aggregates; the accept map is sharded per tile |
| Seeded multiscale support | 2 (half a beam, rounded down), within the refinement halo | support pixel owned by the seed nearest to it along the support, within half a beam | global seed references of owners present in the read | none; a pixel's owner depends only on the support within half a beam of it, and ties are broken by row-major seed reference in-read |
| Segment refinement and persistence, pixel work | 5 (the larger of ⌈r⌉ + 2 and ⌊r⌋ + 3, for the 0.5-beam recovery radius r) | pixel core | none | none |
| Segment refinement, owner connectivity | owner window | owner canonical pixel | one restore decision per owner | none; decisions are applied in the core round |
| Cross-scale association | 0 | scale detection owned by its canonical pixel | per-scale label overlaps observed in the core | union of edge sets, then persistence per connected group |
| Persistent publication, owner bridges | owner window | owner canonical pixel | label patch bounded by the owner window | patches applied in the core round |
| Compact deblending | parent window within the admission limit | parent canonical pixel | each parent's component count | canonical numbering by first pixel; each core relabels or re-deblends the parents it holds from its own window |
| Compact measurement and fitting | fit-parent support + the reviewed context margin | fit-parent canonical pixel | fit records, groups and component records | none; each core derives a parent's measurement support again from the published planes |
| Extended measurement | 8 (1.5 beams) per owned core | object canonical pixel; each intersected core contributes | today the object's pixels from each core, when its window exceeds the read budget; additive accumulators are deferred | reduced on the driver in raster order (see Objects wider than the read budget) |
| Position à trous filter | 14 | pixel core | none | none; see below |
| Source association | one feature's window plus its B3 footprint, or each core that window reaches | feature canonical pixel | hierarchy overlap records | associate_from_hierarchy_overlaps over the reduced records; see Extended association |
| Source measurement and rows | 8 (1.5-beam aperture) | source canonical pixel | catalogue shard | shards ordered canonically on the driver |
| Product materialisation | 0 | row block | written chunk identity | ordered chunk index |
Objects larger than one halo
Extended emission, close blends and association pairs are not bounded by any halo. A three-tier escalation applies, and the tier is chosen from measured extent, never from a guess:
- T1, one task. The object's bounding box, plus its stage halo, fits the admitted task limit. One owner task computes it exactly.
- T2, reducible accumulation. It does not fit, but the quantity is an associative accumulation: pixel counts, moment sums, aperture photometry, bounding boxes, minima along a line, and the label equivalences themselves. Each intersected core contributes its part in global coordinates and the owner sums them. The result equals the T1 result up to floating-point summation order, which is fixed by reducing contributions in canonical core order. No round accumulates this way yet: each returns the object's own pixels from its cores and the driver reduces them, as Objects wider than the read budget describes, and moving those reductions onto the cores is deferred work.
- T3, irreducible. The quantity needs a simultaneous view of the whole object, such as a joint non-linear multi-Gaussian fit across an object larger than the admitted task. The object is not fitted. It is measured through T2 and published with an explicit disposition recording why, exactly as compact deferrals are published today. Silently truncating, dropping or splitting such an object is prohibited; if T3 becomes common on real LOFAR mosaics, the admitted task size is raised, or the case is escalated to a scientific decision, rather than weakened in place.
Owner-scoped connectivity
Two steps of the support pass are scoped to an owner, not to a bounded
neighbourhood, and were found by reading the installed composition rather than
assumed. refine_multiscale_segment_labels ends by restoring an owner's
original support when refinement keeps none of its pixels on its own evidence
(its opened support and its pixels at the 6σ floor), or when the pixels
publication would give it, by measurement ownership, are not one part, and
refine_persistent_publication_labels ends by preserving the previously
published regions that bridge two retained parts of one owner, or all of them
when no part is retained. No admitted owner can therefore lose all of its
published support. Both iterate
over the window holding an owner, so neither can be decided inside a tile core
whose halo is smaller than that owner. One further quantity is global: the
set of owners published anywhere, which decides which owners persistent
support may restore. Which seed a support pixel is attached to is not: the
pixel takes the seed nearest to it along a path through the support, within
half a beam, so every pixel of that path takes the same seed and an owner's
support is connected through its own pixels. The connected components of the
whole support are therefore neither reconciled nor stored.
Pass C therefore runs as rounds, each cheap relative to pass B's filters:
| Round | Scope | Reads | Writes or returns |
|---|---|---|---|
| Topology | core, halo 0 | scale masks | per-scale island summaries, adjacent-scale label overlaps |
| Persistence publication | core, halo 0 | scale masks, plus each core's persistent scale labels | persistent-support |
| Owner connectivity | owner window + refinement halo, or each core a wide owner's window reaches | detection labels, direct signal to noise, reconstruction mask, validity, persistent support, owner reference pixels | one restore decision per owner; for a wide owner, its published support's components and whether refinement keeps any of its pixels, in each core |
| Published owners | core + refinement halo | the published planes, owner reference pixels, restore shard | the owners published in the core |
| Owner bridges | owner window + refinement halo, or each core a wide owner's window reaches | as above, plus the published-owner shard | a label patch bounded by the owner window; for a wide owner, its base and candidate components in each core |
| Final write | core + refinement halo | as above, plus the patch, wide-owner and admission shards | component-labels, measurement-labels, publication-labels, retained-mask |
Of the owner rounds, only the last writes. Each pixel quantity is recomputed in the round that needs it, which costs a bounded repeat of cheap neighbourhood work and saves three intermediate label planes.
The refinement pixel work needs the opening influence and the recovery
radius together, not their maximum: a pixel recovered at the recovery radius
is labelled from opened support that must itself be correct there. A 3x3
binary opening erodes then dilates, so its influence is two pixels, and the
dense-core count reaches one further. Persistence adds the same way: its
dense core is a 3x3 opening and count over the measurement labels, three
pixels, and measurement gives a pixel to a seed up to the recovery radius
r away. The halo is therefore the larger of ceil(r) + 2 and
floor(r) + 3, which is one pixel more than refinement alone needs when
r is a whole number of pixels. Recomputing the refinement in a later
round is preferred to storing it, exactly as pass B recomputes its filters
rather than persisting a response bank.
Both owner quantities are ADR-008 T1 work keyed by the owner's canonical
pixel, so they do not move with tile geometry, label integers or completion
order. An owner whose window exceeds the read budget is not T3, as this
decision first had it, because neither quantity needs the window: both are
connected components of the owner's own pixels, which are T2. Each core the
owner's window reaches labels its same-label components and returns their
labels and core-edge pixels; the components join where they meet across a
core edge, with the island reconciliation's eight-connected edge rule
restricted to equal labels, since two owners that touch must not merge; and
the restore and bridge rules are applied to the joined components
(hebog.algorithms.owner_connectivity). A candidate that touches two base
parts is itself a bridge and two candidates never touch, so the bridge
rule's fallback reduces to keeping the one part that holds the owner's
earlier pixels. The write round relabels its own core exactly as the bridge
round did and applies its share of the decision by component number. The
answer is the window's for every owner, so a wide owner publishes exactly
what a narrow one would.
The object pass's rounds
Every scientific step of pass D already works on one object inside its own bounding box, so converting it is mechanical — except where a step's work unit is itself global. Reading the installed composition found three such steps, and they set the round boundaries:
- Fit parents.
_measurement_fit_parentsdilates the measurement support by the fit context margin and labels the result, so owners whose contexts touch are fitted jointly. That connectivity follows a chain of any length, exactly like an island's, and must be reconciled before any fit runs. A parent of several owners that no joint fit can hold, by the fit's work limits on its owners and their direct pixels or by the compact bound on its window, is fitted island by island: its islands, the zero-margin contexts joined by owner, are reconciled in a further round that runs only when such a parent exists. - Measurement support. Each fit parent contributes persistent measurement support into its own window with a boolean OR. That support is a function of the residual, RMS, validity and fit-parent labels in the window, not of the fit, so each core the window reaches derives it again from the published planes and writes its own pixels; the fit round returns records only.
- Cross-parent loops. The loop search,
_loop_groups_in_featureinhebog.algorithms.component_measurement, works on the accumulated measurement support and reconciles resolved loops that span several fit parents, so it can only run once every parent's patch is known.
The extended-residual search, _residual_groups_in_feature, works on the
same accumulated support with the same connectivity, feature by feature, so
it shares that work unit rather than adding a fourth. One round evaluates
both steps inside one support feature's window, reading the window once
(group_support_feature_components). A
feature is therefore the object of the last grouping round, exactly as a
parent is the object of the deblend round.
That round needs the fit records and the proposed compact groups of every component whose measurement-label footprint reaches its window, which is not the same set as the feature's own members: a fit outside the feature still enters the subtracted model. The scan round therefore also returns each measurement label's global bounds, and the driver shards the records by bounding-box intersection with each feature's window. The shard is a superset of what the task uses, and the task re-checks pixel membership, so no accepted-label table is broadcast whole.
The feature labels are not published. A feature's window is its reconciled global bounds plus the margin, so it contains the feature entirely, and the task recovers it by labelling the support inside its own window and selecting the component holding the feature's canonical first pixel. Publishing a plane that only one round reads would cost a generation for nothing.
| Round | Scope | Reads | Writes or returns |
|---|---|---|---|
| Parent extents | core, halo 0 | component-labels, measurement-labels |
each parent's bounds and first pixel in both planes, and its direct size |
| Deblend | parent window, for an admitted parent only | direct-snr, valid-pixels, both label planes |
each parent's component count |
| Component write | core, halo 0, then the window of each parent it holds that splits | both label planes; direct-snr and valid-pixels in a splitting parent's window |
component-direct-labels, component-measurement-labels |
| Fit parents | core + the 8-pixel fit context margin | component-measurement-labels, component-direct-labels |
context island summaries, owners, direct pixel counts and support bounds; island summaries when a parent no joint fit can hold is split; then fit-parent-labels |
| Component fits | fit-parent window + margin, or a deferred parent's cores | residual, RMS, validity, fit-parent-labels, both component planes |
fit records, groups, grouping evidence, each owned component's association record |
| Support write | core, halo 0, then the window of each measured parent it holds | residual, RMS, validity, fit-parent-labels |
measurement-support |
| Support features | core, halo 0 | measurement-support, valid-pixels, component-measurement-labels |
feature island summaries and each measurement label's bounds |
| Cross-parent loops and extended residual | support-feature window + margin | residual, RMS, validity, measurement-support, component-measurement-labels, the sharded fit records |
extended group records and grouping evidence |
| Hierarchy overlaps | core, halo 0, then one feature's window plus its B3 footprint, or each core that work reaches, under twice the widest B3 radius, when the window exceeds the read budget | component-direct-labels, valid-pixels, reconstruction-mask, the scale label planes |
component, feature, support and envelope overlap records; then persistent-scale-support |
| Source labels | core, halo 0 | component-measurement-labels, the sharded owner-to-source map |
source-labels |
| Source support | core, halo 0, then the window of each connected support component it holds, or a component's cores when that window exceeds the read budget | source-labels, persistent-scale-support, measurement-support, valid-pixels |
support island summaries, then a wide component's seeds, then source-measurement-labels |
| Source apertures | core, halo 1.5 beams | source-measurement-labels |
aperture-labels |
| Source rows | source window + 1.5-beam aperture, or its cores when that window exceeds the read budget | image, background, RMS, validity, source labels, position signal | catalogue shards, each segment's local noise |
| Detection island rows | core, halo 0, then one island's window, or the island's cores when that window exceeds the read budget | retained-mask, component-measurement-labels, measurement-labels; then image, background, RMS, retained-mask |
island boundary summaries and owner-to-island pairs; then catalogue island rows, or a wide island's pixels from each core |
Component numbering is canonical because the driver offsets each parent's local labels by the components every earlier parent produced, in ascending first-pixel order. A parent above either hard compact-work bound is ADR-008 T3 and is already published as one explicit deferred component, which is the reviewed science rather than a new rule. That component is the parent's own support in both planes, so deferral needs no pixel: the extents carry each parent's direct size, the driver decides deferral from them with the deblender's own rule, and the cores holding a deferred parent relabel it in the write round.
The numbering needs each parent's component count and nothing else, so that is all the deblend round returns. Summed over parents, the memberships are as large as all the support in the image, and rule 4 keeps them off the driver. A parent published as one component (deferred, too faint to split, or one watershed region) is its own support in both planes, so its cores relabel it as they relabel a deferred parent. A parent that splits is deblended again by each core that holds it, inside the same windows, which decides the same memberships bit for bit; that costs a second deblend of the parents that split, and of a split parent once more for each further core it crosses. Source support needs no count first: each core assigns the narrow components it holds, each inside its own window, and writes its own share, so a component crossing cores is assigned once by each of them and nothing returns but the component's identity.
Objects wider than the read budget
Every per-object round batches neighbouring objects so that one read serves
several, and maximum_batch_read_pixels bounds that read. The budget alone
bounds nothing, though: no admission limits an object's own area, so a
filament's bounds can span the image, and each round used to exempt a batch's
first object from the budget. hebog.stages.batching now holds the one rule
every round batches under. A batch closes before its read would exceed the
budget, and an object wider than the budget shares a read with nothing. It
is read alone only when a reviewed admission rule bounds its window
independently of the image; otherwise the batcher refuses it, and the round
must measure it from the cores that hold it or not read it at all.
Which of the two a round takes follows from its science. Where the quantity
is a set reduction over the object's pixels, each core returns its part and
the driver reduces them in raster order, which reproduces the window's result
bit for bit. That keeps bit-for-bit equality at the cost of rules 4 and 5:
the driver holds the object's own pixels, not its window, for every wide
object of the round at once, so that memory is bounded by the image and not
the tile. The catalogue-row round costs the most, 186 bytes an object pixel,
which for a segment filling the field is about 1.7 GB at 3,000², 19 GB at
10,000², 44 GB at 15,402² and 1.9 TB at 100,000². It is an explicit limit on
the envelope, and the plan's risks carry its removal. The one measurement so
far is well below those figures: the generated 10,000² case's diagonal
filament, 553,817 pixels, cost the driver about 100 MB (LOG.md,
27 September 2026), and a
smooth object wider than the background box is absorbed by the background
estimate before it can reach the driver. That is a measurement of one
object, not a bound: the rounds gather every pixel of every wide segment
with no cap on its size, so a connected network of narrow filaments could
cost substantially more, up to the field-filling figures. Moving the
reductions onto the cores, as associative partial sums with a reviewed
design for the median, is the plan's deferred work, reopened when a tier's
traced peak shows the term or when the cluster benchmark is planned; it
does not gate the local tiers. Source support escapes it: an unseeded
pixel's owner depends only on the seeds, so the driver holds only those.
Where the science needs the whole object at once, the round relies on an
admission bound instead, and the bound it relies on is named here, in
pipeline order:
| Round | Whole object at once? | An object wider than the budget |
|---|---|---|
| Owner connectivity and bridges (publication) | No: whether cleanup splits an owner, and which earlier regions bridge its parts, are questions about the connected components of its own pixels | Decided from every core its window reaches: same-label components joined across core edges, as under Owner-scoped connectivity, and the write round applies each core's share. PublicationStageResult.wide_owner_count reports how many owners took that path |
| Component topology | Yes: the watershed and the assignment of measurement support to its seeds need the parent's whole support | A parent beyond either hard compact-work bound is deferred as T3, exactly as the whole-plane deblender defers it, and never read. An admitted parent's direct window is within maximum_compact_bounds_pixels (250,000 pixels in the reviewed profile), and the support pass attaches measurement support only within the reviewed recovery radius of it, so a parent wider than the budget is read alone within that bound |
| Component fits | Yes for the fit: a joint model needs the parent's whole window. No for its components' association records, which are moments over each component's own pixels | A parent whose window maximum_bounds_pixels refuses is deferred as T3, exactly as the whole-plane pass defers it, so its window is never read: the records of the components it owns come from the cores that hold them, restored to raster order, bit for bit. A parent the bound admits is read in its window, alone when that window exceeds the budget |
| Cross-parent loops and extended residual | Yes: grouping labels and fits the feature's window | Read alone. support_feature_window leaves a feature wider than maximum_bounds_pixels ungrouped as T3, exactly as the whole-plane pass does, so that bound (250,000 pixels in the reviewed profile) limits every grouping read |
| Hierarchy overlaps | No: an envelope is exact support dilated through valid pixels by the reviewed B3 radius, an influence is that envelope dilated again, and an overlap is one shared pixel | A feature whose influence window, or a pair whose box, exceeds the budget is decided in each core it can reach, read with twice the radius as its halo, which decides every pixel of that core exactly. An influence is the union of the owners the cores find, and a pair overlaps where any core finds a shared pixel |
| Source support | No: each unseeded pixel goes to its nearest seed of the same component, a per-pixel answer that depends only on the component's seeds | Its cores return the component's seeds, and in the write round each core holding its unseeded pixels gets back the seeds that can own them and assigns them itself: those within d + 2h of the core's centre, where h is its half-diagonal and d the distance from the centre to the nearest seed. The driver holds the seeds, never the unseeded support |
| Source and component rows | No: the position, flux, peak, moments and noise are sums, a first maximum and a median over the pixels a segment owns, holds in its measured support or holds in its aperture | Measured from its cores: each returns those pixels with their values, and the row is measured from them restored to raster order, bit for bit |
| Detection island rows | No: a count, sums and a median over the island's pixels | Measured from its cores |
Extended association
Association is a record-graph computation, not a pixel pass. The installed association is the multiscale hierarchy, not the centroid-pair predicate this decision first anticipated, so the boundary is drawn where that hierarchy actually touches pixels. Reading it found exactly five pixel questions, and every one of them is either a per-tile reduction or bounded by one feature's own window:
- which scale features each direct component's exact support intersects;
- which parent feature each child feature overlaps at the adjacent scale;
- which components lie inside a feature's exact support, and which lie inside the reviewed B3 influence of its envelope;
- which retained support component contains each feature and each direct component, over the connected support the detection pass published;
- which two features' B3 envelopes overlap.
HierarchyOverlaps is that answer set, and associate_from_hierarchy_overlaps
is the decision that consumes it. The decision holds no plane, so it cannot
depend on tile geometry or completion order, and
summarize_hierarchy_overlaps evaluates the same reductions over whole planes
as the serial oracle. Envelope masks never cross the executor boundary: a
feature's task derives its own influence set and its envelope's overlaps
inside the pair box, and returns records.
The fit-parent tasks return the component records, and the driver holds them all, ordered by canonical first pixel; they carry no array. Each record is built by the fit parent that already reads that component's residual and validity, so describing a component costs no round and no read of its own.
Every overlap above is stated between globally labelled features, so the
scale feature labels must be readable by window. The detection pass already
reconciles the per-scale islands and writes their support masks, so it gains
one publication round that writes scale-{order}-labels beside
scale-{order}-significant. That is three more stored planes, admitted under
Store a plane only if a later pass or a product needs it because the object
pass now needs them; the alternative, reconciling each scale a second time in
pass D, would repeat a reduction pass B has already performed.
The à trous position filter
The à trous transform is evaluated once per tile in pass B, and the position signal is written there as a stored plane, so pass D reads it by window with the object it measures. Where the 14-pixel halo is clipped by the image edge, the denoised value stays unavailable and the signed residual is the documented fallback, which is not a reason to discard an edge source.
The continuum catalogue
Source rows are built by owner tasks from the source label plane and the stored position signal, and returned as catalogue shards that the driver orders canonically. Row order in the published catalogue is canonical, by source identity, not by completion order. Measurement dispositions and support stages accompany the rows as records, not as planes.
The catalogue holds one global step, and it is the same shape as pass D's
others. assign_persistent_source_support labels the union of the source
seeds and the persistent support, then assigns each unseeded pixel of a
connected component to its nearest source seed, breaking an exact tie towards
the smaller source label. The labelling spans tiles and must be reconciled;
the assignment does not, because a component's candidates and seeds both lie
inside its own bounds. assign_connected_source_support is that per-component
step, and the whole-plane function is it in a loop, which keeps the serial
oracle exact by construction. Tie-breaking needs no global table: ranking the
seeds by canonical source identity and taking the smallest rank among tied
neighbours is the same as taking the smallest label, which one window knows.
Everything else the catalogue does is per core or per object: mapping owners to source labels, expanding apertures by the reviewed 1.5-beam radius, and measuring each component's and each source's moments, and the local noise over the support it owns, inside its own window. The noise is measured for every segment the round observes, not only for the measurable ones, because a row published from a fitted model quotes the same value.
The published catalogue also names islands, and they are the one object in it that is not owner support: an island is a connected region of the retained mask the publication pass wrote. That connectivity spans tiles like every other label plane, so it is reconciled the same way — each core labels its own retained mask and returns boundary labels, and the reduction numbers the islands by canonical first pixel, which is the order labelling a whole plane gives. The island labels are never published, because only two rounds read them: the cores that observe which owners each island holds, and the task that measures an island inside its own global bounds, which contain it entirely and cannot connect it to another island. An owner is named against every island its retained support reaches, because publication can split that support. A component whose own support reaches no island, such as a faint part deblended onto a brighter one's rim outside its retained support, is named against the islands of the owner it was deblended from, so the cores also observe each owner's islands and each component's owner. Admission bounds no island's area, so a filament's bounds can reach across the image. An island whose window exceeds the read budget is therefore never read whole: each core holding it relabels itself exactly as the scan did, the reconciled mapping names the island's pixels there, and the row is measured from those pixels restored to raster order, so it is the row the window would give, bit for bit. The driver then holds that island's own pixels, not its window, because its median noise has to see all of them.
The rows need no reconciliation at all. build_hebog_segment_catalogue
already measures one label at a time inside the window holding its support
and its aperture, so build_segment_row and segment_moment_fields are that
work taken out of the loop. The one step that crosses a segment's bounds is
the aperture expansion, and it reaches no further than the reviewed radius:
every seed that can own a core pixel lies inside the core read plus that
halo, and the tie towards the smaller canonical label is decided the same way
in a window as over the plane. So the row round is cores writing
aperture-labels under that halo, cores observing each label's bounds, and
one task per batch of segments measuring their rows — the simplest of pass
D's rounds, and the only one with no global reduction.
A small image stays one tile
When the image fits inside one tile core, the manifest contains one tile, every halo is clipped to the image, every boundary summary is empty, and union–find performs zero rounds. The executor receives one batch per pass. The only cost relative to today is one Zarr chunk per stored plane, which ADR-007 already accepted as the single intermediate backend. Because filter responses and à trous coefficients are not stored, the number of stored planes does not grow with the number of stages. Every stage after the noise pass uses the contract's smallest admitted core, 2,048 pixels, and the noise pass keeps 128-pixel cores because they are its grid cells, not a memory choice; batches use fixed limits too. Every round declares its tasks' memory, but no stage yet chooses its core from the admitted memory (the plan's task 72). Choosing a larger core from admitted memory within the contract's 2,048–8,192 range remains the intent, and a resource choice may change batch size and core size, but never ownership or results.
Numerical invariance
Partition invariance is defined, and tested, at two levels:
- Exact for everything that carries identity or a decision: labels, masks, memberships, reconciled island identity, component and source identities, accepted-label sets, catalogue row membership and ordering.
- Bounded for continuous filter responses, at the relative and absolute 2×10⁻¹³ already used by the reviewed multiscale partition-equivalence tests. The à trous transform is a separable direct convolution and is exact under tiling; the residual tolerance comes from the matched-filter bank, which uses an FFT whose rounding depends on the transform shape.
The residual risk is explicit: a pixel within 2×10⁻¹³ of a detection threshold could in principle change membership between partitions. Two responses were considered and rejected for now — evaluating the matched filter on image-anchored fixed transform blocks (bitwise, but new machinery and extra padding work), and direct convolution everywhere (bitwise, but a 69×69 kernel per pixel at 4 beams). Both remain available, and the fixed-block form is the required escalation if the invariance suite ever observes a knife-edge flip. The suite therefore includes knife-edge cases deliberately placed at the threshold on tile edges, corners and shifted partition origins.
The float64 and float32 decision path
Science planes stay float64. Tile bounding removes the memory pressure that
would otherwise motivate float32: a 2,048 core with a 125-pixel halo is a
2,298² read, about 42 MB per float64 plane, so a task holding ten planes
stays near 420 MB at any image size. A later change to float32 requires, in
order: a profile showing that memory bandwidth or admitted-plane count is the
binding constraint; restriction to stored intermediate planes only, never to
moment, photometry or fit accumulators; scientific-equivalence evidence on the
dataset matrix within the plan's margins, not a runtime result; and an
amendment to this ADR. Input images that are natively float32 are promoted
at the kernel boundary, as they are today.
Consequence for the executor
map_batches with a driver-side gather cannot express pass C and pass D: both
need bounded submission and worker-side reduction of shards the driver must
never hold. Completing the executor contract is therefore a prerequisite for
this composition rather than an independent improvement, which reorders the
milestone: the executor work comes before the convergence it enables.
Consequences
- Good, because peak memory becomes a function of the tile core, the maximum halo and the batch size, and stops being a function of the image, which is what raises the public envelope past 1,024.
- Good, because one composition serves every size, so one-tile versus many-tile agreement is a meaningful test of the code that actually runs.
- Good, because ownership by canonical pixel makes identity, and therefore the catalogue, independent of tile geometry, worker count, completion order and retry.
- Good, because the escalation tiers make an object too large for one task a declared, published condition instead of a silent truncation.
- Good, because the largest halo is set by the noise grid at about 125 pixels, so halo re-reads cost a few per cent of a 2,048 core rather than a large multiple of it.
- Bad, because the image and the stored planes are read once per round that
needs them, where the whole-array pass read the image once: passes A and B
read it per tile, and the fit, deferred-fit, support-write, source-row and
island-row rounds read it by object window. On a 1,024² field the store's
per-read overhead was measured at 56% of a profiled run before the sink
stopped re-validating generations and reopening arrays (
LOG.md, 27 September 2026); what remains is one chunk decode and checksum per window read and one atomic file per written chunk, and the support write round's reads cost 2 to 4% at 1,024² while its removal of the fit patches makes a crowded 2,048² run faster. Fusing rounds is the lever if a profile shows the reads binding. - Bad, because every new scientific stage must now declare a halo, an ownership rule, a summary and a merge before it can be composed.
- Bad, because an object wider than the read budget is reduced on the driver from its own pixels, which keeps its rows bit for bit but bounds that memory by the image, not the tile, until the reductions move onto the cores (Objects wider than the read budget).
- Bad, because the matched-filter FFT leaves a 2×10⁻¹³ tolerance on continuous responses, so invariance is exact for decisions but not bitwise for every plane.
- Risk: association work is bounded by component density, not by pixels. A pathologically crowded field could produce a large edge set. The spatial cutoff and the per-group resolution bound it in practice; the scalability lane records edge counts so a real bound can be set from measurement.
- Risk: pass boundaries are synchronisation points. At 200 nodes a straggler in pass B delays pass C. Batching and the planner's task-count bounds in M5 address this; it is recorded here as a known property of the shape.
Confirmation
- One-tile versus many-tile equivalence on analytic images, over tile shapes, partition origins, completion orders and retries, asserting exact equality for identity-carrying outputs and 2×10⁻¹³ for continuous responses, with sources on every edge and corner topology and at knife-edge thresholds.
- Architecture tests reject image-shaped arrays in stage results, in executor payloads and in driver-held state, and reject whole-table label broadcasts. The composition records carry no array field, which a static test asserts, and a run that walks the driver's own locals at the terminal builder finds no image-shaped array reachable from them. The component-topology, component-fit and source-support tests record every payload and result their rounds exchange and require them to carry no array but the support scan's core boundary labels.
- A stage-halo admission test proves every declared halo is below one quarter of the admitted core. The executor contract refuses, before submitting anything, a task requirement no worker can hold, and every round declares one that the public contract suite checks no task exceeds.
- Escalation tests place an object wider than the read budget across several cores and assert that the cores' path reproduces the window's result, and that a T3 case publishes a disposition rather than a truncated measurement.
- Traced-allocation peaks (
just traced-peak, the only reproducible figure; peak RSS varied 42% with machine load) across the size ladder show memory scaling with tile size, not image size, as each envelope tier is raised. - The quick science check and Serial/Dask agreement pass at every step of the convergence, and the whole-array path is deleted only once they do.
Links
| Type | Links |
|---|---|
| ADRs | ADR-004, ADR-005, ADR-006, ADR-007 |
| Documentation | Performance and scalability contracts, Domain model |
| Plan | Implementation plan |