Files
giant/tests/test_validate.py
T
lars c4b12b5e7a
CI / Format (ruff format) (push) Successful in 28s
CI / Lint (ruff check) (push) Successful in 29s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 40s
CI / Type check (ty) (push) Successful in 45s
CI / Format (ruff format) (pull_request) Successful in 33s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 36s
CI / Tests (pull_request) Successful in 3m52s
CI / Tests (push) Successful in 4m5s
Pass ConditioningAxisConfig/ParticleTypeConfig themselves instead of raw dicts (gitea #38)
build_models/build_critics parsed model_config into frozen dataclasses
(ConditioningConfig, Stage2ModelConfig, ...) but then threw the parsed
sub-objects away and passed the original raw dicts (conditioning["particle"],
s2_spec.particle_type.to_dict()) down into ConditionEncoder/StageModel/etc,
which re-read them with their own hardcoded .get(key, default) fallbacks —
each an independent copy of a fact the dataclass already stated once. Worst
instance: giant/training/trainers.py:236 converted an already-parsed
ParticleTypeConfig back into a dict for no reason.

Threads ConditioningAxisConfig (particle_cfg/material_cfg) and
ParticleTypeConfig (particle_type_cfg) as the actual dataclass instances
through every signature that used to type them dict: ConditionEncoder,
StageModel/CriticModel, resolve_type_n_classes/stage2_type_dim/
stage2_trunk_sec_dim, giant/model/builders.py, giant/sample.py,
giant/training/stage2_inputs.py, giant/training/trainers.py (StageSpec/
StageTrainer), giant/pipeline.py, giant/rollout.py, giant/validate.py — so ty
now catches a misspelled field instead of it silently falling back. No
config-schema change: config.toml/checkpoint model_config keep the same
nested-dict shape; only what happens after the existing X.from_dict(...)
parse changes.

User-confirmed scope decision: both axes (particle_cfg/material_cfg and
particle_type_cfg), not just the more heavily-duplicated particle_type_cfg
axis, and not stopping at the two most literal parse-then-discard round
trips — matching the issue's own proposal.

Preserved-default decision: StageModel's particle_type_cfg=None sentinel
(hit only by direct/test construction — build_models always passes an
explicit particle_type) still resolves to ParticleTypeConfig(target=
"physical"), not ParticleTypeConfig()'s own target="onehot" config-file
default — switching it would have silently grown an unused, gradient-less
type_head on every test that constructs Stage2OneShot/Stage2Autoregressive
without particle_type_cfg=, breaking their "every param has a grad" checks.

New tests in tests/test_network.py: ConditionEncoder/StageModel store the
exact ConditioningAxisConfig/ParticleTypeConfig instance passed in (identity,
not just equality) — no internal dict round-trip — and build_models's output
carries real dataclass instances end to end, not the plain dicts it produced
before this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:03:55 +02:00

121 lines
4.5 KiB
Python

import numpy as np
import torch
from giant.config import ConditioningAxisConfig, ParticleTypeConfig
from giant.constants import COND_DIM, SEC_SLOT_DIM, X_DIM
from giant.data.dataset import StepBatch
from giant.model.network import Stage1Model, Stage2OneShot, stage2_trunk_sec_dim
from giant.validate import validate_marginals
_PARTICLE_CFG = ConditioningAxisConfig(type="physical", emb_dim=8, n_layers=1)
_MATERIAL_CFG = ConditioningAxisConfig(type="physical", emb_dim=8, n_layers=1)
_K_MAX = 5
def _tiny_models(particle_type_cfg: ParticleTypeConfig | None = None):
"""A fresh v0.3.0 pair: Stage1Model owns no n_sec_head, so n_sec always
comes from Stage2OneShot."""
s1 = Stage1Model(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PARTICLE_CFG,
material_cfg=_MATERIAL_CFG,
hidden_dim=16,
n_res_blocks=1,
)
resolved_type_cfg = particle_type_cfg or ParticleTypeConfig(target="physical")
target = resolved_type_cfg.target
sec_dim = stage2_trunk_sec_dim(
resolved_type_cfg,
"flow",
_K_MAX,
_PARTICLE_CFG.emb_dim,
)
s2 = Stage2OneShot(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PARTICLE_CFG,
material_cfg=_MATERIAL_CFG,
hidden_dim=16,
n_res_blocks=1,
generator="flow",
time_dim=16,
k_max=_K_MAX,
sec_dim=sec_dim,
particle_type_cfg=particle_type_cfg,
)
assert s2.particle_type_cfg.target == target
return s1.eval(), s2.eval()
def _loader(B: int = 4, n_batches: int = 2, n_sec_value: int = 0, n_classes: int = 8):
"""A val_loader matching StreamingStepsDataset's StepBatch shape."""
batches = []
for _ in range(n_batches):
cond_cont = torch.randn(B, COND_DIM)
cond_cat = torch.zeros(B, 2, dtype=torch.long)
x1 = torch.randn(B, X_DIM)
n_sec = torch.full((B,), n_sec_value, dtype=torch.long)
sec_cont = torch.randn(B, _K_MAX, SEC_SLOT_DIM)
proc_idx = torch.zeros(B, dtype=torch.long)
sec_type_idx = torch.randint(0, n_classes, (B, _K_MAX), dtype=torch.long)
batches.append(StepBatch(cond_cont, cond_cat, x1, n_sec, sec_cont, proc_idx, sec_type_idx))
return batches
def test_validate_marginals_all_zero_secondaries_returns_nan_phys_kl(monkeypatch):
"""If n_sec_pred collapses to 0 across the whole validated set (realistic
during early/unstable training), phys_kl must degrade to NaN instead of
crashing on the empty-array .min()/.max() reduction inside
_histogram_kl."""
s1, s2 = _tiny_models()
loader = _loader(n_sec_value=0)
def _fake_resolve_n_sec(stage1_model, sec_decoder, cond_cont, cond_cat, stage1_out, n_sec_pred):
return torch.zeros(cond_cont.size(0), dtype=torch.long)
monkeypatch.setattr("giant.validate.resolve_n_sec", _fake_resolve_n_sec)
result = validate_marginals(s1, loader, sec_decoder=s2, n_batches=2, steps=2)
assert np.asarray(result["phys_real"]).shape == (0, 2)
assert np.asarray(result["phys_generated"]).shape == (0, 2)
assert np.isnan(np.asarray(result["phys_kl"])).all()
def test_validate_marginals_physical_target_shapes():
s1, s2 = _tiny_models()
loader = _loader(n_sec_value=2)
result = validate_marginals(s1, loader, sec_decoder=s2, n_batches=1, steps=2)
assert np.asarray(result["real"]).shape == (4, X_DIM)
assert np.asarray(result["generated"]).shape == (4, X_DIM)
assert np.asarray(result["kl_divergence"]).shape == (X_DIM,)
assert "phys_real" in result and "phys_generated" in result and "phys_kl" in result
assert "type_class_real" not in result
def test_validate_marginals_onehot_type_class_marginal():
particle_type_cfg = ParticleTypeConfig(target="onehot")
s1, s2 = _tiny_models(particle_type_cfg)
loader = _loader(n_sec_value=2, n_classes=s2.type_dim)
result = validate_marginals(s1, loader, sec_decoder=s2, n_batches=1, steps=2)
assert "phys_real" not in result
# Real/generated valid-slot counts need not agree (real: ground-truth
# n_sec=2 always; generated: the untrained n_sec_head's own prediction).
assert np.asarray(result["type_class_real"]).ndim == 1
assert np.asarray(result["type_class_gen"]).ndim == 1
assert np.asarray(result["type_class_real"]).shape[0] > 0
def test_validate_marginals_without_sec_decoder_returns_stage1_only():
s1, _ = _tiny_models()
loader = _loader(n_sec_value=0)
result = validate_marginals(s1, loader, n_batches=1, steps=2)
assert set(result) == {"real", "generated", "kl_divergence"}