Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/source/guides/6_sparsity.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,12 @@ ordinary dense recurrent state before continuation.
from zero, while DASC-WR reconstructs them from a zero-initialized suffix replay of at most the
selected ``Wmax`` tokens. Both retain whole GDN heads, preserve convolution state, and resume with
dense recurrence. KDA and serving-runtime integration are not supported by this initial API.
The initial GDN adapter accepts the ``GatedDeltaNet`` and ``Qwen3NextGatedDeltaNet`` class names
and fails closed for other implementations, even when they expose similarly named decay tensors.
Re-running :func:`~modelopt.torch.sparsity.state_sparsity.calibrate` supersedes a stale policy. A
stale policy remains serializable so it cannot block saving or composing other ModelOpt modes, but
The initial GDN adapter accepts the ``GatedDeltaNet`` and ``Qwen3NextGatedDeltaNet`` base classes,
including ModelOpt-generated dynamic subclasses, and fails closed for unrelated implementations
even when they expose similarly named decay tensors.
Re-running :func:`~modelopt.torch.sparsity.state_sparsity.calibrate` replaces the existing DASC
mode-state entry and supersedes its stale policy without growing the checkpoint history. A stale
policy remains serializable so it cannot block saving or composing other ModelOpt modes, but
:func:`~modelopt.torch.sparsity.state_sparsity.export_policy` rejects it until recalibration.

.. _sparsity-concepts:
Expand Down
16 changes: 12 additions & 4 deletions modelopt/torch/sparsity/state_sparsity/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@

from torch import nn

from modelopt.torch.opt.conversion import apply_mode
from modelopt.torch.opt.conversion import ModeloptStateManager, apply_mode
from modelopt.torch.utils import unwrap_model

from .config import DASCCalibrationMeasurement, DASCConfig
from .conversion import get_attached_dasc_policy
from .conversion import get_attached_dasc_policy, replace_dasc_mode
from .mode import DASCModeRegistry
from .policy import validate_dasc_decay_parameters, validate_dasc_model_structure

