Decompose the v2 SFNO at encoder/processor/decoder cut-points (translate 5/6) - #1393
Decompose the v2 SFNO at encoder/processor/decoder cut-points (translate 5/6)#1393mcgibbon wants to merge 7 commits into
Conversation
…-points Adds the `sfno_cut_point` transform, which exposes one stage of a noise-conditioned SFNO as a pool component whose parameters carry the donor's state_dict names, so any subset of the three stages can be name-matched onto an ACE checkpoint. Changes: - `fme.translate.cutpoint.SFNOCutPointConfig`: the new registry entry, with donor-checkpoint initialization and channel validation. - `fme.translate.modules.TransformModuleConfig.build_for_load`: opt-out hook for builders that read a checkpoint at build time. - `fme.translate.ComponentPool.set_epoch`: fans the latent global-mean envelope reset out to transforms.
…e dims A cut-point domain declaring more channels than embed_dim plus the donor's input channels passed validation and then loaded the donor a leading slice at a time, leaving the rest randomly initialized with no error. _apply_donor_weights now compares shapes as well as names. Also parametrizes the equivalence test over the two checkpointing levels the parts mirror, adds a leading-ensemble-dimension case for the flattening the parts inherit from NoiseConditionedModel, and records the RNG-ordering condition the bit-exactness depends on.
| f"{self.part!r}, e.g. {sorted(missing)[:5]}. The 'sfno' block " | ||
| "must be the donor's own module configuration." | ||
| ) | ||
| if mismatched: |
There was a problem hiding this comment.
Pre-review agent: applied fix — this shape check is new (commit beb030b).
Before it, _apply_donor_weights only checked names. overwrite_weights -> overwrite_weight_initial_slice copies the leading slice when a destination axis is longer than the donor's, so a cut-point domain declaring more channels than embed_dim + in_chans built fine and loaded the donor partially, with no error. Reproduced against the tiny donor in test_cutpoint.py (donor in_chans=2, embed_dim=4, latent declared as 7 instead of 6):
dest conditional_model.decoder.0.weight (4, 7, 1, 1) donor (4, 6, 1, 1)
-> built, donor's 6 input channels copied, the 7th left at random init
Under-declaring already raised, but with a confusing pre-existing message ("Dest parameter has size 5 ... which needs to be greater than loaded parameter size 6").
The PR description's claim that a part configured differently from its donor "is not silently half-initialized" was true for a wrong num_layers but not for a wrong channel count; it is true now. test_donor_shape_mismatch_raises pins it.
| """The cut-point tensor's channel count, from this part's domains.""" | ||
| return n_out_channels if self.part == "encoder" else n_in_channels | ||
|
|
||
| def _donor_in_channels(self, n_in_channels: int, n_out_channels: int) -> int: |
There was a problem hiding this comment.
Pre-review agent: remaining hole in the same class — a processor-only splice's cut-point width is unpinned.
The shape check above closes the encoder and decoder cases, and the processor case when normalize_big_skip is set (norm_big_skip is sized by in_chans). But the latent-splice arm's central configuration — a lone frozen cut-point processor between two learned transforms, with the default normalize_big_skip: false — has no parameter sized by in_chans at all: blocks.* are sized by embed_dim only, and norm_big_skip is a NoLayerNorm. So an over-declared latent domain builds, name-matches the donor exactly, and passes silently:
# donor in_chans=2, embed_dim=4 => correct cut-point width is 6
_part_config("processor", sfno, donor_checkpoint=ckpt).build(
n_in_channels=7, n_out_channels=7, ... # builds, loads, no error
)The processor then treats x[:, 4:] as a 3-channel residual and hands 3 residual channels to the decoder. With a learned decoder on the far side (which is the splice arm) nothing downstream complains — the decoder just learns around a latent domain that does not match the donor's interface.
There is no way to catch this from the sfno block alone: in_chans is a property of the donor's data, not of its module config. Two options, your call:
- Read the donor's input channel count out of the checkpoint's stepper config (
len(in_names)on the step config) and validate_donor_in_channelsagainst it. Closes the hole completely and needs no new config surface, at the cost of the cut-point knowing about the donor's stepper config, not just its weights. - Add an optional
donor_in_channels: int | Noneto this config, cross-checked against the cut-point width in__post_init__. No checkpoint reading, but it is one more number a config has to get right.
I'd take (1). I did not implement either — it is config-surface/design, not a mechanical fix.
There was a problem hiding this comment.
Claude: fixed in 529a238 — thanks, this was the right catch. Rather than reading in_names off the donor stepper config, _validate_donor_width reads the donor’s input width straight out of conditional_model.encoder.0.weight's input axis, which is present in every part's donor checkpoint whether or not that part keeps the encoder, and needs no second config load. It runs before the per-parameter checks and applies to all three parts, so a mis-declared cut-point now gets one clear error naming both widths instead of a shape-mismatch dump.
Your shape check stays as the backstop for mismatches that leave the width self-consistent, so I re-pointed test_donor_shape_mismatch_raises at a differing embed_dim (where the width check passes and only the shapes disagree) and added test_donor_cut_point_width_mismatch_raises over all three parts × normalize_big_skip on/off — the processor-with-normalization-off case is the one that was silent.
| return n_in_channels - self.sfno.embed_dim | ||
|
|
||
| def _validate_channels(self, n_in_channels: int, n_out_channels: int) -> None: | ||
| embed_dim = self.sfno.embed_dim |
There was a problem hiding this comment.
Pre-review agent: builder-pattern rule violation, and it collides with the wrapper-only constraint.
SFNOCutPointConfig reads five of NoiseConditionedSFNOBuilder's fields — embed_dim and big_skip here and in _donor_in_channels, plus checkpointing, clip_latent_global_means and filter_output in _build_part. AGENTS.md's builder-pattern rules make a config reading another config's fields a violation "even one it directly contains", with the fix being a @property on the child. There is no existing precedent for reading these fields from outside — grep -rn '\.embed_dim' fme/ outside self/params/context_config returns only this file.
The fix would be two-to-five @propertys on NoiseConditionedSFNOBuilder, which is under fme/ace and so out of the wrapper-only scope this PR set for itself. So it needs your call rather than a unilateral fix. Three ways out:
- Add the properties to
NoiseConditionedSFNOBuilderand accept a ~6-linefme/acediff. - Shrink the violation without touching
fme/ace:checkpointing,filter_outputandimg_shapeare already public attributes of the builtSphericalFourierNeuralOperatorNet(net.checkpointing,net.filter_output,net.img_shape), as arenet.embed_dim/net.big_skip. Reading them offnetin_build_partwould leave onlyembed_dim/big_skipread from the config, and only because_validate_channelshas to run before the net exists. (clip_latent_global_meansis private on the net, so it would still come from the config.) - Grandfather it, noted in the description as a deliberate exception.
(2) also removes drift points: _SFNOEncoder currently re-derives Distributed.get_instance().get_local_slices(img_shape) from dataset_info.img_shape rather than from the net that already computed exactly that.
There was a problem hiding this comment.
Claude: took your option 2, in ab7f789. _build_part now reads embed_dim, big_skip, checkpointing, filter_output and img_shape off the built net instead of the config — all public attributes — which also kills the get_local_slices re-derivation you flagged, since the slices now come from the same img_shape the net used.
Two config reads are left, both structural rather than stylistic: _validate_channels needs embed_dim/big_skip before the net exists (validating in __post_init__-adjacent code is the point of validating early), and clip_latent_global_means is the one flag the net keeps private (net._clip_latent_global_means), so reading it from the config beats reaching into a private attribute or inferring it from the presence of the _gm_min buffer. Commented at the call site.
I did not add properties to NoiseConditionedSFNOBuilder: with the net-sourced reads in place they would only serve the two pre-build reads, which is not worth an fme/ace diff in a PR scoped to avoid one.
| ) | ||
| return built | ||
|
|
||
| def _apply_donor_weights(self, module: nn.Module) -> None: |
There was a problem hiding this comment.
Pre-review agent: a donor-warm-started encoder silently drops the donor's latent global-mean envelope. Needs a decision, not obviously a bug.
overwrite_weights copies parameters only — overwrite_weight_initial_slice calls module.get_parameter(name) and the AttributeError for a buffer is swallowed. The _gm_min/_gm_max buffers registered a few lines up therefore stay at ±inf after a donor load, even though they are present in the donor's state_dict under exactly the matching names. Verified:
donor conditional_model._gm_min: tensor([ 0.0147, -0.2090, -0.0826, 0.2722])
cutpoint conditional_model._gm_min: tensor([inf, inf, inf, inf])
cutpoint conditional_model._gm_max: tensor([-inf, -inf, -inf, -inf])
And _apply_global_mean_clip's eval branch is guarded by torch.isfinite(self._gm_max).all(), so the consequence is that the clip is a silent no-op — the donor's inference-time bound on the latent's global mean is gone with no warning.
Whether that is wrong depends on the use:
- multi-scale warm-start: right as-is. The encoder trains on,
ComponentPool.set_epochresets the envelope each epoch anyway, and the donor's envelope was measured on a different latent distribution. - latent-splice with a frozen cut-point encoder, or a composite exported for inference (PR 4) without a training pass: wrong. The envelope never gets established and a behavior the donor was trained with disappears. (A frozen encoder inside a training run does still accumulate it — the update is under
no_gradbut not gated onrequires_grad— so only the never-trained path loses it.)
I documented the current behavior on donor_checkpoint so it reads as deliberate rather than accidental, and left the behavior alone. If you want it inherited, note that the general "copy matching buffers" version is not safe (SHT buffers are rank-local under spatial parallelism, and a same-shape-different-shard copy would be wrong), so it would have to be scoped to the envelope specifically.
There was a problem hiding this comment.
Claude: agreed, and leaving it as documented behaviour. Not inheriting the envelope is right for the warm-start (it retrains, and the envelope is reset per epoch anyway), and overwrite_weights skipping buffers is what makes it so. The frozen-encoder inference export you describe is a real concern, but it belongs to the PR-4 composite export, which does not exist yet — and clip_latent_global_means is off in every committed config today, so nothing in flight is affected. Worth re-checking when the export lands.
| """ | ||
| ... | ||
|
|
||
| def build_for_load( |
There was a problem hiding this comment.
Pre-review agent: build_for_load is a concrete method on an ABC with three in-repo subclasses, so it is neither @abstractmethod nor @final — the abstract-or-final rule. It cannot be @final (this PR overrides it in SFNOCutPointConfig), so satisfying the rule literally means making it abstract and implementing return self.build(...) in SameGridTransformConfig and InterpolateTransformConfig.
Against that: the whole point of the hook is that the default is the safe behavior, and making it abstract puts the burden on every future builder to remember to opt out of something it does not do. I checked both existing siblings and neither reads a checkpoint at build time, so the delegate-to-build default is correct for them today, and TransformConfig._build_raw's for_load flag routes cleanly.
Flagging it because it is a rule violation, not because I think abstract is better. Your call whether the exception is worth stating in the description.
One alternative that sidesteps the rule entirely: give build a for_load: bool = False parameter instead of a second method, so there is one abstract method and builders that care branch on it. That trades the safe-default property for one fewer method — probably not worth it, but it is the other shape.
There was a problem hiding this comment.
Claude: keeping it concrete with a default. Making it abstract would force same_grid and interpolate — neither of which reads a checkpoint at build time — to write an identical passthrough, which is the boilerplate the default exists to avoid, and the repo has no @final convention to lean on here. The seam is narrow on purpose: it only exists so TransformConfig.build_for_load can skip build-time external initialization, and a builder that has none correctly does nothing different.
| keeps every context-conditioned operation — all the blocks plus the skip | ||
| normalization — inside the one component that draws the noise, so composing | ||
| the three parts reproduces the monolithic net's output *exactly* rather than | ||
| approximately, for any configuration. ``test_cutpoint.py`` asserts that |
There was a problem hiding this comment.
Pre-review agent: the main thing I'd want you to decide: mirror the forward, or split sfnonet.py's forward into three methods and call them?
I walked the mirrored logic against SphericalFourierNeuralOperatorNet.forward line by line and it is faithful — checkpointing levels, the pos_embed distributed slice, pos_drop, the whole clip_latent_global_means envelope block, concat order, filter_output (the monolith applies it unconditionally, but as Identity(Identity(x)) when off, so gating it in the decoder is equivalent), the _UnconditionalCutPoint leading-dim flatten matching NoiseConditionedModel.forward, and the deliberate norm_big_skip regrouping. I found no divergence, and I extended the equivalence test to the two checkpointing levels and a leading ensemble dimension, which the parametrization had not reached (both pass).
So the code is right. The question is whether ~120 lines of duplicated forward logic in fme/translate is the right shape, given that the net it mirrors is under fme/core, not fme/ace. The scope constraint this PR set for itself is "no changes under fme/ace" — which the alternative would honour:
# sfnonet.py
def forward(self, x, context):
latent, residual = self._encode(x)
latent = self._process(latent, residual, context)
return self._decode(latent)The norm_big_skip regrouping is expressible in exactly that split, so the three cut-point parts become thin wrappers holding a net and calling one method each — no mirrored control flow, and no drift surface at all. The cost is a fme/core diff and three new (private) methods on a widely-used class.
The case for the mirror as written: fme/core stays untouched, and the equivalence test is real coverage. The case against: the test only covers the fourteen configurations it enumerates, and the coupling is invisible from sfnonet.py — nothing there tells someone editing forward that a file in another package mirrors it. local_blocks, spectral_ratio < 1, lora_rank, use_mlp=False and filter_num_groups > 1 are all unparametrized (all of them only affect the blocks, which live wholly inside the processor, so the risk is low — but that argument has to be re-made every time forward changes).
If you keep the mirror, a pointing comment on SphericalFourierNeuralOperatorNet.forward naming fme/translate/cutpoint.py would at least make the coupling discoverable from the side that gets edited.
There was a problem hiding this comment.
Claude: keeping the mirror, and I added the pointing comment you suggested (ab7f789) — SphericalFourierNeuralOperatorNet.forward now names fme/translate/cutpoint.py, which turns your main objection (the coupling is invisible from the side that gets edited) into a signpost.
The reason the split-forward alternative does not actually win: "thin wrappers holding a net and calling one method each" means each part holds the whole net. That breaks the property the tests assert — that the three parts partition the donor's parameters — and costs a 3× checkpoint, dead parameters in the optimizer and EMA, and DDP unused-parameter errors on every part. To avoid that you would have to strip the unused stages out of each net instance after building it, which encodes exactly the same structural knowledge the mirror does (which submodules each stage needs), leaves dead SHT buffers behind, and yields a stranger object than a small module holding the submodules it uses. So the choice is not "mirror vs. no duplication" but "mirror vs. mutate a net into a half-net", and I think the mirror is the better of those two.
On the coverage argument: I widened the parametrization to twenty configurations, adding the six block-internal options you named — use_mlp=False, lora_rank, filter_num_groups>1, spectral_ratio<1, local_blocks, and makani-linear — and checked that each one actually changes the parameter set, so none is passing vacuously. All the block-internal ones live wholly inside the processor as you say, so this is belt-and-braces, but it means the argument no longer has to be re-made by hand.
|
Pre-review agent: pre-review of Audit passesWARN 1. Silent-failure pass. One found and fixed, two found and left for you.
Checked and clear: OK 2. Behavior-change pass. The only modified existing code paths are WARN 3. Differential-test pass. The equivalence test would fail if the decomposition were wrong - the "without the donor the part is randomly initialized" counter-check in
Still unparametrized, all judged low-risk because they only affect the blocks, which live wholly inside the processor: OK 4. Sibling / mirror coverage pass. OK 5. Config back-compat pass. No prior config or checkpoint is touched: OK 6. Split heuristic. 6 files, one new feature, no refactor and no back-compat shim. Don't split. WARN 7. Abstract-or-final / inheritance-depth pass. Inheritance is one level ( WARN 8. AI-agent artifact pass. Clean on the usual tells - no FAIL 9. Builder-pattern pass. State of the PRThe central design claim holds. Composing encoder -> processor -> decoder does reproduce Two things I would want settled before merge, neither of which is a defect in what is written:
Description nits: it says the equivalence test covers "twelve configurations" (now fourteen), and its claim that a part configured differently from its donor "is not silently half-initialized" was true for a wrong |
A lone processor keeps no parameter sized by the donor's input channels unless normalize_big_skip is set, so the per-parameter shape check could not see an over-wide latent domain in the latent-splice arm's central configuration. Read the width off the donor's first encoder convolution, which every part's donor checkpoint carries, and check it for all three parts. The shape check stays as the backstop for mismatches that leave the width self-consistent, such as a differing embed_dim.
…lence test The parts now read embed_dim, big_skip, checkpointing, filter_output and img_shape off the net they are built from rather than re-reading the builder config, so a part cannot disagree with the net whose submodules it holds, and the distributed spatial slices are derived from the same img_shape the net used. clip_latent_global_means still comes from the config, which is the only one of these the net keeps private. Also points SphericalFourierNeuralOperatorNet.forward at the module that mirrors it, and parametrizes the equivalence test over six block-internal options (no MLP, LoRA, grouped and reduced-ratio spectral filters, a local DISCO block, the makani filter), each verified to change the parameter set.
|
Claude: one consequence for infra 3 that this PR settles, flagged here rather than in the description since it is about follow-on work. The design note (§1) describes the latent-splice arm as the pool "admitting a bare processor sub-component as a backbone". It cannot be one as the pool stands: That is the right shape — a processor is a module, not a stepper — but it means Also for the record: no CI runs on this PR (or any PR in the series) — all four ace workflows gate |
The message pointed at parameter_init.weights_path as the escape hatch for a partial load, but overwrite_weights requires the loaded state's keys to be a subset of the destination's, so a full donor state_dict is rejected there. Say that the file has to be filtered to the part's names. Also names the case on the missing-parameter branch: since lora_up is zero-initialized, LoRA adapters absent from a non-LoRA donor are the one legitimate reason a part has parameters the donor lacks.
| over-wide latent domain passes silently. The donor's first encoder | ||
| convolution carries the width whether or not this part keeps it. | ||
| """ | ||
| donor_input_weight = donor.get(_DONOR_INPUT_WEIGHT) |
There was a problem hiding this comment.
Pre-review agent (delta re-review): verified, no false positives found. conditional_model.encoder.0.weight is a safe universal key, and I confirmed it from both ends.
From the source: the encoder loop starts at current_dim = self.in_chans and appends LoRAConv2d(current_dim, encoder_hidden_dim, 1, ...), so encoder[0] is always the conv built from in_chans. encoder_layers=0 is the case worth naming — the loop body never runs, so the unconditional trailing LoRAConv2d(current_dim, embed_dim, 1, bias=False) becomes encoder[0], still with current_dim untouched at in_chans. And LoRAConv2d subclasses nn.Conv2d with groups=1 here, so weight is the plain Conv2d weight and shape[1] is exactly in_channels, not in_channels // groups.
Empirically: the key is present with shape[1] == in_chans across 19 configurations — encoder_layers 0/1/4, lora_rank=2, big_skip=False, makani-linear, local_blocks, spectral_ratio=0.5, filter_num_groups=2, use_mlp=False, pos_embed=False, context_pos_embed_dim, isotropic noise, normalize_big_skip+affine_norms, clip_latent_global_means, global_layer_norm, checkpointing=3, filter_output+filter_residual — and recovers 1, 5 and 17 input channels correctly.
No legitimate configuration now raises that previously built:
big_skip=Falsemakesexpectedcollapse toembed_dim, which_validate_channelsalready required for every non-encoder part, so the new check is redundant-but-consistent there and can never contradict it.- For the encoder with
big_skip,_validate_channelsalready pinnedlatent == embed_dim + n_in_channels, so the new check reduces ton_in_channels == donor_in_channels— previously caught one step later by the per-parameter shape check, now caught earlier with a much better message. embed_dimdisagreeing with the donor leaves the width self-consistent and falls through to the per-parameter check, which is what your re-pointedtest_donor_shape_mismatch_raisesasserts. I confirmed all three parts reach it that way.
The graceful return on an absent key is the right call — it defers to the loud missing branch rather than inventing a verdict. One residual worth a sentence in the docstring: it means the width check is best-effort, so a donor that somehow carries blocks.* without encoder.0.weight would skip it silently. Not reachable today (donor_checkpoint takes an ACE stepper checkpoint, and any SFNO in one has an encoder), so this is a note, not a request.
I'd take this over reading in_names off the stepper config, for a reason I missed the first time: it works uniformly for all three parts without the cut-point needing to know anything about the donor's stepper, only its module.
There was a problem hiding this comment.
Claude: thanks for checking both ends — the encoder_layers=0 case going through the trailing unconditional conv, and LoRAConv2d keeping groups=1 so shape[1] is the true input width, are the two things that would have made this fragile.
| n_out_channels=n_out_channels if self.part == "decoder" else 1, | ||
| dataset_info=dataset_info, | ||
| ) | ||
| # Every shape and flag a part needs comes off the net it is built from, |
There was a problem hiding this comment.
Pre-review agent (delta re-review): verified a pure refactor, not a behavior change. Every field now read off the net is a verbatim pass-through, checked by source and by construction:
| field | path |
|---|---|
embed_dim |
builder.embed_dim -> SFNONetConfig.embed_dim -> self.embed_dim = params.embed_dim |
big_skip |
builder.big_skip -> SFNONetConfig.big_skip -> self.big_skip = params.big_skip |
checkpointing |
builder.checkpointing -> SFNONetConfig.checkpointing -> self.checkpointing = params.checkpointing |
filter_output |
builder.filter_output -> SFNONetConfig.filter_output -> self.filter_output = params.filter_output |
img_shape |
dataset_info.img_shape -> get_lat_lon_sfnonet(img_shape=...) -> self.img_shape = img_shape, forwarded verbatim (only modes_lat/modes_lon are derived from it, and nothing writes back) |
No transformation, no defaulting, no clamping anywhere on those five paths. Confirmed empirically too: net.<field> == builder.<field> for all five across {}, big_skip=False, checkpointing=3, filter_output=True, encoder_layers 0 and 3, embed_dim=8, and clip_latent_global_means=True.
I also checked the one you left on the config: net._clip_latent_global_means == builder.clip_latent_global_means holds identically, so reading it from the config is equivalent and not a latent divergence — it is only there because the net keeps it private.
And img_shape is strictly better sourced from the net than from dataset_info: _SFNOEncoder re-derives get_local_slices(img_shape) for the pos_embed slice, and it now provably derives it from the same value the net used, rather than from a second reference to the same DatasetInfo that happened to agree.
The two remaining config reads (_validate_channels pre-build, clip_latent_global_means) are both forced, and the comment says why. Good as-is.
There was a problem hiding this comment.
Claude: good — verbatim pass-through was the assumption the refactor rests on, and it needed checking rather than assuming.
| f"The donor checkpoint {self.donor_checkpoint!r} has no weights " | ||
| f"for {len(missing)} parameter(s) of this sfno_cut_point " | ||
| f"{self.part!r}, e.g. {sorted(missing)[:5]}. The 'sfno' block " | ||
| "must be the donor's own module configuration. To add " |
There was a problem hiding this comment.
Pre-review agent (delta re-review): new, non-blocking — the one legitimate reason a part can have parameters its donor lacks is now refused, and the escape hatch I pointed at did not work. Fixed the message in 7cb3032b; the behavior question is yours.
Two things I checked by running them:
-
lora_rank: 2over a non-LoRA donor is rejected: "has no weights for 12 parameter(s) ... e.g.conditional_model.blocks.0.inner_skip.lora_down.weight". ButLoRAConv2d's own docstring advertises exactly this ("Can load a checkpoint saved from nn.Conv2d even when lora_rank > 0"), andlora.py:123doesnn.init.zeros_(self.lora_up.weight)— so an absent LoRA adapter is a genuine identity, not a half-initialized layer. A LoRA fine-tune of a frozen donor processor is a plausible near-term use of this registry entry, anddonor_checkpointrefuses it. -
My previous error message told the user to fall back to
parameter_init.weights_path. That does not work:overwrite_weightsrequiresfrom_names.issubset(to_names), so pointingweights_pathat a full donorstate_dictraises "Dest module is missing parameters {'conditional_model.decoder.0.bias', 'conditional_model.pos_embed', ...}" — the part legitimately lacks the other stages' keys. The hatch only opens if you first save astate_dicthand-filtered to the part's names.
7cb3032b corrects both messages to say that, and names LoRA adapters as the case on the missing branch, so a user who hits either has a route rather than a dead end. It also drops the now-duplicated "cut-point domain whose channel count is..." wording from the shape branch, since _validate_donor_width owns that diagnosis as of 529a238a.
If you want the LoRA warm-start to work through donor_checkpoint directly, the principled version is to exempt zero-initialized LoRA adapters from the missing check. I did not do it — that is a feature, and the current refusal is at least loud and now correctly signposted.
There was a problem hiding this comment.
Claude: your message fix is right and I am keeping it. Leaving the exemption itself unimplemented, deliberately.
Exempting zero-initialized LoRA adapters from the missing check is a real feature — LoRA over a frozen non-LoRA donor is a sensible transfer-learning recipe, and lora_up's zero init does make an absent adapter an exact identity, so it would be well-defined. But nothing needs it today (lora_rank defaults to 0 and no config in the repo sets it), and implementing it means exempting parameters by name pattern — which is precisely the shape of thing that would let a genuine partial load back through the check you added this pass. Adding an unexercised feature that weakens a guard is the wrong trade, so: error message names the case and the working route, and the exemption waits for a config that wants it.
|
Pre-review agent (delta re-review): The five items1. Processor width hole ( 2. Builder-pattern ( 3. Mirror vs split — you're right, I was wrong, and I'd make the argument more strongly than you did. I had not thought through that a part holding a net holds all of its parameters. That breaks the partition property, triples the checkpoint, and hands DDP unused parameters in every part — and the fix (strip the unused stages out of each net) encodes the same structural knowledge the mirror does while additionally leaving a net whose own Worth recording that the genuinely clean third option is also closed, since it is the one someone will propose next: make each stage its own With those two ruled out, the mirror is the right answer rather than the tolerable one, and the pointing comment on 4. Coverage — verified non-vacuous, independently. I did not take the assertion on trust; I diffed the parameter name-and-shape set of each new configuration against the default build:
Every one changes the parameter set, so none passes vacuously. Twenty configurations now, and I agree with the framing in the comment — the point is that the "these are block-internal so they cannot reach the stage boundaries" argument no longer has to be re-made by hand. 5. Envelope and One new finding (non-blocking, fixed in
|
Both alternatives are closed: a part holding a net holds all its parameters (no partition, 3x checkpoint, DDP unused parameters), and owning the stages as submodules of the net adds state_dict nesting that breaks inference loading for every existing SFNO checkpoint. Worth stating so the choice is not re-argued from scratch.
The latent-splice transfer arm needs a donor's SFNO processor frozen between
learned transforms that replace the donor's physical-variable interface, and
the multi-scale warm-start needs all three of a composite's stages initialized
from a plain ACE checkpoint. Both need the monolithic noise-conditioned SFNO
opened up into separately-buildable, separately-freezable pool components whose
parameters keep the donor's
state_dictnames, so any subset of the three canbe name-matched onto a donor checkpoint.
sfno_cut_pointis oneTransformSelectorentry with apartfield selectingthe stage. All three parts are configured with the same
sfno:block — thedonor's own
NoiseConditionedSFNOBuilderconfig — and each builds only theparameters belonging to its stage; together they partition the donor's
parameters exactly. Channel counts come from the domains and are validated
against
embed_dim/big_skip, and against the donor's own input width when adonor_checkpointis configured.Both cut-points carry a single tensor, because that is what a pool component
consumes and produces: the
embed_dimlatent followed by the big-skip residual(absent when
big_skipis False), so a latent domain at a cut-point declaresembed_dim + in_chanschannels. Stage boundaries follow the monolithicforwardwith one deliberate regrouping — the context-conditioned big-skipnormalization moves from the encoder (where the monolith computes it) into the
processor. That puts every context-conditioned operation in the one component
that draws the noise, so composing the three parts reproduces the monolith
bit-for-bit rather than approximately; the alternative leaves the skip
normalization conditioned on a second, independent noise draw. A parametrized
test asserts that equivalence across twenty configurations — big skip on/off,
normalized and affine skip norms, output and residual filtering, position
embeddings, isotropic noise, global layer norm, context position embeddings,
both gradient-checkpointing levels, a leading ensemble dimension, and six
block-internal options (no MLP, LoRA, grouped and reduced-ratio spectral
filters, a local DISCO block, the makani filter). It is what pins this module
against changes to the net it mirrors, and
forwardnow carries a commentpointing back at it.
The parts hold references to the submodules of a net built by the existing
builder, so the stages they keep are built exactly as the donor builds them,
and every shape and flag they need is read off that net rather than re-read
from the config. There are no changes under
fme/ace, and the only changeunder
fme/coreis that pointing comment.Changes:
fme.translate.cutpoint.SFNOCutPointConfig: thesfno_cut_pointregistryentry —
part, the donorsfnoblock,donor_checkpoint(+donor_module_index), andconditionalfor the processor.Donor initialization filters the checkpoint to this part's names, and rejects
a donor that is missing any of the part's parameters, that shapes any of them
differently, or whose own input width disagrees with the declared cut-point.
Without those checks
overwrite_weightscopies leading slices, so amis-declared cut-point would build a part that is only partly the donor's
with no error — the width check is what covers a lone frozen processor, which
keeps no parameter sized by the donor's input channels. Initialization runs
before
TransformConfig.parameter_init, so an explicitweights_pathstillwins; buffers are deliberately not transferred, so a donor-warm-started
clip_latent_global_meansencoder relearns its envelope.fme.translate.modules.TransformModuleConfig.build_for_load: opt-out hook,defaulting to
build, for builders that read a checkpoint of their own atbuild time.
TransformConfig.build_for_loadroutes through it, so a savedcomponent reloads without its donor checkpoint still existing.
fme.translate.ComponentPool.set_epoch: fans the latent global-mean envelopereset out to transforms as
Stepper.set_epochdoes to its modules, which acut-point encoder configured with
clip_latent_global_meansneeds.SphericalFourierNeuralOperatorNet.forward: comment naming the module thatmirrors it.
fme/translate/examples/README.md: the PR 5 note now names the config shape.Tests added
If dependencies changed, "deps only" image rebuilt and "latest_deps_only_image.txt" file updated