Files
giant/tests/test_objectives.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

252 lines
8.9 KiB
Python

"""Tests for `giant/model/objectives.py` — the generator/objective registry
(gitea #32) that replaced bare `generator in ("flow", "ddpm", "wgan")`
string checks scattered across models.py/sample.py/builders.py/
stage2_inputs.py/trainers.py."""
import pytest
import torch
from giant.config import ConditioningAxisConfig
from giant.constants import COND_DIM, CONT_SLOT_DIM, PARTICLE_PHYS_DIM, SEC_SLOT_DIM, X_DIM
from giant.model.network import (
OBJECTIVE_REGISTRY,
DdpmObjective,
FlowObjective,
Stage1Model,
Stage2Autoregressive,
Stage2OneShot,
WganObjective,
build_objective,
)
from giant.model.schedule import CosineSchedule, flow_matching_loss, flow_matching_loss_secondary
_PHYS_CFG = ConditioningAxisConfig(type="physical", emb_dim=8, n_layers=1)
def _cond(B: int, pdg: int = 3, mat: int = 2) -> tuple[torch.Tensor, torch.Tensor]:
cond_cont = torch.randn(B, COND_DIM)
cond_cat = torch.stack([torch.randint(0, pdg, (B,)), torch.randint(0, mat, (B,))], dim=1)
return cond_cont, cond_cat
# ── registry ─────────────────────────────────────────────────────────────
def test_registry_has_exactly_the_three_known_objectives():
assert set(OBJECTIVE_REGISTRY) == {"flow", "ddpm", "wgan"}
def test_build_objective_returns_correct_concrete_type():
assert isinstance(build_objective("flow"), FlowObjective)
assert isinstance(build_objective("ddpm"), DdpmObjective)
assert isinstance(build_objective("wgan"), WganObjective)
def test_build_objective_unknown_name_raises():
with pytest.raises(ValueError, match="unknown generator/objective"):
build_objective("bogus")
def test_build_objective_filters_kwargs_by_signature():
# FlowObjective takes no constructor args — n_steps (a DdpmObjective-only
# kwarg) must be silently dropped, not raise a TypeError.
build_objective("flow", n_steps=500)
ddpm = build_objective("ddpm", n_steps=250)
assert isinstance(ddpm, DdpmObjective)
assert ddpm.n_steps == 250
# ── flags ────────────────────────────────────────────────────────────────
def test_flow_objective_flags():
obj = build_objective("flow")
assert obj.needs_time is True
assert obj.is_adversarial is False
assert obj.folds_type_slice is False
assert obj.supports_stage2_decoder is True
def test_ddpm_objective_flags():
obj = build_objective("ddpm")
assert obj.needs_time is True
assert obj.is_adversarial is False
assert obj.folds_type_slice is False
assert obj.supports_stage2_decoder is False
def test_wgan_objective_flags():
obj = build_objective("wgan")
assert obj.needs_time is False
assert obj.is_adversarial is True
assert obj.folds_type_slice is True
assert obj.supports_stage2_decoder is True
# ── trunk_in_dim ─────────────────────────────────────────────────────────
def test_trunk_in_dim_flow_and_ddpm_pass_through_out_dim():
assert build_objective("flow").trunk_in_dim(out_dim=9, noise_dim=8) == 9
assert build_objective("ddpm").trunk_in_dim(out_dim=9, noise_dim=8) == 9
def test_trunk_in_dim_wgan_uses_noise_dim():
assert build_objective("wgan").trunk_in_dim(out_dim=9, noise_dim=8) == 8
# ── ddpm schedule ────────────────────────────────────────────────────────
def test_ddpm_build_schedule_has_requested_length():
schedule = build_objective("ddpm").build_schedule(n_steps=17, device=torch.device("cpu"))
assert isinstance(schedule, CosineSchedule)
assert schedule.T == 17
def test_flow_and_wgan_build_schedule_is_none():
assert build_objective("flow").build_schedule(100, torch.device("cpu")) is None
assert build_objective("wgan").build_schedule(100, torch.device("cpu")) is None
# ── stage1_loss parity ──────────────────────────────────────────────────
def test_flow_objective_stage1_loss_matches_direct_call():
torch.manual_seed(0)
model = Stage1Model(
pdg_vocab=3, mat_vocab=2, particle_cfg=_PHYS_CFG, material_cfg=_PHYS_CFG, hidden_dim=16, n_res_blocks=1
)
cond_cont, cond_cat = _cond(4)
x1 = torch.randn(4, X_DIM)
torch.manual_seed(1)
expected = flow_matching_loss(model, x1, cond_cont, cond_cat)
torch.manual_seed(1)
actual = build_objective("flow").stage1_loss(model, x1, cond_cont, cond_cat)
assert torch.allclose(actual, expected)
def test_ddpm_objective_stage1_loss_matches_direct_call():
torch.manual_seed(0)
model = Stage1Model(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PHYS_CFG,
material_cfg=_PHYS_CFG,
hidden_dim=16,
n_res_blocks=1,
generator="ddpm",
)
cond_cont, cond_cat = _cond(4)
x1 = torch.randn(4, X_DIM)
objective = build_objective("ddpm", n_steps=50)
schedule = objective.build_schedule(50, torch.device("cpu"))
assert isinstance(schedule, CosineSchedule)
torch.manual_seed(1)
expected = schedule.loss(model, x1, cond_cont, cond_cat)
torch.manual_seed(1)
actual = objective.stage1_loss(model, x1, cond_cont, cond_cat, schedule=schedule)
assert torch.allclose(actual, expected)
def test_ddpm_objective_stage1_loss_requires_a_schedule():
model = Stage1Model(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PHYS_CFG,
material_cfg=_PHYS_CFG,
hidden_dim=16,
n_res_blocks=1,
generator="ddpm",
)
cond_cont, cond_cat = _cond(4)
with pytest.raises(AssertionError):
build_objective("ddpm").stage1_loss(model, torch.randn(4, X_DIM), cond_cont, cond_cat, schedule=None)
# ── stage2_loss dispatch ─────────────────────────────────────────────────
def test_flow_objective_stage2_loss_one_shot_matches_direct_call():
torch.manual_seed(0)
B, k_max = 4, 5
sec_dim = k_max * SEC_SLOT_DIM
model = Stage2OneShot(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PHYS_CFG,
material_cfg=_PHYS_CFG,
hidden_dim=16,
n_res_blocks=1,
generator="flow",
sec_dim=sec_dim,
k_max=k_max,
)
cond_cont, cond_cat = _cond(B)
stage1_ctx = torch.randn(B, X_DIM)
x1_s2 = torch.randn(B, sec_dim)
sec_mask = torch.ones(B, k_max, dtype=torch.bool)
torch.manual_seed(1)
expected = flow_matching_loss_secondary(model, x1_s2, cond_cont, cond_cat, stage1_ctx, sec_mask, type_dim=None)
torch.manual_seed(1)
actual = build_objective("flow").stage2_loss(
model, x1_s2, cond_cont, cond_cat, stage1_ctx, sec_mask, type_dim=None, ar_inputs=None
)
assert torch.allclose(actual, expected)
def test_flow_objective_stage2_loss_dispatches_to_ar_when_ar_inputs_given():
torch.manual_seed(0)
B, k_max = 4, 5
model = Stage2Autoregressive(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=_PHYS_CFG,
material_cfg=_PHYS_CFG,
hidden_dim=16,
n_res_blocks=1,
generator="flow",
k_max=k_max,
)
cond_cont, cond_cat = _cond(B)
stage1_ctx = torch.randn(B, X_DIM)
token_dim = CONT_SLOT_DIM + PARTICLE_PHYS_DIM
x1_s2 = torch.randn(B, k_max, token_dim)
sec_mask = torch.ones(B, k_max, dtype=torch.bool)
ar_inputs = {
"history_feat": torch.randn(B, k_max, token_dim),
"has_prev": torch.ones(B, k_max, dtype=torch.bool),
"remaining_frac": torch.rand(B, k_max),
"slot_idx": torch.linspace(0, 1, k_max).unsqueeze(0).expand(B, -1),
}
loss = build_objective("flow").stage2_loss(
model, x1_s2, cond_cont, cond_cat, stage1_ctx, sec_mask, type_dim=None, ar_inputs=ar_inputs
)
assert loss.dim() == 0
assert torch.isfinite(loss)
def test_ddpm_objective_stage2_loss_not_implemented():
dummy_model = torch.nn.Module()
dummy = torch.zeros(1)
with pytest.raises(NotImplementedError):
build_objective("ddpm").stage2_loss(
dummy_model, dummy, dummy, dummy, dummy, torch.ones(1, 1, dtype=torch.bool), type_dim=None
)
def test_wgan_objective_has_no_loss_methods():
dummy_model = torch.nn.Module()
dummy = torch.zeros(1)
objective = build_objective("wgan")
with pytest.raises(NotImplementedError):
objective.stage1_loss(dummy_model, dummy, dummy, dummy)
with pytest.raises(NotImplementedError):
objective.stage2_loss(
dummy_model, dummy, dummy, dummy, dummy, torch.ones(1, 1, dtype=torch.bool), type_dim=None
)