render.py was at 58% coverage — the module's plotting dispatch (router_gating, router_share, unavailable) and glue logic (_figure_params/_figure_params_v2, _plot_metadata, render_all's gallery subprocess call, render_run's condor RunMeta wiring) had no tests at all. Brings it to 100%: pure-function unit tests for the v0.2/v0.3.0 figure-param branches and _plot_metadata, real LaTeX-rendered fixtures for the previously-untested plot kinds and a 4-group grouped_hist (exercises the hidden-leftover-axis branch), and mocked subprocess/condor calls to isolate render_all/render_run's own logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
giant
Geant4 Inference via Autoregressive Neural sTep surrogate.
A conditional generative model that replaces the Geant4 step function: given a pre-step particle state it samples a post-step outcome — the primary's continuation plus its secondary particles — and autoregressively rolls that out into full showers. Trained entirely from parquet dumps of the miniCaloSim steps tree; no Geant4 runtime dependency.
Quick start
uv sync --extra cpu # install deps (CPU torch; use --extra cuda for GPU)
giant new-run --hidden-dim 512 --lr 3e-4 # scaffold config.toml + run dir
giant train path/to/steps.parquet # train (flow + wgan by default)
giant predict path/to/steps.parquet --checkpoint checkpoints/.../best.pt
dwarf build-geometry-oracle path/to/steps.parquet --out oracle.pkl # needed for rollout
giant rollout path/to/steps.parquet --checkpoint checkpoints/.../best.pt --geometry oracle.pkl
Every command takes --help for the full flag list, and --config config.toml for anything not exposed as a flag.
Architecture
A two-stage model, checkpointed together. Either stage's outcome can be produced by one of three interchangeable generative objectives (--stage1-generator/--stage2-generator, or --mode to set both at once): flow (conditional flow matching, ODE-sampled in ~10 steps), ddpm (denoising diffusion), or wgan (single-pass WGAN-GP generator/critic).
Stage 1 — primary step. Predicts the 9D post-step outcome (giant/constants.py:LOCAL_TARGET_NAMES) from the pre-step conditioning:
| Index | Variable | Encoding |
|---|---|---|
| 0 | step_length [mm] |
log |
| 1–2 | edep_logit, sec_logit |
ALR coords of the deposit/secondary/post-energy simplex |
| 3–5 | post_dir in local frame |
unit vector |
| 6–8 | travel_dir (post_pos − pre_pos) in local frame |
unit vector |
- Energy logits decode via softmax over
[edep_logit, sec_logit, 0]×pre_E, soedep + e_sec + post_E == pre_Eexactly — conservation is architectural, not learned. post_dir/travel_dirlive in the frame wherepre_dir = ẑ.post_posisn't a target — it's reconstructed aspre_pos + step_length * world_frame(travel_dir).
Stage 2 — secondaries. Conditioned on the pre-step state and Stage 1's outcome, it generates the variable-length list of secondary particles. Two decoding strategies (--stage2-decoder):
autoregressive— emits secondaries one at a time in descending-energy order, each token conditioned on a running history of prior tokens (markov: previous token only, orattention: causal self-attention, KV-cached at inference)one_shot— allK_MAXslots generated in a single forward pass, masked past the predictedn_sec
Either way, secondary energies stick-break the e_sec budget handed down from Stage 1, so the full chain conserves energy. A secondary's particle identity is represented as onehot (categorical, top-N PDG codes + "other"), physical (continuous log-mass/charge), or embedding (nearest-neighbour lookup).
Conditioning. Pre-step position/energy/direction/layer, plus particle mass/charge and material Z_eff/A_eff/density/X0/λ_int, encoded the same three ways as particle identity above (--conditioning) — the physical representation generalizes to species/materials outside the training menu since it's computed rather than looked up. n_sec/e_sec are always model outputs, never conditioning inputs.
MoE routing (--router, either stage): a pluggable Router (energy/pdg/process/composed axes) top-1-dispatches each row to one of several small expert trunks at eval time, instead of running one monolithic trunk.
Data
- Input: parquet files produced by miniCaloSim, or converted from ROOT via
dwarf convert. One row = one Geant4 step. - Conditioning (pre-step) columns:
event_id,pdg,pre_x/pre_y/pre_z,pre_E,pre_dx/pre_dy/pre_dz(direction),material,layer_id. - Primary outcome (post-step) columns:
post_x/post_y/post_z,post_E,post_dx/post_dy/post_dz,step_length,edep(energy deposited in this step),e_sec(total energy carried off by secondaries),child_track_ids(its length givesn_sec). - Secondary columns, one variable-length list per step:
sec_pdg_list,sec_E_list,sec_dx_list/sec_dy_list/sec_dz_list— padded/truncated toK_MAX(15) slots on load, ordered by descending energy. - Optional:
process— the physics process that produced the step (e.g.compt,phot,eBrem); a post-step label used only as classifier supervision (ProcessRouter), never as conditioning. - Train/val split is by
event_id(--seed-controlled), not row shuffle, so correlated steps from the same shower never leak across the split. - Loading a directory or
.manifestof multiple parquet files (each one Geant4 job,event_idrestarting from 0) offsets each file'sevent_ids by a fixed per-file stride so ids stay globally unique across files.
Project structure
giant/
├── giant/
│ ├── data/
│ │ ├── loader.py # parquet → numpy arrays (incl. streaming/chunked reads)
│ │ ├── transforms.py # log transforms, local-frame rotation, energy simplex, secondary encode/decode
│ │ └── dataset.py # StepsDataset / StreamingStepsDataset (PyTorch)
│ ├── model/
│ │ ├── network.py # ConditionEncoder, Stage1Model, Stage2OneShot/Stage2Autoregressive, Router/MoE, CriticModel
│ │ ├── schedule.py # CosineSchedule (DDPM) and flow matching utilities
│ │ └── wgan.py # WGAN-GP gradient penalty / critic / generator losses
│ ├── constants.py # output/conditioning dims, K_MAX, secondary slot layout, schema keys
│ ├── particles.py # PDG → (mass, charge) decode, incl. nuclear/ion codes; onehot/embedding secondary-identity decode
│ ├── materials.py # material name → (Z_eff, A_eff, density, X0, λ_int)
│ ├── config.py # default hyperparameters, TOML config merging, device autodetect
│ ├── pipeline.py # builds datasets/normalizers and kicks off a training run (with setup-stage caching)
│ ├── training/ # two-stage training: loop, per-stage trainers, metrics, checkpointing
│ │ ├── loop.py # epoch loop, graceful shutdown, best-checkpoint selection
│ │ ├── trainers.py # StageSpec + flow/ddpm and WGAN-GP per-stage trainers
│ │ ├── stage2_inputs.py# ground-truth stage-2 targets + autoregressive/teacher-forcing inputs
│ │ ├── metrics.py # MetricsCollector: metrics.csv columns, W&B logging, progress/summary
│ │ └── checkpoint.py # checkpoint assembly/restore (format unchanged since v0.2)
│ ├── sample.py # DDPM / DDIM / flow matching / WGAN samplers + secondary sampling
│ ├── geometry.py # GeometryOracle: position → (material, layer_id, escaped) for rollout
│ ├── rollout.py # autoregressive shower rollout driver
│ ├── validate.py # step-level marginal + KL-divergence validation
│ ├── analysis/ # rollout-vs-reference analysis pipeline (see `giant analyze` below)
│ │ ├── sources.py # canonical LazyFrames + secondary view
│ │ ├── reduce.py # streaming reduction primitives (hist1d, per-event scalars, profiles, ...)
│ │ ├── grouping.py # fixed bin edges + energy/pdg/material group sets
│ │ ├── context.py # resolves grouping into `shared.json` once per run
│ │ ├── catalog.py # declarative PlotSpec registry
│ │ ├── condor.py # prep / compute-one / submit-description plumbing
│ │ └── render.py # PDFs + HTML gallery (only module importing plotstyle/LaTeX)
│ └── cli.py # `giant train` / `new-run` / `predict` / `rollout` / `analyze` Typer app
├── scripts/ # dataset/tooling logic, unified under the `dwarf` CLI (`dwarf --help`)
│ ├── dwarf.py # Typer app: convert, migrate, bump-gen, bump-schema, status,
│ │ # update-manifest, create-manifest, make-root,
│ │ # build-geometry-oracle, warm-cache, hparam-scan
│ ├── steps_to_parquet.py # ROOT → parquet conversion (uproot/awkward/polars) — `dwarf convert`
│ ├── steps_to_parquet_parallel.py # fan out conversion over several ROOT files — `dwarf convert --jobs N`
│ ├── migrate_geant_steps.py # one-time move into the raw/processed/pools/derived layout — `dwarf migrate`
│ ├── bump_dataset_version.py # cut a new raw gen or parquet schema, with a logged reason —
│ │ # `dwarf bump-gen` / `bump-schema` / `status` / `update-manifest` / `create-manifest`
│ ├── create_root_files.py # generate new ROOT shards via a minicalosim executable — `dwarf make-root`
│ ├── geometry_oracle.py # fit a position → (material, layer_id) oracle — `dwarf build-geometry-oracle`
│ ├── warm_setup_cache.py # precompute `giant train`'s setup-stage sidecar — `dwarf warm-cache`
│ ├── hparam_scan.py # hyperparameter grid scan over `giant train` runs — `dwarf hparam-scan`
│ └── profile_analysis_costs.py # profiling helper for the `giant analyze` reduction pipeline
└── tests/
Setup
uv sync --extra cpu # CPU-only torch (use --extra cuda for CUDA 11.8 instead)
uv sync --extra cpu --extra dev # add dev tools (pytest, ruff, ty)
uv sync --extra cpu --extra geometry # add scikit-learn, for `dwarf build-geometry-oracle` / rollout
cpu and cuda are mutually exclusive — pick one to select the torch build (pinned to 2.3.x). Plain uv sync installs no torch at all. See CLAUDE.md for details.
Training, prediction, rollout
giant new-run --hidden-dim 512 --lr 3e-4 --comment "..." # scaffold a config.toml + run dir
giant train path/to/steps.parquet # train (flow stage 1 + wgan stage 2, default)
giant predict path/to/steps.parquet --checkpoint checkpoints/.../best.pt
dwarf build-geometry-oracle path/to/steps.parquet --out oracle.pkl # position → material/layer_id
giant rollout path/to/steps.parquet --checkpoint checkpoints/.../best.pt --geometry oracle.pkl
Useful flags on giant train:
--mode {flow,ddpm,wgan}sets both stages' objective at once;--stage1-generator/--stage2-generatoroverride per stage--stage2-decoder {autoregressive,one_shot}— Stage 2 decoding strategy (see Architecture)--conditioning {physical,embedding,onehot}— conditioning representation--router/--router-type/--n-experts/--router-axis— MoE routing--wandb— log per-epoch metrics to Weights & Biases (needsuv sync --extra wandb); metric names are<stage>/<split>/<metric>plus an unprefixed run-level tail, all derived fromgiant/training/trainers.pyMetricSpecs--no-cache-setup/--rebuild-setup-cache— control the setup-stage sidecar cache (vocab maps, event split, normalizer stats);dwarf warm-cacheprecomputes it
Config-file-only knobs (no CLI flag — use --config config.toml): stage2_model.autoregressive.teacher_forcing/.history, stage2_model.particle_type.target. v0.2 flat-schema configs and checkpoints load fine (auto-migrated).
giant rollout seeds showers from each event's highest-energy entry step, then autoregressively steps the model to completion, pushing secondaries as new tracks and looking up material/layer_id from the geometry oracle each step. Tracks terminate on energy cutoff, max steps, detector escape, or natural end; energy is deposited locally on every stop except escape, so showers conserve energy by construction.
Validation and analysis
giant.validate.validate_marginals— step-level marginal + KL-divergence checks during training (--validate-every)giant analyze— deeper rollout-vs-reference diagnostics (marginals by energy/pdg/material, per-event totals, shower profiles, species share, leakage, secondaries):
giant analyze submit rollout.yaml --accounting-group cms # prep + one HTCondor job per plot (compute only)
giant analyze render <run_dir> --gallery # local: styled PDFs + HTML gallery (needs LaTeX)
<run_dir> is derived next to the rollout parquet (analyze prep/submit print it). Compute jobs are polars/numpy only; only render needs LaTeX, so it always runs locally.
Development
uv run pytest # run tests
uv run ruff check . # lint
uv run ruff format . # format
uv run ty check . # type check