da7cde3ef9
CI / Lint (ruff check) (push) Successful in 27s
CI / Format (ruff format) (push) Successful in 28s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 36s
CI / Type check (ty) (push) Successful in 39s
CI / Format (ruff format) (pull_request) Successful in 30s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 30s
CI / Tests (pull_request) Successful in 2m50s
CI / Tests (push) Successful in 2m58s
Works through docs/v0.3.0-followups.md item by item, closing the gap between the design doc and the shipped v0.3.0-stage2-autoregressive code: 1. validate.py: 7-tuple batch unpacking, sample_stage1/sample_stage2 dispatch, stage-2 particle-type-class marginal. 2. Stage-prefixed --stage1-*/--stage2-* CLI flags for train/new-run. 3. Thread stage2_model.k_max through loader/transforms/dataset/pipeline/ train instead of the hardcoded K_MAX constant. 4. Mixed conditioning.particle.type / conditioning.material.type support end-to-end (data pipeline + dwarf warm-cache). 5. conditioning.share_stages = true: one shared ConditionEncoder instance across both stages. 6. stage2_model.generator = "ddpm" formally deferred into design doc §11.2 (was silently unimplemented). 7. giant predict/rollout: implement conditioning.*.type = "onehot" via the checkpoint's saved pdg_topn_map/mat_topn_map. 8. network.py's checkpoint-path model_config migration now fails loudly on non-zero legacy expert_hidden_dim/expert_n_blocks, matching config.py's TOML-load path (§4.2). 9. validate_config now rejects stage2_model.n_sec.mode = "truth" for a rollout-capable checkpoint (§9). Also cleared all pre-existing `ty check` noise (44 -> 0 diagnostics), mostly a test-helper dict-unpack pattern that made every unrelated constructor keyword look like a type error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
696 lines
24 KiB
Python
696 lines
24 KiB
Python
"""Tests for the autoregressive shower rollout driver."""
|
|
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from giant.constants import TERM_ESCAPED, TERM_MAX_STEPS, TERM_UNKNOWN_PDG, K_MAX
|
|
from giant.data.loader import TopNMap
|
|
from giant.data.transforms import Normalizer
|
|
from giant.model.network import (
|
|
Stage1Model,
|
|
Stage2Autoregressive,
|
|
Stage2OneShot,
|
|
stage2_trunk_sec_dim,
|
|
)
|
|
from giant.rollout import L1DistCollector, make_seed_frontier, rollout
|
|
|
|
pytest.importorskip("sklearn")
|
|
from giant import geometry as g # noqa: E402
|
|
|
|
PDG_MAP = {22: 0, 11: 1, -11: 2}
|
|
MAT_MAP = {"G4_AIR": 0, "G4_PbWO4": 1}
|
|
|
|
|
|
def _models(conditioning="embedding"):
|
|
particle_cfg = {"type": conditioning, "emb_dim": 16, "n_layers": 1}
|
|
material_cfg = {"type": conditioning, "emb_dim": 16, "n_layers": 1}
|
|
s1 = Stage1Model(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
n_sec_head_k_max=K_MAX,
|
|
)
|
|
s2 = Stage2OneShot(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
generator="flow",
|
|
time_dim=16,
|
|
)
|
|
return s1.eval(), s2.eval()
|
|
|
|
|
|
def _norms():
|
|
rng = np.random.default_rng(0)
|
|
cond = Normalizer().fit(rng.standard_normal((1000, 15)).astype(np.float32))
|
|
tgt = Normalizer().fit(rng.standard_normal((1000, 9)).astype(np.float32))
|
|
sec_phys = Normalizer().fit(rng.standard_normal((1000, 2)).astype(np.float32))
|
|
return cond, tgt, sec_phys
|
|
|
|
|
|
def _oracle():
|
|
rng = np.random.default_rng(0)
|
|
pos = rng.uniform(-200, 200, (20000, 3)).astype(np.float32)
|
|
inside = (np.abs(pos) < 100).all(axis=1)
|
|
mat = np.where(inside, "G4_PbWO4", "G4_AIR").astype(object)
|
|
lay = np.where(inside, 0, -1).astype(np.int64)
|
|
with patch.object(g, "_iter_point_batches", lambda p: iter([(pos, mat, lay)])):
|
|
# Pinned to "knn" explicitly: this test's escape-threshold semantics
|
|
# (tiny threshold -> escape even at a valid interior point, because no
|
|
# training point is that close) are KNN-specific, and the fixture's
|
|
# box geometry isn't a layer stack the "slab" method could fit anyway.
|
|
return g.build_geometry_oracle([Path("x")], method="knn", subsample=20000)
|
|
|
|
|
|
def _seeds(n=6):
|
|
return {
|
|
"event_id": np.arange(n, dtype=np.int64),
|
|
"pdg": np.full(n, 11, dtype=np.int64),
|
|
"pre_pos": np.zeros((n, 3)),
|
|
"pre_E": np.linspace(30.0, 90.0, n),
|
|
"pre_dir": np.tile([0.0, 0.0, 1.0], (n, 1)),
|
|
}
|
|
|
|
|
|
def _run(
|
|
escape_threshold=1e9,
|
|
energy_cutoff=1.0,
|
|
max_steps=30,
|
|
max_tracks_per_event=300,
|
|
seeds=None,
|
|
conditioning="embedding",
|
|
):
|
|
torch.manual_seed(0)
|
|
np.random.seed(0)
|
|
s1, s2 = _models(conditioning)
|
|
cond, tgt, sec_phys = _norms()
|
|
return rollout(
|
|
s1,
|
|
s2,
|
|
_oracle(),
|
|
seeds or _seeds(),
|
|
cond,
|
|
tgt,
|
|
sec_phys,
|
|
PDG_MAP,
|
|
MAT_MAP,
|
|
energy_cutoff=energy_cutoff,
|
|
max_steps=max_steps,
|
|
steps=4,
|
|
batch_size=128,
|
|
max_tracks_per_event=max_tracks_per_event,
|
|
escape_threshold=escape_threshold,
|
|
particle_conditioning=conditioning,
|
|
material_conditioning=conditioning,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_material_props(monkeypatch):
|
|
import giant.materials as gm
|
|
|
|
fake = {
|
|
"G4_AIR": gm.MaterialProperties(
|
|
z_eff=7.3, a_eff=14.4, density=1.2e-3, x0=3.0e4, lambda_int=7.0e5
|
|
),
|
|
"G4_PbWO4": gm.MaterialProperties(
|
|
z_eff=75.6, a_eff=205.3, density=8.28, x0=0.89, lambda_int=20.7
|
|
),
|
|
}
|
|
monkeypatch.setattr(gm, "MATERIAL_PROPERTIES", fake)
|
|
return fake
|
|
|
|
|
|
def test_rollout_physical_conditioning_end_to_end(fake_material_props):
|
|
"""Physical-mode rollout runs to completion; spawned secondaries carry
|
|
mass/charge forward (no snapping) and the output pdg column is populated
|
|
via the reporting-only nearest-known-PDG label."""
|
|
rec = _run(conditioning="physical")
|
|
assert len(rec["event_id"]) > 0
|
|
assert set(np.unique(rec["pdg"]).tolist()) <= set(PDG_MAP.keys())
|
|
|
|
|
|
def test_rollout_physical_conditioning_generalizes_to_out_of_vocab_pdg(
|
|
fake_material_props,
|
|
):
|
|
"""A real, giant.particles-resolvable species outside the training PDG
|
|
vocab (muon, 13) must run through physical-property conditioning rather
|
|
than terminate via TERM_UNKNOWN_PDG — that generalization is the entire
|
|
point of "physical" mode (see build_cond_features(strict=...))."""
|
|
seeds = _seeds(6)
|
|
seeds["pdg"] = np.full(6, 13, dtype=np.int64)
|
|
assert 13 not in PDG_MAP
|
|
rec = _run(seeds=seeds, conditioning="physical")
|
|
assert len(rec["event_id"]) > 0
|
|
assert TERM_UNKNOWN_PDG not in set(rec["termination_reason"].tolist())
|
|
|
|
|
|
def test_seed_frontier_track_ids():
|
|
seeds = _seeds(3)
|
|
fr, counts = make_seed_frontier(**seeds)
|
|
assert (fr["track_id"] == [0, 0, 0]).all() # one primary per event -> id 0
|
|
assert (fr["parent_id"] == -1).all()
|
|
assert (fr["generation"] == 0).all()
|
|
assert all(counts[e] == 1 for e in range(3))
|
|
# pre_dir is normalised.
|
|
np.testing.assert_allclose(np.linalg.norm(fr["pre_dir"], axis=1), 1.0, atol=1e-6)
|
|
|
|
|
|
def test_seed_frontier_embedding_mode_skips_unresolvable_pdg_lookup():
|
|
""" "embedding" mode must not call giant.particles at all, so a seed PDG
|
|
code it can't resolve (e.g. a fabricated/garbage code) must not crash
|
|
frontier construction — mass/charge are simply zero-filled, unused."""
|
|
seeds = _seeds(3)
|
|
seeds["pdg"] = np.full(3, 999999999, dtype=np.int64)
|
|
fr, _counts = make_seed_frontier(**seeds, particle_conditioning="embedding")
|
|
np.testing.assert_array_equal(fr["mass"], 0.0)
|
|
np.testing.assert_array_equal(fr["charge"], 0.0)
|
|
|
|
|
|
def test_rollout_embedding_mode_unresolvable_pdg_terminates_gracefully():
|
|
"""A rollout seeded with a PDG code giant.particles can't resolve, and
|
|
which isn't in the training vocabulary either, must terminate via the
|
|
existing TERM_UNKNOWN_PDG gate rather than crash in make_seed_frontier —
|
|
"embedding" mode has no dependency on giant.particles at all."""
|
|
seeds = _seeds(3)
|
|
seeds["pdg"] = np.full(3, 999999999, dtype=np.int64)
|
|
rec = _run(seeds=seeds, conditioning="embedding")
|
|
assert len(rec["event_id"]) > 0
|
|
assert set(rec["termination_reason"].tolist()) == {TERM_UNKNOWN_PDG}
|
|
|
|
|
|
def test_rollout_terminates_and_has_rows():
|
|
rec = _run()
|
|
assert len(rec["event_id"]) > 0
|
|
# Every seed event appears.
|
|
assert set(rec["event_id"].tolist()) == set(range(6))
|
|
|
|
|
|
def test_max_steps_respected():
|
|
# Disable the energy cutoff so tracks survive long enough to hit the step cap.
|
|
rec = _run(max_steps=5, energy_cutoff=0.0)
|
|
assert rec["step_no"].max() <= 5
|
|
assert (rec["termination_reason"] == TERM_MAX_STEPS).any()
|
|
|
|
|
|
def test_energy_conserved_deposit_plus_leak():
|
|
seeds = _seeds()
|
|
rec = _run(seeds=seeds)
|
|
for i, ev in enumerate(seeds["event_id"]):
|
|
m = rec["event_id"] == ev
|
|
dep = rec["edep"][m].sum()
|
|
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
|
|
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
|
|
|
|
|
|
def test_secondaries_have_valid_parents():
|
|
rec = _run()
|
|
orphans = 0
|
|
for ev in np.unique(rec["event_id"]):
|
|
m = rec["event_id"] == ev
|
|
tids = set(rec["track_id"][m].tolist())
|
|
for pid in rec["parent_id"][m]:
|
|
if pid >= 0 and pid not in tids:
|
|
orphans += 1
|
|
assert orphans == 0
|
|
# At least one secondary (generation > 0) is produced by the tiny model.
|
|
assert (rec["generation"] > 0).any()
|
|
|
|
|
|
def test_escape_terminates_immediately():
|
|
# A tight escape threshold makes even the seed position (origin) escape.
|
|
rec = _run(escape_threshold=1e-3)
|
|
assert (rec["termination_reason"] == TERM_ESCAPED).all()
|
|
assert rec["step_no"].max() == 0
|
|
|
|
|
|
def test_output_schema_complete():
|
|
from giant.rollout import _RECORD_KEYS
|
|
|
|
rec = _run()
|
|
assert set(rec.keys()) == set(_RECORD_KEYS)
|
|
n = len(rec["event_id"])
|
|
assert all(len(v) == n for v in rec.values())
|
|
|
|
|
|
def test_max_tracks_cap_conserves_energy():
|
|
# A very small cap forces sub-cap secondaries to deposit in place; energy
|
|
# must still balance.
|
|
seeds = _seeds()
|
|
rec = _run(seeds=seeds, max_tracks_per_event=3)
|
|
for i, ev in enumerate(seeds["event_id"]):
|
|
m = rec["event_id"] == ev
|
|
dep = rec["edep"][m].sum()
|
|
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
|
|
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
|
|
assert len(np.unique(rec["track_id"][m])) <= 3
|
|
|
|
|
|
# ── Streaming output (on_chunk) ──────────────────────────────────────────────
|
|
|
|
|
|
def _run_streaming(on_chunk, **kwargs):
|
|
torch.manual_seed(0)
|
|
np.random.seed(0)
|
|
s1, s2 = _models()
|
|
cond, tgt, sec_phys = _norms()
|
|
seeds = kwargs.pop("seeds", None) or _seeds()
|
|
return rollout(
|
|
s1,
|
|
s2,
|
|
_oracle(),
|
|
seeds,
|
|
cond,
|
|
tgt,
|
|
sec_phys,
|
|
PDG_MAP,
|
|
MAT_MAP,
|
|
energy_cutoff=kwargs.pop("energy_cutoff", 1.0),
|
|
max_steps=kwargs.pop("max_steps", 30),
|
|
steps=4,
|
|
batch_size=128,
|
|
max_tracks_per_event=kwargs.pop("max_tracks_per_event", 300),
|
|
escape_threshold=kwargs.pop("escape_threshold", 1e9),
|
|
on_chunk=on_chunk,
|
|
)
|
|
|
|
|
|
def test_on_chunk_receives_every_row_exactly_once():
|
|
"""Concatenating the streamed chunks must reproduce the buffered result."""
|
|
from giant.rollout import _RECORD_KEYS
|
|
|
|
buffered = _run()
|
|
|
|
chunks: list[dict[str, np.ndarray]] = []
|
|
summary = _run_streaming(chunks.append)
|
|
|
|
streamed = {k: np.concatenate([c[k] for c in chunks]) for k in _RECORD_KEYS}
|
|
assert summary["n_rows"] == len(buffered["event_id"])
|
|
assert len(streamed["event_id"]) == len(buffered["event_id"])
|
|
for k in _RECORD_KEYS:
|
|
np.testing.assert_array_equal(streamed[k], buffered[k])
|
|
|
|
|
|
def test_on_chunk_summary_termination_reason_counts_match_buffered():
|
|
buffered = _run()
|
|
summary = _run_streaming(lambda row: None)
|
|
|
|
expected = Counter(r for r in buffered["termination_reason"].tolist() if r)
|
|
assert summary["termination_reason_counts"] == dict(expected)
|
|
|
|
|
|
def test_on_chunk_never_buffers_full_records():
|
|
"""Streaming mode must not accumulate rows for later to_dict() retrieval."""
|
|
from giant.rollout import _Recorder
|
|
|
|
rec = _Recorder(sink=lambda row: None)
|
|
rec.add(
|
|
event_id=np.array([0]),
|
|
track_id=np.array([0]),
|
|
parent_id=np.array([-1]),
|
|
generation=np.array([0]),
|
|
step_no=np.array([0]),
|
|
pdg=np.array([11]),
|
|
pre_x=np.array([0.0]),
|
|
pre_y=np.array([0.0]),
|
|
pre_z=np.array([0.0]),
|
|
pre_E=np.array([1.0]),
|
|
pre_dx=np.array([0.0]),
|
|
pre_dy=np.array([0.0]),
|
|
pre_dz=np.array([1.0]),
|
|
post_x=np.array([0.0]),
|
|
post_y=np.array([0.0]),
|
|
post_z=np.array([1.0]),
|
|
post_E=np.array([0.0]),
|
|
post_dx=np.array([0.0]),
|
|
post_dy=np.array([0.0]),
|
|
post_dz=np.array([1.0]),
|
|
edep=np.array([1.0]),
|
|
step_length=np.array([1.0]),
|
|
material=np.array(["G4_AIR"], dtype=object),
|
|
layer_id=np.array([0]),
|
|
n_sec_pred=np.array([0]),
|
|
termination_reason=np.array(["natural_end"], dtype=object),
|
|
)
|
|
assert rec.n_rows == 1
|
|
assert rec.termination_reason_counts == {"natural_end": 1}
|
|
with pytest.raises(AssertionError):
|
|
rec.to_dict()
|
|
|
|
|
|
# ── v0.3.0 step 6: per-stage generators, AR decoder, particle_type.target ───
|
|
|
|
# emb_dim=3: "other" (class idx 2) is shared by -11 and 13 (muon), matching
|
|
# the real shape build_pdg_topn_map_from_files produces — see
|
|
# decode_topn_class's docstring.
|
|
PDG_TOPN_MAP = TopNMap(
|
|
class_map={22: 0, 11: 1, -11: 2, 13: 2},
|
|
other_members={-11: 5, 13: 1},
|
|
)
|
|
|
|
|
|
def _models_v3(
|
|
conditioning="physical",
|
|
decoder="one_shot",
|
|
target="physical",
|
|
generator1="flow",
|
|
generator2="flow",
|
|
k_max=6,
|
|
emb_dim=4,
|
|
stage2_has_n_sec_head=True,
|
|
):
|
|
particle_cfg = {"type": conditioning, "emb_dim": emb_dim, "n_layers": 1}
|
|
material_cfg = {"type": conditioning, "emb_dim": emb_dim, "n_layers": 1}
|
|
# A fresh v0.3.0 Stage1Model — no n_sec_head_k_max, unlike _models() above
|
|
# (decision 1 moves n_sec ownership to stage 2 by default).
|
|
s1 = Stage1Model(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
generator=generator1,
|
|
noise_dim=8,
|
|
)
|
|
particle_type_cfg = {"target": target}
|
|
# Explicit kwargs rather than a shared **common dict: a dict() call whose
|
|
# values have heterogeneous types (str/int/dict/bool) widens under static
|
|
# analysis to dict[str, <big union>], which then makes every constructor
|
|
# keyword not itself part of that union (router, cond_enc, ...) look like
|
|
# a type mismatch to `ty` even though every actual value passed is fine.
|
|
if decoder == "one_shot":
|
|
sec_dim = stage2_trunk_sec_dim(particle_type_cfg, generator2, k_max, emb_dim)
|
|
s2 = Stage2OneShot(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
generator=generator2,
|
|
time_dim=16,
|
|
noise_dim=8,
|
|
k_max=k_max,
|
|
particle_type_cfg=particle_type_cfg,
|
|
build_n_sec_head=stage2_has_n_sec_head,
|
|
sec_dim=sec_dim,
|
|
)
|
|
else:
|
|
s2 = Stage2Autoregressive(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
generator=generator2,
|
|
time_dim=16,
|
|
noise_dim=8,
|
|
k_max=k_max,
|
|
particle_type_cfg=particle_type_cfg,
|
|
build_n_sec_head=stage2_has_n_sec_head,
|
|
)
|
|
return s1.eval(), s2.eval()
|
|
|
|
|
|
def _run_v3(
|
|
s1,
|
|
s2,
|
|
escape_threshold=1e9,
|
|
energy_cutoff=1.0,
|
|
max_steps=15,
|
|
max_tracks_per_event=100,
|
|
seeds=None,
|
|
conditioning="physical",
|
|
pdg_topn_map=None,
|
|
other_policy="sample",
|
|
seed=0,
|
|
stage1_ddpm_steps=1000,
|
|
l1_dist_collector=None,
|
|
):
|
|
torch.manual_seed(0)
|
|
np.random.seed(0)
|
|
cond, tgt, sec_phys = _norms()
|
|
return rollout(
|
|
s1,
|
|
s2,
|
|
_oracle(),
|
|
seeds or _seeds(),
|
|
cond,
|
|
tgt,
|
|
sec_phys,
|
|
PDG_MAP,
|
|
MAT_MAP,
|
|
energy_cutoff=energy_cutoff,
|
|
max_steps=max_steps,
|
|
steps=3,
|
|
batch_size=128,
|
|
max_tracks_per_event=max_tracks_per_event,
|
|
escape_threshold=escape_threshold,
|
|
particle_conditioning=conditioning,
|
|
material_conditioning=conditioning,
|
|
pdg_topn_map=pdg_topn_map,
|
|
other_policy=other_policy,
|
|
seed=seed,
|
|
stage1_ddpm_steps=stage1_ddpm_steps,
|
|
l1_dist_collector=l1_dist_collector,
|
|
)
|
|
|
|
|
|
def test_rollout_stage2_owns_n_sec_when_stage1_has_no_head(fake_material_props):
|
|
"""A fresh v0.3.0 Stage1Model has no n_sec_head (decision 1) — n_sec
|
|
must come from Stage2's own head instead, and the run must still
|
|
complete and conserve energy."""
|
|
s1, s2 = _models_v3()
|
|
rec = _run_v3(s1, s2)
|
|
assert len(rec["event_id"]) > 0
|
|
seeds = _seeds()
|
|
for i, ev in enumerate(seeds["event_id"]):
|
|
m = rec["event_id"] == ev
|
|
dep = rec["edep"][m].sum()
|
|
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
|
|
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
|
|
|
|
|
|
def test_resolve_n_sec_raises_when_neither_stage_owns_head(fake_material_props):
|
|
"""Neither stage owning n_sec_head only happens for a
|
|
stage2_model.n_sec.mode other than "head" — not a valid rollout-capable
|
|
checkpoint, and must fail with a clear error rather than crash deep
|
|
inside predict_n_sec."""
|
|
s1, s2 = _models_v3(stage2_has_n_sec_head=False)
|
|
with pytest.raises(RuntimeError, match="n_sec_head"):
|
|
_run_v3(s1, s2)
|
|
|
|
|
|
@pytest.mark.parametrize("decoder", ["one_shot", "autoregressive"])
|
|
@pytest.mark.parametrize("generator2", ["flow", "wgan"])
|
|
def test_rollout_physical_target_decoder_generator_matrix(
|
|
fake_material_props, decoder, generator2
|
|
):
|
|
"""Every (decoder, stage2 generator) combination under
|
|
particle_type.target="physical" must run to completion and conserve
|
|
energy — the matrix docs/v0.3.0-design.md §7 calls out for comparison."""
|
|
s1, s2 = _models_v3(decoder=decoder, generator2=generator2)
|
|
rec = _run_v3(s1, s2)
|
|
assert len(rec["event_id"]) > 0
|
|
seeds = _seeds()
|
|
for i, ev in enumerate(seeds["event_id"]):
|
|
m = rec["event_id"] == ev
|
|
dep = rec["edep"][m].sum()
|
|
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
|
|
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
|
|
|
|
|
|
def test_sample_stage1_dispatches_ddpm_by_generator_kind():
|
|
"""stage1_model.generator="ddpm" must be dispatched to sample_ddpm
|
|
(previously _step_chunk silently fell through to the flow ODE sampler
|
|
regardless of the checkpoint's actual generator — see giant.sample.sample_stage1).
|
|
A short T avoids the reverse-diffusion numerical blowup an untrained,
|
|
random-weight network produces over many steps; that instability is a
|
|
property of sampling from an untrained net, not of the dispatch logic
|
|
under test here, so a full oracle-driven rollout isn't needed."""
|
|
from giant.sample import sample_stage1
|
|
|
|
s1, _ = _models_v3(generator1="ddpm")
|
|
cond_cont = torch.randn(6, 15)
|
|
cond_cat = torch.zeros(6, 2, dtype=torch.long)
|
|
sample, n_sec = sample_stage1(s1, cond_cont, cond_cat, steps=10, ddpm_steps=5)
|
|
assert sample.shape == (6, 9)
|
|
assert n_sec is None # fresh v0.3.0 Stage1Model owns no n_sec_head
|
|
|
|
|
|
@pytest.mark.parametrize("decoder", ["one_shot", "autoregressive"])
|
|
def test_rollout_onehot_target_end_to_end(fake_material_props, decoder):
|
|
"""particle_type.target="onehot" resolves a concrete PDG via
|
|
decode_topn_class (argmax + other_policy), and that PDG's real physics
|
|
(giant.particles.particle_phys_array) become the secondary's identity —
|
|
unlike "physical", not just a reporting label."""
|
|
s1, s2 = _models_v3(decoder=decoder, target="onehot", emb_dim=3)
|
|
rec = _run_v3(s1, s2, pdg_topn_map=PDG_TOPN_MAP, other_policy="modal")
|
|
assert len(rec["event_id"]) > 0
|
|
# Every spawned secondary's nominal pdg must be one decode_topn_class can
|
|
# actually produce (the topn map's known classes + its "other" members).
|
|
possible = set(PDG_TOPN_MAP.class_map.keys()) | set(
|
|
PDG_TOPN_MAP.other_members.keys()
|
|
)
|
|
secondary_pdgs = set(rec["pdg"][rec["generation"] > 0].tolist())
|
|
assert secondary_pdgs <= possible
|
|
|
|
|
|
def test_rollout_onehot_target_missing_topn_map_raises(fake_material_props):
|
|
s1, s2 = _models_v3(target="onehot", emb_dim=3)
|
|
with pytest.raises(RuntimeError, match="pdg_topn_map"):
|
|
_run_v3(s1, s2, pdg_topn_map=None)
|
|
|
|
|
|
# --- conditioning.{particle,material}.type = "onehot" (docs/v0.3.0-followups.md
|
|
# item 7) — a separate axis from stage2_model.particle_type.target above: this
|
|
# is what feeds cond_cat's extra top-N columns for ConditionEncoder's own
|
|
# "onehot" mode, not the secondary-species decode. ---------------------------
|
|
|
|
COND_PDG_TOPN_MAP = TopNMap(class_map=dict(PDG_MAP), other_members={})
|
|
COND_MAT_TOPN_MAP = TopNMap(class_map={"G4_AIR": 0, "G4_PbWO4": 1}, other_members={})
|
|
|
|
|
|
def _onehot_conditioning_models():
|
|
particle_cfg = {"type": "onehot", "emb_dim": len(PDG_MAP), "n_layers": 1}
|
|
material_cfg = {"type": "onehot", "emb_dim": len(MAT_MAP), "n_layers": 1}
|
|
s1 = Stage1Model(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
)
|
|
s2 = Stage2OneShot(
|
|
pdg_vocab=3,
|
|
mat_vocab=2,
|
|
particle_cfg=particle_cfg,
|
|
material_cfg=material_cfg,
|
|
hidden_dim=32,
|
|
n_res_blocks=2,
|
|
sec_dim=stage2_trunk_sec_dim({"target": "physical"}, "flow", K_MAX, 3),
|
|
generator="flow",
|
|
time_dim=16,
|
|
)
|
|
return s1.eval(), s2.eval()
|
|
|
|
|
|
def _run_onehot_conditioning(
|
|
pdg_topn_map=COND_PDG_TOPN_MAP, mat_topn_map=COND_MAT_TOPN_MAP
|
|
):
|
|
s1, s2 = _onehot_conditioning_models()
|
|
cond, tgt, sec_phys = _norms()
|
|
return rollout(
|
|
s1,
|
|
s2,
|
|
_oracle(),
|
|
_seeds(),
|
|
cond,
|
|
tgt,
|
|
sec_phys,
|
|
PDG_MAP,
|
|
MAT_MAP,
|
|
energy_cutoff=1.0,
|
|
max_steps=30,
|
|
steps=4,
|
|
batch_size=128,
|
|
max_tracks_per_event=300,
|
|
escape_threshold=1e9,
|
|
particle_conditioning="onehot",
|
|
material_conditioning="onehot",
|
|
pdg_topn_map=pdg_topn_map,
|
|
mat_topn_map=mat_topn_map,
|
|
)
|
|
|
|
|
|
def test_rollout_conditioning_onehot_end_to_end(fake_material_props):
|
|
rec = _run_onehot_conditioning()
|
|
assert len(rec["event_id"]) > 0
|
|
assert set(rec["event_id"].tolist()) == set(range(6))
|
|
|
|
|
|
def test_rollout_conditioning_onehot_particle_missing_topn_map_raises(
|
|
fake_material_props,
|
|
):
|
|
with pytest.raises(RuntimeError, match="pdg_topn_map"):
|
|
_run_onehot_conditioning(pdg_topn_map=None)
|
|
|
|
|
|
def test_rollout_conditioning_onehot_material_missing_topn_map_raises(
|
|
fake_material_props,
|
|
):
|
|
with pytest.raises(RuntimeError, match="mat_topn_map"):
|
|
_run_onehot_conditioning(mat_topn_map=None)
|
|
|
|
|
|
@pytest.mark.parametrize("decoder", ["one_shot", "autoregressive"])
|
|
def test_rollout_embedding_target_end_to_end(decoder):
|
|
"""particle_type.target="embedding" L1-snaps to the nearest row of the
|
|
conditioning's own particle embedding table, so every resolved PDG must
|
|
be a real member of the dense training vocab (pdg_map) — unlike
|
|
"onehot", there is no "other" bucket to fall outside of."""
|
|
s1, s2 = _models_v3(
|
|
conditioning="embedding", decoder=decoder, target="embedding", emb_dim=4
|
|
)
|
|
rec = _run_v3(s1, s2, conditioning="embedding")
|
|
assert len(rec["event_id"]) > 0
|
|
secondary_pdgs = set(rec["pdg"][rec["generation"] > 0].tolist())
|
|
assert secondary_pdgs <= set(PDG_MAP.keys())
|
|
|
|
|
|
def test_l1_dist_collector_populated_only_for_embedding_target():
|
|
"""§11.3: the L1-distance diagnostic only makes sense under
|
|
particle_type.target="embedding" — a physical-target run must leave the
|
|
collector empty rather than silently accumulating garbage."""
|
|
s1, s2 = _models_v3(target="physical")
|
|
collector = L1DistCollector()
|
|
_run_v3(s1, s2, l1_dist_collector=collector)
|
|
assert collector.n == 0
|
|
assert collector.summary() is None
|
|
|
|
|
|
def test_l1_dist_collector_accumulates_for_embedding_target():
|
|
s1, s2 = _models_v3(conditioning="embedding", target="embedding", emb_dim=4)
|
|
collector = L1DistCollector()
|
|
rec = _run_v3(s1, s2, conditioning="embedding", l1_dist_collector=collector)
|
|
n_secondaries = int((rec["generation"] > 0).sum())
|
|
assert n_secondaries > 0 # sanity: the tiny model does spawn secondaries
|
|
summary = collector.summary()
|
|
assert summary is not None
|
|
assert summary["n"] == collector.n > 0
|
|
assert summary["min"] <= summary["mean"] <= summary["max"]
|
|
assert summary["std"] >= 0.0
|
|
assert len(summary["hist_edges"]) == len(summary["hist_counts"]) + 1
|
|
assert sum(summary["hist_counts"]) <= summary["n"] # some may fall outside [lo, hi)
|
|
|
|
|
|
def test_l1_dist_collector_add_ignores_invalid_slots():
|
|
collector = L1DistCollector()
|
|
dist = np.array([[1.0, 5.0, 9.0]])
|
|
valid = np.array([[True, False, True]])
|
|
collector.add(dist, valid)
|
|
assert collector.n == 2
|
|
assert collector.minimum == 1.0
|
|
assert collector.maximum == 9.0
|
|
|
|
|
|
def test_l1_dist_collector_add_empty_is_noop():
|
|
collector = L1DistCollector()
|
|
collector.add(np.zeros((0, 3)), np.zeros((0, 3), dtype=bool))
|
|
assert collector.n == 0
|
|
assert collector.summary() is None
|