Skip to content

Port the native-grid SamudraMulti model layer onto main - #840

Open
alxmrs wants to merge 5 commits into
mainfrom
u/alxmrs/samudra-multi-model-layer
Open

Port the native-grid SamudraMulti model layer onto main#840
alxmrs wants to merge 5 commits into
mainfrom
u/alxmrs/samudra-multi-model-layer

Conversation

@alxmrs

@alxmrs alxmrs commented Aug 5, 2026

Copy link
Copy Markdown
Member

Why

Jesse's SamudraMulti rebuild lives on codex/decoder-root-cause-report — 217 commits ahead of the merge-base, ~10k lines of src/ changes, no PR. main still ships the architecture those reports show is broken: a Perceiver encoder compressing fixed 3°×5° patches into one token, feeding a Perceiver IO decoder that has to learn spatial routing and channel rendering jointly.

1° normalized MSE
main SamudraMulti, 1-step, full data 0.29469
Samudra 2 reference 0.0236
Native-grid rebuild, single-res 1°, lead 1 0.0205

We want that architecture on main so we can reproduce it, then take it to long rollouts and to the ¼° goal.

Why a port and not a merge

I tried the whole-branch merge first. It bottoms out on the data layer, where three designs are in flight at once:

Where Design
main DataSource + DatasetSpec + OceanData
codex/decoder-root-cause-report CanonicalDataset + CanonicalReader
#823 (draft) CanonicalSource + DataLayout + BatchPreprocessor

#823's own summary says it replaces "the overlapping DatasetSpec / CanonicalDataset / OceanData abstractions." Resolving ~900 lines of conflict onto CanonicalDataset would be deleted work the moment #823 lands.

The model layer turns out to be completely decoupled from that argument — zero references to CanonicalDataset, rust_data, or data_backend; it reaches the data pipeline only through GridContext, masks, and resolutions. So it ports independently, and the data-layer rewrite stays where it belongs, in #800 and #823.

What's here

  • samudra_multi.py / base.py — encode → process → decode, plus reconstruct_once (zero-depth inverse) and latent_forecast (encode once, advance N steps in latent space, decode at the requested lead).
  • modules/encoder.pyDirectPatchEncoder with native_projection: one learned vector per native input cell rather than per fixed patch. This is the fix for the encoder-bandwidth failure, where ½°→½° reconstruction retained only 49.6% of target high-wavenumber power even though input and output shared a grid. Also PatchMomentEncoder and CanonicalResampleEncoder for the coarse-latent line.
  • modules/decoder.pyResampleProjectionDecoder with project_before_resample: decode physical channels on the native grid, then transport each with its own wet mask. On an exact checkpoint swap that ordering removed 78% of the excess ½°→1° error while leaving both same-grid routes bit-identical.
  • modules/augment_input.pyBoundaryEncoder (one time-aligned forcing state per processor call) and ProcessorGeometryConditioner (zero-initialized geometry sidecar).
  • aggregator/validate/spatial.py — high-wavenumber power and patch-seam jump diagnostics. For the ¼° goal these matter more than MSE; aggregate MSE hid the velocity-spectrum failures in several of these experiments.
  • utils/ctx.py — optional input_mask so interpolation weights renormalize per channel.
  • stepper.py / utils/output.pyinitialize_rollout plus a rollout_state carried across inference chunks, so a latent model never decode/re-encodes at a chunk boundary.
  • config.pyPerceiverConfig / EncoderConfig / DecoderConfig / SamudraMultiConfig spliced onto main's, plus the Checkpointing / LayerCheckpointing split making "selective" a SamudraMulti-only mode.

