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
9 changes: 8 additions & 1 deletion docs/source/guides/6_sparsity.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ derives one static decay horizon per complete GDN head from
``A_log`` and ``dt_bias``, then selects the largest caller-evaluated ``Wmax`` that passes every
configured quality, lifecycle, and physical-storage gate. ``Wmax`` may be any positive integer.
The measurements are evidence inputs produced by a caller-owned paired evaluation; this API does
not run the dense-versus-recovery suffix evaluation itself.
not run the dense-versus-recovery suffix evaluation itself. ``perplexity_retention`` is defined as
``dense_perplexity / DASC_perplexity`` (equivalently ``exp(dense_NLL - DASC_NLL)``), so higher is
better and values above one are valid.

The initial API exports policy metadata only. It does not change model execution, quantize state,
pack ragged checkpoints, replay a suffix, or add a linear-attention kernel. A serving integration
Expand Down Expand Up @@ -170,6 +172,11 @@ 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
:func:`~modelopt.torch.sparsity.state_sparsity.export_policy` rejects it until recalibration.

.. _sparsity-concepts:

Expand Down
26 changes: 22 additions & 4 deletions modelopt/torch/sparsity/state_sparsity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,25 @@

"""Decay-aware sparsity policies for persisted recurrent state."""

from . import mode
from .api import *
from .config import *
from .policy import *
from . import mode # imported for mode-registration side effects
from .api import calibrate, export_policy
from .config import (
DASCCalibrationMeasurement,
DASCConfig,
DASCLayerPolicy,
DASCPolicy,
DASCQualityMeasurement,
)
from .policy import analyze_gdn_decay, compute_gdn_decay_horizons

__all__ = [
"DASCCalibrationMeasurement",
"DASCConfig",
"DASCLayerPolicy",
"DASCPolicy",
"DASCQualityMeasurement",
"analyze_gdn_decay",
"calibrate",
"compute_gdn_decay_horizons",
"export_policy",
]
13 changes: 11 additions & 2 deletions modelopt/torch/sparsity/state_sparsity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@
import math
from typing import Literal

from pydantic import Field, field_validator, model_validator
from pydantic import ConfigDict, Field, field_validator, model_validator

from modelopt.torch.opt.config import ModeloptBaseConfig, ModeloptField

__all__ = [
"DASCCalibrationMeasurement",
"DASCConfig",
"DASCLayerPolicy",
"DASCPolicy",
"DASCQualityMeasurement",
]
Expand All @@ -34,7 +35,11 @@ class DASCQualityMeasurement(ModeloptBaseConfig):
"""Quality and lifecycle measurements for one calibration slice."""

slice_id: str = Field(min_length=1)
perplexity_retention: float = Field(gt=0.0, le=1.0, allow_inf_nan=False)
perplexity_retention: float = Field(
gt=0.0,
allow_inf_nan=False,
description="Dense perplexity divided by DASC perplexity; values above one are valid.",
)
top1_agreement: float = Field(ge=0.0, le=1.0, allow_inf_nan=False)
finite_continuation_logits: bool = Field(strict=True)
retained_state_exact: bool = Field(strict=True)
Expand Down Expand Up @@ -67,6 +72,8 @@ 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=())

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] extra="forbid" and validate_assignment=True are already inherited — only protected_namespaces=() is new.

ModeloptBaseConfig sets exactly those two (modelopt/torch/opt/config.py:74):

model_config = PyDanticConfigDict(extra="forbid", validate_assignment=True)

Pydantic v2 merges a subclass's model_config into the parent's, so re-stating them here is a no-op today but silently pins DASC to the current values — if the base class ever changes one, DASCConfig/DASCPolicy won't follow, and the divergence is invisible at the call site. Narrowing it to just the new setting keeps the inheritance intact and makes the Pydantic-compat intent obvious:

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

Same on line 198 for DASCPolicy.

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 signed commit cfd7053 via #2379. Both subclasses now set only ConfigDict(protected_namespaces=()), so extra and validate_assignment continue to inherit from ModeloptBaseConfig.


