Files
giant/giant/config.py
T
lars 87e37ebe14
CI / Format (ruff format) (push) Successful in 29s
CI / Lint (ruff check) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 38s
CI / Lint (ruff check) (pull_request) Successful in 31s
CI / Format (ruff format) (pull_request) Successful in 44s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 47s
CI / Tests (push) Successful in 4m32s
CI / Tests (pull_request) Successful in 4m25s
Add per-stage init_from/freeze (gitea #42)
stage{1,2}_model.active = false already trains one stage alone, but the
checkpoint it writes holds only that stage, so giant rollout refuses it --
the "retrain stage 2 alone against a fixed, known-good stage 1" experiment
the 2026-08-03 species failure calls for wasn't runnable end to end.

Adds stage{1,2}_model.init_from (a checkpoint .pt to load this stage's
weights from before training) and .freeze (never update them), symmetric
across both stages. Both stages stay active = true, so both get built and
both land in the output checkpoint -- the frozen stage is merely
initialized from disk instead of from scratch.

Decisions made during planning:
- Soft freeze: forward/backward still run every batch (loss/grad_norm stay
  meaningful, no autograd special-casing), only optimizer.step() (and, for
  the frozen stage, lr_sched.step()/EMA update) is skipped -- weights are
  byte-identical for the whole run. This is StageTrainer._step_optimizer,
  shared by the non-adversarial path and both halves (generator + critic)
  of the WGAN path, so a frozen WGAN stage's critic freezes too.
- validate_config requires init_from whenever freeze = true, unless the run
  is a --resume (a resumed frozen stage's weights come from the resume
  checkpoint instead) -- freezing a randomly-initialized model is almost
  certainly a mistake.
- CLI flags on both `giant train` and `giant new-run`
  (--stage{1,2}-init-from/--stage{1,2}-freeze), matching every other
  per-stage model knob's existing treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:23:20 +02:00

1791 lines
75 KiB
Python

import copy
import difflib
import hashlib
import random
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
import numpy as np
import torch
from giant._migration import V02_FIXED_FACTS, V02_MODEL_KEY_TO_STAGES, reject_legacy_router_expert_sizing
from giant.model.history import HISTORY_REGISTRY
class Conditioning(str, Enum):
"""`conditioning.particle.type` / `conditioning.material.type` choices —
shared by `giant.cli` and `giant.tools.dwarf`'s Typer commands so the two
CLIs can't silently drift apart on the option's valid values (see
DEFAULT_CONFIG["conditioning"] for what each value means)."""
physical = "physical"
embedding = "embedding"
onehot = "onehot"
# Tags a config dict (config.toml, or a checkpoint's model_config) as the new
# v0.3 nested format. Absence of `[meta].config_version == CONFIG_VERSION` is
# read as "this is a v0.2 dict" by migrate_config below.
CONFIG_VERSION = 3
# --- Config dataclasses -----------------------------------------------------
#
# These are the single source of truth for every default below. DEFAULT_CONFIG
# (a plain dict, for merge_cli_overrides/save_config/TOML round-tripping) is
# *generated* from GiantConfig().to_dict() rather than hand-maintained, so it
# cannot drift from the fallback defaults that build_models/build_critics
# (giant/model/network.py) and StageSpec.from_config (giant/training/trainers.py)
# read off these same dataclasses — see issues.md Issue 1.
#
# Each dataclass is frozen and carries an explicit from_dict/to_dict pair
# (mirroring StageSpec's established style in trainers.py) rather than a
# generic reflection-based helper, so every default is fully type-checkable.
# `lambda` is a Python keyword, so dict key "lambda" is always exposed as the
# field `lambda_weight`.
#
# The router sub-block carries genuinely dynamic keys that don't fit a fixed schema:
# composed-router `axis{i}_{field}` flags (see giant.model.network._parse_composed_axes)
# and pipeline.py's runtime-seeded `centers_init`. It carries an `extra: dict` catch-all
# so these keys round-trip losslessly without becoming named fields that would leak into
# every new run's config.toml.
@dataclass(frozen=True)
class ConditioningAxisConfig:
"""One conditioning axis: `conditioning.particle` or `conditioning.material`."""
# "physical": a small MLP over log(mass)/charge, computable for any PDG
# code — generalizes beyond the training menu.
# "embedding": a learned nn.Embedding over a dense training-vocab index —
# memorizes the training menu; the generalization-comparison baseline,
# and the only mode compatible with stage2_model.particle_type.target =
# "embedding".
# "onehot": a fixed, unlearned vector — top (emb_dim - 1) PDG codes by
# training-set count, plus one "other" bin. NOT a reparameterization of
# "embedding": the vocabulary cap is the real difference.
type: str = "physical"
# Width of this axis's vector. Under "onehot" this also sets the class
# count.
emb_dim: int = 16
# Depth of the sub-MLP under "physical". Ignored under "embedding"/"onehot".
n_layers: int = 1
@classmethod
def from_dict(cls, d: dict | None) -> "ConditioningAxisConfig":
d = d or {}
return cls(
type=d.get("type", "physical"),
emb_dim=d.get("emb_dim", 16),
n_layers=d.get("n_layers", 1),
)
def to_dict(self) -> dict:
return {"type": self.type, "emb_dim": self.emb_dim, "n_layers": self.n_layers}
@dataclass(frozen=True)
class ConditioningConfig:
# Width of the fused conditioning vector produced by the encoder's fusion
# MLP, consumed by every downstream trunk.
out_dim: int = 128
# false: stage 1 and stage 2 each construct their own ConditionEncoder
# with identical config but independent weights. true: one instance,
# shared by reference (halves the conditioning parameter count, forces a
# common representation).
share_stages: bool = False
particle: ConditioningAxisConfig = field(default_factory=ConditioningAxisConfig)
material: ConditioningAxisConfig = field(default_factory=ConditioningAxisConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "ConditioningConfig":
d = d or {}
return cls(
out_dim=d.get("out_dim", 128),
share_stages=d.get("share_stages", False),
particle=ConditioningAxisConfig.from_dict(d.get("particle")),
material=ConditioningAxisConfig.from_dict(d.get("material")),
)
def to_dict(self) -> dict:
return {
"out_dim": self.out_dim,
"share_stages": self.share_stages,
"particle": self.particle.to_dict(),
"material": self.material.to_dict(),
}
@dataclass(frozen=True)
class FlowConfig:
# Width of the SinusoidalEmbedding for the flow time variable.
time_dim: int = 64
@classmethod
def from_dict(cls, d: dict | None) -> "FlowConfig":
d = d or {}
return cls(time_dim=d.get("time_dim", 64))
def to_dict(self) -> dict:
return {"time_dim": self.time_dim}
@dataclass(frozen=True)
class DdpmConfig:
time_dim: int = 64
n_steps: int = 1000
@classmethod
def from_dict(cls, d: dict | None) -> "DdpmConfig":
d = d or {}
return cls(time_dim=d.get("time_dim", 64), n_steps=d.get("n_steps", 1000))
def to_dict(self) -> dict:
return {"time_dim": self.time_dim, "n_steps": self.n_steps}
@dataclass(frozen=True)
class Stage1WganConfig:
noise_dim: int = 64
n_critic: int = 5
gp_weight: float = 10.0
# 0.0 means "inherit train.lr" — not None, since the TOML writer has no
# null literal to round-trip.
critic_lr: float = 0.0
# 0 means "inherit stage1_model.hidden_dim/n_res_blocks" — same
# round-trip-friendly sentinel as critic_lr above.
critic_hidden_dim: int = 0
critic_n_res_blocks: int = 0
@classmethod
def from_dict(cls, d: dict | None) -> "Stage1WganConfig":
d = d or {}
return cls(
noise_dim=d.get("noise_dim", 64),
n_critic=d.get("n_critic", 5),
gp_weight=d.get("gp_weight", 10.0),
critic_lr=d.get("critic_lr", 0.0),
critic_hidden_dim=d.get("critic_hidden_dim", 0),
critic_n_res_blocks=d.get("critic_n_res_blocks", 0),
)
def to_dict(self) -> dict:
return {
"noise_dim": self.noise_dim,
"n_critic": self.n_critic,
"gp_weight": self.gp_weight,
"critic_lr": self.critic_lr,
"critic_hidden_dim": self.critic_hidden_dim,
"critic_n_res_blocks": self.critic_n_res_blocks,
}
@dataclass(frozen=True)
class Stage2WganConfig(Stage1WganConfig):
# Straight-through Gumbel temperature for the particle-type one-hot
# (distinct from router.gumbel_tau_start/_end, which anneal
# expert-combination weights). Read only under particle_type.target =
# "onehot".
gumbel_tau_start: float = 1.0
gumbel_tau_end: float = 0.1
@classmethod
def from_dict(cls, d: dict | None) -> "Stage2WganConfig":
d = d or {}
return cls(
noise_dim=d.get("noise_dim", 64),
n_critic=d.get("n_critic", 5),
gp_weight=d.get("gp_weight", 10.0),
critic_lr=d.get("critic_lr", 0.0),
critic_hidden_dim=d.get("critic_hidden_dim", 0),
critic_n_res_blocks=d.get("critic_n_res_blocks", 0),
gumbel_tau_start=d.get("gumbel_tau_start", 1.0),
gumbel_tau_end=d.get("gumbel_tau_end", 0.1),
)
def to_dict(self) -> dict:
return {
**super().to_dict(),
"gumbel_tau_start": self.gumbel_tau_start,
"gumbel_tau_end": self.gumbel_tau_end,
}
# Fixed router fields shared by stage1_model.router and stage2_model.router.
# Composed-router axis{i}_{field} keys and pipeline.py's runtime-seeded
# centers_init are NOT in this set — they land in RouterConfig.extra instead
# (see giant.model.network._parse_composed_axes).
_ROUTER_KNOWN_KEYS = frozenset(
{
"enabled",
"type",
"n_experts",
"temperature",
"learn_centers",
"learn_width",
"learn_temperature",
"width_min_ratio",
"width_max_ratio",
"lambda_balance",
"lambda_entropy",
"gumbel",
"gumbel_tau_start",
"gumbel_tau_end",
"emb_dim",
"hidden_dim",
"lambda_proc",
}
)
@dataclass(frozen=True)
class RouterConfig:
"""`stage1_model.router`'s fixed fields. `extra` holds any key not named
below — composed-router `axis{i}_{field}` flags and pipeline.py's
runtime-seeded `centers_init` — so from_dict()/to_dict() round-trip
losslessly without this dataclass needing to know about them."""
enabled: bool = False
type: str = "energy" # selects the Router impl from ROUTER_REGISTRY
n_experts: int = 4
temperature: float = 0.5 # energy/pdg-router kwarg
learn_centers: bool = True # energy/pdg-router kwarg
# energy-router kwargs: mutually exclusive optional learnable
# gate-sharpness modes. learn_width generalizes the shared `temperature`
# to one learnable width per expert; learn_temperature instead makes the
# single shared `temperature` itself learnable. Both are bounded to
# [width_min_ratio, width_max_ratio] * temperature so gate sharpness
# can't run away to a collapse-inducing extreme during training.
learn_width: bool = False
learn_temperature: bool = False
width_min_ratio: float = 0.1
width_max_ratio: float = 10.0
# Importance-CV^2 load-balancing aux loss weight (Shazeer et al. 2017).
lambda_balance: float = 0.0
# Entropy-regularization weight penalizing uniform/collapsed gating — a
# secondary guard against all experts' widths co-inflating together,
# which lambda_balance alone can't see.
lambda_entropy: float = 0.0
# Opt-in straight-through Gumbel-softmax train-time combine weights: the
# training 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.
gumbel: bool = False
gumbel_tau_start: float = 1.0
gumbel_tau_end: float = 0.1
emb_dim: int = 8 # process/pdg-router kwarg: own pdg(/mat) embedding width
hidden_dim: int = 64 # process-router kwarg: its classifier's hidden width
lambda_proc: float = 0.0 # process-router kwarg: supervised process-CE weight
# type = "composed" routes on multiple axes at once (e.g. energy x pdg),
# each with its own expert count/hyperparameters. Axes are NOT in these
# defaults — set them as flat axis{i}_{field} keys instead of
# "n_experts", e.g. axis0_type = "energy", axis0_n_experts = 4,
# axis1_type = "pdg", axis1_n_experts = 3, axis1_emb_dim = 8. See
# giant.model.network._parse_composed_axes.
extra: dict = field(default_factory=dict)
@classmethod
def from_dict(cls, d: dict | None) -> "RouterConfig":
d = d or {}
return cls(
enabled=d.get("enabled", False),
type=d.get("type", "energy"),
n_experts=d.get("n_experts", 4),
temperature=d.get("temperature", 0.5),
learn_centers=d.get("learn_centers", True),
learn_width=d.get("learn_width", False),
learn_temperature=d.get("learn_temperature", False),
width_min_ratio=d.get("width_min_ratio", 0.1),
width_max_ratio=d.get("width_max_ratio", 10.0),
lambda_balance=d.get("lambda_balance", 0.0),
lambda_entropy=d.get("lambda_entropy", 0.0),
gumbel=d.get("gumbel", False),
gumbel_tau_start=d.get("gumbel_tau_start", 1.0),
gumbel_tau_end=d.get("gumbel_tau_end", 0.1),
emb_dim=d.get("emb_dim", 8),
hidden_dim=d.get("hidden_dim", 64),
lambda_proc=d.get("lambda_proc", 0.0),
extra={k: v for k, v in d.items() if k not in _ROUTER_KNOWN_KEYS},
)
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"type": self.type,
"n_experts": self.n_experts,
"temperature": self.temperature,
"learn_centers": self.learn_centers,
"learn_width": self.learn_width,
"learn_temperature": self.learn_temperature,
"width_min_ratio": self.width_min_ratio,
"width_max_ratio": self.width_max_ratio,
"lambda_balance": self.lambda_balance,
"lambda_entropy": self.lambda_entropy,
"gumbel": self.gumbel,
"gumbel_tau_start": self.gumbel_tau_start,
"gumbel_tau_end": self.gumbel_tau_end,
"emb_dim": self.emb_dim,
"hidden_dim": self.hidden_dim,
"lambda_proc": self.lambda_proc,
**self.extra,
}
@dataclass(frozen=True)
class TrunkConfig:
"""`stage1_model.trunk`/`stage2_model.trunk`: selects the trunk's expert
*body* architecture from `giant.model.trunks.TRUNK_REGISTRY` (default
`"resmlp"` — today's only body, `input_proj -> ResBlock stack ->
out_proj`). Orthogonal to whether that body is mixed: mixing is still
controlled entirely by `router.enabled`/`router.n_experts` on the same
stage, unaffected by this block. A future body's own hyperparameters
(e.g. a transformer's `n_heads`/`n_layers`) would get their own sibling
field here, matching how `flow`/`ddpm`/`wgan` already coexist selected by
`generator`.
`block_conditioning` selects each body's conditioning-injection mechanism
from `giant.model.layers.BLOCK_REGISTRY` — `"add"` (default, today's
conditional-bias `ResBlock`, bit-identical to pre-gitea-#34 behaviour),
`"film"`, or `"adaln"`."""
type: str = "resmlp"
block_conditioning: str = "add"
@classmethod
def from_dict(cls, d: dict | None) -> "TrunkConfig":
d = d or {}
return cls(type=d.get("type", "resmlp"), block_conditioning=d.get("block_conditioning", "add"))
def to_dict(self) -> dict:
return {"type": self.type, "block_conditioning": self.block_conditioning}
@dataclass(frozen=True)
class Stage2RouterConfig(RouterConfig):
# 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.
tie_to_stage1: bool = False
@classmethod
def from_dict(cls, d: dict | None) -> "Stage2RouterConfig":
d = d or {}
known = _ROUTER_KNOWN_KEYS | {"tie_to_stage1"}
return cls(
tie_to_stage1=d.get("tie_to_stage1", False),
enabled=d.get("enabled", False),
type=d.get("type", "energy"),
n_experts=d.get("n_experts", 4),
temperature=d.get("temperature", 0.5),
learn_centers=d.get("learn_centers", True),
learn_width=d.get("learn_width", False),
learn_temperature=d.get("learn_temperature", False),
width_min_ratio=d.get("width_min_ratio", 0.1),
width_max_ratio=d.get("width_max_ratio", 10.0),
lambda_balance=d.get("lambda_balance", 0.0),
lambda_entropy=d.get("lambda_entropy", 0.0),
gumbel=d.get("gumbel", False),
gumbel_tau_start=d.get("gumbel_tau_start", 1.0),
gumbel_tau_end=d.get("gumbel_tau_end", 0.1),
emb_dim=d.get("emb_dim", 8),
hidden_dim=d.get("hidden_dim", 64),
lambda_proc=d.get("lambda_proc", 0.0),
extra={k: v for k, v in d.items() if k not in known},
)
def to_dict(self) -> dict:
return {"tie_to_stage1": self.tie_to_stage1, **super().to_dict()}
@dataclass(frozen=True)
class NSecConfig:
# "head": a classifier over {0..k_max} on the condition encoding alone
# (no diffusion noise), callable independently at inference.
# "stop_token": an EOS-style per-slot stop head on the autoregressive
# secondary decoder (Stage2Autoregressive only — see validate_config),
# evaluated against the generated prefix instead of conditioning alone.
# Replaces n_sec_head entirely: the two are mutually exclusive, so this
# mode builds no n_sec_head and stage2_model.n_sec.lambda instead weights
# the stop head's BCE term.
# "truth": take n_sec from ground truth — standalone stage-2 evaluation
# only, never for rollout.
mode: str = "head"
lambda_weight: float = 0.1 # dict key "lambda" — cross-entropy/BCE weight for the head
# Which stage's module physically owns the n_sec_head weights: "stage2" (default,
# fresh v0.3.0 runs — Stage2OneShot/Stage2Autoregressive builds it) or "stage1"
# (a migrated v0.2 checkpoint — see network._migrate_legacy_model_config, whose
# n_sec head was trained against Stage 1's own ConditionEncoder output and so has
# to stay attached there, not just be labeled as such).
owner: str = "stage2"
# mode="stop_token" only: how sample_secondaries_ar turns a slot's stop logit into a
# stop/continue decision. "greedy": sigmoid(logit) >= 0.5 (deterministic). "sample":
# a Bernoulli draw at sigmoid(logit) (a real sample from the learned length
# distribution, at the cost of an extra RNG draw per slot).
stop_sampling: str = "greedy"
@classmethod
def from_dict(cls, d: dict | None) -> "NSecConfig":
d = d or {}
return cls(
mode=d.get("mode", "head"),
lambda_weight=d.get("lambda", 0.1),
owner=d.get("owner", "stage2"),
stop_sampling=d.get("stop_sampling", "greedy"),
)
def to_dict(self) -> dict:
return {
"mode": self.mode,
"lambda": self.lambda_weight,
"owner": self.owner,
"stop_sampling": self.stop_sampling,
}
@dataclass(frozen=True)
class ParticleTypeConfig:
# The three targets mirror the three conditioning.particle modes.
# "onehot": class logits over conditioning.particle.emb_dim classes.
# "physical": regressed (log mass, charge). "embedding": regressed
# against conditioning's own particle embedding table (requires
# conditioning.particle.type = "embedding").
target: str = "onehot"
lambda_weight: float = 1.0 # dict key "lambda"
# How a predicted "other" class becomes a concrete PDG code at rollout.
# "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".
other_policy: str = "sample"
# Secondary-species class count under target = "onehot" — independent of
# conditioning.particle.emb_dim (see gitea #29: the two used to be
# silently the same number). 0 = inherit conditioning.particle.emb_dim,
# preserving pre-#29 behavior.
n_classes: int = 0
@classmethod
def from_dict(cls, d: dict | None) -> "ParticleTypeConfig":
d = d or {}
return cls(
target=d.get("target", "onehot"),
lambda_weight=d.get("lambda", 1.0),
other_policy=d.get("other_policy", "sample"),
n_classes=d.get("n_classes", 0),
)
def to_dict(self) -> dict:
return {
"target": self.target,
"lambda": self.lambda_weight,
"other_policy": self.other_policy,
"n_classes": self.n_classes,
}
@dataclass(frozen=True)
class AutoregressiveConfig:
# Canonical generation order. Single-valued for now; the key exists so
# an alternative ordering is not a config break.
order: str = "energy_desc"
# 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.
history: str = "markov"
# "always": condition on the ground-truth previous secondary throughout
# training. "scheduled": scheduled sampling — interpolate toward the
# model's own prediction. "never": free-running from the start.
teacher_forcing: str = "always"
tf_p_start: float = 1.0
tf_p_end: float = 1.0
attn_n_heads: int = 4
attn_n_layers: int = 2
@classmethod
def from_dict(cls, d: dict | None) -> "AutoregressiveConfig":
d = d or {}
return cls(
order=d.get("order", "energy_desc"),
history=d.get("history", "markov"),
teacher_forcing=d.get("teacher_forcing", "always"),
tf_p_start=d.get("tf_p_start", 1.0),
tf_p_end=d.get("tf_p_end", 1.0),
attn_n_heads=d.get("attn_n_heads", 4),
attn_n_layers=d.get("attn_n_layers", 2),
)
def to_dict(self) -> dict:
return {
"order": self.order,
"history": self.history,
"teacher_forcing": self.teacher_forcing,
"tf_p_start": self.tf_p_start,
"tf_p_end": self.tf_p_end,
"attn_n_heads": self.attn_n_heads,
"attn_n_layers": self.attn_n_layers,
}
@dataclass(frozen=True)
class HeadConfig:
"""A single classifier head's shape — `n_sec_head`/`type_head` (gitea
#36 deduplicated their five identical hand-rolled
`Linear -> SiLU -> Linear` definitions into
`giant.model.layers.build_mlp_head`, which this config drives).
`hidden_ratio=0.5`/`depth=2` are the exact pre-#36 hardcoded values
(hidden width = `hidden_dim // 2`, one hidden layer), so omitting a
`heads` block — including every migrated v0.2 config — reproduces the
old architecture bit-for-bit."""
hidden_ratio: float = 0.5 # hidden width = round(hidden_dim * hidden_ratio)
depth: int = 2 # matches build_mlp_head's depth
@classmethod
def from_dict(cls, d: dict | None) -> "HeadConfig":
d = d or {}
return cls(hidden_ratio=d.get("hidden_ratio", 0.5), depth=d.get("depth", 2))
def to_dict(self) -> dict:
return {"hidden_ratio": self.hidden_ratio, "depth": self.depth}
@dataclass(frozen=True)
class Stage1HeadsConfig:
"""Stage 1 only ever owns `n_sec_head`, and only for a migrated v0.2
checkpoint (`stage2_model.n_sec.owner = "stage1"`) — see
`Stage1Model`'s docstring."""
n_sec: HeadConfig = field(default_factory=HeadConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "Stage1HeadsConfig":
d = d or {}
return cls(n_sec=HeadConfig.from_dict(d.get("n_sec")))
def to_dict(self) -> dict:
return {"n_sec": self.n_sec.to_dict()}
@dataclass(frozen=True)
class Stage2HeadsConfig:
"""`n_sec` and `type` are independently configurable — n_sec accuracy
and secondary-species accuracy are separately known weak spots (gitea
#36)."""
n_sec: HeadConfig = field(default_factory=HeadConfig)
type: HeadConfig = field(default_factory=HeadConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "Stage2HeadsConfig":
d = d or {}
return cls(n_sec=HeadConfig.from_dict(d.get("n_sec")), type=HeadConfig.from_dict(d.get("type")))
def to_dict(self) -> dict:
return {"n_sec": self.n_sec.to_dict(), "type": self.type.to_dict()}
@dataclass(frozen=True)
class Stage1ModelConfig:
# false skips building/training stage 1 entirely. The resulting
# checkpoint holds only stage 2 and cannot be rolled out.
active: bool = True
# Checkpoint .pt to load this stage's weights from before training starts
# (its own "model"/"sec_decoder" key, not this run's own resume state) —
# "" means start from a fresh init. See `freeze` below for the partial-
# retrain use case this exists for (gitea #42).
init_from: str = ""
# true keeps this stage's weights exactly as loaded from `init_from` —
# forward/backward still run every batch (so its loss/grad_norm metrics
# stay meaningful, and a WGAN stage's critic still gets a real signal to
# report), but its optimizer never steps. Lets a rollout-capable
# checkpoint retrain only the *other* stage against a fixed, known-good
# one (gitea #42) — `validate_config` requires `init_from` to be set
# whenever this is true, unless the run is a `--resume`.
freeze: bool = False
# "flow": conditional flow matching (~10 ODE steps at inference).
# "ddpm": cosine-schedule diffusion baseline.
# "wgan": WGAN-GP, single forward pass at inference.
generator: str = "flow"
# Trunk width — also the width of every expert under a routed trunk.
hidden_dim: int = 256
# Number of ResBlocks in the trunk, and in every expert under a routed
# trunk.
n_res_blocks: int = 6
dropout: float = 0.0
# Weight of this stage's loss in the total 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.
lambda_weight: float = 1.0 # dict key "lambda"
flow: FlowConfig = field(default_factory=FlowConfig)
ddpm: DdpmConfig = field(default_factory=DdpmConfig)
wgan: Stage1WganConfig = field(default_factory=Stage1WganConfig)
router: RouterConfig = field(default_factory=RouterConfig)
trunk: TrunkConfig = field(default_factory=TrunkConfig)
heads: Stage1HeadsConfig = field(default_factory=Stage1HeadsConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "Stage1ModelConfig":
d = d or {}
return cls(
active=d.get("active", True),
init_from=d.get("init_from", ""),
freeze=d.get("freeze", False),
generator=d.get("generator", "flow"),
hidden_dim=d.get("hidden_dim", 256),
n_res_blocks=d.get("n_res_blocks", 6),
dropout=d.get("dropout", 0.0),
lambda_weight=d.get("lambda", 1.0),
flow=FlowConfig.from_dict(d.get("flow")),
ddpm=DdpmConfig.from_dict(d.get("ddpm")),
wgan=Stage1WganConfig.from_dict(d.get("wgan")),
router=RouterConfig.from_dict(d.get("router")),
trunk=TrunkConfig.from_dict(d.get("trunk")),
heads=Stage1HeadsConfig.from_dict(d.get("heads")),
)
def to_dict(self) -> dict:
return {
"active": self.active,
"init_from": self.init_from,
"freeze": self.freeze,
"generator": self.generator,
"hidden_dim": self.hidden_dim,
"n_res_blocks": self.n_res_blocks,
"dropout": self.dropout,
"lambda": self.lambda_weight,
"flow": self.flow.to_dict(),
"ddpm": self.ddpm.to_dict(),
"wgan": self.wgan.to_dict(),
"router": self.router.to_dict(),
"trunk": self.trunk.to_dict(),
"heads": self.heads.to_dict(),
}
@dataclass(frozen=True)
class Stage2ModelConfig:
# false trains stage 1 alone. giant rollout must then refuse the
# checkpoint; giant predict still works.
active: bool = True
# See Stage1ModelConfig.init_from/.freeze — same semantics, this stage.
init_from: str = ""
freeze: bool = False
# "one_shot": predict all k_max slots simultaneously with padded slots
# masked from the loss (v0.2 behaviour).
# "autoregressive": emit one secondary at a time in descending-energy
# order.
decoder: str = "autoregressive"
# As stage1_model.generator, but under "autoregressive" this is the
# objective for each token.
generator: str = "wgan"
hidden_dim: int = 256
n_res_blocks: int = 6
dropout: float = 0.0
lambda_weight: float = 1.0 # dict key "lambda"
# Maximum secondary slots. Under "one_shot" this is the fixed output
# width; under "autoregressive" it is a safety cap on the generation loop.
k_max: int = 15
# Width of the projected stage-1 outcome fed into stage 2's conditioning.
context_dim: int = 64
# "truth": the ground-truth stage-1 target vector, detached — stage-level
# teacher forcing (v0.2 behaviour). "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.
stage1_context: str = "truth"
# Ramp for "sampled": P(condition on the ground-truth stage-1 outcome
# rather than a fresh sample), linearly interpolated from ctx_p_start
# (epoch 0) to ctx_p_end (the final epoch) — the same scheduled-sampling
# shape as autoregressive.tf_p_start/tf_p_end, so stage 2 doesn't chase a
# wildly moving stage-1 target in early epochs. Unread under "truth".
ctx_p_start: float = 1.0
ctx_p_end: float = 0.0
n_sec: NSecConfig = field(default_factory=NSecConfig)
particle_type: ParticleTypeConfig = field(default_factory=ParticleTypeConfig)
autoregressive: AutoregressiveConfig = field(default_factory=AutoregressiveConfig)
flow: FlowConfig = field(default_factory=FlowConfig)
ddpm: DdpmConfig = field(default_factory=DdpmConfig)
wgan: Stage2WganConfig = field(default_factory=Stage2WganConfig)
router: Stage2RouterConfig = field(default_factory=Stage2RouterConfig)
trunk: TrunkConfig = field(default_factory=TrunkConfig)
heads: Stage2HeadsConfig = field(default_factory=Stage2HeadsConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "Stage2ModelConfig":
d = d or {}
return cls(
active=d.get("active", True),
init_from=d.get("init_from", ""),
freeze=d.get("freeze", False),
decoder=d.get("decoder", "autoregressive"),
generator=d.get("generator", "wgan"),
hidden_dim=d.get("hidden_dim", 256),
n_res_blocks=d.get("n_res_blocks", 6),
dropout=d.get("dropout", 0.0),
lambda_weight=d.get("lambda", 1.0),
k_max=d.get("k_max", 15),
context_dim=d.get("context_dim", 64),
stage1_context=d.get("stage1_context", "truth"),
ctx_p_start=d.get("ctx_p_start", 1.0),
ctx_p_end=d.get("ctx_p_end", 0.0),
n_sec=NSecConfig.from_dict(d.get("n_sec")),
particle_type=ParticleTypeConfig.from_dict(d.get("particle_type")),
autoregressive=AutoregressiveConfig.from_dict(d.get("autoregressive")),
flow=FlowConfig.from_dict(d.get("flow")),
ddpm=DdpmConfig.from_dict(d.get("ddpm")),
wgan=Stage2WganConfig.from_dict(d.get("wgan")),
router=Stage2RouterConfig.from_dict(d.get("router")),
trunk=TrunkConfig.from_dict(d.get("trunk")),
heads=Stage2HeadsConfig.from_dict(d.get("heads")),
)
def to_dict(self) -> dict:
return {
"active": self.active,
"init_from": self.init_from,
"freeze": self.freeze,
"decoder": self.decoder,
"generator": self.generator,
"hidden_dim": self.hidden_dim,
"n_res_blocks": self.n_res_blocks,
"dropout": self.dropout,
"lambda": self.lambda_weight,
"k_max": self.k_max,
"context_dim": self.context_dim,
"stage1_context": self.stage1_context,
"ctx_p_start": self.ctx_p_start,
"ctx_p_end": self.ctx_p_end,
"n_sec": self.n_sec.to_dict(),
"particle_type": self.particle_type.to_dict(),
"autoregressive": self.autoregressive.to_dict(),
"flow": self.flow.to_dict(),
"ddpm": self.ddpm.to_dict(),
"wgan": self.wgan.to_dict(),
"router": self.router.to_dict(),
"trunk": self.trunk.to_dict(),
"heads": self.heads.to_dict(),
}
@dataclass(frozen=True)
class TrainConfig:
epochs: int = 100
batch_size: int = 4096
lr: float = 3e-4
weight_decay: float = 0.01 # AdamW default — exposed so it can be tuned
ema_decay: float = 0.9999 # EMA of model weights for sampling; 0 disables
warmup_epochs: int = 5
val_fraction: float = 0.1
# per-epoch val loss (not the marginal/KL validate_every pass) is capped
# to this many batches; 0 = full val set every epoch
max_val_batches: int = 200
num_workers: int = 4
seed: int = 0
validate_every: int = 10
validate_steps: int = 10
# Weights & Biases per-epoch metric logging. Default true in v0.3.0 (was
# opt-in false): the v0.3.0 work is a sequence of architecture
# comparisons, and a run that wasn't logged isn't comparable. Set false
# for throwaway/debug runs.
wandb: bool = True
wandb_project: str = "giant"
# "" means "use the checkpoint out_dir name" — not None, since the TOML
# writer has no null literal to round-trip.
wandb_run_name: str = ""
# Batch-granularity metrics (loss/grad_norm/lr) are logged every N
# optimizer steps, not every batch — a single epoch can be tens of
# thousands of steps. Per-epoch metrics (the metrics.csv row) always log
# in full.
wandb_log_every: int = 50
@classmethod
def from_dict(cls, d: dict | None) -> "TrainConfig":
d = d or {}
return cls(
epochs=d.get("epochs", 100),
batch_size=d.get("batch_size", 4096),
lr=d.get("lr", 3e-4),
weight_decay=d.get("weight_decay", 0.01),
ema_decay=d.get("ema_decay", 0.9999),
warmup_epochs=d.get("warmup_epochs", 5),
val_fraction=d.get("val_fraction", 0.1),
max_val_batches=d.get("max_val_batches", 200),
num_workers=d.get("num_workers", 4),
seed=d.get("seed", 0),
validate_every=d.get("validate_every", 10),
validate_steps=d.get("validate_steps", 10),
wandb=d.get("wandb", True),
wandb_project=d.get("wandb_project", "giant"),
wandb_run_name=d.get("wandb_run_name", ""),
wandb_log_every=d.get("wandb_log_every", 50),
)
def to_dict(self) -> dict:
return {
"epochs": self.epochs,
"batch_size": self.batch_size,
"lr": self.lr,
"weight_decay": self.weight_decay,
"ema_decay": self.ema_decay,
"warmup_epochs": self.warmup_epochs,
"val_fraction": self.val_fraction,
"max_val_batches": self.max_val_batches,
"num_workers": self.num_workers,
"seed": self.seed,
"validate_every": self.validate_every,
"validate_steps": self.validate_steps,
"wandb": self.wandb,
"wandb_project": self.wandb_project,
"wandb_run_name": self.wandb_run_name,
"wandb_log_every": self.wandb_log_every,
}
@dataclass(frozen=True)
class GiantConfig:
"""Root config dataclass — the single source of truth for every default
in DEFAULT_CONFIG below, which is generated from `GiantConfig().to_dict()`
rather than hand-maintained (see issues.md Issue 1)."""
conditioning: ConditioningConfig = field(default_factory=ConditioningConfig)
stage1_model: Stage1ModelConfig = field(default_factory=Stage1ModelConfig)
stage2_model: Stage2ModelConfig = field(default_factory=Stage2ModelConfig)
train: TrainConfig = field(default_factory=TrainConfig)
@classmethod
def from_dict(cls, d: dict | None) -> "GiantConfig":
d = d or {}
return cls(
conditioning=ConditioningConfig.from_dict(d.get("conditioning")),
stage1_model=Stage1ModelConfig.from_dict(d.get("stage1_model")),
stage2_model=Stage2ModelConfig.from_dict(d.get("stage2_model")),
train=TrainConfig.from_dict(d.get("train")),
)
def to_dict(self) -> dict:
return {
"conditioning": self.conditioning.to_dict(),
"stage1_model": self.stage1_model.to_dict(),
"stage2_model": self.stage2_model.to_dict(),
"train": self.train.to_dict(),
}
DEFAULT_CONFIG: dict = GiantConfig().to_dict()
def leaf_paths(node: dict, prefix: str = "") -> list[str]:
"""Every dotted leaf path in a DEFAULT_CONFIG-shaped dict, e.g.
"stage1_model.router.n_experts". `[meta]` (run provenance, no schema
counterpart) is skipped at the top level, matching `validate_config_keys`.
Shared by `tests/test_config_consumed_keys.py` (the static per-identifier
audit) and `giant.model.summary` (the runtime per-config audit, gitea
#46) so both walk the exact same tree."""
paths = []
for key, value in node.items():
if prefix == "" and key == "meta":
continue
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
paths.extend(leaf_paths(value, path))
else:
paths.append(path)
return paths
def git_hash() -> str:
try:
return subprocess.check_output(["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL).decode().strip()
except Exception:
return "unknown"
def auto_device() -> torch.device:
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
# Calibration point for estimate_batch_size(training=True): hidden_dim=1024,
# n_blocks=6, batch_size=29696 measured at ~7683 MiB VRAM (post-Phase-2
# architecture, including the Stage-2 secondary decoder and n_sec head).
# Activation memory is assumed to scale linearly with
# batch_size * hidden_dim * n_blocks (the ResBlock stack dominates), so this
# is a rough estimate rather than a guaranteed bound.
# NOTE: not yet recalibrated for the v0.3.0 autoregressive stage-2 trunk —
# deliberately last in the implementation order.
_REF_BYTES = 7683 * 1024**2
_REF_BATCH_SIZE = 29696
_REF_HIDDEN_DIM = 1024
_REF_N_BLOCKS = 6
# Calibration point for estimate_batch_size(training=False): inference has no
# backward graph or optimizer state, so its memory footprint is much smaller
# per sample. hidden_dim=1024, n_blocks=8, batch_size=65536 measured at ~2037
# MiB VRAM.
_REF_BYTES_PREDICT = 2037 * 1024**2
_REF_BATCH_SIZE_PREDICT = 65536
_REF_HIDDEN_DIM_PREDICT = 1024
_REF_N_BLOCKS_PREDICT = 8
def estimate_batch_size(
hidden_dim: int,
n_blocks: int,
device: torch.device,
safety_factor: float = 0.8,
min_batch_size: int = 1024,
training: bool = True,
) -> int:
"""Estimate a batch size that fits in the free memory on `device`.
Only supported on CUDA devices, which expose a free/total memory query;
other backends (cpu, mps) raise ValueError. Pass `training=False` for
inference (e.g. `predict`), which uses a much lower per-sample memory
calibration since there's no backward graph or optimizer state.
"""
if device.type != "cuda":
raise ValueError(f"--batch-size auto is only supported on cuda devices, got {device.type!r}")
device_index = device.index if device.index is not None else torch.cuda.current_device()
free_bytes, _total_bytes = torch.cuda.mem_get_info(device_index)
if training:
ref_bytes, ref_batch_size, ref_hidden_dim, ref_n_blocks = (
_REF_BYTES,
_REF_BATCH_SIZE,
_REF_HIDDEN_DIM,
_REF_N_BLOCKS,
)
else:
ref_bytes, ref_batch_size, ref_hidden_dim, ref_n_blocks = (
_REF_BYTES_PREDICT,
_REF_BATCH_SIZE_PREDICT,
_REF_HIDDEN_DIM_PREDICT,
_REF_N_BLOCKS_PREDICT,
)
bytes_per_unit = ref_bytes / (ref_batch_size * ref_hidden_dim * ref_n_blocks)
bytes_per_sample = bytes_per_unit * hidden_dim * n_blocks
batch_size = int(free_bytes * safety_factor / bytes_per_sample)
batch_size = max(min_batch_size, (batch_size // 1024) * 1024)
return batch_size
def load_toml(path: Path) -> dict:
with open(path, "rb") as f:
return tomllib.load(f)
def warn_if_git_hash_mismatch(file_cfg: dict, config_path: Path) -> None:
"""Warn (don't fail) if a config.toml's [meta].git_hash predates the current checkout.
A config saved by a previous run may have been produced by code that has
since changed, so its hyperparameters might not mean what they used to —
surface that as a heads-up rather than blocking the rerun.
"""
file_hash = file_cfg.get("meta", {}).get("git_hash")
current_hash = git_hash()
if not file_hash or file_hash == "unknown" or current_hash == "unknown":
return
if file_hash != current_hash:
print(
f"warning: {config_path} was generated at git commit {file_hash}, "
f"but the current checkout is at {current_hash} — hyperparameters "
"may not match the code that originally produced this config",
file=sys.stderr,
)
def load_checkpoint_config(ckpt_path: str | Path) -> dict:
"""Load the full config.toml written alongside a checkpoint by
``save_config``.
Returns ``{}`` if no config.toml sits next to the checkpoint (older runs,
or a checkpoint moved without its sidecar) — this is best-effort
provenance for threading into a rollout's YAML sidecar, not a hard
requirement for using the checkpoint itself. Returned as-loaded (v0.2 or
v0.3 shape); callers that need the v0.3 shape should run it through
`migrate_config` themselves.
"""
config_path = Path(ckpt_path).parent / "config.toml"
if not config_path.exists():
return {}
return load_toml(config_path)
def warn_if_checkpoint_config_mismatch(ckpt_path: str | Path) -> None:
"""Look for a config.toml next to a checkpoint and warn on a git_hash mismatch.
Training writes config.toml into the same out_dir as its checkpoints, so a
checkpoint loaded later (for `predict` or `giant.analysis`) can be
cross-checked the same way `--config` loading is, without the caller having
to pass the toml path explicitly. Silently does nothing if no config.toml
is found alongside the checkpoint.
"""
config_path = Path(ckpt_path).parent / "config.toml"
if not config_path.exists():
return
warn_if_git_hash_mismatch(load_toml(config_path), config_path)
def _get_path(d: dict, dotted: str):
"""Read a dotted path (e.g. "stage1_model.router.enabled") out of a
nested dict. Returns None if any component along the path is missing."""
cur = d
for part in dotted.split("."):
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return cur
def _set_path(d: dict, dotted: str, value) -> None:
"""Write a dotted path into a nested dict, creating intermediate dicts as
needed."""
parts = dotted.split(".")
cur = d
for part in parts[:-1]:
cur = cur.setdefault(part, {})
cur[parts[-1]] = value
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge `override` onto a copy of `base`.
Dict-valued keys recurse instead of being replaced wholesale, so
overriding one leaf (e.g. stage1_model.router.enabled) never drops
untouched siblings — the rest of stage1_model.router, or of
stage1_model — the same property v0.2's router-only bespoke merge had,
generalized here to arbitrary depth.
"""
result = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(result.get(k), dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = v
return result
@dataclass(frozen=True)
class FlagSpec:
"""One CLI flag's mapping into the config-overrides tree.
`paths` lists every dotted config path this flag writes (>1 means fan-out
to multiple stages/axes, e.g. `--mode` -> both stages' `generator`).
`precedence` controls write order when two flags target the same path:
specs are applied in ascending precedence, so a higher-precedence (more
specific) flag overwrites a lower-precedence (shared/shorthand) one —
this is the "build a shared dict, then let a more specific dict win"
pattern `giant train`/`giant new-run` need (e.g. `--hidden-dim` vs
`--stage1-hidden-dim`, or `--n-critic` vs `--stage1-n-critic`),
generalized to one mechanism instead of three different ad hoc ones.
"""
name: str
paths: tuple[str, ...]
precedence: int = 0
# Flag -> config-path table shared by `giant train`/`giant new-run`
# (giant/cli.py) so both commands resolve CLI overrides identically. See
# issues.md Issue 3: this replaces ~140 lines of hand-written, imperative
# dict-building in cli.py with one declarative table plus
# `overrides_from_flags` below.
FLAG_SPECS: tuple[FlagSpec, ...] = (
# train block -- flat pass-through, unique paths, precedence irrelevant.
FlagSpec("epochs", ("train.epochs",)),
FlagSpec("batch_size", ("train.batch_size",)),
FlagSpec("lr", ("train.lr",)),
FlagSpec("weight_decay", ("train.weight_decay",)),
FlagSpec("ema_decay", ("train.ema_decay",)),
FlagSpec("warmup_epochs", ("train.warmup_epochs",)),
FlagSpec("val_fraction", ("train.val_fraction",)),
FlagSpec("num_workers", ("train.num_workers",)),
FlagSpec("seed", ("train.seed",)),
FlagSpec("validate_every", ("train.validate_every",)),
FlagSpec("validate_steps", ("train.validate_steps",)),
FlagSpec("max_val_batches", ("train.max_val_batches",)),
FlagSpec("wandb", ("train.wandb",)),
FlagSpec("wandb_project", ("train.wandb_project",)),
FlagSpec("wandb_run_name", ("train.wandb_run_name",)),
FlagSpec("wandb_log_every", ("train.wandb_log_every",)),
# --hidden-dim/--n-blocks/--dropout are stage-1-only backward-compat
# shorthands (they predate stage2_model having its own flags);
# --stage1-* wins when both are given.
FlagSpec("hidden_dim", ("stage1_model.hidden_dim",), precedence=0),
FlagSpec("stage1_hidden_dim", ("stage1_model.hidden_dim",), precedence=1),
FlagSpec("n_blocks", ("stage1_model.n_res_blocks",), precedence=0),
FlagSpec("stage1_n_res_blocks", ("stage1_model.n_res_blocks",), precedence=1),
FlagSpec("dropout", ("stage1_model.dropout",), precedence=0),
FlagSpec("stage1_dropout", ("stage1_model.dropout",), precedence=1),
# stage2-only knobs.
FlagSpec("stage2_hidden_dim", ("stage2_model.hidden_dim",)),
FlagSpec("stage2_n_res_blocks", ("stage2_model.n_res_blocks",)),
FlagSpec("stage2_dropout", ("stage2_model.dropout",)),
FlagSpec("stage2_decoder", ("stage2_model.decoder",)),
FlagSpec("stage2_k_max", ("stage2_model.k_max",)),
FlagSpec("stage2_context_dim", ("stage2_model.context_dim",)),
FlagSpec("stage2_stage1_context", ("stage2_model.stage1_context",)),
# --mode applies to both stages by default (v0.2 had one shared
# mode/wgan config); --stage{1,2}-generator override a single stage.
FlagSpec("mode", ("stage1_model.generator", "stage2_model.generator"), precedence=0),
FlagSpec("stage1_generator", ("stage1_model.generator",), precedence=1),
FlagSpec("stage2_generator", ("stage2_model.generator",), precedence=1),
# --emb-dim/--conditioning set both conditioning axes (v0.2 had one
# shared value for particle+material).
FlagSpec("conditioning", ("conditioning.particle.type", "conditioning.material.type")),
FlagSpec("emb_dim", ("conditioning.particle.emb_dim", "conditioning.material.emb_dim")),
# Pre-aggregated router override dict (built by `_router_cli_overrides`
# in cli.py from --router/--router-type/--n-experts/--router-axis).
# Router overrides only ever land on stage1_model -- this asymmetry is
# deliberate (see cli.py) and must not be "fixed" into a fan-out here.
FlagSpec("router_config", ("stage1_model.router",)),
# WGAN: shared knobs apply to both stages by default (v0.2 had one
# shared wgan config); --stage{1,2}-* override a single stage.
FlagSpec("n_critic", ("stage1_model.wgan.n_critic", "stage2_model.wgan.n_critic"), precedence=0),
FlagSpec("stage1_n_critic", ("stage1_model.wgan.n_critic",), precedence=1),
FlagSpec("stage2_n_critic", ("stage2_model.wgan.n_critic",), precedence=1),
FlagSpec("gp_weight", ("stage1_model.wgan.gp_weight", "stage2_model.wgan.gp_weight"), precedence=0),
FlagSpec("stage1_gp_weight", ("stage1_model.wgan.gp_weight",), precedence=1),
FlagSpec("stage2_gp_weight", ("stage2_model.wgan.gp_weight",), precedence=1),
FlagSpec("noise_dim", ("stage1_model.wgan.noise_dim", "stage2_model.wgan.noise_dim"), precedence=0),
FlagSpec("stage1_noise_dim", ("stage1_model.wgan.noise_dim",), precedence=1),
FlagSpec("stage2_noise_dim", ("stage2_model.wgan.noise_dim",), precedence=1),
FlagSpec("critic_lr", ("stage1_model.wgan.critic_lr", "stage2_model.wgan.critic_lr"), precedence=0),
FlagSpec("stage1_critic_lr", ("stage1_model.wgan.critic_lr",), precedence=1),
FlagSpec("stage2_critic_lr", ("stage2_model.wgan.critic_lr",), precedence=1),
# Critic sizing: stage-scoped only, no shared alias — this is an
# architectural per-stage knob like hidden_dim/n_res_blocks above, not a
# shared training hyperparameter like the wgan knobs above it.
FlagSpec("stage1_critic_hidden_dim", ("stage1_model.wgan.critic_hidden_dim",)),
FlagSpec("stage1_critic_n_res_blocks", ("stage1_model.wgan.critic_n_res_blocks",)),
FlagSpec("stage2_critic_hidden_dim", ("stage2_model.wgan.critic_hidden_dim",)),
FlagSpec("stage2_critic_n_res_blocks", ("stage2_model.wgan.critic_n_res_blocks",)),
# Partial-retrain (gitea #42): stage-scoped only, no shared alias — a
# shared "freeze both stages from the same file" flag has no sensible
# meaning (a checkpoint has one set of weights per stage).
FlagSpec("stage1_init_from", ("stage1_model.init_from",)),
FlagSpec("stage1_freeze", ("stage1_model.freeze",)),
FlagSpec("stage2_init_from", ("stage2_model.init_from",)),
FlagSpec("stage2_freeze", ("stage2_model.freeze",)),
)
def overrides_from_flags(values: dict[str, object]) -> dict:
"""Build the nested, section-keyed config-overrides dict
`merge_cli_overrides` expects, from `{flag_name: value}`.
Flags absent from `values`, or mapped to `None` (= not given on the
CLI), are skipped. See `FlagSpec`/`FLAG_SPECS` above for the precedence
rule applied when two flags target the same path.
"""
overrides: dict = {}
for spec in sorted(FLAG_SPECS, key=lambda s: s.precedence):
if spec.name not in values or values[spec.name] is None:
continue
for path in spec.paths:
_set_path(overrides, path, values[spec.name])
return overrides
# v0.2 [train] keys that pass through to v0.3 [train] unchanged (same name,
# same meaning) when present in the loaded file — everything model-shaped
# moved to the stage/conditioning blocks instead (see the rest of
# migrate_config below).
_V02_TRAIN_PASSTHROUGH = (
"epochs",
"batch_size",
"lr",
"weight_decay",
"ema_decay",
"max_val_batches",
"val_fraction",
"num_workers",
"seed",
"validate_every",
"validate_steps",
"warmup_epochs",
"wandb",
"wandb_project",
"wandb_run_name",
"wandb_log_every",
)
# v0.2 train.{n_critic,gp_weight,critic_lr} applied identically to both
# stages' wgan sub-table (there was only ever one wgan objective, shared).
_V02_TRAIN_TO_BOTH_STAGES_WGAN = (
("n_critic", "n_critic"),
("gp_weight", "gp_weight"),
("critic_lr", "critic_lr"),
)
def migrate_config(cfg: dict) -> dict:
"""Translate a v0.2 config dict (single [train] + [model]) into the v0.3
nested format ([conditioning]/[stage1_model]/[stage2_model]/[train]).
Called on every config.toml load (see merge_cli_overrides) so old
training configs on disk keep working under new code without hand-
editing. `[meta].config_version == CONFIG_VERSION` marks a dict as
already-v0.3; its absence is read as "this is v0.2", so an
already-migrated dict is returned unchanged (deep-copied).
Only keys actually present in `cfg` are translated — `cfg` may be a
partial file (e.g. `[train]\\nepochs = 5\\n` with no [model] section at
all, relying on v0.2 defaults for everything else). Separately, a fixed
set of v0.2 architectural facts that were never exposed as config keys at
all (e.g. the conditioning MLP was always 2 layers deep, not the v0.3
default of 1) are injected unconditionally whenever this function decides
it is migrating a v0.2 dict, regardless of which keys the file happened
to set.
Operates on the config.toml shape. A checkpoint's `model_config` dict
(which additionally carries n_sec_head ownership and needs
`network.build_models`'s cooperation) is a separate migration surface,
deferred to the network.py refactor.
"""
if _get_path(cfg, "meta.config_version") == CONFIG_VERSION:
return copy.deepcopy(cfg)
cfg = copy.deepcopy(cfg)
old_train = cfg.pop("train", {})
old_model = cfg.pop("model", {})
old_router = dict(old_model.pop("router", {}))
new: dict = {}
for key in _V02_TRAIN_PASSTHROUGH:
if key in old_train:
_set_path(new, f"train.{key}", old_train[key])
if "mode" in old_train:
_set_path(new, "stage1_model.generator", old_train["mode"])
_set_path(new, "stage2_model.generator", old_train["mode"])
if "lambda_nsec" in old_train:
_set_path(new, "stage2_model.n_sec.lambda", old_train["lambda_nsec"])
if "lambda_s2" in old_train:
_set_path(new, "stage2_model.lambda", old_train["lambda_s2"])
for old_key, new_key in _V02_TRAIN_TO_BOTH_STAGES_WGAN:
if old_key in old_train:
_set_path(new, f"stage1_model.wgan.{new_key}", old_train[old_key])
_set_path(new, f"stage2_model.wgan.{new_key}", old_train[old_key])
for old_key, new_key in V02_MODEL_KEY_TO_STAGES:
if old_key in old_model:
_set_path(new, f"stage1_model.{new_key}", old_model[old_key])
_set_path(new, f"stage2_model.{new_key}", old_model[old_key])
if "emb_dim" in old_model:
_set_path(new, "conditioning.particle.emb_dim", old_model["emb_dim"])
_set_path(new, "conditioning.material.emb_dim", old_model["emb_dim"])
if "conditioning" in old_model:
_set_path(new, "conditioning.particle.type", old_model["conditioning"])
_set_path(new, "conditioning.material.type", old_model["conditioning"])
if "noise_dim" in old_model:
_set_path(new, "stage1_model.wgan.noise_dim", old_model["noise_dim"])
_set_path(new, "stage2_model.wgan.noise_dim", old_model["noise_dim"])
if "k_max" in old_model:
_set_path(new, "stage2_model.k_max", old_model["k_max"])
if old_router:
reject_legacy_router_expert_sizing(old_router, source="v0.2 config's model.router")
_set_path(new, "stage1_model.router", dict(old_router))
stage2_router = dict(old_router)
stage2_router["tie_to_stage1"] = False
_set_path(new, "stage2_model.router", stage2_router)
# v0.2 architectural facts with no corresponding config key at all —
# always set once we've determined we're migrating a v0.2 dict,
# independent of what the file did/didn't specify (see giant._migration).
for path, value in V02_FIXED_FACTS.items():
_set_path(new, path, value)
new_meta = dict(cfg.pop("meta", {}))
new_meta["config_version"] = CONFIG_VERSION
new["meta"] = new_meta
# Anything else in the original dict (unrecognized top-level sections)
# carries through untouched rather than being silently dropped.
for k, v in cfg.items():
new.setdefault(k, v)
return new
# axis{i}_{field} composed-router keys (see network._parse_composed_axes) — field-name
# agnostic, matching network.py's own _AXIS_KEY_RE, since anything after axis{i}_ is
# passed straight through as a router kwarg there.
_AXIS_KEY_RE = re.compile(r"^axis\d+_.+$")
# Paths (dotted, relative to the merged cfg root) that carry genuinely dynamic keys not
# in DEFAULT_CONFIG's fixed schema — composed-router axis{i}_{field} flags and
# pipeline.seed_router_centers's runtime-seeded centers_init (see RouterConfig.extra
# above). validate_config_keys allows any key under these paths through unconditionally
# apart from the axis-pattern/centers_init check below.
_DYNAMIC_ROUTER_PATHS = {"stage1_model.router", "stage2_model.router"}
def _unknown_key_error(full_path: str, key: str, valid_keys) -> ValueError:
hint = difflib.get_close_matches(key, list(valid_keys), n=1)
suggestion = f" — did you mean {hint[0]!r}?" if hint else ""
return ValueError(f"unknown config key {full_path!r}{suggestion}")
def _validate_keys(node: dict, default_node: dict, path: str) -> None:
for key, value in node.items():
if path == "" and key == "meta":
continue
full_path = f"{path}.{key}" if path else key
if path in _DYNAMIC_ROUTER_PATHS and (key == "centers_init" or _AXIS_KEY_RE.match(key)):
continue
if key not in default_node:
raise _unknown_key_error(full_path, key, default_node.keys())
if isinstance(value, dict) and isinstance(default_node[key], dict):
_validate_keys(value, default_node[key], full_path)
def validate_config_keys(cfg: dict) -> None:
"""Reject any config key not part of the known v0.3 schema (DEFAULT_CONFIG's tree).
Catches typos like `n_res_block` for `n_res_blocks` that would otherwise merge
cleanly, pass `validate_config`, and silently build the wrong model — see
issues.md Issue 2.
Only exercised on the config.toml/CLI-overrides path (called from
`merge_cli_overrides` below). A checkpoint's `model_config` dict goes through
`network._migrate_legacy_model_config`/`build_models` instead and must keep
loading regardless of schema drift; old checkpoints predate this validator and
are never passed through here.
Two areas are deliberately dynamic and excluded: `[meta]` (run provenance, no
DEFAULT_CONFIG counterpart), and `stage{1,2}_model.router`'s `axis{i}_{field}`
keys (composed-router axes, see `network._parse_composed_axes`) /
`centers_init` (runtime-seeded by `pipeline.seed_router_centers`).
"""
_validate_keys(cfg, DEFAULT_CONFIG, "")
def merge_cli_overrides(
defaults: dict,
config_path: Path | None,
overrides: dict,
) -> dict:
"""Resolve config as defaults -> TOML file -> explicit overrides.
`overrides` is keyed by top-level section name (e.g. "stage1_model",
"train"), each value an arbitrarily nested dict of overrides to
deep-merge (see `_deep_merge`) — the shape stage-prefixed CLI flags
naturally produce. A v0.2-shaped TOML file is transparently migrated
(`migrate_config`) before merging, so old configs on disk keep working
under the new schema. The result is checked against the known schema
(`validate_config_keys`) before being returned, so a typo'd key raises
here rather than silently building the wrong model.
"""
cfg = copy.deepcopy(defaults)
if config_path is not None:
file_cfg = migrate_config(load_toml(config_path))
for section, values in file_cfg.items():
if section == "meta":
continue
if isinstance(values, dict):
cfg[section] = _deep_merge(cfg.get(section, {}), values)
else:
cfg[section] = values
warn_if_git_hash_mismatch(file_cfg, config_path)
for section, values in overrides.items():
if isinstance(values, dict):
cfg[section] = _deep_merge(cfg.get(section, {}), values)
else:
cfg[section] = values
validate_config_keys(cfg)
return cfg
def validate_config(cfg: dict, *, resume: bool = False) -> None:
"""Cross-block validation the per-block schema can't express on its own.
Raises ValueError with a clear message on the first violation found. Call
after `merge_cli_overrides` has produced a fully-merged v0.3 config —
these checks need to see across blocks, so they don't belong in
`migrate_config` (which only ever sees one dict's own keys) or in any
single block's defaults.
`resume=True` (only `giant train --resume` passes this) relaxes the
`stage{1,2}_model.freeze` -> `.init_from` requirement below: a resumed
frozen stage's weights come from the resume checkpoint, not `init_from`.
"""
particle_type = _get_path(cfg, "conditioning.particle.type")
pt_target = _get_path(cfg, "stage2_model.particle_type.target")
if pt_target == "embedding" and particle_type != "embedding":
raise ValueError(
"stage2_model.particle_type.target = 'embedding' requires "
"conditioning.particle.type = 'embedding' (there is no embedding "
"table to regress against under conditioning.particle.type = "
f"{particle_type!r})"
)
for stage_name in ("stage1_model", "stage2_model"):
if _get_path(cfg, f"{stage_name}.freeze") and not _get_path(cfg, f"{stage_name}.init_from") and not resume:
raise ValueError(
f"{stage_name}.freeze = true requires {stage_name}.init_from "
"to be set (or --resume) — freezing a randomly-initialized "
"model is almost certainly a mistake"
)
router = _get_path(cfg, f"{stage_name}.router") or {}
if router.get("enabled") and router.get("type") in ("pdg", "process") and particle_type == "physical":
raise ValueError(
f"{stage_name}.router.type = {router['type']!r} builds its "
"own training-vocab-scoped embedding, incompatible with "
"conditioning.particle.type = 'physical' (defeats "
"generalization beyond the training menu) — pick a "
"different router type or a different "
"conditioning.particle.type"
)
if _get_path(cfg, "stage2_model.router.tie_to_stage1") and not _get_path(cfg, "stage1_model.active"):
raise ValueError(
"stage2_model.router.tie_to_stage1 = true requires "
"stage1_model.active = true (there is no stage-1 router to tie to)"
)
if _get_path(cfg, "stage2_model.n_sec.mode") == "stop_token":
if _get_path(cfg, "stage2_model.decoder") != "autoregressive":
raise ValueError(
"stage2_model.n_sec.mode = 'stop_token' requires "
"stage2_model.decoder = 'autoregressive' — there is no "
"per-token loop to stop under 'one_shot'"
)
if _get_path(cfg, "stage2_model.n_sec.owner") != "stage2":
raise ValueError(
"stage2_model.n_sec.mode = 'stop_token' requires "
"stage2_model.n_sec.owner = 'stage2' — a migrated v0.2 "
"checkpoint's stage-1 n_sec_head has no per-token "
"conditioning to hang an EOS decision off"
)
stop_sampling = _get_path(cfg, "stage2_model.n_sec.stop_sampling")
if stop_sampling not in ("greedy", "sample"):
raise ValueError(f"stage2_model.n_sec.stop_sampling = {stop_sampling!r} — must be 'greedy' or 'sample'")
stage1_context = _get_path(cfg, "stage2_model.stage1_context")
if stage1_context not in ("truth", "sampled"):
raise ValueError(f"stage2_model.stage1_context = {stage1_context!r} — must be 'truth' or 'sampled'")
if stage1_context == "sampled":
if not (_get_path(cfg, "stage1_model.active") and _get_path(cfg, "stage2_model.active")):
raise ValueError(
"stage2_model.stage1_context = 'sampled' requires both "
"stage1_model.active and stage2_model.active = true — there is "
"no stage-1 model to sample from in a stage-2-only run"
)
ctx_p_start = _get_path(cfg, "stage2_model.ctx_p_start")
ctx_p_end = _get_path(cfg, "stage2_model.ctx_p_end")
for name, value in (("ctx_p_start", ctx_p_start), ("ctx_p_end", ctx_p_end)):
if not (0.0 <= value <= 1.0):
raise ValueError(f"stage2_model.{name} = {value} — must be in [0, 1]")
if ctx_p_start == 1.0 and ctx_p_end == 1.0:
raise ValueError(
"stage2_model.stage1_context = 'sampled' with ctx_p_start = "
"ctx_p_end = 1.0 always conditions on the ground truth — "
"identical to 'truth' but silently so; use 'truth' instead or "
"lower ctx_p_end"
)
if (
_get_path(cfg, "stage2_model.n_sec.mode") == "truth"
and _get_path(cfg, "stage1_model.active")
and _get_path(cfg, "stage2_model.active")
):
raise ValueError(
"stage2_model.n_sec.mode = 'truth' is invalid for a "
"rollout-capable checkpoint (both stage1_model.active and "
"stage2_model.active = true): "
"'truth' takes n_sec from ground truth, which giant rollout "
"doesn't have. 'truth' is for standalone stage-2 evaluation "
"only — set stage1_model.active = false for that, or use "
"'head' (default) for a rollout-capable checkpoint."
)
if _get_path(cfg, "stage2_model.decoder") == "autoregressive":
order = _get_path(cfg, "stage2_model.autoregressive.order")
if order != "energy_desc":
raise ValueError(
f"stage2_model.autoregressive.order = {order!r} — must be "
"'energy_desc' (the only implemented ordering; see "
"AutoregressiveConfig.order's docstring)"
)
history = _get_path(cfg, "stage2_model.autoregressive.history")
if history not in HISTORY_REGISTRY:
raise ValueError(
f"stage2_model.autoregressive.history = {history!r} — must be one of {sorted(HISTORY_REGISTRY)}"
)
teacher_forcing = _get_path(cfg, "stage2_model.autoregressive.teacher_forcing")
if teacher_forcing not in ("always", "scheduled", "never"):
raise ValueError(
"stage2_model.autoregressive.teacher_forcing = "
f"{teacher_forcing!r} — must be 'always', 'scheduled' or "
"'never'"
)
_CONDITIONING_CODE = {"physical": "phys", "embedding": "emb", "onehot": "oh"}
def _path_candidate(dotted_path: str, prefix: str, formatter=str):
"""Candidate factory: show `prefix + formatter(value)` when the value at
`dotted_path` differs from its DEFAULT_CONFIG value, else omit."""
def _candidate(cfg):
value = _get_path(cfg, dotted_path)
default = _get_path(DEFAULT_CONFIG, dotted_path)
if value == default:
return None
return f"{prefix}{formatter(value)}"
return _candidate
def _conditioning_candidate(axis: str, short: str):
def _candidate(cfg):
value = _get_path(cfg, f"conditioning.{axis}.type")
default = _get_path(DEFAULT_CONFIG, f"conditioning.{axis}.type")
if value == default:
return None
code = _CONDITIONING_CODE.get(value, value)
return f"{short}{code}"
return _candidate
def _router_candidate(stage_key: str, short: str):
"""Candidate for a stage's router as a single unit, gated on
`router.enabled` (a disabled router's type/n_experts are meaningless)."""
def _candidate(cfg):
router = _get_path(cfg, f"{stage_key}.router") or {}
default_router = _get_path(DEFAULT_CONFIG, f"{stage_key}.router") or {}
if router.get("enabled") == default_router.get("enabled"):
return None
return f"{short}r-{router['type']}{router['n_experts']}"
return _candidate
def _router_flag_candidate(stage_key: str, short: str, field: str, token_map: dict):
"""Candidate factory for a boolean field inside a stage's router block.
Gated on `router.enabled` like `_router_candidate`, then omitted unless
`field` differs from its DEFAULT_CONFIG value. `token_map` need only
cover the non-default value(s), since the default value always yields
None.
"""
def _candidate(cfg):
router = _get_path(cfg, f"{stage_key}.router") or {}
default_router = _get_path(DEFAULT_CONFIG, f"{stage_key}.router") or {}
if router.get("enabled") == default_router.get("enabled"):
return None
value = router.get(field)
if value == default_router.get(field):
return None
return f"{short}{token_map[value]}"
return _candidate
# Priority-ordered candidate fields for default_out_dir_name: (label,
# candidate(cfg) -> str | None). Beyond _OUT_DIR_NAME_MAX_FIELDS non-default
# fields, the remainder collapse into a hash suffix (see
# default_out_dir_name). Candidates read the whole nested cfg via dotted
# paths — there is no single "model" dict anymore now that architecture is
# split across conditioning/stage1_model/stage2_model.
_OUT_DIR_NAME_CANDIDATES = [
("stage1_generator", _path_candidate("stage1_model.generator", "")),
("stage2_generator", _path_candidate("stage2_model.generator", "s2-")),
("stage2_decoder", _path_candidate("stage2_model.decoder", "dec-")),
(
"stage2_history",
_path_candidate("stage2_model.autoregressive.history", "hist-"),
),
(
"particle_type_target",
_path_candidate("stage2_model.particle_type.target", "pt-"),
),
("stage1_trunk_type", _path_candidate("stage1_model.trunk.type", "s1t-")),
("stage2_trunk_type", _path_candidate("stage2_model.trunk.type", "s2t-")),
("stage1_block_cond", _path_candidate("stage1_model.trunk.block_conditioning", "s1bc-")),
("stage2_block_cond", _path_candidate("stage2_model.trunk.block_conditioning", "s2bc-")),
("stage1_router", _router_candidate("stage1_model", "s1")),
("stage2_router", _router_candidate("stage2_model", "s2")),
(
"stage1_gumbel",
_router_flag_candidate("stage1_model", "s1", "gumbel", {True: "gum"}),
),
(
"stage2_gumbel",
_router_flag_candidate("stage2_model", "s2", "gumbel", {True: "gum"}),
),
(
"stage1_learn_centers",
_router_flag_candidate("stage1_model", "s1", "learn_centers", {False: "nolc"}),
),
(
"stage2_learn_centers",
_router_flag_candidate("stage2_model", "s2", "learn_centers", {False: "nolc"}),
),
(
"stage1_learn_width",
_router_flag_candidate("stage1_model", "s1", "learn_width", {True: "lw"}),
),
(
"stage2_learn_width",
_router_flag_candidate("stage2_model", "s2", "learn_width", {True: "lw"}),
),
(
"stage1_learn_temperature",
_router_flag_candidate("stage1_model", "s1", "learn_temperature", {True: "lt"}),
),
(
"stage2_learn_temperature",
_router_flag_candidate("stage2_model", "s2", "learn_temperature", {True: "lt"}),
),
("particle_conditioning", _conditioning_candidate("particle", "c")),
("material_conditioning", _conditioning_candidate("material", "m")),
("stage1_freeze", _path_candidate("stage1_model.freeze", "s1frozen", formatter=lambda _: "")),
("stage2_freeze", _path_candidate("stage2_model.freeze", "s2frozen", formatter=lambda _: "")),
("stage1_hidden_dim", _path_candidate("stage1_model.hidden_dim", "h")),
("stage2_hidden_dim", _path_candidate("stage2_model.hidden_dim", "s2h")),
("stage1_n_res_blocks", _path_candidate("stage1_model.n_res_blocks", "b")),
("stage2_n_res_blocks", _path_candidate("stage2_model.n_res_blocks", "s2b")),
("particle_emb_dim", _path_candidate("conditioning.particle.emb_dim", "e")),
("lr", _path_candidate("train.lr", "lr")),
("batch_size", _path_candidate("train.batch_size", "bs")),
("seed", _path_candidate("train.seed", "seed")),
("epochs", _path_candidate("train.epochs", "ep")),
]
_OUT_DIR_NAME_MAX_FIELDS = 6
def default_out_dir_name(cfg: dict, now: datetime | None = None) -> str:
"""Build a default checkpoint out_dir name from what's non-default in `cfg`.
Only fields that differ from DEFAULT_CONFIG are included, so a fully
default run's name is just its timestamp — see
`_OUT_DIR_NAME_CANDIDATES` for the fixed, priority-ordered field list.
Beyond `_OUT_DIR_NAME_MAX_FIELDS` non-default fields, the remainder
collapse into a short deterministic hash suffix rather than growing the
name unboundedly. This name doubles as the run's W&B id (see
giant.training), which is the reason a timestamp is always included.
"""
now = now or datetime.now()
tokens = []
overflow = []
for label, candidate in _OUT_DIR_NAME_CANDIDATES:
token = candidate(cfg)
if token is None:
continue
if len(tokens) < _OUT_DIR_NAME_MAX_FIELDS:
tokens.append(token)
else:
overflow.append(f"{label}={token}")
name = now.strftime("%Y%m%d_%H%M")
if tokens:
name += "_" + "_".join(tokens)
if overflow:
digest = hashlib.md5("|".join(sorted(overflow)).encode()).hexdigest()[:6]
name += f"_+{len(overflow)}more-{digest}"
return name
def resolve_default_out_dir(cfg: dict, base: Path = Path("checkpoints")) -> Path:
"""Auto-derived out dir from cfg's hyperparams (see `default_out_dir_name`),
with a numeric suffix loop so two runs whose name collides (same
non-default hyperparams, same to-the-minute timestamp) don't clobber each
other's directory. Shared by `giant train` and `giant new-run`.
"""
base_name = default_out_dir_name(cfg)
out_dir = base / base_name
suffix = 2
while out_dir.exists():
out_dir = base / f"{base_name}_{suffix}"
suffix += 1
return out_dir
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def _toml_value(v) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, str):
return repr(v)
return str(v)
def _write_section(lines: list[str], path: str, values: dict) -> None:
"""Write one TOML table (`[path]`) and recurse depth-first into any
dict-valued keys as `[path.subkey]` — handles the v0.3 schema's 2-3 level
nesting (e.g. stage1_model.router, stage2_model.n_sec) with no depth
limit, unlike the one-level-only writer this replaces."""
lines.append(f"[{path}]")
nested: list[tuple[str, dict]] = []
for k, v in values.items():
if isinstance(v, dict):
nested.append((f"{path}.{k}", v))
else:
lines.append(f"{k:<18} = {_toml_value(v)}")
lines.append("")
for sub_path, sub_values in nested:
_write_section(lines, sub_path, sub_values)
def save_config(cfg: dict, out_dir: Path, meta: dict) -> None:
lines: list[str] = []
for section, values in cfg.items():
_write_section(lines, section, values)
lines.append("[meta]")
for k, v in meta.items():
lines.append(f"{k:<18} = {_toml_value(v)}")
(out_dir / "config.toml").write_text("\n".join(lines))
def build_run_meta(
data: Path,
seed: int,
n_pdg_codes: int,
n_materials: int,
n_train_events: int,
n_val_events: int,
n_train_steps: int,
) -> dict:
return {
"config_version": CONFIG_VERSION,
"git_hash": git_hash(),
"seed": seed,
"timestamp_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"python_version": sys.version.split()[0],
"torch_version": torch.__version__,
"command": " ".join(sys.argv),
"data_path": str(data),
"n_pdg_codes": n_pdg_codes,
"n_materials": n_materials,
"n_train_events": n_train_events,
"n_val_events": n_val_events,
"n_train_steps": n_train_steps,
}