Expand All @@ -40,6 +41,7 @@ def calibrate(

``measurements`` must contain exactly one entry for every configured ``Wmax`` candidate.
The largest candidate passing every quality, lifecycle, and storage gate is selected.
Recalibrating replaces the existing DASC mode-state entry in place.

Example::

Expand All @@ -58,10 +60,16 @@ def calibrate(
Returns:
The input model with a serializable DASC policy attached through ModelOpt state.
"""
config_dict = config.model_dump() if isinstance(config, DASCConfig) else config
model = unwrap_model(model, force_unwrap=True)
config_object = config if isinstance(config, DASCConfig) else DASCConfig(**config)
if ModeloptStateManager.is_converted(model, is_root=True) and any(
mode == "dasc" for mode, _ in ModeloptStateManager(model).state_dict()
):
return replace_dasc_mode(model, config_object, measurements)

return apply_mode(
model,
mode=[("dasc", config_dict)],
mode=[("dasc", config_object.model_dump())],
registry=DASCModeRegistry,
mode_kwargs={"measurements": measurements},
)
Expand Down
9 changes: 6 additions & 3 deletions modelopt/torch/sparsity/state_sparsity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def validate_unique_slices(
class DASCConfig(ModeloptBaseConfig):
"""Configuration for GDN decay-aware state checkpoint sparsity."""

model_config = ConfigDict(extra="forbid", validate_assignment=True, protected_namespaces=())
model_config = ConfigDict(protected_namespaces=())

variant: Literal["dasc_nr", "dasc_wr"] = ModeloptField(
default="dasc_wr",
Expand Down Expand Up @@ -195,7 +195,7 @@ def validate_partition(self) -> "DASCLayerPolicy":
class DASCPolicy(ModeloptBaseConfig):
"""Standalone JSON-safe DASC deployment policy."""

model_config = ConfigDict(extra="forbid", validate_assignment=True, protected_namespaces=())
model_config = ConfigDict(protected_namespaces=())

format_version: Literal[1] = 1
variant: Literal["dasc_nr", "dasc_wr"]
Expand All @@ -213,7 +213,10 @@ class DASCPolicy(ModeloptBaseConfig):
preserve_convolution_state: Literal[True]
active_runtime_state: Literal["dense"] = "dense"
model_structure_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
decay_parameters_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
decay_parameters_sha256: str = Field(
pattern=r"^[0-9a-f]{64}$",
description="BF16-canonicalized calibration snapshot retained for provenance.",
)
layers: dict[str, DASCLayerPolicy] = Field(min_length=1)
measurements: list[DASCCalibrationMeasurement] = Field(min_length=1)

Expand Down
33 changes: 31 additions & 2 deletions modelopt/torch/sparsity/state_sparsity/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from pydantic import ValidationError
from torch import nn

from modelopt.torch.opt.conversion import ApplyModeError
from modelopt.torch.opt.conversion import ApplyModeError, ModeloptStateManager
from modelopt.torch.opt.mode import ConvertReturnType, MetadataDict
from modelopt.torch.utils import unwrap_model

Expand Down Expand Up @@ -98,8 +98,8 @@ def restore_dasc_model(model: nn.Module, config: DASCConfig, metadata: MetadataD
def update_dasc_metadata(model: nn.Module, config: DASCConfig, metadata: MetadataDict) -> None:
"""Refresh metadata without making unrelated ModelOpt save or compose paths unusable."""
policy = get_attached_dasc_policy(model)
validate_dasc_model_structure(model, policy)
try:
validate_dasc_model_structure(model, policy)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] Save is now lenient about structural staleness, but restore_dasc_model is still strict — so this change lets ModelOpt write a checkpoint that can never be loaded again.

Moving validate_dasc_model_structure inside the try means mto.save() succeeds with only a warning when the calibrated GDN geometry has changed (exactly what test_structure_staleness_does_not_block_checkpoint_save asserts). But the persisted metadata is the stale policy, and restore_dasc_model (line 93) still calls validate_dasc_model_structure unconditionally and raises ApplyModeError. Since restore_from_modelopt_state replays every mode in order and does not catch per-mode failures, that one raise aborts the whole restore — including any unrelated modes stored alongside DASC in the same modelopt_state.

Why it matters: the stated goal is that "a stale policy remains serializable so it cannot block saving or composing other ModelOpt modes." As written, DASC no longer blocks saving other modes, but it does block restoring them, and the failure now surfaces at load time (possibly a different machine, days later) instead of fail-fast at save. The new test only covers the save half — nothing asserts what happens when that checkpoint is read back.

Suggested fix — make restore consistent with save, and let export_policy remain the single strict gate (it already is, per api.py:86-87):

def restore_dasc_model(model: nn.Module, config: DASCConfig, metadata: MetadataDict) -> nn.Module:
    ...
    try:
        validate_dasc_model_structure(model, policy)
    except ApplyModeError as error:
        warnings.warn(
            f"{error}. The restored DASC policy is stale; re-run calibrate() before deployment",
            stacklevel=2,
        )
    _attach_policy(model, policy)
    return model

If restore must stay strict instead, then the save-side warning should say the DASC entry will not restore, and a round-trip test (mto.savemto.restore) should pin whichever behavior you pick.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in #2380. Restore now attaches structurally stale policy metadata with an actionable warning, so a saved stale checkpoint remains recoverable. Export remains strict and rejects deployment until recalibration; the save/restore/export lifecycle is tested.

validate_dasc_decay_parameters(model, policy)
except ApplyModeError as error:
warnings.warn(
Expand All @@ -110,6 +110,35 @@ def update_dasc_metadata(model: nn.Module, config: DASCConfig, metadata: Metadat
metadata["policy"] = copy.deepcopy(policy.model_dump(mode="json"))


def replace_dasc_mode(
model: nn.Module,
config: DASCConfig,
measurements: Iterable[DASCCalibrationMeasurement | dict],
) -> nn.Module:
"""Replace existing DASC mode state in place with a newly derived policy."""
model = unwrap_model(model, force_unwrap=True)
policy = build_dasc_policy(model, config, measurements)
manager = ModeloptStateManager(model)
manager.update_last_state_before_new_mode(model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] update_last_state_before_new_mode is doing net-negative work here: no new mode is being appended, and the entry it refreshes is overwritten eight lines later.

Two concrete consequences:

  1. Misleading warning. It routes into update_dasc_metadata, which warns "The saved DASC policy is stale; re-run calibrate() before deployment" — emitted from inside calibrate(), the call that is clearing the staleness. test_export_rejects_changed_decay_parameters_and_restore_rejects_structure currently pins that warning, so it reads as intentional, but the advice it gives the user is already being followed.

  2. Recalibration can be blocked by the state it replaces. update_dasc_metadata starts with get_attached_dasc_policy(model), which raises ApplyModeError("Model has no valid attached DASC policy") if _modelopt_dasc_policy is absent or unparseable. That attribute is a plain Python attribute, not a buffer, so it does not travel with state_dict() and is not copied by ModeloptStateManager.transfer_state_dict. A model carrying DASC state but no attached policy therefore cannot be recalibrated at all — even though policy (line 120) has already been successfully rebuilt and is about to replace everything.

Since replace_dasc_mode writes both config and metadata for the DASC entry itself, dropping the call is the simplest fix. If the intent is to keep a non-DASC trailing mode's metadata fresh, guard it so it only runs when the last mode isn't the DASC entry being replaced:

policy = build_dasc_policy(model, config, measurements)
manager = ModeloptStateManager(model)
state = manager.state_dict()
dasc_indices = [index for index, (mode, _) in enumerate(state) if mode == "dasc"]
if not dasc_indices:
    raise ApplyModeError("Cannot replace DASC mode because the model has no DASC state")
if dasc_indices[-1] != len(state) - 1:
    # a later mode owns the tail of the state; refresh it before we rewrite ours
    manager.update_last_state_before_new_mode(model)

Note the ordering dependency if you keep the call as-is: it must stay before the state = manager.state_dict() read only by accident — state_dict() returns the live list, so it happens to work either way. Making that independence explicit (or removing the call) would be clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in #2380. In-place DASC recalibration skips update_last_state_before_new_mode when DASC is already the trailing mode, so missing or stale attached metadata cannot block replacement. Refresh occurs only when a non-DASC mode trails DASC; both paths are tested.

state = manager.state_dict()
dasc_indices = [index for index, (mode, _) in enumerate(state) if mode == "dasc"]
if not dasc_indices:
raise ApplyModeError("Cannot replace DASC mode because the model has no DASC state")

first_index = dasc_indices[0]
state[first_index] = (
"dasc",
{
"config": config.model_dump(),
"metadata": {"policy": policy.model_dump(mode="json")},
},
)
for index in reversed(dasc_indices[1:]):
del state[index]
_attach_policy(model, policy)
return model


def get_attached_dasc_policy(model: nn.Module) -> DASCPolicy:
"""Return the validated policy attached by conversion or restoration."""
model = unwrap_model(model, force_unwrap=True)
Expand Down
5 changes: 5 additions & 0 deletions modelopt/torch/sparsity/state_sparsity/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ def config_class(self) -> type[ModeloptBaseConfig]:
"""Return the validated DASC configuration class."""
return DASCConfig

@property
def next_prohibited_modes(self) -> set[str]:
"""Route repeat calibration through the replacing public API."""
return {"dasc"}

Comment on lines +51 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This routes repeat calibration away from apply_mode, but the resulting error loses the actionable message that convert_dasc_model carefully provides.

assert_compatibility_as_next_mode_of (modelopt/torch/opt/mode.py:266-269) enforces this with a bare assert, so a generic mto.apply_mode(model, mode=[("dasc", cfg)]) on an already-calibrated model now fails with a framework-level AssertionError: Cannot add dasc after dasc! dasc does not allow dasc to be its next mode. — no mention of calibrate(). That's a step back from the ApplyModeError at conversion.py:50-53, which does name the public entrypoint. The new test asserts the property value but not the raise, so nothing pins the user-facing behavior.

Also worth noting: next_prohibited_modes is only checked against last_mode and the top of the export stack, so dasc → some_other_mode → dasc still slips through and appends a second entry. replace_dasc_mode's duplicate collapsing covers that, so this is consistent — just be aware the guard is not a global "at most one DASC entry" invariant.

Consider documenting the intended recovery in the property docstring, or raising the actionable error from convert_dasc_model by detecting existing DASC state there (which would also survive python -O, where assert is stripped entirely).

@property
def convert(self) -> ConvertEntrypoint:
"""Return the DASC calibration entrypoint."""
Expand Down
16 changes: 8 additions & 8 deletions modelopt/torch/sparsity/state_sparsity/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
__all__ = ["analyze_gdn_decay", "compute_gdn_decay_horizons"]

_SUPPORTED_GDN_CLASS_NAMES = frozenset({"GatedDeltaNet", "Qwen3NextGatedDeltaNet"})
# Covers ordinary FP16/BF16 storage casts; the exact retained-head mask below is the semantic gate.
_HORIZON_DTYPE_CAST_RTOL = 0.05
Comment on lines +34 to 35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] The exact-digest check was removed and _HORIZON_DTYPE_CAST_RTOL = 0.05 is now the sole numerical gate on horizon drift — but it is a hand-tuned constant with a thin margin, and the new float16/bfloat16 test cannot exercise it.

Two connected problems:

1. The tolerance is not derived from dtype precision. Horizon error is dominated by softplus(dt_bias + static_gate_input); for strongly negative x, softplus(x) ≈ exp(x), so the relative horizon error is roughly the absolute rounding error of dt_bias, which grows with |dt_bias| under a fixed-mantissa cast. Real Qwen3-Next dt_bias is initialized via inverse-softplus of dt ∈ [1e-3, 0.1], i.e. dt_bias ≈ -7 … -2.3. At |dt_bias| ≈ 12 a bf16 cast (8 significant bits → step 2^-4 at that magnitude) contributes ~3% on its own, and A_log ≈ 2.7 adds ~0.8% via exp(A_log) — roughly 4% against a 5% budget. That margin is undocumented and not much more than 1x, so a legitimate model.to(torch.bfloat16) on a slow-decay checkpoint can trip "DASC policy horizons do not match current decay parameters" at export_policy(), which is the primary deployment path.

2. Nothing in the suite validates the lenient path. test_dtype_cast_preserves_policy_when_the_selected_mask_is_unchanged now parametrizes over bfloat16 and float16, but the fixture uses A_log = torch.zeros(...) and dt_bias = torch.tensor([-2.0, 2.0]). 0.0, -2.0, and 2.0 are all exactly representable in both dtypes, so model.to(dtype) is bit-exact for the horizon computation and the test passes without ever going through a lossy cast. The PR's central claim — "validate FP16/BF16 casts by re-derived horizons plus the exact selected-head mask" — is therefore unverified in either direction (accepts a real cast / rejects real drift).

Suggested fix: make the tolerance dtype-aware rather than a magic 5%, e.g. scale it from torch.finfo(param.dtype).eps and the observed |A_log| / |dt_bias + static_gate_input| maxima, so the bound tracks the actual storage precision instead of assuming a parameter range. And give the fixture values that are not representable in fp16/bf16 (e.g. A_log = torch.tensor([0.1, 0.7]), dt_bias = torch.tensor([-2.3, 1.7])) so the parametrized test actually distinguishes an accepted cast from rejected drift. Keeping the exact retained_heads mask comparison as the semantic gate is the right call — it's the tolerance and its coverage that need shoring up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in #2380. The fixed 5% tolerance is replaced by an analytic interval derived from one rounding step in the current storage dtype. Tests use values that demonstrably change under both FP16 and BF16 casts.



Expand Down Expand Up @@ -62,9 +63,9 @@ def compute_gdn_decay_horizons(


def _is_gdn_module(module: nn.Module) -> bool:
"""Return whether a module has one of the explicitly supported GDN implementations."""
"""Accept supported GDN implementations and their ModelOpt dynamic subclasses."""
return (
type(module).__name__ in _SUPPORTED_GDN_CLASS_NAMES
any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in type(module).__mro__)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The MRO walk accepts any subclass of a supported GDN class, which is broader than the stated intent and silently re-opens the case the old check fenced off.

The docstring and the doc update say "ModelOpt-generated dynamic subclasses," but type(module).__mro__ matches every user-defined subclass too. That's exactly the case the test used to assert as unsupported — UnsupportedGatedDeltaNet(GatedDeltaNet) was rejected before and had to be rewritten as a non-subclass to keep the test passing. A user subclass that overrides how A_log/dt_bias map to decay (or reparameterizes dt_bias) now silently passes the gate, and compute_gdn_decay_horizons will apply the base-class formula -exp(A_log) * softplus(dt_bias + g) anyway — producing a plausible-looking but wrong policy rather than failing closed.

If the goal is specifically to survive ModelOpt dynamic conversion, gate the subclass allowance on that rather than on subclassing in general:

from modelopt.torch.opt.dynamic import DynamicModule

def _is_gdn_module(module: nn.Module) -> bool:
    """Accept supported GDN implementations and their ModelOpt dynamic subclasses."""
    cls = type(module)
    is_supported_class = cls.__name__ in _SUPPORTED_GDN_CLASS_NAMES or (
        isinstance(module, DynamicModule)
        and any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in cls.__mro__)
    )
    return (
        is_supported_class
        and isinstance(getattr(module, "A_log", None), torch.Tensor)
        and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
    )

If accepting arbitrary subclasses is a deliberate choice, the docs at docs/source/guides/6_sparsity.rst:175-177 should say so plainly rather than implying only ModelOpt-generated subclasses are covered — a reader currently gets a stronger fail-closed guarantee than the code provides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in #2380. Ordinary subclasses are no longer accepted. Only exact supported class identities, or ModelOpt DynamicModule subclasses whose MRO contains one of those exact identities, pass. Arbitrary subclass and same-name lookalike tests were added.

and isinstance(getattr(module, "A_log", None), torch.Tensor)
and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
)
Expand Down Expand Up @@ -122,7 +123,7 @@ def _model_structure(modules: dict[str, nn.Module]) -> list[dict[str, object]]:


def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]:
"""Serialize decay parameters at canonical BF16 precision for dtype-stable hashing."""
"""Serialize a compact BF16-canonicalized calibration snapshot for provenance."""
return [
{
"name": name,
Expand Down Expand Up @@ -275,12 +276,11 @@ def validate_dasc_model_structure(model: nn.Module, policy: DASCPolicy) -> None:


def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None:
"""Reject deployment when current decay parameters no longer derive the stored policy."""
modules = _get_gdn_modules(model)
actual = _canonical_sha256(_decay_parameters(modules))
if actual != policy.decay_parameters_sha256:
raise ApplyModeError("DASC policy does not match the model's GDN decay parameters")
"""Reject deployment when current decay parameters no longer derive the stored policy.

Numerical validation deliberately uses re-derived horizons and the exact selected mask rather
than the provenance digest because an FP16 or BF16 storage cast is lossy.
"""
current_horizons = analyze_gdn_decay(
model,
epsilon=policy.epsilon,
Expand Down
67 changes: 58 additions & 9 deletions tests/unit/torch/sparsity/state_sparsity/test_dasc.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@

import modelopt.torch.opt as mto
import modelopt.torch.sparsity.state_sparsity as mtss
from modelopt.torch.opt.conversion import ApplyModeError
from modelopt.torch.opt.conversion import ApplyModeError, ModeloptStateManager
from modelopt.torch.sparsity.state_sparsity.conversion import replace_dasc_mode
from modelopt.torch.sparsity.state_sparsity.mode import DASCModeRegistry


class GatedDeltaNet(nn.Module):
Expand Down Expand Up @@ -218,8 +220,13 @@ class NotGDN(nn.Module):
with pytest.raises(ApplyModeError, match="no supported GDN modules"):
mtss.calibrate(NotGDN(), _config(wmax_candidates=[7]), [_candidate(7)])

class UnsupportedGatedDeltaNet(GatedDeltaNet):
pass
class UnsupportedGatedDeltaNet(nn.Module):
"""Unrelated lookalike must not satisfy the supported-base-class contract."""

def __init__(self):
super().__init__()
self.A_log = nn.Parameter(torch.zeros(2))
self.dt_bias = nn.Parameter(torch.zeros(2))

with pytest.raises(ApplyModeError, match="no supported GDN modules"):
mtss.calibrate(UnsupportedGatedDeltaNet(), _config(wmax_candidates=[7]), [_candidate(7)])
Expand All @@ -232,34 +239,52 @@ class UnsupportedGatedDeltaNet(GatedDeltaNet):

def test_generic_mode_application_reports_missing_measurements():
"""Give generic apply_mode callers an actionable calibration-evidence error."""
assert DASCModeRegistry["dasc"].next_prohibited_modes == {"dasc"}
with pytest.raises(ApplyModeError, match="requires calibration measurements"):
mto.apply_mode(
TinyGatedDeltaNetForCausalLM(),
mode=[("dasc", _config(wmax_candidates=[7]))],
)

model = TinyGatedDeltaNetForCausalLM()
ModeloptStateManager(model, init_state=True)
with pytest.raises(ApplyModeError, match="model has no DASC state"):
replace_dasc_mode(
model,
mtss.DASCConfig(**_config(wmax_candidates=[7])),
[_candidate(7)],
)


def test_public_exports_and_wrapped_model_export():
"""Expose only supported symbols and unwrap recognized parallel wrappers."""
"""Expose only supported symbols and accept wrappers and ModelOpt subclasses."""
model = mtss.calibrate(
TinyGatedDeltaNetForCausalLM(), _config(wmax_candidates=[7]), [_candidate(7)]
)

assert "DASCLayerPolicy" in mtss.__all__
assert "mode" not in mtss.__all__
assert mtss.export_policy(nn.DataParallel(model)) == mtss.export_policy(model)

class _DynamicGatedDeltaNet(GatedDeltaNet):
"""Stand in for the subclass synthesized by ModelOpt dynamic conversion."""

model.linear_attn = _DynamicGatedDeltaNet()
assert mtss.export_policy(model)["layers"]["linear_attn"]["num_heads"] == 2

with pytest.raises(ApplyModeError, match="no valid attached DASC policy"):
mtss.export_policy(TinyGatedDeltaNetForCausalLM())


def test_dtype_cast_preserves_policy_when_the_selected_mask_is_unchanged():
"""Treat an ordinary BF16 cast as equivalent when it preserves the policy mask."""
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_dtype_cast_preserves_policy_when_the_selected_mask_is_unchanged(dtype):
"""Treat ordinary low-precision casts as equivalent when they preserve the policy mask."""
model = mtss.calibrate(
TinyGatedDeltaNetForCausalLM(), _config(wmax_candidates=[7]), [_candidate(7)]
)
policy = mtss.export_policy(model)

model.to(torch.bfloat16)
model.to(dtype)

assert mtss.export_policy(model) == policy

Expand All @@ -273,7 +298,7 @@ def test_export_rejects_changed_decay_parameters_and_restore_rejects_structure()

with torch.no_grad():
model.linear_attn.A_log.add_(1.0)
with pytest.raises(ApplyModeError, match="decay parameters"):
with pytest.raises(ApplyModeError, match="horizons"):
mtss.export_policy(model)
with pytest.warns(UserWarning, match="saved DASC policy is stale"):
mto.modelopt_state(model)
Expand All @@ -282,11 +307,19 @@ def test_export_rejects_changed_decay_parameters_and_restore_rejects_structure()
mto.save(model, checkpoint)
assert checkpoint.tell() > 0

manager_state = ModeloptStateManager(model).state_dict()
manager_state.append(copy.deepcopy(manager_state[0]))
with pytest.warns(UserWarning, match="saved DASC policy is stale"):
model = mtss.calibrate(model, _config(wmax_candidates=[7]), [_candidate(7)])
model = mtss.calibrate(
model,
_config(wmax_candidates=[7], model_revision="revision-2"),
[_candidate(7)],
)
policy = mtss.export_policy(model)
assert policy["model_revision"] == "revision-2"
model_state = copy.deepcopy(model.state_dict())
recalibrated_state = mto.modelopt_state(model)
assert [mode for mode, _ in recalibrated_state["modelopt_state_dict"]] == ["dasc"]
restored = mto.restore_from_modelopt_state(TinyGatedDeltaNetForCausalLM(), recalibrated_state)
restored.load_state_dict(model_state)
assert mtss.export_policy(restored) == policy
Expand Down Expand Up @@ -323,6 +356,22 @@ def test_export_rejects_changed_decay_parameters_and_restore_rejects_structure()
mtss.export_policy(restored)


def test_structure_staleness_does_not_block_checkpoint_save():
"""Keep checkpoint saving available after a calibrated GDN structure changes."""
model = mtss.calibrate(
TinyGatedDeltaNetForCausalLM(), _config(wmax_candidates=[7]), [_candidate(7)]
)
model.linear_attn = GatedDeltaNet(num_heads=3)

checkpoint = io.BytesIO()
with pytest.warns(UserWarning, match="saved DASC policy is stale"):
mto.save(model, checkpoint)

assert checkpoint.tell() > 0
with pytest.raises(ApplyModeError, match="module structure"):
mtss.export_policy(model)


def test_export_rederives_the_selected_head_mask():
"""Reject a self-consistent stored mask that current decay parameters do not derive."""
model = mtss.calibrate(
Expand Down
Loading