Please look at

  1. SamudraMultiConfig.build signature. Dropped static_data_for_corrector (correctors deleted in Delete correctors and static-data plumbing #822) and the unused tensor_map / normalize / dataset_spec params; CanonicalDatasetDataSource.
  2. reject_selective_checkpointing added to SamudraConfig and SamudraMiniConfig so the widened Checkpointing literal can't reach a UNet that doesn't understand it.
  3. @jder — a question for later, not a change here. Remove data "schedules" #783 removed dst and the "mix" schedule because "it was always a bit unclear what 'mix' schedules would do for autoregression." The four-route training that produced these numbers needs differently-resolved input/label pairs, so dst has to come back eventually. I think the latent-AR contract answers the original objection — mix was ambiguous under decode/re-encode, but under encode-once → roll latent → render at the requested grid it's well defined. Not in this PR; single-scale doesn't need it. Want your read before the four-route work.

Not here yet

train.py contract (freeze_model_parameters / frozen_model_prefixes, train_processor_depths cycling, validation_boundary_ablations), identity.py, model and train configs, and dst. None of it is needed to review this layer, and all of it is needed before any run reproduces a published number.

Testing

  • uv run pytest -m "not manual and not cuda" — 358 passed, 2 skipped, 10 xfailed
  • uvx pre-commit run --all-files — all hooks pass (ruff, ruff-format, mypy, schemas, reuse)

No training or eval run has been done against this port yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff

…ause work

Jesse's SamudraMulti rebuild lives on `codex/decoder-root-cause-report`, 217
commits ahead of main with no PR. Its single-resolution 1-degree result (lead-1
normalized MSE 0.0205) is competitive with Samudra 2's 0.0236, against main's
current SamudraMulti at 0.29469 - so the architecture we want is real, but
unlanded.

This ports the model layer only. It deliberately leaves behind that branch's
data-layer rewrite (`CanonicalDataset`, `CanonicalReader`, `rust_data.py`,
`data_backend.py`, the `datasets.py` / `utils/data.py` rework), because draft
PR #823 explicitly replaces those abstractions with `CanonicalSource` /
`DataLayout` / `BatchPreprocessor`. Resolving onto `CanonicalDataset` would be
work thrown away when #823 lands. The model layer turns out to be entirely
decoupled from that churn - it references none of those names and reaches the
data pipeline only through `GridContext`, masks, and resolutions.

What lands:

- `samudra_multi.py` / `base.py`: the encode -> process -> decode contract, plus
  `reconstruct_once` (zero-depth inverse) and `latent_forecast` (encode once,
  advance N steps in latent space, decode at the requested lead).
- `modules/encoder.py`: `DirectPatchEncoder` with `native_projection`, which
  keeps one learned vector per native input cell instead of compressing a fixed
  3x5 degree patch to one token. Also `PatchMomentEncoder` and
  `CanonicalResampleEncoder` for the coarse-latent line of work.
- `modules/decoder.py`: `ResampleProjectionDecoder` with
  `project_before_resample`, which decodes physical channels on the native grid
  before transporting each one with its own wet mask, plus periodic-longitude
  physical-coordinate resampling and the conservative-restriction prototype.
- `modules/augment_input.py`: `BoundaryEncoder` (one time-aligned forcing state
  per processor call) and `ProcessorGeometryConditioner` (zero-initialized
  geometry sidecar).
- `aggregator/validate/spatial.py`: high-wavenumber power and patch-seam jump
  diagnostics. These are how we judge spectral fidelity, which matters more than
  MSE for the quarter-degree goal.
- `utils/ctx.py`: optional `input_mask` so the decoder can renormalize
  interpolation weights per channel.
- `stepper.py` / `utils/output.py`: `initialize_rollout` and a `rollout_state`
  carried across inference chunks, so a latent model never decode/re-encodes at
  a chunk boundary. This is the machinery long rollouts will need.
- `config.py`: `PerceiverConfig`, `EncoderConfig`, `DecoderConfig`, and
  `SamudraMultiConfig` spliced onto main's, plus the
  `Checkpointing`/`LayerCheckpointing` split that makes "selective" a
  SamudraMulti-only mode.

Three adaptations to main worth review: `SamudraMultiConfig.build` drops
`static_data_for_corrector` (correctors were deleted in #822) along with the
unused `tensor_map` / `normalize` / `dataset_spec` parameters, and takes
`DataSource` rather than `CanonicalDataset`.

Not yet ported, and needed before any run reproduces a published number: the
`train.py` contract (`freeze_model_parameters` / `frozen_model_prefixes`,
`train_processor_depths` cycling, `validation_boundary_ablations`),
`identity.py`, the model and train configs, and `dst` restoration for
cross-resolution routes. `dst` is only required by the four-route runs, so it
does not block a single-scale 1-degree reproduction.

Test: `uv run pytest -m "not manual and not cuda"` - 358 passed.
Lint: `uvx pre-commit run --all-files` - all hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0aeac9ec6e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/samudra/config.py Outdated
Comment on lines +841 to +842
if self.native_projection:
return DirectPatchEncoder(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject native projection with the default decoder

When encoder.native_projection is set, this branch makes the encoder emit one latent cell for every native input cell, but SamudraMultiConfig.build can still leave the decoder as the default PerceiverDecoder. With the shipped/default patch extents (for example 3°×5° or 6°×10°), PerceiverDecoder.forward builds position encodings for patch cells (H/patch_h * W/patch_w) and adds them to native-cell tokens (H * W), so the first forward in this advertised native-grid mode hits a shape mismatch unless the user also knows to select a resampling/direct decoder. Please reject or auto-pair incompatible decoder modes at build time.

Useful? React with 👍 / 👎.

Comment thread src/samudra/models/modules/decoder.py Outdated
Comment on lines +789 to +790
if correction_mask is not None and correction_mask.shape != x.shape[-2:]:
correction_mask = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Raise instead of dropping mismatched correction masks

When continuous_resample_attention_residual receives a valid mask whose spatial shape does not match the processor grid (for example, a physical-grid ctx.input_mask passed while the latent grid is coarser), this fallback silently disables masking for the attention correction. That makes the residual attend to source cells the caller marked invalid instead of surfacing the bad mask, so the diagnostic/training result can be corrupted without an error; raise like the other correction path rather than replacing it with None.

AGENTS.md reference: AGENTS.md:L310-L310

Useful? React with 👍 / 👎.

…ning

Code review caught two ways the partial port misbehaves rather than failing.

Inference took the latent-autoregressive path whenever a BoundaryEncoder
existed, which `SamudraMultiConfig.build` creates for every non-bypassed config.
So `samudra eval` encoded once and advanced the latent, while training still
decoded and re-encoded at every step through `forward_once` - the latent-depth
training contract that would match it is not ported yet. Measured ~10% max-abs
divergence over three steps on a freshly built model, and the two paths diverge
structurally rather than numerically. Gate the latent path behind an explicit
`latent_autoregression` flag defaulting to false, so inference reproduces the
step function training actually used until the training contract lands.

`decode()` also read `GridContext.input_mask` but nothing sets it: both dataset
constructors build a context without one. A user setting
`decoder.project_before_resample: true` - documented as preserving per-channel
wet masks across resolutions, and worth 78% of the excess half-to-one error in
the original experiments - got plain unmasked interpolation with land bleeding
into ocean cells, no error and no warning. Raise instead, but only when the
decode actually transports between grids; same-grid decoding reduces exactly to
the learned 1x1 channel map, so the single-scale route stays usable while
`input_mask` is unwired.

Tests cover both contracts plus the same-grid exemption that keeps single-scale
working.

Test: `uv run pytest -m "not manual and not cuda"` - 362 passed.
Lint: `uvx pre-commit run --all-files` - all hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff
@alxmrs

alxmrs commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Code review pass — two real defects fixed in 7f4963c

I ran a review over the port. Two findings were genuine defects in what landed here (both caused by the model layer's defaults assuming a training contract this PR doesn't bring), and both were failing silently, which the repo guidance explicitly forbids. Fixed:

1. Inference computed a different function than training. initialize_rollout/inference took the latent-autoregressive path whenever a BoundaryEncoder existed — which SamudraMultiConfig.build creates for every non-bypassed config. So samudra eval encoded once and rolled the latent, while training still decoded and re-encoded every step through forward_once. Confirmed numerically: ~10% max-abs divergence over three steps on a freshly built model, diverging structurally rather than just numerically. Train samudra_multi_om4 then eval, and the zarr output came from a computation the weights were never fit for.

Now gated behind an explicit latent_autoregression flag, default false, so inference reproduces the step function training actually used. It flips on together with the latent-depth training contract in the follow-up, and that's the natural place to assert the two agree.

2. project_before_resample silently did unmasked interpolation. decode() reads GridContext.input_mask, but nothing populates it — both dataset constructors build a context without one. So setting decoder.project_before_resample: true (documented as preserving per-channel wet masks across resolutions, and worth 78% of the excess ½°→1° error in the original experiments) got plain bilinear with land bleeding into ocean cells. No error, no warning. ResampleProjectionDecoder.__init__ even guards that project_before_resample requires coordinate_resampling "so the source validity mask can renormalize interpolation" — a guard for a mask that never arrived.

Now raises, but only when the decode actually transports between grids. Same-grid decoding reduces exactly to the learned 1×1 channel map, so the single-scale route stays usable while input_mask is unwired — which is what Phase 1 needs.

Three tests added covering both contracts and the same-grid exemption. 362 passed, pre-commit clean.

Known-and-deliberate, listed so review doesn't re-find them

  • train.py never passes processor_depth, so physical_forecast_loss_weight, latent_teacher_loss_weight, processor_iterations != 1, and processor_residual are inert config today. They land with the training contract.
  • NormalizedSpatialDiagnosticsAggregator is not registered in ValidateAggregator. The module is here; the wiring comes with the training contract.

@jder — three pre-existing issues on your branch, unchanged here

Not touched, since they behave differently in the context of your full branch and you should decide:

  1. ResampleAttentionResidualDecoder.forward (decoder.py:830) lacks the mismatched-mask guard its sibling ContinuousResampleAttentionResidualDecoder.forward has. Latent today only because input_mask is unset; it raises the moment that's wired up with a patch encoder.
  2. ContinuousCoordinateAttentionCorrection._routing (decoder.py:650) hardcodes 180.0 / len(output_lat) and 360.0 / len(output_lon) for output spacing, then divides by source spacing derived from real coordinates. Within ~0.3% for global OM4 grids, wrong for any regional or non-uniform output grid.
  3. ablate_boundary_forcing("batch_shuffle") (stepper.py:32) rolls the batch axis, so it's a no-op at batch_size: 1 — the shipped value in train_samudra_multi.yaml. It would report a shuffled-forcing ablation while feeding correct forcing.

Also patch_seam_jump_ratio returns NaN for patch_size == (1, 1) (every row and column is a seam, interior is empty), and NormalizedSpatialDiagnosticsAggregator.get_logs raises on a rank that saw zero batches while other ranks enter all_reduce_mean — a DDP deadlock rather than a clean failure. Both matter once the aggregator is registered.

A second review pass found two more places where unported configuration is
accepted and quietly ignored rather than refused.

`forward(processor_depth=...)` decodes an absolute state and compares it to the
label, but `BaseModel.forward` adds the input state back when `pred_residuals`
is set. Training on the depth path with a residual config would therefore fit a
different function than inference evaluates. `initialize_rollout` already
refuses that pairing; the training side now refuses it too, so the two stay
symmetric.

`physical_forecast_loss_weight` and `latent_teacher_loss_weight` are only read
on that same depth branch, and nothing supplies a depth yet. Setting
`physical_forecast_loss_weight: 0.0` to train latent-only silently ran ordinary
full-weight physical-loss training instead. Reject non-default values until
`train_processor_depths` is wired into the training loop; the validator comes
out with that change.

Two other reported findings do not reproduce and are left alone.
`processor_iterations: 0` was reported as a DDP hazard from parameters that
never receive gradients, but `forward_once` raises whenever a BoundaryEncoder
is present and the count is not one, and one is present for every non-bypassed
config -- so it fails on the first forward and never reaches a backward. With
`bypass_processor` the processor is a parameterless Identity. Separately,
`processor_residual` was reported inert in an earlier pass; `process()` applies
the residual scale and `forward_once` calls `process()`, so it is live.

Test: `uv run pytest -m "not manual and not cuda"` - 363 passed.
Lint: `uvx pre-commit run --all-files` - all hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff
@alxmrs

alxmrs commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Second review pass — two more fixed in 1b48790, one finding doesn't reproduce

Same theme as the last round: unported configuration accepted and quietly ignored instead of refused.

Fixed: forward(processor_depth=...) dropped pred_residuals. The depth path returns the decode directly, but BaseModel.forward adds prog_tensor back when pred_residuals is set — so depth training would fit an absolute-state function while inference rolls out a residual one. initialize_rollout already refuses that pairing; the training side now does too. Latent today (nothing passes a depth), but it's the identical failure mode to the one fixed last round, and cheaper to guard than to debug later.

Fixed: physical_forecast_loss_weight / latent_teacher_loss_weight silently inert. Only read on the depth branch. Setting physical_forecast_loss_weight: 0.0 to train latent-only ran ordinary full-weight physical-loss training and reported nothing. Now rejected at config validation until train_processor_depths lands; the validator comes out with that change.

Does not reproduce: processor_iterations: 0 as a DDP hazard. Reported as ungraded parameters killing the first backward on multi-GPU. But forward_once raises whenever a BoundaryEncoder is present and the count isn't one, and one is present for every non-bypassed config — so it dies loudly on the first forward and never reaches a backward. With bypass_processor: true the processor is a parameterless nn.Identity. No hazard on either path, so no change.

Also correcting the record from round one: processor_residual was listed there as inert config. It isn't — process() applies the residual scale and forward_once calls process(), so it's live on the default training path. I didn't act on that one at the time.

Still deliberately unchanged (all previously flagged to @jder above): the ResampleAttentionResidualDecoder mask-shape asymmetry, patch_seam_jump_ratio returning NaN for 1×1 patches, and batch_shuffle being a no-op at batch size 1. On the first of those — the sibling silently drops a mismatched mask while this one raises. Given the repo's "don't swallow errors" guidance, raising is arguably the correct half of that asymmetry and the silent drop is the side worth revisiting. That's the author's call, not a drive-by change.

363 passed, pre-commit clean.

@alxmrs alxmrs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 1/N

from samudra.utils.distributed import all_reduce_mean, is_main_process
from samudra.utils.wandb import Metrics, MetricsDict, WandBLogger


Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can wait to include these spectral metrics until we land #834, so we can re-use some of the spectral code used at eval time? WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible that these val aggregators are complements to the new spectral metrics and thus should be structured as they are.

Comment thread src/samudra/models/modules/augment_input.py
return torch.stack([x, y, z], dim=0).float() # [3, H, W]


def make_position_scale_grid(lat: Lat, lon: Lon) -> Float[torch.Tensor, "4 H W"]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the aurora sources which we depend on may have something that calculates this. Worth a review to see if we can import something instead of reimplement.

Comment thread src/samudra/models/modules/augment_input.py
Comment thread src/samudra/models/modules/blocks.py
return torch.nn.Conv2d(in_ch, out_ch, kernel_size=1, padding="same")


def _normalization(norm: str, channels: int) -> torch.nn.Module | None:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy this is extracted.

Comment thread src/samudra/models/modules/decoder.py Outdated
Comment on lines +453 to +454
query_coordinates = make_3d_coordinate_grid(output_lat, output_lon)
query_coordinates = rearrange(query_coordinates, "d h w -> (h w) d")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

Comment thread src/samudra/models/modules/decoder.py Outdated
…n the loop

Removes `aggregator/validate/spatial.py`. It is unreferenced -- nothing
constructs `NormalizedSpatialDiagnosticsAggregator` -- so it is dead code in
this PR, and deferring it means the question of whether to reuse the eval-time
spectral code from #834 gets decided with that code in hand rather than guessed
at now. It also moots two latent defects found in review (NaN seam ratios for
one-cell patches, and a rank that saw no batches raising while its peers enter
all_reduce_mean).

Splits `normalized_log_cell_area` out of `make_position_scale_grid`. There is
still only one caller, so YAGNI applies to the split on its own merits; what
makes it worth doing is that the seam falls exactly where two known future
changes land. Our area math is the same formula as aurora's `patch_root_area`
-- verified equal to 2.5e-5, correlation 1.0 -- and the normalization cancels
every constant factor including Earth radius, so swapping to aurora buys reuse
rather than correctness. The stronger reason to swap later is curvilinear
grids: `aurora.area.compute_patch_areas` takes 2-D lat/lon and computes real
spherical-polygon areas, while this implementation assumes separable 1-D
coordinates. Doing that now would need a flip-and-meshgrid adapter (aurora
wants latitude decreasing) and would change a numeric feature inside a port
whose job is to reproduce a published number. The docstring records where the
replacement goes. The split is bit-identical to the previous concat.

Expands the `ProcessorGeometryConditioner` docstring to explain why it owns a
Conv2d: geometry is projected to the processor width and added to the latent
rather than concatenated onto the input, so processor width is independent of
whether geometry is on, the zero-initialized projection makes enabling it an
exact no-op, and geometry stays out of the representation the decoder inverts.

Comments the chunked query loop in `LocalCoordinateAttentionCorrection.forward`,
which bounds peak memory over the output-pixel axis without changing the result.

Test: `uv run pytest -m "not manual and not cuda"` - 363 passed.
Lint: `uvx pre-commit run --all-files` - all hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff
@alxmrs

alxmrs commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Thanks — all addressed in 55cf656. Replies below, including two where I pushed back.

spatial.py — agreed, and I went further: removed it from this PR

You asked whether to wait for #834 and reuse the eval-time spectral code, then noted these might be complements that belong as they are. I think both readings are right, and the way to avoid guessing is to not ship it yet: NormalizedSpatialDiagnosticsAggregator is never constructed by ValidateAggregator, so it's dead code here. It comes back when it's actually wired into validation, by which point #834 has landed and the reuse question answers itself with the code in front of us.

My guess is they are complements — these run per-batch on normalized GPU tensors inside the training loop and DDP-reduce; #834's operate on physical-unit xarray over a whole rollout against observations. Different data, different lifecycle. But the FFT itself is genuinely shared and worth extracting once, and I'd rather make that call against real code.

Bonus: it moots two latent defects review found in that file — patch_seam_jump_ratio returns NaN for one-cell patches (exactly the native_projection config this PR adds), and get_logs raises on a rank that saw zero batches while its peers enter all_reduce_mean, deadlocking DDP rather than failing cleanly. Both need fixing before it's registered.

Aurora already has this — you were right, and I still don't want to swap it here

Checked it numerically. Our normalized log cell area equals aurora.model.posencoding.patch_root_area to 2.5e-5 (float32 rounding), correlation 1.0. Same formula, R²π(sin φ₁ − sin φ₂)(θ₁ − θ₂).

But the normalization is zero-mean/unit-RMS, so every constant factor — Earth radius included — cancels. Swapping buys code reuse, not correctness. And patch_root_area wants explicit corner arrays, which is most of our function's body, so it'd replace about two lines.

The version actually worth adopting is aurora.area.compute_patch_areas, and the reason is curvilinear grids: it takes 2-D lat/lon and computes real spherical-polygon areas, where ours assumes separable 1-D coordinates. That's the tripolar-grid direction. Doing it now needs a flip-and-meshgrid adapter (aurora requires latitude decreasing along rows; ours increases) and changes a numeric feature inside a port whose whole job is reproducing a published number. So: later, tied to the curvilinear work. The docstring now names compute_patch_areas as the replacement so nobody re-derives this.

Splitting the log-area function — done, though your YAGNI counter was correct

One caller, so on its own merits YAGNI wins. What tipped it: the seam falls exactly where both known future changes land — the aurora swap above, and the curvilinear rewrite. Isolating it makes each a one-function change instead of surgery inside a 40-line function. Verified bit-identical to the previous concat.

ProcessorGeometryConditioner — yes to both, docstring expanded

You have it exactly right. It owns a Conv2d because it holds learnable state, and that's what separates it from the concatenating helpers around it. Two details behind the design:

It adds rather than concatenates — geometry is projected to the processor's own width and summed into the latent. So processor input width doesn't depend on whether geometry is on, which is what lets the same weights be applied zero or N times in a row. Concatenation would change the width and break the repeated-application contract.

And the decoupling is the whole point, with evidence: adding position/scale directly to encoder output measurably hurt reconstruction, while supplying it once per processor call did not. Keeping geometry out of the representation the decoder must invert is why the frozen-inverse result holds. The zero-init makes turning it on an exact no-op, so it can't perturb an already-trained inverse.

decoder.py chunked loop — comment added

Explains that each output cell attends to a bounded neighborhood, that at quarter degree materializing all the logits at once is what exhausts memory, that chunking the query axis bounds peak memory without changing the result (each chunk's softmax is over its own neighborhood), and what the all-invalid branch is for — land-only neighborhoods would otherwise softmax over all -inf and produce NaN.

blocks.py / decoder.py:454

No action. Worth noting _normalization fixed a real pre-existing hole: NormType allowed "layer" but ConvNeXtBlock raised NotImplementedError for it.

363 passed, pre-commit clean.

@alxmrs alxmrs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self review, mostly understanding.

Comment on lines +42 to +45
Kept separate from `make_position_scale_grid` because this is the part that
assumes a separable lat/lon grid. Curvilinear grids need `lat`/`lon` as 2-D
matrices, at which point `aurora.area.compute_patch_areas` computes real
spherical-polygon areas and should replace this body.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type of reflection is not helpful in documentation.

Comment thread src/samudra/models/modules/decoder.py Outdated
)


class ContinuousResampleAttentionResidualDecoder(nn.Module):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is very similar to the one below it. Are both needed? Could they be abstracted or combined into one?

source_resolution: tuple[Lat, Lon] | None = None,
valid_mask: torch.Tensor | None = None,
) -> Float[torch.Tensor, "batch {self.out_channels} H W"]:
del source_resolution, valid_mask

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we do this?

return patch_h, patch_w


def pos_scale_enc_for_grid(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should go in augment_input? Where did the old function that preceded it live?

def forward_once(
def encode(
self, prognostic: Prognostic, boundary: Boundary | None, ctx: GridContext
) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor]]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the shape of the latent_resolution tensor? is it a tensor of integers?

…p its config

The PR carried the whole decoder root-cause search: every retained control,
falsified alternative, and diagnostic from the experiments. This reduces it to
the arrangement that is actually selected, so main gets a durable baseline
rather than an experiment surface. 2,114 lines removed, 235 added.

Latent autoregression is deferred. It is genuinely absent from the 0.0205
one-degree result's *architecture* -- that number came from a two-stage
training contract (`train_processor_depths: [1,2,4]`, `finetune: true`,
`frozen_model_prefixes: ["encoder.", "decoder."]`) layered on top. The
architectural claim stands without it: the 32-sample autoencoder factorial
(direct/direct 0.012 vs direct/Perceiver 0.279) ran with the processor
bypassed, the decoder swap moved a matched one-degree proxy from 0.381735 to
0.051655 in a single factor, and the native-grid encoder result is a zero-depth
reconstruction measurement. So the arrangement is evidenced on its own, and the
training contract can land as its own reviewable change. Removing it takes
`latent_rollout`, `latent_forecast`, `latent_teacher_loss`, `reconstruct_once`,
`training_auxiliary_loss`, the `processor_depth` branch, `rollout_state`
carry, and `ablate_boundary_forcing` with it; `base.py`, `stepper.py`, and
`utils/output.py` return to main unchanged.

Removed as not-selected: the packed spatial-query encoder (structurally
incomplete -- its outputs are query-blind), the canonical-resampling encoder
(falsified: upsampling one-degree features to a half-degree grid and sampling
back is a smoothing operation, not an inverse), the patch-moment encoder and
its continuous attention decoder (the coarse-latent line, explicitly not a
replacement for the native-grid baseline), both attention-residual decoders and
their correction modules (the report declines to add them: no measured defect
justifies their 4-10x cost), the direct patch decoder, and conservative
restriction (checked in but never validated, explicitly not selected
architecture). `PerceiverConfig` returns to main's, since its widening controls
existed to diagnose the decoder we are replacing.

`DirectPatchEncoder` becomes `NativeProjectionEncoder`. Its one-pixel-patch
restriction and `patch_extent` only existed to keep it honest as a diagnostic
control; it is now the selected encoder, and it takes neither.

Ships the arrangement as `samudra_multi_om4/model.yaml` -- native projection,
geometry sidecar, project-before-masked-resample, zero-initialized latent
residual, width 160 -- plus `train_1deg.yaml`, single-source one-degree with
plain MSE so results compare directly against the recorded single-step
baselines.

Tests cover masked transport actually excluding land (poisoning a land cell
must not move any output value) and the state encoder ignoring forcing.

Test: `uv run pytest -m "not manual and not cuda"` - 362 passed.
Lint: `uvx pre-commit run --all-files` - all hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b4Bk5YegprhJojXj6wbff
)

return torch.where(ctx.label_mask, fts, 0.0)
# TODO(alxmrs): When the output resolution differs from the input (i.e. in a "mix" schedule), we cannot use

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "mix" schedule even still in this codebase? I thought it was removed. If so, we should delete this comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant