Port the native-grid SamudraMulti model layer onto main - #840
Conversation
…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
There was a problem hiding this comment.
💡 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".
| if self.native_projection: | ||
| return DirectPatchEncoder( |
There was a problem hiding this comment.
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 👍 / 👎.
| if correction_mask is not None and correction_mask.shape != x.shape[-2:]: | ||
| correction_mask = None |
There was a problem hiding this comment.
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
Code review pass — two real defects fixed in 7f4963cI 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. Now gated behind an explicit 2. 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 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
@jder — three pre-existing issues on your branch, unchanged hereNot touched, since they behave differently in the context of your full branch and you should decide:
Also |
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
Second review pass — two more fixed in 1b48790, one finding doesn't reproduceSame theme as the last round: unported configuration accepted and quietly ignored instead of refused. Fixed: Fixed: Does not reproduce: Also correcting the record from round one: Still deliberately unchanged (all previously flagged to @jder above): the 363 passed, pre-commit clean. |
| from samudra.utils.distributed import all_reduce_mean, is_main_process | ||
| from samudra.utils.wandb import Metrics, MetricsDict, WandBLogger | ||
|
|
||
|
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
It's possible that these val aggregators are complements to the new spectral metrics and thus should be structured as they are.
| 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"]: |
There was a problem hiding this comment.
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.
| return torch.nn.Conv2d(in_ch, out_ch, kernel_size=1, padding="same") | ||
|
|
||
|
|
||
| def _normalization(norm: str, channels: int) -> torch.nn.Module | None: |
| query_coordinates = make_3d_coordinate_grid(output_lat, output_lon) | ||
| query_coordinates = rearrange(query_coordinates, "d h w -> (h w) d") |
…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
|
Thanks — all addressed in 55cf656. Replies below, including two where I pushed back.
|
alxmrs
left a comment
There was a problem hiding this comment.
Self review, mostly understanding.
| 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. |
There was a problem hiding this comment.
This type of reflection is not helpful in documentation.
| ) | ||
|
|
||
|
|
||
| class ContinuousResampleAttentionResidualDecoder(nn.Module): |
There was a problem hiding this comment.
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 |
| return patch_h, patch_w | ||
|
|
||
|
|
||
| def pos_scale_enc_for_grid( |
There was a problem hiding this comment.
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]]: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Is "mix" schedule even still in this codebase? I thought it was removed. If so, we should delete this comment.
Why
Jesse's SamudraMulti rebuild lives on
codex/decoder-root-cause-report— 217 commits ahead of the merge-base, ~10k lines ofsrc/changes, no PR.mainstill 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.mainSamudraMulti, 1-step, full dataWe want that architecture on
mainso 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:
mainDataSource+DatasetSpec+OceanDatacodex/decoder-root-cause-reportCanonicalDataset+CanonicalReaderCanonicalSource+DataLayout+BatchPreprocessor#823's own summary says it replaces "the overlapping
DatasetSpec/CanonicalDataset/OceanDataabstractions." Resolving ~900 lines of conflict ontoCanonicalDatasetwould 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, ordata_backend; it reaches the data pipeline only throughGridContext, 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, plusreconstruct_once(zero-depth inverse) andlatent_forecast(encode once, advance N steps in latent space, decode at the requested lead).modules/encoder.py—DirectPatchEncoderwithnative_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. AlsoPatchMomentEncoderandCanonicalResampleEncoderfor the coarse-latent line.modules/decoder.py—ResampleProjectionDecoderwithproject_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.py—BoundaryEncoder(one time-aligned forcing state per processor call) andProcessorGeometryConditioner(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— optionalinput_maskso interpolation weights renormalize per channel.stepper.py/utils/output.py—initialize_rolloutplus arollout_statecarried across inference chunks, so a latent model never decode/re-encodes at a chunk boundary.config.py—PerceiverConfig/EncoderConfig/DecoderConfig/SamudraMultiConfigspliced onto main's, plus theCheckpointing/LayerCheckpointingsplit making"selective"a SamudraMulti-only mode.Please look at
SamudraMultiConfig.buildsignature. Droppedstatic_data_for_corrector(correctors deleted in Delete correctors and static-data plumbing #822) and the unusedtensor_map/normalize/dataset_specparams;CanonicalDataset→DataSource.reject_selective_checkpointingadded toSamudraConfigandSamudraMiniConfigso the widenedCheckpointingliteral can't reach a UNet that doesn't understand it.dstand 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, sodsthas 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.pycontract (freeze_model_parameters/frozen_model_prefixes,train_processor_depthscycling,validation_boundary_ablations),identity.py, model and train configs, anddst. 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 xfaileduvx 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