variant: Literal["dasc_nr", "dasc_wr"] = ModeloptField(
default="dasc_wr",
description="Use zero recovery (DASC-NR) or suffix replay recovery (DASC-WR).",
Expand Down Expand Up @@ -188,6 +195,8 @@ def validate_partition(self) -> "DASCLayerPolicy":
class DASCPolicy(ModeloptBaseConfig):
"""Standalone JSON-safe DASC deployment policy."""

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

format_version: Literal[1] = 1
variant: Literal["dasc_nr", "dasc_wr"]
recovery: Literal["zero", "suffix_replay"]
Expand Down
26 changes: 19 additions & 7 deletions modelopt/torch/sparsity/state_sparsity/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
"""ModelOpt conversion and restoration for DASC policy metadata."""

import copy
import warnings
from collections.abc import Iterable

from pydantic import ValidationError
from torch import nn

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

from .config import DASCCalibrationMeasurement, DASCConfig, DASCPolicy
from .policy import build_dasc_policy, validate_dasc_decay_parameters, validate_dasc_model_structure
Expand All @@ -33,16 +35,22 @@


def _attach_policy(model: nn.Module, policy: DASCPolicy) -> None:
"""Attach a JSON-safe DASC policy to an unwrapped model."""
setattr(model, _DASC_POLICY_ATTRIBUTE, policy.model_dump(mode="json"))


def convert_dasc_model(
model: nn.Module,
config: DASCConfig,
*,
measurements: Iterable[DASCCalibrationMeasurement | dict],
measurements: Iterable[DASCCalibrationMeasurement | dict] | None = None,
) -> ConvertReturnType:
"""Analyze GDN decay and attach the selected DASC policy without changing execution."""
if measurements is None:
raise ApplyModeError(
"DASC requires calibration measurements; use "
"modelopt.torch.sparsity.state_sparsity.calibrate(model, config, measurements)"
)
policy = build_dasc_policy(model, config, measurements)
_attach_policy(model, policy)
return model, {"policy": policy.model_dump(mode="json")}
Expand Down Expand Up @@ -88,19 +96,23 @@ def restore_dasc_model(model: nn.Module, config: DASCConfig, metadata: MetadataD


def update_dasc_metadata(model: nn.Module, config: DASCConfig, metadata: MetadataDict) -> None:
"""Refresh serialized metadata from the immutable attached DASC policy."""
try:
policy = DASCPolicy(**getattr(model, _DASC_POLICY_ATTRIBUTE))
except (AttributeError, TypeError, ValidationError) as error:
raise ApplyModeError("Model has no valid attached DASC policy") from error
"""Refresh metadata without making unrelated ModelOpt save or compose paths unusable."""
policy = get_attached_dasc_policy(model)
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 ModeState] validate_dasc_model_structure still raises here, so save/compose is only partly non-fatal.

The docstring now promises to "refresh metadata without making unrelated ModelOpt save or compose paths unusable", and the decay-parameter check was correctly demoted to a warning. But the structure check on this line is outside the try/except and still raises ApplyModeError, which propagates out of mto.save() / mto.modelopt_state() / any subsequent apply_mode() (via update_last_state_before_new_mode).

Why it matters — the raise is reachable without the user doing anything wrong:

  • _get_gdn_modules raises "no supported GDN modules" whenever the GDN class no longer matches exactly (see the _DynamicGatedDeltaNet case on policy.py:66).
  • Any structural edit to the GDN stack (layer count / head count) makes the model unsavable for every mode in the state, not just DASC.

A stale policy should never be able to destroy a checkpoint containing other modes' state.

Suggested fix — treat structure staleness the same way as decay staleness: warn on save, stay strict on export.

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)
    try:
        validate_dasc_model_structure(model, policy)
        validate_dasc_decay_parameters(model, policy)
    except ApplyModeError as error:
        warnings.warn(
            f"{error}. The saved DASC policy is stale; re-run calibrate() before deployment",
            stacklevel=2,
        )
    metadata.clear()
    metadata["policy"] = copy.deepcopy(policy.model_dump(mode="json"))

export_policy() already calls both validators directly, so deployment export stays fail-closed. Worth adding a negative test that mto.save() still succeeds when the GDN structure changed after calibration.

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 signed commit cfd7053 via #2379. Both structure and decay validation now run inside the non-fatal stale-policy handler used by save and composition; export_policy() still calls both validators directly and remains strict. A new test changes the GDN head count, verifies mto.save() succeeds with a stale-policy warning, and verifies export rejects the structure.

validate_dasc_decay_parameters(model, policy)
try:
validate_dasc_decay_parameters(model, policy)
except ApplyModeError as error:
warnings.warn(
f"{error}. The saved DASC policy is stale; re-run calibrate() before deployment",
stacklevel=2,
)
metadata.clear()
metadata["policy"] = copy.deepcopy(policy.model_dump(mode="json"))


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)
try:
return DASCPolicy(**getattr(model, _DASC_POLICY_ATTRIBUTE))
except (AttributeError, TypeError, ValidationError) as error:
Expand Down
5 changes: 0 additions & 5 deletions modelopt/torch/sparsity/state_sparsity/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,6 @@ def config_class(self) -> type[ModeloptBaseConfig]:
"""Return the validated DASC configuration class."""
return DASCConfig

@property
def next_prohibited_modes(self) -> set[str]:

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 ModeState] Dropping next_prohibited_modes makes dasc stack without bound in modelopt_state.

calibrate() goes through apply_mode(model, mode=[("dasc", cfg)], ...), and ModeloptStateManager.add_mode unconditionally appends (modelopt/torch/opt/conversion.py:296, self._state.append(...)). With the guard removed, nothing collapses a re-application, so calling calibrate() N times leaves N ("dasc", ...) entries in modelopt_state_dict.

Why it matters:

  • Checkpoint growth is linear in recalibration count. Each entry embeds a full DASCPolicy — every DASCCalibrationMeasurement plus static_horizons for every head of every GDN layer. On a real Qwen3-Next this is not small, and it is duplicated per recalibration.
  • Stale policies are persisted permanently. Only the last entry is refreshed on save (update_last_state_before_save touches self._state[-1] only, conversion.py:306-312). Earlier entries keep the pre-recalibration policy forever, and restore_dasc_model will happily attach each one in turn during replay before the final entry overwrites it — so mid-restore the model transiently carries a policy that does not describe its weights.
  • mto.modelopt_state(model) now reports dasc several times, which is surprising for anything that inspects applied modes.

The test only recalibrates once with an identical config, so none of this shows up.

Suggested fix — supersede in place rather than append. Keep the guard and have calibrate() handle re-calibration explicitly, e.g. detect an existing dasc entry via ModeloptStateManager and rewrite that entry's config + metadata (this also gives a natural place to drop the now-stale policy). If appending really is the intended semantics, please say so in the docstring for calibrate() and in docs/source/guides/6_sparsity.rst — the guide's "Re-running calibrate supersedes a stale policy" wording reads as replacement, which is not what happens to the serialized state.

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 signed commit cfd7053 via #2379. The repeat-mode prohibition is restored. Public calibrate() now detects existing DASC state and replaces that entry in place through ModeloptStateManager, updating both config and policy metadata and collapsing any provisional duplicates. The test changes provenance during recalibration and asserts the serialized mode list contains exactly one updated dasc entry.

"""Prevent applying DASC twice to the same model state."""
return {"dasc"}

@property
def convert(self) -> ConvertEntrypoint:
"""Return the DASC calibration entrypoint."""
Expand Down
66 changes: 56 additions & 10 deletions modelopt/torch/sparsity/state_sparsity/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@
from torch import nn

from modelopt.torch.opt.conversion import ApplyModeError
from modelopt.torch.utils import unwrap_model

from .config import DASCCalibrationMeasurement, DASCConfig, DASCLayerPolicy, DASCPolicy

__all__ = ["analyze_gdn_decay", "compute_gdn_decay_horizons"]

