Add v0.3.0 design doc: Stage-2 autoregressive redesign
Design contract for the v0.3.0 config break and network.py refactor, following the 2026-08-04 meeting with Jan. Not implemented yet. The 2026-08-03 WGAN rollout benchmark failed specifically at the secondary-species level (zero photons, ~4M hallucinated -14 muon antineutrinos). The response pivots Stage 2 to autoregressive generation in descending-energy order with teacher forcing, and reverts the particle type to a categorical representation. That needs a config break: [conditioning] / [stage1_model] / [stage2_model] / [train] replace the single global train.mode and [model] block, so per-stage generators (stage1 flow + stage2 wgan), stage-2-only training, and one-shot-vs-autoregressive comparison all become expressible. The particle and material conditioning axes are configured independently and mix freely, each with physical / embedding / onehot modes; the stage-2 type target mirrors the same three names, with conditioning.particle.emb_dim sizing both so the two share one class map. network.py collapses from ten permutation classes (stage x objective x routed) into composable parts — encoder x trunk x objective — which also makes routed WGAN work for the first time; it was only ever rejected because no routed WGAN generator class existed. The doc specifies every config option, the v0.2 migration (shim for both configs and checkpoints, gated on a bit-identical output diff), the refactor, and the implementation order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,4 +91,6 @@ GIANT is a conditional generative surrogate for the Geant4 step function. It rep
|
||||
|
||||
A sampling-calorimeter (multi-material) dataset is still a planned future direction, not yet built. See the knowledge base (`/home/lars/knowledge-base/meta/roadmap.md`).
|
||||
|
||||
**v0.3.0 — Stage-2 autoregressive redesign (designed, not implemented; branch `v0.3.0-stage2-autoregressive`):** the 2026-08-03 WGAN rollout benchmark failed specifically at the secondary-species level (zero photon secondaries, ~4M hallucinated `-14` muon antineutrinos). The agreed response pivots Stage 2 to **autoregressive generation** in descending-energy order with teacher forcing, and switches the particle-type representation back to **categorical** (top N−1 by training-set count + an "other" bucket), reversing the 2026-07-17 continuous `(log-mass, charge)` target. This requires a config break: `[conditioning]` / `[stage1_model]` / `[stage2_model]` / `[train]` blocks replace the single global `train.mode` + `[model]`, so per-stage generators (`stage1 = flow` + `stage2 = wgan`), stage-2-only training, and one-shot-vs-autoregressive comparison are all expressible. `network.py` is refactored from ten permutation classes into composable parts (encoder × trunk × objective), which also makes routed WGAN work for the first time. **Full design contract, with every config option documented: `docs/v0.3.0-design.md` — read it before touching `giant/config.py` or `giant/model/network.py`.**
|
||||
|
||||
**Condor-submitted GPU training/rollout (in progress, `condor-gpu-train-rollout` branch, not yet merged):** moves `giant train`/`giant rollout` off the shared portal GPU dev machines (see Compute environment) onto remote-GPU HTCondor submission on TOpAS/NEMO2 (`giant/condor.py`). Partway between "needs major features" and feature-complete — not ready to merge yet.
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
# GIANT v0.3.0 — Stage-2 autoregressive redesign
|
||||
|
||||
**Status:** design agreed, not implemented. Branch `v0.3.0-stage2-autoregressive`.
|
||||
**Date:** 2026-08-04.
|
||||
**Source:** `~/knowledge-base/meetings/2026-08-04-jan-stage2-autoregressive-architecture.md`
|
||||
(meeting with Jan), plus the design decisions taken in the session that produced
|
||||
this document.
|
||||
|
||||
This document is the implementation contract for v0.3.0. It specifies the new
|
||||
config format option by option, the `network.py` refactor, and the order in which
|
||||
to build it. Read it before touching `giant/config.py` or `giant/model/network.py`.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Why](#1-why)
|
||||
2. [Decisions register](#2-decisions-register)
|
||||
3. [Config format reference](#3-config-format-reference)
|
||||
4. [Migration: v0.2 -> v0.3](#4-migration-v02---v03)
|
||||
5. [network.py refactor](#5-networkpy-refactor)
|
||||
6. [Stage-2 autoregressive design](#6-stage-2-autoregressive-design)
|
||||
7. [Training loop](#7-training-loop)
|
||||
8. [Data and setup-cache changes](#8-data-and-setup-cache-changes)
|
||||
9. [Config machinery changes](#9-config-machinery-changes)
|
||||
10. [Callers that need updating](#10-callers-that-need-updating)
|
||||
11. [Open questions](#11-open-questions)
|
||||
12. [Implementation order](#12-implementation-order)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
The 2026-08-03 WGAN rollout benchmark
|
||||
(`~/knowledge-base/experiments/giant-wgan-physical-rollout-validation.md`) was
|
||||
good at the primary-step level and **failed at the secondary-species level**:
|
||||
zero photon secondaries generated, ~4M hallucinated `-14` (muon antineutrino)
|
||||
secondaries — a species essentially absent from Geant4.
|
||||
|
||||
Stage 1 is not implicated; the meeting scoped everything to Stage 2. Two changes
|
||||
were agreed together:
|
||||
|
||||
- **Autoregressive generation** over secondaries in decreasing energy order,
|
||||
replacing the one-shot masked `K_MAX=15` prediction, trained with teacher
|
||||
forcing.
|
||||
- **Categorical particle type** with a data-derived "other" bucket, reversing the
|
||||
2026-07-17 move to a continuous `(log-mass, charge)` target. Working hypothesis:
|
||||
the continuous target is part of what let the generator collapse onto degenerate
|
||||
species.
|
||||
|
||||
The meeting also set a **methodology**: compare Stage-2 architectures *standalone*
|
||||
(trained directly on secondary columns, no Stage-1 forward pass) before chaining
|
||||
the winner behind Stage 1. Iterating on the compounding-rollout-error problem is
|
||||
far cheaper that way than paying for a full two-stage run per candidate.
|
||||
|
||||
That methodology is what forces the config refactor: v0.2 has a single global
|
||||
`train.mode` and a single `[model]` block, with no way to express "train stage 2
|
||||
only", "stage 1 flow + stage 2 WGAN", or "one-shot vs autoregressive stage 2".
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions register
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|----------|-----------|
|
||||
| 1 | **`n_sec` head moves from stage 1 to stage 2** | Stage 1 becomes the pure 9D primary step. A stage-2-only run is then self-contained (it can predict its own multiplicity), and §3's "implicit stop token" alternative gets a natural home in the same config block. |
|
||||
| 2 | **Full mixed per-stage objectives** | `stage1 = flow` + `stage2 = wgan` must actually run — Stage 1 is good as flow, Stage 2 is what is being iterated on. The train loop becomes one trainer object per stage, each owning its optimizers and update cadence. |
|
||||
| 3 | **Migration shim for configs *and* checkpoints** | Nothing on `/ceph` goes dead. v0.2 `config.toml` files and v0.2 `model_config` dicts are translated on load. |
|
||||
| 4 | **Nested objective sub-tables** | `[stage1_model.wgan]`, `[stage2_model.ddpm]` rather than flat `wgan_n_critic` keys — self-documenting about which keys the active generator ignores, and validation can warn on a populated sub-table that is never read. |
|
||||
| 5 | **Particle type is adversarial: straight-through Gumbel into the critic** | The critic sees a relaxed one-hot alongside energy/direction, so the joint (species, kinematics) distribution is learned rather than factorized. Makes the collapse hypothesis directly testable instead of assumed. |
|
||||
| 6 | **No charge conservation in v0.3.0** | Explicitly "not yet worked out" in the meeting. No `[stage2_model.conservation]` block at all. Energy conservation stays exact and implicit in the stick-breaking encoding. |
|
||||
| 7 | **Explicit stage-prefixed CLI flags** | `--stage2-hidden-dim` etc., no generic `--set path=value`. Discoverable via `--help` and tab-completable; the cost is a flag list kept in sync with `DEFAULT_CONFIG` by hand. |
|
||||
| 8 | **AR history is a config axis, markov default** | Markov (previous token + remaining budget + slot index) is the baseline that makes the meeting's §6 "is attention useful" question answerable by ablation rather than by comparing differently-shaped models. Both sit behind one `history_encoder(prefix) -> vector` interface. |
|
||||
|
||||
### 2.1 Consequence of decision 5
|
||||
|
||||
Straight-through Gumbel needs a critic to receive the relaxed one-hot. So the
|
||||
type mechanism is **implied by the generator**, and needs no config key of its own:
|
||||
|
||||
| `stage2_model.generator` | Type mechanism |
|
||||
|--------------------------|----------------|
|
||||
| `"wgan"` | The type slice goes into the critic's input alongside energy/direction. Adversarial; learns the joint. Under `particle_type.target = "onehot"` it is relaxed through ST-Gumbel first; `"physical"` and `"embedding"` are already continuous and feed the critic directly. |
|
||||
| `"flow"` / `"ddpm"` | No critic exists -> the type slice trains against its own target, weighted by `particle_type.lambda` (same pattern as `n_sec` today): cross-entropy for `"onehot"`, regression for `"physical"` / `"embedding"`. Non-adversarial. |
|
||||
|
||||
There is therefore **no `particle_type.adversarial` key** — the mechanism follows
|
||||
from `generator` × `particle_type.target`.
|
||||
|
||||
### 2.2 Rejected alternatives worth remembering
|
||||
|
||||
- **`tie_to_stage1` as a tri-state** (`none`/`gate`/`full`). Sharing experts
|
||||
between stages is impossible — the stage-1 trunk's input is the 9D target
|
||||
vector, stage 2's is a token vector of a different width. Sharing the *gate*
|
||||
is the only meaningful tying, so the key is a bool.
|
||||
- **Generic `--set path.to.key=value` CLI overrides.** Considered and rejected in
|
||||
favour of explicit flags (decision 7).
|
||||
- **Per-expert trunk sizing** (`expert_hidden_dim` / `expert_n_blocks`). Removed
|
||||
in v0.3.0: experts always use the stage's own `hidden_dim` / `n_res_blocks`.
|
||||
This was already the effective behaviour — v0.2's `0` sentinel meant "inherit"
|
||||
and nothing ever set it otherwise. Consequence worth knowing: a routed model is
|
||||
`n_experts` × the parameters of the monolith at **equal per-row eval cost**
|
||||
(top-1 dispatch runs one full-size trunk), so routing buys specialization, not
|
||||
a per-call speedup.
|
||||
|
||||
### 2.3 Correction to an earlier claim
|
||||
|
||||
An earlier draft of this design asserted that a one-hot conditioning mode is
|
||||
"mathematically identical to an `nn.Embedding` lookup" and should be dropped.
|
||||
**That is wrong for the mode specified in §3.1.** One-hot here is a *fixed,
|
||||
unlearned* vector of width `emb_dim` covering the top `emb_dim - 1` species by
|
||||
training-set count plus an "other" bin. The difference from `embedding` is the
|
||||
**vocabulary cap**, not the parameterization: with 237 PDG codes and
|
||||
`emb_dim = 16`, `embedding` gives 237 distinct learned vectors while `onehot`
|
||||
gives 16 classes. That is a real capacity difference and a real statement about
|
||||
how rare species are treated, so all three modes are kept.
|
||||
|
||||
---
|
||||
|
||||
## 3. Config format reference
|
||||
|
||||
Six top-level blocks: `[conditioning]`, `[stage1_model]`, `[stage2_model]`,
|
||||
`[train]`, plus per-stage sub-tables and the existing `[meta]` (written by
|
||||
`save_config`, never hand-authored).
|
||||
|
||||
### 3.1 `[conditioning]`
|
||||
|
||||
One block shared by both stages. Each stage still builds its own encoder
|
||||
*instance* (separate weights) unless `share_stages = true`.
|
||||
|
||||
**The particle and material axes are configured independently and may mix
|
||||
freely** — e.g. material `physical` with particle `embedding` is a valid and
|
||||
intended combination.
|
||||
|
||||
```toml
|
||||
[conditioning]
|
||||
out_dim = 128
|
||||
share_stages = false
|
||||
|
||||
[conditioning.particle]
|
||||
type = "physical"
|
||||
emb_dim = 16
|
||||
n_layers = 1
|
||||
|
||||
[conditioning.material]
|
||||
type = "physical"
|
||||
emb_dim = 16
|
||||
n_layers = 1
|
||||
```
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `out_dim` | int | `128` | Width of the fused conditioning vector produced by the encoder's fusion MLP, consumed by every downstream trunk. **New in v0.3.0** — v0.2 hardcoded this as `cond_out_dim = 128` in every constructor signature, unreachable from config. |
|
||||
| `share_stages` | bool | `false` | `false`: stage 1 and stage 2 each construct their own `ConditionEncoder` with identical config but independent weights (v0.2 behaviour). `true`: one instance, shared by reference. Shared weights halve the conditioning parameter count and force a common representation; independent weights let each stage specialize its view of the pre-step state. |
|
||||
|
||||
#### `[conditioning.particle]` and `[conditioning.material]`
|
||||
|
||||
Identical key sets, applied to the two identity axes independently.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `type` | `"physical"` \| `"embedding"` \| `"onehot"` | `"physical"` | How this axis's identity becomes an `emb_dim`-wide vector. See the table below. |
|
||||
| `emb_dim` | int | `16` | Width of this axis's vector. Under `"onehot"` it **also sets the class count** — see below. |
|
||||
| `n_layers` | int | `1` | Depth of the sub-MLP under `"physical"`. `1` is the single-layer net with `emb_dim` neurons. Ignored under `"embedding"` / `"onehot"`. |
|
||||
|
||||
**The three modes:**
|
||||
|
||||
| mode | particle input | material input | width | learned parameters |
|
||||
|------|----------------|----------------|-------|--------------------|
|
||||
| `"physical"` | `log(mass)`, `charge` (`PARTICLE_PHYS_DIM = 2`) | `Z_eff`, `A_eff`, `log(density)`, `log(X0)`, `log(λ_int)` (`MATERIAL_PHYS_DIM = 5`) | `emb_dim` | one `n_layers`-deep MLP with `emb_dim` neurons |
|
||||
| `"embedding"` | dense vocab index | dense vocab index | `emb_dim` | `nn.Embedding(vocab, emb_dim)` |
|
||||
| `"onehot"` | top `emb_dim - 1` PDG codes by training-set count, plus one "other" bin | top `emb_dim - 1` materials by count, plus "other" | `emb_dim` | **none** — a fixed vector |
|
||||
|
||||
- `"physical"` reads columns already present in `cond_cont[:, COND_DIM_BASE:]`
|
||||
(see `giant.data.transforms.build_features`). It is computable for **any** PDG
|
||||
code or material, which is what allows generalization beyond the training menu.
|
||||
- `"embedding"` memorizes the training menu — the generalization-comparison
|
||||
baseline, and the only mode that supports
|
||||
`stage2_model.particle_type.target = "embedding"` (§3.3).
|
||||
- `"onehot"` is a *fixed, unlearned* representation. It is **not** a
|
||||
reparameterization of `"embedding"`: the difference is the vocabulary cap. With
|
||||
237 PDG codes and `emb_dim = 16`, `"embedding"` gives 237 distinct learned
|
||||
vectors while `"onehot"` gives 16 classes. Needs the same data-derived top-N
|
||||
map as the stage-2 type target (§8).
|
||||
|
||||
**Interaction:** `particle.type = "physical"` is incompatible with router types
|
||||
`"pdg"` and `"process"`, which build their own dataset-scoped
|
||||
`nn.Embedding(pdg_vocab, ...)` regardless of the trunk's conditioning mode.
|
||||
Pairing them silently reintroduces a training-menu-scoped lookup at the routing
|
||||
layer, defeating the point of physical conditioning. Rejected loudly at build time
|
||||
— `_check_router_conditioning_compat` in `network.py`, which carries over but must
|
||||
now read `conditioning.particle.type` rather than a single global mode.
|
||||
|
||||
### 3.2 `[stage1_model]`
|
||||
|
||||
Stage 1 predicts the 9D primary post-step vector
|
||||
(`giant/constants.py:LOCAL_TARGET_NAMES`). As of decision 1 it carries **no**
|
||||
`n_sec` head.
|
||||
|
||||
```toml
|
||||
[stage1_model]
|
||||
active = true
|
||||
generator = "flow"
|
||||
hidden_dim = 256
|
||||
n_res_blocks = 6
|
||||
dropout = 0.1
|
||||
lambda = 1.0
|
||||
```
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `active` | bool | `true` | `false` skips building and training stage 1 entirely. The resulting checkpoint holds only stage 2 and **cannot be rolled out** — `giant rollout` must refuse it with a clear error. Used for the meeting's Stage-2-only architecture comparison. |
|
||||
| `generator` | `"flow"` \| `"ddpm"` \| `"wgan"` | `"flow"` | The generative objective. `"flow"`: conditional flow matching (Lipman et al. 2022), ~10 ODE steps at inference. `"ddpm"`: cosine-schedule diffusion baseline. `"wgan"`: WGAN-GP, single forward pass at inference. Replaces the global `train.mode`. Objective-specific knobs live in the matching sub-table below. |
|
||||
| `hidden_dim` | int | `256` | Trunk width — also the width of **every expert** under a routed trunk. |
|
||||
| `n_res_blocks` | int | `6` | Number of `ResBlock`s in the trunk, and in every expert under a routed trunk. Was `model.n_blocks`; renamed for clarity since v0.3.0 also has attention layers in stage 2. |
|
||||
| `dropout` | float | `0.1` | Dropout inside each `ResBlock`. |
|
||||
| `lambda` | float | `1.0` | Weight of this stage's loss in the total. Meaningful when both stages are active and non-adversarial; a WGAN stage's adversarial loss drives its own optimizer, so `lambda` scales only its non-adversarial auxiliary terms. |
|
||||
|
||||
#### `[stage1_model.flow]` — read only when `generator = "flow"`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `time_dim` | int | `64` | Width of the `SinusoidalEmbedding` for the flow time variable, concatenated into the trunk's conditioning. **New in v0.3.0** — v0.2 hardcoded 64. |
|
||||
|
||||
#### `[stage1_model.ddpm]` — read only when `generator = "ddpm"`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `time_dim` | int | `64` | As above, for the diffusion time variable. |
|
||||
| `n_steps` | int | `1000` | Cosine-schedule diffusion steps. **New in v0.3.0** — v0.2 hardcoded this in `CosineSchedule`. |
|
||||
|
||||
#### `[stage1_model.wgan]` — read only when `generator = "wgan"`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `noise_dim` | int | `64` | Width of the generator's input noise vector. There is no time variable, hence no `time_dim`. |
|
||||
| `n_critic` | int | `5` | Critic updates per generator update (Gulrajani et al. 2017). |
|
||||
| `gp_weight` | float | `10.0` | Gradient-penalty coefficient. |
|
||||
| `critic_lr` | float | `0.0` | Critic learning rate. `0.0` means "inherit `train.lr`" — not `None`, since the TOML writer has no null literal to round-trip. |
|
||||
| `critic_hidden_dim` | int | `0` | Critic trunk width. `0` = inherit `stage1_model.hidden_dim`. **New in v0.3.0** — v0.2 always sized the critic from the generator. |
|
||||
| `critic_n_res_blocks` | int | `0` | Critic trunk depth. `0` = inherit `stage1_model.n_res_blocks`. |
|
||||
|
||||
#### `[stage1_model.router]`
|
||||
|
||||
Content carries over from v0.2's `[model.router]` unchanged. Reproduced here in
|
||||
full because the block is now per-stage and its semantics are easy to lose.
|
||||
|
||||
```toml
|
||||
[stage1_model.router]
|
||||
enabled = false
|
||||
type = "energy"
|
||||
n_experts = 4
|
||||
temperature = 0.5
|
||||
learn_centers = true
|
||||
learn_width = false
|
||||
learn_temperature = false
|
||||
width_min_ratio = 0.1
|
||||
width_max_ratio = 10.0
|
||||
lambda_balance = 0.0
|
||||
lambda_entropy = 0.0
|
||||
lambda_proc = 0.0
|
||||
gumbel = false
|
||||
gumbel_tau_start = 1.0
|
||||
gumbel_tau_end = 0.1
|
||||
emb_dim = 8
|
||||
hidden_dim = 64
|
||||
```
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `enabled` | bool | `false` | Replace the monolithic trunk with a mixture of per-expert trunks: soft-mixed over all experts at train time, **top-1 dispatched at eval time** (each row runs exactly one expert). Every expert is `hidden_dim` × `n_res_blocks` — v0.3.0 removes the per-expert sizing keys, so a routed model costs `n_experts` × the monolith's parameters at equal per-row eval cost. Routing buys specialization, not a per-call speedup. |
|
||||
| `type` | str | `"energy"` | Router impl from `ROUTER_REGISTRY`. `"energy"`: soft turn-on gate over normalized pre-step log-energy. `"pdg"`: gate over a learned PDG embedding. `"process"`: own classifier over pre-step conditioning predicting the step-ending physics process. `"composed"`: joint outer-product gating over multiple axes via `axis{i}_{field}` keys. |
|
||||
| `n_experts` | int | `4` | Number of experts. For `type = "process"` this doubles as the number of process classes. Ignored for `type = "composed"` (each axis has its own). |
|
||||
| `temperature` | float | `0.5` | Softmax denominator for the distance-based gate. As `tau -> 0` the gate hardens to nearest-center (Voronoi) selection, which is exactly what eval-time `top1` uses. Energy/pdg routers only. |
|
||||
| `learn_centers` | bool | `true` | Whether gate centers are `nn.Parameter` or a fixed buffer. |
|
||||
| `learn_width` | bool | `false` | Give each expert its own learnable width, sigmoid-bounded to `[width_min_ratio, width_max_ratio] * temperature`. Mutually exclusive with `learn_temperature`. |
|
||||
| `learn_temperature` | bool | `false` | Make the single shared `temperature` learnable, same bounding. Mutually exclusive with `learn_width`. |
|
||||
| `width_min_ratio` | float | `0.1` | Lower bound multiplier for the above. Must bracket 1.0 with `width_max_ratio` so enabling either mode is a no-op at init. |
|
||||
| `width_max_ratio` | float | `10.0` | Upper bound multiplier. **Deliberately bounded rather than `softplus`/`exp`:** an unbounded width lets one expert's logit `-d²/width -> 0` almost everywhere, so it wins nearly every row regardless of distance — the same "experts overlap instead of partitioning" failure the router design exists to avoid. |
|
||||
| `lambda_balance` | float | `0.0` | Importance-CV² load-balancing auxiliary loss weight (Shazeer et al. 2017). **The 2026-07-22 benchmark failure ran with `0.0`; do not repeat that.** |
|
||||
| `lambda_entropy` | float | `0.0` | Entropy-regularization weight penalizing uniform/collapsed gating. Secondary guard against all experts' widths co-inflating together, which `lambda_balance` cannot see (usage shares stay even throughout that failure). Use with caution: indiscriminate entropy penalties also suppress legitimate ambiguity near a decision boundary. |
|
||||
| `lambda_proc` | float | `0.0` | Supervised process-classification CE weight. `"process"` router only; `0.0` still trains a working router (the gate gets gradient through the downstream loss) but only `> 0` grounds it in the true `process` label. |
|
||||
| `gumbel` | bool | `false` | Straight-through Gumbel-softmax train-time combine weights: the forward pass samples a hard one-hot combination (matching eval-time top-1 dispatch exactly) while the backward pass still flows smooth gradient to every expert. Targets the train/eval mismatch. |
|
||||
| `gumbel_tau_start` | float | `1.0` | Gumbel temperature at step 0, annealed linearly over training. |
|
||||
| `gumbel_tau_end` | float | `0.1` | Gumbel temperature at the final step. |
|
||||
| `emb_dim` | int | `8` | The router's **own** pdg (and material) embedding width, separate from the trunk's `ConditionEncoder`. `"pdg"` / `"process"` routers only. |
|
||||
| `hidden_dim` | int | `64` | The `"process"` router's internal classifier hidden width. |
|
||||
|
||||
**Composed routers** use flat `axis{i}_{field}` keys instead of `type`/`n_experts`
|
||||
— e.g. `axis0_type = "energy"`, `axis0_n_experts = 4`, `axis1_type = "pdg"`,
|
||||
`axis1_n_experts = 3`, `axis1_emb_dim = 8`. Indices must be contiguous from 0.
|
||||
Flat keys keep the block a table of scalars, which the merge machinery relies on.
|
||||
|
||||
**`centers_init`** is not authored by hand: `giant/pipeline.py` populates it for
|
||||
`type = "energy"` from real data quantiles collected during the existing
|
||||
normalizer-fitting pass, then writes it into the checkpoint's `model_config`.
|
||||
|
||||
### 3.3 `[stage2_model]`
|
||||
|
||||
Stage 2 predicts `n_sec` and the per-secondary energy/direction/type.
|
||||
|
||||
```toml
|
||||
[stage2_model]
|
||||
active = true
|
||||
decoder = "autoregressive"
|
||||
generator = "wgan"
|
||||
hidden_dim = 256
|
||||
n_res_blocks = 6
|
||||
dropout = 0.1
|
||||
lambda = 1.0
|
||||
k_max = 15
|
||||
context_dim = 64
|
||||
stage1_context = "truth"
|
||||
```
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `active` | bool | `true` | `false` trains stage 1 alone. The checkpoint then has no secondary decoder; `giant rollout` must refuse it, `giant predict` still works. |
|
||||
| `decoder` | `"one_shot"` \| `"autoregressive"` | `"autoregressive"` | `"one_shot"`: predict all `k_max` slots simultaneously with padded slots masked from the loss — v0.2 behaviour, kept as the baseline arm of the meeting's §7 comparison. `"autoregressive"`: emit one secondary at a time in descending-energy order. |
|
||||
| `generator` | `"flow"` \| `"ddpm"` \| `"wgan"` | `"wgan"` | As stage 1. Under `"autoregressive"` this is the objective for **each token**: a WGAN token costs one forward pass, a flow token costs ~10 ODE steps. See the cost note in §6.4. |
|
||||
| `hidden_dim` | int | `256` | Trunk width. |
|
||||
| `n_res_blocks` | int | `6` | Trunk depth. |
|
||||
| `dropout` | float | `0.1` | Dropout inside each `ResBlock`. |
|
||||
| `lambda` | float | `1.0` | Weight of stage 2's loss in the total. Was `train.lambda_s2`. |
|
||||
| `k_max` | int | `15` | Maximum secondary slots. Under `"one_shot"` this is the fixed output width; under `"autoregressive"` it is a safety cap on the generation loop. Was the global constant `K_MAX` in `giant/constants.py` (max observed `n_sec` is 14 in the PbWO4 dataset, so 15 covers it with one spare). |
|
||||
| `context_dim` | int | `64` | Width of the projected stage-1 outcome fed into stage 2's conditioning. Was the hardcoded `stage1_proj_dim = 64`. |
|
||||
| `stage1_context` | `"truth"` \| `"sampled"` | `"truth"` | What stage 2 conditions on during training. `"truth"`: the ground-truth stage-1 target vector, detached — v0.2 behaviour (`train.py:254` passes `x1_s1.detach()`), i.e. stage-level teacher forcing. `"sampled"`: stage 1's own sampled output, closing the train/inference gap at the cost of a sampling pass per batch and a moving target early in training. |
|
||||
|
||||
#### `[stage2_model.n_sec]`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `mode` | `"head"` \| `"stop_token"` \| `"truth"` | `"head"` | `"head"`: a classifier over `{0..k_max}` on the condition encoding alone (no diffusion noise), so it is callable independently at inference — v0.2 behaviour, and what the meeting's §3 explicitly decided to keep. `"stop_token"`: an EOS-style implicit stop, documented in §3 as a later possibility, not a decision. `"truth"`: take `n_sec` from ground truth — only valid for standalone stage-2 evaluation, never for rollout. |
|
||||
| `lambda` | float | `0.1` | Cross-entropy weight for the head. Was `train.lambda_nsec`. |
|
||||
|
||||
#### `[stage2_model.particle_type]`
|
||||
|
||||
**The three targets mirror the three conditioning modes of §3.1**, and use the
|
||||
same names.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `target` | `"onehot"` \| `"physical"` \| `"embedding"` | `"onehot"` | What the token's type slice *is*. See the table below. |
|
||||
| `lambda` | float | `1.0` | Loss weight. Under `generator = "flow"`/`"ddpm"` this weights the cross-entropy (`"onehot"`) or regression (`"physical"`/`"embedding"`) term; under `"wgan"` the type is adversarial (§2.1) and this weights only any auxiliary term. |
|
||||
| `other_policy` | `"sample"` \| `"modal"` \| `"drop"` | `"sample"` | How a predicted "other" class becomes a concrete PDG code at rollout, needed because a secondary's mass/charge feed its own downstream conditioning. `"sample"`: draw from the empirical within-bucket distribution recorded at map-build time. `"modal"`: always the most common member. `"drop"`: discard the secondary. Read only under `target = "onehot"`. **Not decided in the meeting** — see §11. |
|
||||
|
||||
**There is no `n_classes` key.** The class count under `target = "onehot"` is
|
||||
`conditioning.particle.emb_dim` — the same number that sizes the particle axis
|
||||
everywhere else. One knob sets the model's particle-type resolution, and the
|
||||
stage-2 onehot classes are by construction the same classes the conditioning
|
||||
onehot uses, so an emitted secondary's type is directly consumable as the
|
||||
conditioning of its own next step with no re-mapping.
|
||||
|
||||
Note the coupling this creates: under `conditioning.particle.type = "physical"`,
|
||||
`emb_dim` primarily means "sub-MLP output width", yet it still sets the stage-2
|
||||
class count. Intentional, but worth knowing when tuning either.
|
||||
|
||||
| target | token type slice | width | training target | inverse map at rollout |
|
||||
|--------|------------------|-------|-----------------|------------------------|
|
||||
| `"physical"` | regressed `(log mass, charge)` | `2` | the true PDG's physics values (`giant.particles.particle_mass_charge`) | none needed — mass/charge are used directly; `giant.particles.nearest_known_pdg` gives a reporting-only label. v0.2 behaviour. |
|
||||
| `"onehot"` | class logits | `conditioning.particle.emb_dim` | true class index | `argmax` -> class -> PDG (via `other_policy` for the "other" bin) |
|
||||
| `"embedding"` | an `emb_dim`-wide vector | `conditioning.particle.emb_dim` | `emb.weight[class].detach()` | L1-nearest row of `emb.weight` — see below |
|
||||
|
||||
So the type slice is `conditioning.particle.emb_dim` wide for **both** `"onehot"`
|
||||
and `"embedding"`, and `2` only for `"physical"`.
|
||||
|
||||
#### `target = "embedding"` in detail
|
||||
|
||||
Stage 2 emits a vector that should equal **the conditioning's own particle
|
||||
embedding** for the secondary's species — the same `nn.Embedding` table
|
||||
`[conditioning.particle]` builds, not a second one.
|
||||
|
||||
**Requires `conditioning.particle.type = "embedding"`.** There is no table to
|
||||
match against under `"physical"` or `"onehot"`; reject at config-validation time
|
||||
with an explicit error.
|
||||
|
||||
**Why detached:** the regression target is `emb.weight[class].detach()`, so the
|
||||
embedding table receives gradient **only through the conditioning path**, never
|
||||
through the stage-2 output loss. Without the detach the target moves as the
|
||||
decoder chases it — the exact failure mode that motivated abandoning the learned
|
||||
type target in the first place (see `decisions/physical-property-conditioning`).
|
||||
The detach is what makes this option viable again.
|
||||
|
||||
**Inverse map.** The natural exact-match form
|
||||
|
||||
```python
|
||||
((out - emb.weight).abs().sum(1) < 1e-6).nonzero()
|
||||
```
|
||||
|
||||
is correct as a **round-trip assertion in tests** (encode a known PDG, decode,
|
||||
recover the same PDG) but **must not be used at inference**: a generative model's
|
||||
continuous output essentially never lands within `1e-6` of a table row, so it
|
||||
returns an empty tensor almost always. Inference needs the nearest row:
|
||||
|
||||
```python
|
||||
pdg_idx = (out.unsqueeze(-2) - emb.weight).abs().sum(-1).argmin(-1) # L1 nearest
|
||||
```
|
||||
|
||||
Note this decode is **unbounded in vocabulary**, unlike `"onehot"` — every PDG
|
||||
code in the training vocab is reachable, and there is no "other" bucket, hence no
|
||||
`other_policy`. The trade-off is that nearest-neighbour decode has no notion of
|
||||
confidence: an output far from every row still snaps to something.
|
||||
|
||||
#### `[stage2_model.autoregressive]` — read only when `decoder = "autoregressive"`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `order` | `"energy_desc"` | `"energy_desc"` | Canonical generation order. Descending energy is the ordering already flagged as natural in the Phase-2 note's open questions, and the one the existing stick-breaking encoding assumes. Single-valued for now; the key exists so an alternative ordering is not a config break. |
|
||||
| `history` | `"markov"` \| `"attention"` | `"markov"` | How token *i+1* sees tokens ≤ *i*. `"markov"`: previous token plus running scalars (remaining energy budget, slot index) — a fixed-width summary. `"attention"`: causal self-attention over all emitted tokens. See §6.2 for the trade-off. |
|
||||
| `teacher_forcing` | `"always"` \| `"scheduled"` \| `"never"` | `"always"` | `"always"`: condition on the ground-truth previous secondary throughout training (the meeting's confirmed plan). `"scheduled"`: scheduled sampling — interpolate toward conditioning on the model's own prediction. `"never"`: free-running from the start. |
|
||||
| `tf_p_start` | float | `1.0` | Under `"scheduled"`, P(use ground truth) at epoch 0. |
|
||||
| `tf_p_end` | float | `1.0` | Under `"scheduled"`, P(use ground truth) at the final epoch. Linear interpolation between the two. |
|
||||
| `attn_n_heads` | int | `4` | Attention heads. Read only under `history = "attention"`. |
|
||||
| `attn_n_layers` | int | `2` | Causal self-attention layers. Read only under `history = "attention"`. |
|
||||
|
||||
#### `[stage2_model.flow]` / `[stage2_model.ddpm]`
|
||||
|
||||
Same keys as their `[stage1_model.*]` counterparts (`time_dim`; plus `n_steps`
|
||||
for ddpm).
|
||||
|
||||
#### `[stage2_model.wgan]` — read only when `generator = "wgan"`
|
||||
|
||||
Same keys as `[stage1_model.wgan]` (`noise_dim`, `n_critic`, `gp_weight`,
|
||||
`critic_lr`, `critic_hidden_dim`, `critic_n_res_blocks`), plus:
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `gumbel_tau_start` | float | `1.0` | Straight-through Gumbel temperature for the **particle-type one-hot** at step 0, annealed linearly. Read only under `particle_type.target = "onehot"` (the other two targets are continuous and need no relaxation). Distinct from `router.gumbel_tau_start`, which anneals expert-combination weights — two unrelated Gumbel relaxations that must not share a key. |
|
||||
| `gumbel_tau_end` | float | `0.1` | Same, at the final step. |
|
||||
|
||||
Under `"autoregressive"` a fresh `noise_dim` draw is made **per token**.
|
||||
|
||||
#### `[stage2_model.router]`
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `tie_to_stage1` | bool | `false` | `true`: stage 2 shares **stage 1's `Router` module instance**, so expert *i* in stage 1 and expert *i* in stage 2 gate on identical conditions by construction. Every other key in this block is then ignored. `false`: an independent router — note this is v0.2's actual behaviour, which built *two separate routers from one config*, so stage-1 expert *i* and stage-2 expert *i* had no semantic relationship despite identical hyperparameters. |
|
||||
|
||||
All other keys are as `[stage1_model.router]`. Invalid when `stage1_model.active
|
||||
= false` and `tie_to_stage1 = true` — reject at config-validation time.
|
||||
|
||||
### 3.4 `[train]`
|
||||
|
||||
Optimizer, schedule, data split and logging only. Everything model-shaped moved
|
||||
into the stage blocks.
|
||||
|
||||
```toml
|
||||
[train]
|
||||
epochs = 100
|
||||
batch_size = 4096
|
||||
lr = 3e-4
|
||||
weight_decay = 0.01
|
||||
ema_decay = 0.9999
|
||||
warmup_epochs = 5
|
||||
val_fraction = 0.1
|
||||
max_val_batches = 200
|
||||
num_workers = 4
|
||||
seed = 0
|
||||
validate_every = 10
|
||||
validate_steps = 10
|
||||
wandb = false
|
||||
wandb_project = "giant"
|
||||
wandb_run_name = ""
|
||||
wandb_log_every = 50
|
||||
```
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----|------|---------|---------|
|
||||
| `epochs` | int | `100` | Training epochs. |
|
||||
| `batch_size` | int | `4096` | Steps per batch. `auto` on the CLI estimates from free VRAM. |
|
||||
| `lr` | float | `3e-4` | AdamW learning rate for every stage's generator. |
|
||||
| `weight_decay` | float | `0.01` | AdamW weight decay. |
|
||||
| `ema_decay` | float | `0.9999` | EMA of model weights used for sampling; `0` disables. Maintained per stage. |
|
||||
| `warmup_epochs` | int | `5` | Linear LR warmup. |
|
||||
| `val_fraction` | float | `0.1` | Fraction of **events** (not steps) held out — the split is by `event_id` to avoid leaking correlated steps from the same shower. |
|
||||
| `max_val_batches` | int | `200` | Cap on the per-epoch val-loss pass; `0` = full val set. Distinct from `validate_every`'s marginal/KL pass. |
|
||||
| `num_workers` | int | `4` | DataLoader workers. Warns above ~1/4 of the machine's CPUs — portal machines are shared. |
|
||||
| `seed` | int | `0` | Seeds Python/numpy/torch and the event split. |
|
||||
| `validate_every` | int | `10` | Epochs between full marginal/KL validation passes. |
|
||||
| `validate_steps` | int | `10` | Sampler steps used during those passes. |
|
||||
| `wandb` | bool | `false` | Opt-in W&B logging. |
|
||||
| `wandb_project` | str | `"giant"` | W&B project. |
|
||||
| `wandb_run_name` | str | `""` | `""` means "use the checkpoint out_dir name" — not `None`, since the TOML writer has no null literal. |
|
||||
| `wandb_log_every` | int | `50` | Optimizer steps between batch-granularity metric logs. A single epoch can be tens of thousands of steps; per-epoch metrics always log in full. |
|
||||
|
||||
### 3.5 Removed from v0.2
|
||||
|
||||
| Key | Fate |
|
||||
|-----|------|
|
||||
| `train.mode` | Split into `stage1_model.generator` / `stage2_model.generator`. |
|
||||
| `train.lambda_nsec` | -> `stage2_model.n_sec.lambda`. |
|
||||
| `train.lambda_s2` | -> `stage2_model.lambda`. |
|
||||
| `train.n_critic`, `gp_weight`, `critic_lr` | -> `stage{1,2}_model.wgan.*`. |
|
||||
| `[model]` (whole block) | Split across `[conditioning]` and the two stage blocks. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration: v0.2 -> v0.3
|
||||
|
||||
`[meta] config_version = 3` tags the new format; **absent means v0.2**.
|
||||
`migrate_config(cfg) -> cfg` applies the table below and is called on both
|
||||
`config.toml` load and checkpoint `model_config` load, so nothing on `/ceph` goes
|
||||
dead (decision 3).
|
||||
|
||||
| v0.2 | v0.3 |
|
||||
|------|------|
|
||||
| `train.mode` | `stage1_model.generator` **and** `stage2_model.generator` (same value) |
|
||||
| `train.lambda_nsec` | `stage2_model.n_sec.lambda` |
|
||||
| `train.lambda_s2` | `stage2_model.lambda` |
|
||||
| `train.n_critic` / `gp_weight` / `critic_lr` | `stage1_model.wgan.*` **and** `stage2_model.wgan.*` |
|
||||
| `model.hidden_dim` / `n_blocks` / `dropout` | `stage{1,2}_model.hidden_dim` / `n_res_blocks` / `dropout` |
|
||||
| `model.emb_dim` | `conditioning.particle.emb_dim` **and** `conditioning.material.emb_dim` (v0.2 had one shared value) |
|
||||
| `model.conditioning` | `conditioning.particle.type` **and** `conditioning.material.type` (v0.2 had one shared mode) |
|
||||
| `model.noise_dim` | `stage{1,2}_model.wgan.noise_dim` |
|
||||
| `model.router.*` | `stage1_model.router.*`, copied verbatim to `stage2_model.router.*` with `tie_to_stage1 = false` (preserves v0.2's two-independent-routers behaviour) |
|
||||
| `model.expert_hidden_dim` / `expert_n_blocks` | **dropped.** v0.2's `0` sentinel meant "inherit from the monolith", which is now unconditional. A v0.2 config with a *non-zero* value must fail loudly rather than silently resize the experts — see §4.3. |
|
||||
| `model.k_max` | `stage2_model.k_max` |
|
||||
| — | `conditioning.out_dim = 128` (v0.2's hardcoded value) |
|
||||
| — | `conditioning.{particle,material}.n_layers = 2` (v0.2's hardcoded depth; note the v0.3 *default* is 1) |
|
||||
| — | `stage{1,2}_model.flow.time_dim = 64` / `.ddpm.time_dim = 64` (v0.2's hardcoded value) |
|
||||
| — | `stage2_model.context_dim = 64` (v0.2's hardcoded `stage1_proj_dim`) |
|
||||
| — | `stage2_model.decoder = "one_shot"` (v0.2 had no other option) |
|
||||
| — | `stage2_model.particle_type.target = "physical"` (v0.2 behaviour) |
|
||||
| — | `stage{1,2}_model.active = true` |
|
||||
|
||||
### 4.1 The awkward one: `n_sec` head ownership
|
||||
|
||||
Decision 1 moves the `n_sec` head to stage 2, but **v0.2 checkpoints carry
|
||||
`n_sec_head` weights inside the stage-1 module** (`DenoisingMLP.n_sec_head`,
|
||||
`WGANGenerator.n_sec_head`, `RoutedDenoisingMLP.n_sec_head`). The shim must keep
|
||||
those loading where they are.
|
||||
|
||||
Handling: `migrate_config` sets an internal
|
||||
`stage2_model.n_sec.legacy_owner = "stage1"` that `build_models` honours by
|
||||
attaching the head to the stage-1 module. Never written by new runs, never
|
||||
CLI-settable, never documented as a user-facing option.
|
||||
|
||||
### 4.2 Non-inheriting expert dims
|
||||
|
||||
v0.3.0 drops `expert_hidden_dim` / `expert_n_blocks` (§2.2). Migration must
|
||||
distinguish two cases:
|
||||
|
||||
- value is `0` (the "inherit" sentinel, and what every real run used) — drop the
|
||||
key silently, behaviour is unchanged.
|
||||
- value is non-zero — **fail loudly.** Silently resizing those experts to
|
||||
`hidden_dim` would change the architecture, so the checkpoint's weights would no
|
||||
longer match. Such a checkpoint can only be loaded by v0.2.
|
||||
|
||||
### 4.3 Migration test
|
||||
|
||||
The acceptance criterion for the whole shim: **load a v0.2 checkpoint through
|
||||
`migrate_config` + the new `build_models`, and diff its outputs against v0.2 code
|
||||
on the same input batch.** Bit-identical, or the refactor has changed something it
|
||||
should not have. Pick one flow checkpoint and one WGAN checkpoint from `/ceph`.
|
||||
|
||||
---
|
||||
|
||||
## 5. network.py refactor
|
||||
|
||||
### 5.1 What it looks like today
|
||||
|
||||
Ten classes that are permutations of three independent choices:
|
||||
|
||||
| | flow/ddpm | wgan generator | wgan critic |
|
||||
|---|---|---|---|
|
||||
| **stage 1** | `DenoisingMLP` | `WGANGenerator` | `Critic` |
|
||||
| **stage 1, routed** | `RoutedDenoisingMLP` | — | — |
|
||||
| **stage 2** | `SecondaryDecoder` | `WGANSecondaryGenerator` | `SecondaryCritic` |
|
||||
| **stage 2, routed** | `RoutedSecondaryDecoder` | — | — |
|
||||
|
||||
The empty cells are the entire reason `giant/pipeline.py:275` hard-rejects
|
||||
`--mode wgan --router`: no routed WGAN generator class was ever written. There is
|
||||
no deeper reason — the routed trunk is orthogonal to the objective.
|
||||
|
||||
Every one of those classes repeats the same body: build a condition encoder,
|
||||
optionally a time embedding, project input, run blocks, project output.
|
||||
|
||||
### 5.2 Proposed decomposition — one axis per config block
|
||||
|
||||
**(a) `[conditioning]` -> encoders**
|
||||
|
||||
```python
|
||||
ConditionEncoder(type, emb_dim, n_layers, out_dim) # behaviour unchanged, now configurable
|
||||
ContextAdapter(in_dim, context_dim) # stage-1 outcome -> context vector
|
||||
```
|
||||
|
||||
`SecondaryConditionEncoder` **disappears as a class**. It was
|
||||
`ConditionEncoder` + a `stage1_proj` linear + a fuse layer; those compose at the
|
||||
stage-model level instead.
|
||||
|
||||
**(b) `[stage*_model]` + `.router` -> trunks, behind one interface**
|
||||
|
||||
```python
|
||||
class Trunk(nn.Module):
|
||||
def forward(self, x, cond, cond_cont=None, cond_cat=None) -> Tensor: ...
|
||||
|
||||
MonolithicTrunk(in_dim, out_dim, hidden_dim, n_res_blocks, cond_dim, dropout)
|
||||
RoutedTrunk(router, in_dim, out_dim, hidden_dim, n_res_blocks, cond_dim, dropout)
|
||||
|
||||
build_trunk(stage_cfg, in_dim, out_dim, cond_dim) -> Trunk
|
||||
```
|
||||
|
||||
`ExpertTrunk` and `_route_forward` carry over unchanged. **One required change:**
|
||||
`MonolithicTrunk` and `ExpertTrunk` need a separate `out_dim` — today
|
||||
`ExpertTrunk` hardcodes `out_proj = nn.Linear(hidden_dim, in_dim)`, i.e.
|
||||
`out_dim == in_dim`. That stops working the moment stage 2's per-token output is
|
||||
`4 + type_dim` wide while its input is a noise vector of width `noise_dim`.
|
||||
|
||||
**(c) `[stage*_model].generator` -> a thin wrapper, not a class family**
|
||||
|
||||
The generator choice controls exactly two things:
|
||||
|
||||
- whether `SinusoidalEmbedding(t)` is concatenated into `cond` (flow/ddpm) or not (wgan)
|
||||
- whether the trunk's `x` is the diffused/interpolated `x_t` (flow/ddpm) or a noise draw `z` (wgan)
|
||||
|
||||
That is small enough for one wrapper, collapsing six of today's ten classes:
|
||||
|
||||
```python
|
||||
class GenerativeTrunk(nn.Module):
|
||||
"""cond-encode -> (optional time-embed) -> trunk. Backs flow, ddpm and wgan."""
|
||||
def forward(self, x, cond_cont, cond_cat, t=None, context=None) -> Tensor: ...
|
||||
```
|
||||
|
||||
### 5.3 Resulting class list
|
||||
|
||||
```
|
||||
# building blocks
|
||||
SinusoidalEmbedding, ResBlock, ConditionEncoder, ContextAdapter
|
||||
|
||||
# trunks
|
||||
Trunk (ABC), MonolithicTrunk, RoutedTrunk, ExpertTrunk
|
||||
|
||||
# routers — carried over unchanged
|
||||
Router, EnergyRouter, PdgRouter, ProcessRouter, ComposedRouter
|
||||
ROUTER_REGISTRY, register_router, build_router, build_composed_router
|
||||
|
||||
# history encoders — new, stage-2 AR only
|
||||
HistoryEncoder (ABC), MarkovHistory, AttentionHistory
|
||||
|
||||
# stage models
|
||||
Stage1Model # 9D primary step
|
||||
Stage2OneShot # k_max slots at once (v0.2 behaviour)
|
||||
Stage2Autoregressive # one token at a time
|
||||
CriticModel # stage-1 or stage-2 critic, generator-agnostic
|
||||
|
||||
# factories
|
||||
build_models(cfg) -> {"stage1": ... | None, "stage2": ... | None}
|
||||
build_critics(cfg) -> {"stage1": ... | None, "stage2": ... | None}
|
||||
```
|
||||
|
||||
Ten classes become four stage classes plus reusable parts, and **routed WGAN comes
|
||||
for free** — `pipeline.py`'s rejection can be deleted.
|
||||
|
||||
### 5.4 Factory signature change
|
||||
|
||||
`build_models` returns a **dict, not a tuple**: `active = false` on either stage
|
||||
means that key is `None`. Every caller unpacking
|
||||
`stage1, sec_decoder = build_models(...)` must be updated
|
||||
(`pipeline.py:390`, `cli.py:871`, `cli.py:1254`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Stage-2 autoregressive design
|
||||
|
||||
### 6.1 Token layout
|
||||
|
||||
Secondaries are emitted one at a time in descending energy order. Per-token output
|
||||
width is `4 + type_dim`:
|
||||
|
||||
| slice | meaning |
|
||||
|-------|---------|
|
||||
| `[0]` | stick-breaking logit — fraction of the **remaining** energy budget |
|
||||
| `[1:4]` | local-frame direction, normalized to a unit vector |
|
||||
| `[4:4+type_dim]` | particle type — see below |
|
||||
|
||||
`type_dim` follows `particle_type.target` (§3.3): `2` for `"physical"`, and
|
||||
`conditioning.particle.emb_dim` for both `"onehot"` and `"embedding"`.
|
||||
Only `"onehot"` involves a relaxation — ST-Gumbel under `wgan`, cross-entropy
|
||||
under `flow`/`ddpm`; the other two are continuous and feed the critic (or the
|
||||
regression loss) directly.
|
||||
|
||||
Per-token conditioning is:
|
||||
|
||||
```
|
||||
base condition encoding (ConditionEncoder, [conditioning].out_dim)
|
||||
+ stage-1 context (ContextAdapter, stage2_model.context_dim)
|
||||
+ history_encoder(prefix) (§6.2)
|
||||
+ running scalars (remaining energy budget, slot index)
|
||||
```
|
||||
|
||||
### 6.2 History encoders
|
||||
|
||||
One interface, `history_encoder(prefix) -> fixed-width vector`:
|
||||
|
||||
- **`MarkovHistory`** — the previous token's `(energy_fraction, direction,
|
||||
type_embedding)`. Fixed-width, one small MLP.
|
||||
- **`AttentionHistory`** — causal self-attention over all emitted tokens, taking
|
||||
the last position. `attn_n_heads` × `attn_n_layers`.
|
||||
|
||||
**The trade-off** (this is decision 8's reasoning, recorded so it does not have to
|
||||
be re-derived):
|
||||
|
||||
1. **Markov is less impoverished than it sounds.** The remaining-energy budget is
|
||||
an *exact sufficient statistic* for the conservation constraint — stick-breaking
|
||||
needs nothing else from history. Slot index likewise. What markov genuinely
|
||||
cannot see is set *composition*: "I have already emitted two photons and an
|
||||
electron." That matters for correlated production — pair production emits
|
||||
exactly e⁺e⁻, a brems cascade correlates species across the set. Note that the
|
||||
meeting's charge-conservation idea was exactly such a hand-engineered summary
|
||||
statistic, and it is out of scope for v0.3.0 (decision 6), so markov does not
|
||||
get that crutch.
|
||||
2. **Sequences are short and front-loaded.** `n_sec` is 0–2 for most steps, max 14.
|
||||
For K ≤ 2, attention *is* markov. They diverge only in the high-multiplicity
|
||||
tail — physically interesting but data-poor, so the attention path trains on
|
||||
few examples where it actually matters.
|
||||
3. **Cost is not where intuition puts it.** Under teacher forcing both train in a
|
||||
single parallel pass over all K tokens (every token's input is ground truth, so
|
||||
nothing is sequential). At inference both need K sequential forwards; attention
|
||||
additionally needs a KV cache to avoid re-encoding the prefix. Attention's
|
||||
marginal FLOPs over ≤15 tokens are rounding error.
|
||||
4. **Exposure bias cuts against attention.** Attention conditions on the entire
|
||||
generated prefix, so one off-manifold early token poisons every later token
|
||||
through the context. Markov's fixed summary sees only one bad token, and the
|
||||
budget scalars stay exact regardless. Given the explicitly-flagged train/
|
||||
inference gap and the known compounding-rollout-error problem, the more
|
||||
expressive history is also the more fragile one.
|
||||
|
||||
Hence: markov is the default and the baseline; attention is a flag.
|
||||
|
||||
### 6.3 Energy budget under AR
|
||||
|
||||
**No re-derivation needed**, despite the meeting's action item suggesting
|
||||
otherwise. The existing stick-breaking is already sequential in spirit — each slot
|
||||
takes a fraction of what remains — so it carries over to per-token generation
|
||||
directly by feeding "remaining budget" as a per-token conditioning scalar.
|
||||
Conservation stays exact by construction: the valid slots' energies sum to `e_sec`,
|
||||
which the Stage-1 simplex already guarantees sums correctly with `edep` and
|
||||
`post_E`.
|
||||
|
||||
### 6.4 Cost warning
|
||||
|
||||
AR costs **K sequential forward passes per step** where one-shot costs one.
|
||||
Against the ~10× native-Geant4 eval budget that motivated the whole fast-eval
|
||||
track, this is the number to watch — not the history-encoder choice. With
|
||||
`generator = "flow"` it is worse still: ~10 ODE steps per token, so ~150 forwards
|
||||
per step in the worst case. `generator = "wgan"` (one pass per token) is the only
|
||||
configuration that plausibly meets the budget; flow AR is for quality comparison.
|
||||
|
||||
---
|
||||
|
||||
## 7. Training loop
|
||||
|
||||
Decision 2 (full mixed per-stage objectives) makes `train.py` one trainer object
|
||||
per active stage:
|
||||
|
||||
```python
|
||||
class StageTrainer: # owns optimizers, EMA, update cadence
|
||||
def step(self, batch, global_step) -> dict[str, float]: ...
|
||||
|
||||
FlowTrainer, DDPMTrainer, WGANTrainer(critic, n_critic, gp_weight)
|
||||
```
|
||||
|
||||
- Non-adversarial stages contribute `lambda * loss` to one backward pass.
|
||||
- A WGAN stage runs its own critic inner loop on the same batch, with a generator
|
||||
update every `n_critic`-th batch — today's `_wgan_train_step` cadence.
|
||||
- A mixed run (`flow` + `wgan`) steps stage 1 every batch while stage 2 does 5
|
||||
critic updates then a generator update. Independent optimizers, independent EMA.
|
||||
- Router auxiliary losses (`lambda_balance` / `lambda_proc` / `lambda_entropy`)
|
||||
become per-stage, summed over whichever stages are routed. Today's
|
||||
`hasattr(model, "router")` check (`train.py:262`) generalizes cleanly.
|
||||
- `metrics.csv` and W&B metric names gain a stage prefix.
|
||||
- With `stage1_model.active = false`, stage 2 still needs its stage-1 context: it
|
||||
comes from the ground-truth target already in the batch (`x1_s1`), which is
|
||||
exactly what v0.2 does anyway. **Stage-2-only training is therefore a cheap
|
||||
ablation, not new plumbing** — drop the stage-1 loss, skip building stage 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Data and setup-cache changes
|
||||
|
||||
Three config options need a "top N−1 by training-set count plus other" map, but
|
||||
they resolve to **at most two distinct maps per run** — one per axis — because
|
||||
the class count always comes from that axis's `emb_dim`:
|
||||
|
||||
| consumer | axis | N |
|
||||
|----------|------|---|
|
||||
| `conditioning.particle.type = "onehot"` | PDG | `conditioning.particle.emb_dim` |
|
||||
| `stage2_model.particle_type.target = "onehot"` | PDG | `conditioning.particle.emb_dim` |
|
||||
| `conditioning.material.type = "onehot"` | material | `conditioning.material.emb_dim` |
|
||||
|
||||
The two PDG consumers therefore **share one map** — which is the point of
|
||||
dropping `n_classes`: a secondary's emitted type is directly consumable as the
|
||||
conditioning of its own next step, with no re-mapping between two class systems.
|
||||
Build one shared helper, structurally identical to today's `proc_map`:
|
||||
|
||||
- `build_topn_map_from_files(files, column, n_classes=...)` in
|
||||
`giant/data/loader.py`, next to `build_process_map_from_files`
|
||||
- a `setup_cache` section keyed by `(axis, N)`, same shape as `cache.proc_maps`
|
||||
(which is keyed by `n_experts`). N is still part of the key so the sidecar stays
|
||||
reusable across runs with different `emb_dim`, even though a single run only
|
||||
ever needs one N per axis.
|
||||
- persisted into the checkpoint beside `pdg_map` / `mat_map`
|
||||
- inverted at rollout to recover a concrete PDG -> mass/charge per secondary
|
||||
|
||||
Record the **empirical within-bucket distribution** at map-build time as well —
|
||||
`other_policy = "sample"` needs it.
|
||||
|
||||
`particle_type.target = "embedding"` needs no map: it reaches the full training
|
||||
vocab through the conditioning's embedding table (§3.3).
|
||||
|
||||
`giant/constants.py`: `K_MAX` and `SEC_DIM` stop being authoritative constants
|
||||
(they become `stage2_model.k_max` and a derived quantity). Keep them as defaults
|
||||
only, and audit the ~12 modules importing `K_MAX` for places that assume it is
|
||||
global truth.
|
||||
|
||||
---
|
||||
|
||||
## 9. Config machinery changes
|
||||
|
||||
All in `giant/config.py`:
|
||||
|
||||
- **`merge_cli_overrides`** — replace the hand-written one-level router merge with
|
||||
a generic recursive deep-merge. The new layout is three levels deep
|
||||
(`stage1_model.router.axis0_type`).
|
||||
- **`save_config`** — recursive TOML writer; today it handles exactly one nesting
|
||||
level (see its `nested_sections` list).
|
||||
- **`default_out_dir_name`** — `_OUT_DIR_NAME_CANDIDATES` entries become dotted
|
||||
paths (`"stage2_model.decoder"`) instead of `(section, field)` pairs. Add
|
||||
candidates for the new discriminating fields: `decoder`, per-stage `generator`,
|
||||
`particle_type.target`, `autoregressive.history`.
|
||||
- **`resolve_expert_dims`** — **deleted.** Experts always take the stage's
|
||||
`hidden_dim` / `n_res_blocks` (§2.2). Its two callers (`pipeline.py:351` and the
|
||||
CLI's batch-size auto-estimate) read the stage keys directly, as does
|
||||
`pipeline.py`'s "experts are NxM, different from model.hidden_dim" warning,
|
||||
which becomes unreachable and should go.
|
||||
- **`migrate_config(cfg) -> cfg`** — §4's table, applied on both `config.toml` load
|
||||
and checkpoint `model_config` load. New `[meta] config_version = 3`.
|
||||
- **`Conditioning` enum** — gains a third member `onehot`, and now feeds **two**
|
||||
keys (`conditioning.particle.type`, `conditioning.material.type`) rather than
|
||||
one. Shared with `scripts/dwarf.py`, so both CLIs stay in sync.
|
||||
- **Cross-block validation** — a new `validate_config(cfg)` pass, since v0.3.0 has
|
||||
constraints no single block can check:
|
||||
`particle_type.target = "embedding"` requires
|
||||
`conditioning.particle.type = "embedding"`; router types `"pdg"`/`"process"`
|
||||
require `conditioning.particle.type != "physical"`;
|
||||
`stage2_model.router.tie_to_stage1` requires `stage1_model.active`;
|
||||
`n_sec.mode = "truth"` is invalid for a rollout-capable checkpoint.
|
||||
- **`estimate_batch_size`** — its calibration constants assume the v0.2
|
||||
architecture ("post-Phase-2, including the Stage-2 secondary decoder and n_sec
|
||||
head"). AR stage 2 changes the activation-memory profile; re-measure and note
|
||||
the new calibration point.
|
||||
|
||||
---
|
||||
|
||||
## 10. Callers that need updating
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `giant/pipeline.py` | Builds `model_config`; now per-stage. Delete the wgan+router rejection at `:275`. Router `centers_init` seeding becomes per-stage. |
|
||||
| `giant/train.py` | Per-stage trainers (§7). |
|
||||
| `giant/cli.py` | Stage-prefixed flags for `train` and `new-run`; `build_models` now returns a dict (`:871`, `:1254`); `:1339` writes `model_config` into the rollout sidecar. |
|
||||
| `giant/sample.py` | Sampler picked per stage from `stage*_model.generator`; new AR sampling loop with KV cache under `history = "attention"`. |
|
||||
| `giant/rollout.py` | AR secondary generation; categorical class -> PDG decode; `other_policy` handling. |
|
||||
| `giant/validate.py` | Stage-2 marginals gain a type-class marginal. |
|
||||
| `giant/analysis/render.py` | `_router_summary(model_config)` at `:44` reads `model_config["router"]`. |
|
||||
| `giant/analysis/router_gating.py` | Same, at `:76`. |
|
||||
| `giant/constants.py` | `K_MAX` / `SEC_DIM` demoted to defaults (§8). |
|
||||
| `configs/*.toml` | All eight shipped configs are v0.2-format; regenerate or rely on the shim. |
|
||||
| `condor-gpu-train-rollout` branch | Submits `giant train` flags; needs rebasing onto the new flag surface. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **"Other" bucket at rollout.** A predicted "other" class has no concrete PDG,
|
||||
so no mass/charge for that secondary's own downstream conditioning.
|
||||
`other_policy` is the config surface; the mechanism is a judgement call
|
||||
(`"sample"` from the empirical within-bucket distribution seems least biased).
|
||||
**Not decided in the meeting.**
|
||||
2. **Charge conservation.** Out of scope for v0.3.0 (decision 6), but worth
|
||||
recording *why* it interacts with decision 5: a categorical head makes a hard
|
||||
mechanism tractable — mask classes whose charge cannot fit the remaining charge
|
||||
budget before the softmax, analogous to the energy simplex. This is **only**
|
||||
possible with a categorical type; the continuous `(mass, charge)` target admits
|
||||
no such mask. That is a second, independent argument for the §5 switch beyond
|
||||
the species-collapse one.
|
||||
3. **Does `"stop_token"` deserve building in v0.3.0?** The meeting kept the `n_sec`
|
||||
head and recorded the implicit stop as a later possibility. The config key
|
||||
exists; the implementation can be deferred.
|
||||
4. **`estimate_batch_size` calibration** for AR stage 2 (§9).
|
||||
5. **Nearest-neighbour decode has no reject option.** Under
|
||||
`particle_type.target = "embedding"`, an output far from every table row still
|
||||
snaps to its nearest neighbour — there is no equivalent of `"onehot"`'s "other"
|
||||
bin, and no confidence signal. Worth logging the L1 distance distribution at
|
||||
rollout: a heavy tail means the decoder is emitting vectors off the embedding
|
||||
manifold, which would be the direct analogue of the species-collapse symptom
|
||||
this redesign is chasing.
|
||||
6. **Should `flow` and `ddpm` share one sub-table?** Both need `time_dim`; only
|
||||
ddpm needs `n_steps`. Currently duplicated as `[stage*_model.flow]` and
|
||||
`[stage*_model.ddpm]`. A single `[stage*_model.diffusion]` would avoid the
|
||||
duplication at the cost of a name that fits flow matching poorly.
|
||||
5. **Differentiability sanity check.** The meeting's §5 notes that the original
|
||||
argument for the continuous type target rested on avoiding a non-differentiable
|
||||
categorical *sampling* step — but per-token training loss is differentiable
|
||||
either way under teacher forcing, and full shower-rollout backprop is already
|
||||
structurally non-differentiable once secondaries spawn branches. The note flags
|
||||
this as "worth confirming explicitly with Jan rather than assuming it".
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation order
|
||||
|
||||
1. **`config.py`** — new `DEFAULT_CONFIG`, recursive merge/write, `migrate_config`,
|
||||
tests. Nothing else can land first.
|
||||
2. **`network.py`** — the §5 decomposition, with `Stage2OneShot` reproducing v0.2
|
||||
exactly. Gate on the §4.3 migration test: load a v0.2 checkpoint through the
|
||||
shim and diff outputs against v0.2 code.
|
||||
3. **`train.py`** — per-stage trainers; `active = false` paths. At this point
|
||||
Stage-2-only training works and the meeting's step 2 (one-shot WGAN baseline,
|
||||
trained standalone) is runnable.
|
||||
4. **Type map** — `loader.py` + `setup_cache.py` + checkpoint persistence +
|
||||
`particle_type.target = "onehot"` in `Stage2OneShot`. This is the meeting's
|
||||
action item 1, and it is testable against the one-shot baseline before any AR
|
||||
work.
|
||||
5. **`Stage2Autoregressive`** with `history = "markov"`, `teacher_forcing =
|
||||
"always"`. The meeting's step 3.
|
||||
6. **`sample.py` / `rollout.py`** — AR generation and class -> PDG decode, so an AR
|
||||
model can actually be rolled out and put through `giant analyze`.
|
||||
7. **`history = "attention"`, scheduled sampling** — then run the meeting's §7
|
||||
comparison (one-shot vs autoregressive, standalone) and only chain the winner
|
||||
behind Stage 1.
|
||||
|
||||
Steps 1–3 are pure refactor with a bit-identical acceptance criterion. Steps 4–7
|
||||
are the actual physics change.
|
||||
Reference in New Issue
Block a user