_SUPPORTED_GDN_CLASS_NAMES = frozenset({"GatedDeltaNet", "Qwen3NextGatedDeltaNet"})
_HORIZON_DTYPE_CAST_RTOL = 0.05

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 name _HORIZON_DTYPE_CAST_RTOL overstates what this 5% window can absorb.

validate_dasc_decay_parameters raises on the exact decay_parameters_sha256 mismatch before reaching the torch.allclose on line 294, so by the time the tolerance is applied the BF16-canonicalized parameters are already bit-identical. The only residual difference the tolerance can ever see is sub-BF16-roundoff (≤ 2⁻⁹ ≈ 0.2%, amplified through softplus), which makes 5% roughly 25× wider than needed — and it can't widen the gate either, since the hash gate dominates.

That's not a correctness hole, but the constant reads as though a 5% horizon drift were tolerated, which is misleading for anyone tuning it later. Consider deriving it from the canonicalization dtype and saying so, e.g.:

# Residual slack after the exact BF16-canonicalized parameter hash matches: only
# sub-roundoff differences from the caller's storage dtype can reach the comparison.
_HORIZON_DTYPE_CAST_RTOL = 8 * torch.finfo(torch.bfloat16).eps

The genuinely load-bearing check in this function is the retained mask comparison right below it (a head whose horizon straddles selected_wmax), which is a good addition — a brief comment saying the allclose is a redundant guard and the mask comparison is the real gate would help future readers.

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 by the validation change in signed commit cfd7053 via #2379. The exact BF16 digest no longer dominates export validation, so the 5 percent horizon tolerance now genuinely absorbs ordinary FP16/BF16 storage casts. The constant comment states that the exact retained-head mask is the semantic gate, and both dtype paths are covered by tests.



def compute_gdn_decay_horizons(
a_log: torch.Tensor,
Expand Down Expand Up @@ -58,20 +62,21 @@ def compute_gdn_decay_horizons(


def _is_gdn_module(module: nn.Module) -> bool:
class_name = "".join(
character for character in type(module).__name__.lower() if character.isalnum()
)
"""Return whether a module has one of the explicitly supported GDN implementations."""
return (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL ModeState] Exact type(module).__name__ matching breaks composition with ModelOpt's own DynamicModule class swap.

modelopt/torch/nas/plugins/megatron.py:660 registers Megatron's GDN into the NAS/pruning registry:

@DMRegistry.register({GatedDeltaNet: "megatron.core.ssm.gated_delta_net.GatedDeltaNet"})
class _DynamicGatedDeltaNet(_DynamicAttention):

_DMRegistryCls.convert performs an in-place class swap (modelopt/torch/opt/dynamic.py:639, module.__class__ = cls) to a synthesized class named f"{prefix}{nn_cls.__name__}" (dynamic.py:921). So once a NAS/pruning mode converts a Megatron hybrid model, type(module).__name__ is _DynamicGatedDeltaNet, not GatedDeltaNet.

Why it matters — the previous normalized-substring match ("gateddeltanet" in class_name) did match _DynamicGatedDeltaNet, so this is a regression in two directions:

  1. dascmtn.convert/mtp.prunemto.save(model): update_dasc_metadatavalidate_dasc_model_structure_get_gdn_modules raises ApplyModeError("DASC found no supported GDN modules"). Because that call sits outside the new try/except, the whole save fails and the pruning state is lost too — exactly the "save must stay non-fatal" failure this PR set out to fix.
  2. mtn.convertcalibrate(...): fails with the same misleading "found no supported GDN modules" on a model that plainly has them.

Nothing in next_modes/next_prohibited_modes prevents either ordering.

Suggested fix — keep fail-closed semantics but resolve through the MRO so dynamic subclasses of a supported base still match, e.g.:

def _is_gdn_module(module: nn.Module) -> bool:
    """Return whether a module has one of the explicitly supported GDN implementations."""
    return (
        any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in type(module).__mro__)
        and isinstance(getattr(module, "A_log", None), torch.Tensor)
        and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
    )

This still rejects unrelated implementations that merely expose similarly named decay tensors (the documented goal), while accepting _DynamicGatedDeltaNet. Note the existing test UnsupportedGatedDeltaNet(GatedDeltaNet) asserts the opposite; if hand-written subclasses must stay rejected, the DM-generated classes need an explicit allowance instead (e.g. also accept names produced by DMRegistry.prefix).

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 signed commit cfd7053 via #2379. Supported implementations are now recognized through the class MRO, which accepts ModelOpt synthesized _DynamicGatedDeltaNet classes because their MRO includes the registered GatedDeltaNet base. An unrelated lookalike with A_log and dt_bias remains rejected. The regression test swaps in a dynamic-style subclass and verifies strict export still succeeds.

"gateddeltanet" in class_name
type(module).__name__ in _SUPPORTED_GDN_CLASS_NAMES
and isinstance(getattr(module, "A_log", None), torch.Tensor)
and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
)


def _get_gdn_modules(model: nn.Module) -> dict[str, nn.Module]:
"""Find supported GDN layers after removing a recognized model wrapper."""
model = unwrap_model(model, force_unwrap=True)
modules = {name: module for name, module in model.named_modules() if _is_gdn_module(module)}
if not modules:
raise ApplyModeError("DASC found no GatedDeltaNet modules; only GDN is supported")
supported = ", ".join(sorted(_SUPPORTED_GDN_CLASS_NAMES))
raise ApplyModeError(f"DASC found no supported GDN modules; expected one of: {supported}")
return dict(sorted(modules.items()))


Expand Down Expand Up @@ -100,11 +105,13 @@ def analyze_gdn_decay(


def _canonical_sha256(value: object) -> str:
"""Hash a JSON value with deterministic ordering and no non-finite numbers."""
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
return hashlib.sha256(payload.encode()).hexdigest()


def _model_structure(modules: dict[str, nn.Module]) -> list[dict[str, object]]:
"""Describe the layer names and head counts that define policy geometry."""
return [
{
"name": name,
Expand All @@ -115,11 +122,18 @@ 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."""
return [
{
"name": name,
"A_log": module.A_log.detach().to(device="cpu", dtype=torch.float64).tolist(),
"dt_bias": module.dt_bias.detach().to(device="cpu", dtype=torch.float64).tolist(),
"A_log": module.A_log.detach()
.to(device="cpu", dtype=torch.bfloat16)
.to(dtype=torch.float32)
.tolist(),
"dt_bias": module.dt_bias.detach()
.to(device="cpu", dtype=torch.bfloat16)
.to(dtype=torch.float32)
.tolist(),
}
Comment on lines 124 to 137

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] BF16 canonicalization only stabilizes the hash for BF16/FP32 — an FP16 model still gets a false "does not match" rejection.

The canonicalization works because fp32 → bf16 is idempotent, so a model.to(torch.bfloat16) cast hashes identically (which the new test_dtype_cast_preserves_policy_... covers). It does not hold for FP16: fp32 → fp16 → bf16 is a double rounding (11 significand bits, then 8) and differs from fp32 → bf16 whenever the FP16 step carries a value across BF16's round-to-nearest-even boundary. Over a real A_log/dt_bias pair with dozens of heads, at least one element differing is likely, not exotic.

Why it matters — FP16 is a first-class inference dtype (from_pretrained(..., torch_dtype=torch.float16)). Calibrating an FP32/BF16 checkpoint and then .half()-ing it before export_policy() raises "DASC policy does not match the model's GDN decay parameters" with no parameter having actually changed, and the recommended remedy (re-run calibrate()) requires re-running the caller's whole paired evaluation. Same for mto.save(), which now emits a spurious "policy is stale" warning.

Suggested fix — canonicalize onto a grid that is independent of the storage dtype rather than onto one specific float format. The cheapest version, given compute_gdn_decay_horizons already upcasts to float64:

_DECAY_HASH_DECIMALS = 3  # coarser than bf16 unit roundoff (2**-9 ~= 2e-3)


def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]:
    """Serialize decay parameters on a dtype-independent decimal grid for stable hashing."""
    return [
        {
            "name": name,
            "A_log": [
                round(value, _DECAY_HASH_DECIMALS)
                for value in module.A_log.detach().to(device="cpu", dtype=torch.float64).tolist()
            ],
            "dt_bias": [
                round(value, _DECAY_HASH_DECIMALS)
                for value in module.dt_bias.detach().to(device="cpu", dtype=torch.float64).tolist()
            ],
        }
        for name, module in modules.items()
    ]

(Rounding on a decimal grid has its own tie-boundary caveat, but it is dtype-agnostic, so BF16, FP16 and FP32 all land on the same value.) Either way, the safety here really comes from the new horizon-equivalence and retained-mask checks in validate_dasc_decay_parameters — the exact hash is the brittle part. Please extend test_dtype_cast_preserves_policy_when_the_selected_mask_is_unchanged to parametrize over torch.bfloat16 and torch.float16.

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 signed commit cfd7053 via #2379. The exact BF16 digest is now explicitly calibration provenance, not the deployment-validity gate. Export re-derives numerical horizons with cast tolerance and exact-compares the selected retained-head mask, which is the runtime-relevant contract. The dtype regression is parameterized over both BF16 and FP16.

for name, module in modules.items()
]
Expand All @@ -128,6 +142,7 @@ def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]:
def _validate_measurement_coverage(
config: DASCConfig, measurements: list[DASCCalibrationMeasurement]
) -> None:
"""Require exactly one matching measurement for every configured window."""
measured = [measurement.wmax for measurement in measurements]
unexpected = sorted(set(measured) - set(config.wmax_candidates))
missing = sorted(set(config.wmax_candidates) - set(measured))
Expand All @@ -145,6 +160,7 @@ def _validate_measurement_coverage(
def _validate_measurement_geometry(
horizons: dict[str, list[float]], measurements: list[DASCCalibrationMeasurement]
) -> None:
"""Bind caller-reported retained and total head counts to the analyzed model."""
total_heads = sum(len(layer_horizons) for layer_horizons in horizons.values())
for measurement in measurements:
retained_heads = sum(
Expand All @@ -161,6 +177,7 @@ def _validate_measurement_geometry(


def _candidate_passes(config: DASCConfig, measurement: DASCCalibrationMeasurement) -> bool:
"""Return whether one candidate passes every configured evidence gate."""
return measurement.checkpoint_savings >= config.min_checkpoint_savings and all(
result.perplexity_retention >= config.min_perplexity_retention
and result.top1_agreement >= config.min_top1_agreement
Expand Down Expand Up @@ -245,14 +262,43 @@ def build_dasc_policy(
def validate_dasc_model_structure(model: nn.Module, policy: DASCPolicy) -> None:
"""Reject restoring a policy onto a different GDN module structure."""
modules = _get_gdn_modules(model)
actual = _canonical_sha256(_model_structure(modules))
if actual != policy.model_structure_sha256:
actual_structure = _model_structure(modules)
policy_structure = [
{"name": name, "num_heads": layer.num_heads}
for name, layer in sorted(policy.layers.items())
]
if (
actual_structure != policy_structure
or _canonical_sha256(actual_structure) != policy.model_structure_sha256
):
raise ApplyModeError("DASC policy does not match the model's GDN module structure")


def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None:
"""Reject exporting a policy for different GDN decay parameters."""
"""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")

current_horizons = analyze_gdn_decay(
model,
epsilon=policy.epsilon,
static_gate_input=policy.static_gate_input,
)
for name, values in current_horizons.items():
layer = policy.layers[name]
if not torch.allclose(
torch.tensor(values, dtype=torch.float64),
torch.tensor(layer.static_horizons, dtype=torch.float64),
rtol=_HORIZON_DTYPE_CAST_RTOL,
atol=0.0,
):
raise ApplyModeError(
f"DASC policy horizons do not match current decay parameters in layer {name!r}"
)
retained = [head for head, horizon in enumerate(values) if horizon > policy.selected_wmax]
if retained != layer.retained_heads:
raise ApplyModeError(
f"DASC policy head mask does not match current decay parameters in layer {name!r}"
)
Loading
Loading