733c13c31c
CI / Lint (ruff check) (push) Successful in 27s
CI / Format (ruff format) (push) Successful in 29s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 45s
CI / Type check (ty) (push) Successful in 48s
CI / Format (ruff format) (pull_request) Successful in 34s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 34s
CI / Tests (pull_request) Successful in 3m47s
CI / Tests (push) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1282 lines
62 KiB
Markdown
1282 lines
62 KiB
Markdown
# GIANT — architecture issues
|
||
|
||
Software-engineering review of the `giant` codebase, conducted 2026-08-12 on branch
|
||
`v0.3.0-stage2-autoregressive` at commit `55332db`.
|
||
|
||
This document is written to be read **without the context of the review conversation**.
|
||
Each issue states what the code does today, why that is a problem, how to verify the
|
||
claim independently, what the fix looks like, and — importantly — what the fix should
|
||
*not* touch. Line numbers are accurate as of `55332db`; if they have drifted, the
|
||
accompanying code excerpts and `grep` commands will still locate the code.
|
||
|
||
## Baseline: what is already good
|
||
|
||
Read this first, so the issues below are calibrated correctly. **This is a healthy
|
||
codebase.** The problems listed are about structural resilience as the v0.3.0
|
||
architecture matrix grows, not about rot or breakage.
|
||
|
||
- `pytest` — **725 tests pass in ~18 s**. Fast enough that there is no excuse for not
|
||
running it on every change.
|
||
- `uv run ruff check .`, `uv run ruff format --check .`, `uv run ty check .` — all clean,
|
||
and all three are enforced in CI (`.gitea/workflows/ci.yml`).
|
||
- Zero `TODO` / `FIXME` / `XXX` / `HACK` markers in `giant/` or `scripts/`.
|
||
- The internal import graph is **acyclic** with clean layering:
|
||
`constants → data → model → training → pipeline → cli`, and `analysis` almost fully
|
||
independent of the rest.
|
||
- Line coverage is **96–99.6 % on every core module** (`network.py` 98.6 %,
|
||
`trainers.py` 99.5 %, `rollout.py` 99.6 %, `transforms.py` 96.2 %, `pipeline.py`
|
||
97.1 %, `analysis/catalog.py` 99.6 %). The single exception is `cli.py` at 35.8 % —
|
||
see Issue 4.
|
||
- Comment and docstring quality is unusually high. Docstrings routinely explain *why*
|
||
a decision was made, not just what the code does, and several call out their own
|
||
known limitations honestly. **Preserve this when refactoring.** A refactor that
|
||
deletes the reasoning in `giant/config.py`'s `DEFAULT_CONFIG` comments or
|
||
`giant/analysis/router_gating.py`'s module docstring is a net loss even if the code
|
||
gets shorter.
|
||
- `giant/analysis/` is the best-designed subsystem in the repo and should be treated as
|
||
the template the rest of the codebase moves toward. See Issue 11.
|
||
|
||
## Issue index
|
||
|
||
| # | Issue | Severity | Effort | Status |
|
||
|---|---|---|---|---|
|
||
| 1 | Config defaults are declared twice; `DEFAULT_CONFIG` and consumers already disagree | **High** | Medium | **Fixed** (`9bf5874`) |
|
||
| 2 | No unknown-key validation — a typo in `config.toml` silently trains the wrong model | **High** | Small | **Fixed** |
|
||
| 3 | `cli.py:train()` is a 58-parameter, 510-line fat controller | **High** | Medium | **Fixed** (`2bfb1ab`) |
|
||
| 4 | `cli.py` is at 35.8 % coverage and holds untested override-precedence logic | **High** | Medium | **Fixed** (`2bfb1ab`, partial — see status note) |
|
||
| 5 | Inference bootstrap is duplicated verbatim between `predict` and `rollout` | **High** | Small | **Fixed** |
|
||
| 6 | Two independent v0.2→v0.3 migration surfaces encode the same knowledge | Medium | Medium | Open |
|
||
| 7 | Positional tuple contracts (9-tuple, 7-tuple) between data, model and training layers | Medium | Small | Open |
|
||
| 8 | `network.py` is 1745 lines holding three distinct modules | Medium | Small | Open |
|
||
| 9 | `scripts` is published as a top-level distribution package | Medium | Small | Open |
|
||
| 10 | `torch.load(weights_only=False)` — checkpoints are arbitrary pickles | Low | Medium | Open |
|
||
| 11 | Minor: `echo=print` threading, `particles → data.loader` layering | Low | Small | Open |
|
||
|
||
---
|
||
|
||
## Issue 1 — Config defaults are declared twice, and the two declarations already disagree
|
||
|
||
> **Status: Fixed, commit `9bf5874` on `v0.3.0-stage2-autoregressive`.** `giant/config.py` now
|
||
> declares a hierarchy of frozen dataclasses (`GiantConfig` and its nested blocks, following
|
||
> `StageSpec`'s existing style) as the single source of truth; `DEFAULT_CONFIG` is generated from
|
||
> `GiantConfig().to_dict()` rather than hand-maintained, and `build_models`/`build_critics`
|
||
> (`network.py`) and `StageSpec.from_config` (`trainers.py`) convert their dict input into these
|
||
> dataclasses at the top of each function and read attributes instead of duplicating
|
||
> `.get(key, literal)` defaults. Both drifted keys (`decoder`, `particle_type.target`) now resolve
|
||
> to `DEFAULT_CONFIG`'s documented v0.3.0 values (`"autoregressive"`/`"onehot"`) unconditionally.
|
||
> Router/`n_sec` sub-blocks keep a dict-shaped `extra` catch-all for their genuinely dynamic keys
|
||
> (composed-router `axis{i}_*`, runtime-seeded `centers_init`, legacy `legacy_owner`) rather than
|
||
> being fully typed — see the "Recommended fix" section below, which this diverges from slightly
|
||
> on that one point. Fixing the fallback surfaced two existing callers that had been silently
|
||
> depending on the old (wrong) default — a `tests/test_train.py` fixture and
|
||
> `scripts/warm_setup_cache.py`'s hand-built minimal config (now merged against `DEFAULT_CONFIG`
|
||
> instead of hand-rolled) — both fixed in the same commit. Regression tests added in
|
||
> `tests/test_config.py`, `tests/test_network.py`, `tests/test_train.py`, including one pinning
|
||
> `GiantConfig().to_dict() == DEFAULT_CONFIG` so this class of drift can't recur silently.
|
||
> Everything below this point describes the pre-fix state and is kept for historical context.
|
||
|
||
**Severity: High. Effort: Medium. Risk if unfixed: silent wrong-model training.**
|
||
|
||
**Location:** `giant/config.py:32` (`DEFAULT_CONFIG`) versus `giant/model/network.py:1570-1693`
|
||
(`build_models`), `giant/training/trainers.py:130-175` (`StageSpec.from_config`),
|
||
`giant/pipeline.py:381`.
|
||
|
||
### What the code does today
|
||
|
||
`DEFAULT_CONFIG` in `giant/config.py:32` is a fully-specified nested dict. Every key has
|
||
a value and most have an explanatory comment. It is, on paper, the single source of
|
||
truth for configuration defaults.
|
||
|
||
The consumers do not treat it that way. They re-declare the same defaults inline via
|
||
`dict.get(key, default)`:
|
||
|
||
```python
|
||
# giant/model/network.py, inside build_models
|
||
cond_out_dim = conditioning.get("out_dim", 128)
|
||
...
|
||
hidden_dim=s1cfg.get("hidden_dim", 256),
|
||
n_res_blocks=s1cfg.get("n_res_blocks", 6),
|
||
dropout=s1cfg.get("dropout", 0.0),
|
||
time_dim=gen_sub.get("time_dim", 64),
|
||
```
|
||
|
||
```python
|
||
# giant/training/trainers.py, inside StageSpec.from_config
|
||
lambda_weight=stage_cfg.get("lambda", 1.0),
|
||
n_sec_lambda=cfg["stage2_model"].get("n_sec", {}).get("lambda", 0.1),
|
||
ddpm_n_steps=stage_cfg.get("ddpm", {}).get("n_steps", 1000),
|
||
n_critic=wgan_cfg.get("n_critic", 5),
|
||
gp_weight=wgan_cfg.get("gp_weight", 10.0),
|
||
```
|
||
|
||
There are **132 `.get("key", default)` call sites across `giant/`**, concentrated in
|
||
`network.py` (55) and `trainers.py` (25). Each one is an independent copy of a value
|
||
that `DEFAULT_CONFIG` also declares.
|
||
|
||
### Why it's a problem
|
||
|
||
Because `cfg` is typed `dict` (i.e. `dict[str, Any]`), neither `ty` nor the tests can
|
||
see that the two declarations are supposed to agree. When they drift, nothing fails —
|
||
the model is simply built differently than the config file says.
|
||
|
||
**This has already happened.** Two defaults currently disagree:
|
||
|
||
| Key | `DEFAULT_CONFIG` says | `build_models` fallback says |
|
||
|---|---|---|
|
||
| `stage2_model.decoder` | `"autoregressive"` | `"one_shot"` (`network.py:1632`) |
|
||
| `stage2_model.particle_type.target` | `"onehot"` | `"physical"` (`network.py:1644`, `trainers.py:143`, `pipeline.py:381`) |
|
||
|
||
Both are *architecturally load-bearing*: `decoder` selects between `Stage2Autoregressive`
|
||
and `Stage2OneShot` — two different networks — and `particle_type.target` selects between
|
||
categorical class logits and continuous `(log-mass, charge)` regression, which is the
|
||
exact axis the v0.3.0 redesign was created to change (see `CLAUDE.md`, "v0.3.0 —
|
||
Stage-2 autoregressive redesign").
|
||
|
||
### Is it currently a live bug?
|
||
|
||
**No — it is currently latent.** Two things save it today:
|
||
|
||
1. The training path always passes a fully-merged config. `giant/pipeline.py:446` builds
|
||
`model_config` from `cfg["stage1_model"]` / `cfg["stage2_model"]`, and `cfg` always
|
||
originates from `merge_cli_overrides(DEFAULT_CONFIG, ...)`, so every key is present
|
||
and no fallback fires.
|
||
2. The legacy path also populates the keys explicitly.
|
||
`_migrate_legacy_model_config` (`network.py:1446`) hard-codes
|
||
`"decoder": "one_shot"` and `"particle_type": {"target": "physical"}` into the dict it
|
||
returns, so a v0.2 checkpoint does not rely on the fallbacks either.
|
||
3. The tests build their configs by deep-copying `DEFAULT_CONFIG` (e.g.
|
||
`tests/test_network.py:611` `_minimal_model_config`), so they never exercise the
|
||
fallback branch.
|
||
|
||
That is precisely what makes this dangerous rather than harmless. The fallbacks are
|
||
**unreachable by every current caller, untested, and wrong.** The first caller that
|
||
hand-builds a partial `model_config` — a new test, a notebook, a debugging script, a
|
||
future `giant train --stage2-only` path — silently gets the v0.2 architecture while the
|
||
config documentation promises the v0.3 one.
|
||
|
||
### How to verify
|
||
|
||
```bash
|
||
uv run python - <<'EOF'
|
||
import copy
|
||
from giant import config as gc
|
||
from giant.model.network import build_models
|
||
|
||
full = copy.deepcopy(gc.DEFAULT_CONFIG)
|
||
print("DEFAULT_CONFIG stage2 decoder :", full["stage2_model"]["decoder"])
|
||
print("DEFAULT_CONFIG particle_type.target:", full["stage2_model"]["particle_type"]["target"])
|
||
|
||
partial = {
|
||
"pdg_vocab": 3, "mat_vocab": 2,
|
||
"conditioning": full["conditioning"],
|
||
"stage1_model": {"hidden_dim": 8, "n_res_blocks": 1},
|
||
"stage2_model": {"hidden_dim": 8, "n_res_blocks": 1, "k_max": 3},
|
||
}
|
||
print("build_models fallback stage2 class :", type(build_models(partial)["stage2"]).__name__)
|
||
EOF
|
||
```
|
||
|
||
Observed output on `55332db`:
|
||
|
||
```
|
||
DEFAULT_CONFIG stage2 decoder : autoregressive
|
||
DEFAULT_CONFIG particle_type.target: onehot
|
||
build_models fallback stage2 class : Stage2OneShot
|
||
```
|
||
|
||
### Recommended fix
|
||
|
||
Make the config **typed at the boundary**, so the defaults exist exactly once and the
|
||
type checker can see them.
|
||
|
||
1. Define a frozen dataclass per config block in `giant/config.py`:
|
||
`ConditioningAxisConfig`, `ConditioningConfig`, `RouterConfig`, `WganConfig`,
|
||
`Stage1ModelConfig`, `Stage2ModelConfig`, `TrainConfig`, `GiantConfig`. The dataclass
|
||
field defaults become the *only* declaration of each default. Move the existing
|
||
`DEFAULT_CONFIG` comments onto the fields — they are the most valuable thing in that
|
||
file and must not be lost.
|
||
2. Give each a `from_dict(d)` classmethod that constructs from the merged TOML dict and
|
||
**rejects unknown keys** (that is Issue 2, and the same constructor solves both).
|
||
3. Change `build_models`, `build_critics` and `StageSpec.from_config` to take the
|
||
dataclasses instead of dicts. Every `.get(key, default)` becomes `cfg.key`. The 132
|
||
duplicate defaults disappear by construction.
|
||
4. Keep `DEFAULT_CONFIG` as a derived artifact if anything still needs the dict shape
|
||
(`save_config`, `default_out_dir_name`, the setup-cache sidecar) — generate it from
|
||
the dataclasses via `dataclasses.asdict`, do not maintain it by hand.
|
||
|
||
Precedent exists in this codebase and should be followed:
|
||
`@dataclass(frozen=True) StageSpec` (`trainers.py:79`) and `PlotSpec`
|
||
(`analysis/catalog.py:65`) already do exactly this, well.
|
||
|
||
### Interim fix if the full change is too large
|
||
|
||
If the dataclass migration is deferred, at minimum add a test that asserts the inline
|
||
fallbacks agree with `DEFAULT_CONFIG`, so the drift is caught:
|
||
|
||
```python
|
||
def test_build_models_fallbacks_match_default_config():
|
||
"""Every `.get(key, default)` in build_models must use the same default
|
||
DEFAULT_CONFIG declares — see issues.md Issue 1."""
|
||
```
|
||
|
||
and fix the two divergent values. Decide deliberately which way: if the fallbacks are
|
||
meant to encode *v0.2 legacy* behaviour, they are redundant (the legacy migration at
|
||
`network.py:1446` already sets both keys explicitly) and should be **deleted**, letting
|
||
a missing key raise `KeyError` instead of silently choosing an architecture.
|
||
|
||
### Scope guard
|
||
|
||
Do **not** change any actual default *value* as part of this refactor. The goal is to
|
||
make the two declarations agree and then have only one; changing what the model does is
|
||
a separate, physics-relevant decision that belongs in its own commit with its own
|
||
retraining. When resolving the two divergences above, the correct target is whatever
|
||
`DEFAULT_CONFIG` says (`autoregressive` / `onehot`), because that is the documented
|
||
v0.3.0 intent per `CLAUDE.md`.
|
||
|
||
---
|
||
|
||
## Issue 2 — No unknown-key validation: a typo in `config.toml` silently trains the wrong model
|
||
|
||
> **Status: Fixed.** `giant/config.py` now has `validate_config_keys(cfg)`, which walks a
|
||
> merged config dict recursively against `DEFAULT_CONFIG`'s tree (itself generated from
|
||
> the `GiantConfig` dataclasses added for Issue 1) and raises `ValueError` on any key not
|
||
> present there, with a `difflib`-based "did you mean" suggestion. `[meta]` is skipped
|
||
> unconditionally, and `stage{1,2}_model.router`'s `axis{i}_{field}` composed-router keys
|
||
> and runtime-seeded `centers_init` are allowed through explicitly. `merge_cli_overrides`
|
||
> calls it right before returning, so all three real entry points (`giant train`,
|
||
> `giant new-run`, `scripts/warm_setup_cache.py`) are covered automatically; checkpoint
|
||
> `model_config` loading (a separate code path, `network._migrate_legacy_model_config`)
|
||
> is untouched, so old checkpoints keep loading regardless of schema drift. This is the
|
||
> "standalone fallback" option from the recommended fix below rather than the
|
||
> dataclass-`from_dict`-rejects-unknown-keys option, specifically to keep the check scoped
|
||
> to the config.toml/CLI-overrides path without touching `validate_config`'s many direct
|
||
> unit-test callers or `run_train_job`'s redundant internal `validate_config` call.
|
||
> Regression tests added in `tests/test_config.py` cover top-level and nested typos, the
|
||
> axis/`centers_init` allowances, `[meta]` skipping, and both the file and CLI-overrides
|
||
> paths, plus the two real `configs/*.toml` fixtures that don't already fail
|
||
> `migrate_config` for unrelated reasons. Everything below this point describes the
|
||
> pre-fix state and is kept for historical context.
|
||
|
||
**Severity: High. Effort: Small. Risk if unfixed: wasted GPU-days on a run that did not
|
||
use the setting you thought it did.**
|
||
|
||
**Location:** `giant/config.py:456` (`_deep_merge`), `giant/config.py:628`
|
||
(`merge_cli_overrides`), `giant/config.py:661` (`validate_config`).
|
||
|
||
### What the code does today
|
||
|
||
`merge_cli_overrides` resolves configuration as `DEFAULT_CONFIG → TOML file → CLI
|
||
overrides`, deep-merging at each step. `_deep_merge` accepts any key:
|
||
|
||
```python
|
||
def _deep_merge(base: dict, override: dict) -> dict:
|
||
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 # <-- unknown k is accepted and stored
|
||
return result
|
||
```
|
||
|
||
`migrate_config` explicitly preserves unrecognised sections
|
||
(`giant/config.py:612-615`: *"Anything else in the original dict (unrecognized top-level
|
||
sections) carries through untouched rather than being silently dropped"*).
|
||
|
||
`validate_config` (`giant/config.py:661`) exists and is *good* — it catches cross-block
|
||
contradictions with genuinely excellent, actionable error messages (e.g. "`router.type =
|
||
'pdg'` builds its own training-vocab-scoped embedding, incompatible with
|
||
`conditioning.particle.type = 'physical'`"). But by design it only inspects keys it knows
|
||
about. It cannot detect a key that should not exist.
|
||
|
||
### Why it's a problem
|
||
|
||
A config file containing
|
||
|
||
```toml
|
||
[stage1_model]
|
||
n_res_block = 12 # typo: should be n_res_blocks
|
||
```
|
||
|
||
merges cleanly, validates cleanly, trains to completion, and reports success — having
|
||
built a 6-block model. The stray `n_res_block` key is faithfully written back into the
|
||
run's saved `config.toml` by `save_config`, so the artifact of record also claims the
|
||
typo was a real setting. Nothing anywhere in the pipeline will ever say otherwise.
|
||
|
||
The blast radius scales with the config surface. v0.3.0 introduced a large nested schema
|
||
(`conditioning` × `stage1_model` × `stage2_model` × per-generator sub-tables × router
|
||
sub-tables), which is a great deal of surface for typos, and the v0.3.0 workflow is
|
||
explicitly *"a sequence of architecture comparisons"* (`config.py`, the `wandb` default
|
||
comment). A comparison in which one arm silently ignored its distinguishing setting is
|
||
worse than no comparison — it produces a confident, wrong conclusion.
|
||
|
||
There is a partial mitigation already: `warn_if_git_hash_mismatch` and
|
||
`warn_if_checkpoint_config_mismatch` warn about *provenance* drift. Neither looks at key
|
||
names.
|
||
|
||
### How to verify
|
||
|
||
```bash
|
||
uv run python - <<'EOF'
|
||
import tempfile, pathlib
|
||
from giant import config as gc
|
||
|
||
p = pathlib.Path(tempfile.mkdtemp()) / "config.toml"
|
||
p.write_text("[meta]\nconfig_version = 3\n\n[stage1_model]\nn_res_block = 12\n")
|
||
cfg = gc.merge_cli_overrides(gc.DEFAULT_CONFIG, p, {})
|
||
gc.validate_config(cfg) # passes
|
||
print("typo key survived :", cfg["stage1_model"]["n_res_block"])
|
||
print("real key unchanged:", cfg["stage1_model"]["n_res_blocks"])
|
||
EOF
|
||
```
|
||
|
||
### Recommended fix
|
||
|
||
Add strict key validation against the known schema. Two options, in order of preference:
|
||
|
||
1. **Preferred — fold into Issue 1.** If each config block becomes a dataclass with a
|
||
`from_dict` that raises on unknown keys, this issue is solved for free and cannot
|
||
regress. Raise a `ValueError` in the established house style, naming the offending
|
||
key, its section, and the closest valid key by edit distance
|
||
(`difflib.get_close_matches`) — that last part matters, because "unknown key
|
||
`n_res_block`; did you mean `n_res_blocks`?" is the message that actually saves the
|
||
run.
|
||
|
||
2. **Standalone fallback.** Add `validate_config_keys(cfg)` to `giant/config.py` that
|
||
walks `cfg` alongside `DEFAULT_CONFIG` and raises on any key not present in the
|
||
defaults tree. Call it from `merge_cli_overrides` right before returning, so every
|
||
entry point gets it.
|
||
|
||
Either way, the following need explicit allowances, since they legitimately carry keys
|
||
not in `DEFAULT_CONFIG`:
|
||
|
||
- **`meta`** — holds `config_version`, `git_hash`, and run metadata from
|
||
`build_run_meta`, none of which are in the defaults tree.
|
||
- **Composed-router axis keys** — `stage{1,2}_model.router.axis{i}_{field}` are
|
||
deliberately dynamic and deliberately absent from `DEFAULT_CONFIG` (see the comment at
|
||
the end of the `router` block in `config.py`, and `_parse_composed_axes` in
|
||
`network.py:499`). Validate these against the `axis(\d+)_(\w+)` pattern plus the known
|
||
per-axis field names rather than against a fixed key list.
|
||
- **`stage2_model.n_sec.legacy_owner`** — injected by `_migrate_legacy_model_config`, not
|
||
a user-facing key. It appears in checkpoint `model_config` dicts, not in `config.toml`,
|
||
so it should not reach this validator; confirm that before assuming.
|
||
|
||
### Scope guard
|
||
|
||
Strict validation must apply to the **`config.toml` path only**, not to loading old
|
||
checkpoints. A v0.2 checkpoint's `model_config` is migrated by a different function
|
||
(`network._migrate_legacy_model_config`, see Issue 6) and must keep loading. Adding
|
||
strictness that rejects historical checkpoints would break `giant predict` / `giant
|
||
rollout` against every model trained so far.
|
||
|
||
---
|
||
|
||
## Issue 3 — `cli.py:train()` is a 58-parameter, 510-line fat controller
|
||
|
||
> **Status: Fixed, commit `2bfb1ab` on `v0.3.0-stage2-autoregressive`.** `giant/config.py`
|
||
> now declares `FlagSpec` (a frozen dataclass: `name`, `paths` — one or more dotted config
|
||
> paths, `precedence`) and a `FLAG_SPECS` table covering every flag `train`/`new-run` map
|
||
> into the config tree, plus `overrides_from_flags(values: dict[str, object]) -> dict`,
|
||
> which applies specs in ascending precedence order (so a more-specific flag overwrites a
|
||
> shared/shorthand one written earlier at the same path) — one mechanism replacing the
|
||
> three different ad hoc "more specific wins" patterns identified below
|
||
> (`.update()`-call-order, `setdefault(...)[...] =` overwrite-order, and
|
||
> `{**shared, **specific}` merge). `train()`'s ~140-line override-building block
|
||
> (`cli.py:656-790` pre-fix) is now a single flat `flag_values` dict (mostly enum `.value`
|
||
> unwrapping) plus one call to `overrides_from_flags`; `new_run()`'s near-verbatim copy
|
||
> (`cli.py:915-983` pre-fix) collapsed the same way, reusing the identical table. Router
|
||
> overrides (`_router_cli_overrides`, unchanged, still shared by both commands) feed into
|
||
> the table as a single pre-aggregated `router_config` entry mapped only to
|
||
> `stage1_model.router` — the stage1-only asymmetry noted below is preserved exactly, with
|
||
> a regression test. No flag was added, removed, or renamed, and no precedence semantics
|
||
> changed: `giant train --help`/`giant new-run --help` are byte-identical before and after,
|
||
> verified by diffing both. Everything below this point describes the pre-fix state and is
|
||
> kept for historical context.
|
||
|
||
**Severity: High. Effort: Medium.**
|
||
|
||
**Location:** `giant/cli.py:329-839`.
|
||
|
||
### What the code does today
|
||
|
||
Measured on `55332db`:
|
||
|
||
| Command | Parameters | Lines |
|
||
|---|---|---|
|
||
| `train` | **58** | **510** |
|
||
| `new_run` | 31 | 188 |
|
||
| `predict` | 9 | 387 |
|
||
| `rollout` | 14 | 237 |
|
||
|
||
Of `train`'s 510 lines, roughly 430 are the Typer signature (one `Annotated[...]` block
|
||
per flag) and roughly 80 are hand-written translation from flags into the nested
|
||
overrides dict. That translation follows the same shape five times over — for `train`,
|
||
`stage1_model`, `stage2_model`, `conditioning`, and the WGAN sub-tables:
|
||
|
||
```python
|
||
cli_stage1_model: dict[str, object] = {
|
||
k: v for k, v in {
|
||
"hidden_dim": hidden_dim,
|
||
"n_res_blocks": n_blocks,
|
||
"dropout": dropout,
|
||
}.items() if v is not None
|
||
}
|
||
cli_stage1_model.update(
|
||
{k: v for k, v in {
|
||
"hidden_dim": stage1_hidden_dim,
|
||
"n_res_blocks": stage1_n_res_blocks,
|
||
"dropout": stage1_dropout,
|
||
}.items() if v is not None}
|
||
)
|
||
```
|
||
|
||
On top of that sits genuinely intricate precedence logic, correctly implemented but
|
||
expressed imperatively:
|
||
|
||
- `--mode` sets `generator` on **both** stages; `--stage1-generator` / `--stage2-generator`
|
||
then override a single stage (`cli.py:713-720`).
|
||
- `--hidden-dim` / `--n-blocks` / `--dropout` are stage-1-only backward-compatible
|
||
shorthands; `--stage1-*` wins when both are given (`cli.py:676-700`).
|
||
- `--n-critic` / `--gp-weight` / `--noise-dim` / `--critic-lr` fan out to
|
||
`stage{1,2}_model.wgan.*`, with `--stage{1,2}-*` variants overriding per stage — built
|
||
by merging a shared dict under a stage-specific one (`cli.py:722-753`).
|
||
- `--emb-dim` and `--conditioning` each set **two** places
|
||
(`conditioning.particle.*` and `conditioning.material.*`) (`cli.py:702-710`).
|
||
|
||
### Why it's a problem
|
||
|
||
1. **The mapping is data, written as code.** Flag → dotted config path → fan-out rule is
|
||
a table. Written as 80 lines of copy-adapted dict comprehensions, correctness has to
|
||
be re-verified by reading, every time a flag is added. `giant/config.py:446` already
|
||
provides `_set_path(d, "stage1_model.wgan.n_critic", v)` — the primitive a table-driven
|
||
version needs.
|
||
2. **Adding a flag touches three places at once** (signature, the right comprehension,
|
||
the right precedence rule) with no mechanism forcing them to stay consistent. v0.3.0's
|
||
design explicitly anticipates more per-stage flags.
|
||
3. **It is the least-tested code in the repo** (Issue 4), because unit-testing a
|
||
58-parameter Typer command means going through `CliRunner` with argv strings.
|
||
4. It obscures the ~15 lines that actually do something (`cli.py:825-839`): resolve the
|
||
out-dir, echo two lines, call `run_train_job`.
|
||
|
||
### Recommended fix
|
||
|
||
Extract the mapping into a pure, table-driven function in `giant/config.py` (not
|
||
`cli.py`), so it is importable and directly testable without Typer:
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class FlagSpec:
|
||
"""One CLI flag's mapping into the config tree."""
|
||
name: str # "stage1_hidden_dim"
|
||
paths: tuple[str, ...] # ("stage1_model.hidden_dim",) — >1 means fan-out
|
||
precedence: int = 0 # higher wins; per-stage flags outrank shorthands
|
||
|
||
FLAG_SPECS: tuple[FlagSpec, ...] = (
|
||
FlagSpec("hidden_dim", ("stage1_model.hidden_dim",), precedence=0),
|
||
FlagSpec("stage1_hidden_dim", ("stage1_model.hidden_dim",), precedence=1),
|
||
FlagSpec("mode", ("stage1_model.generator", "stage2_model.generator")),
|
||
FlagSpec("n_critic", ("stage1_model.wgan.n_critic", "stage2_model.wgan.n_critic")),
|
||
FlagSpec("emb_dim", ("conditioning.particle.emb_dim", "conditioning.material.emb_dim")),
|
||
...
|
||
)
|
||
|
||
def overrides_from_flags(values: dict[str, object]) -> dict:
|
||
"""Build the nested overrides dict from {flag_name: value}, dropping
|
||
None (= flag not given) and applying precedence."""
|
||
```
|
||
|
||
`train()` then becomes: parse `--batch-size auto`, collect `locals()`-style flag values,
|
||
call `overrides_from_flags`, `merge_cli_overrides`, `validate_config`, resolve out-dir,
|
||
call `run_train_job`. The 430-line Typer signature stays — that is irreducible, it *is*
|
||
the user interface — but it becomes the only bulk in the function.
|
||
|
||
The precedence table also becomes self-documenting, which today requires reading three
|
||
separate inline comment blocks to reconstruct.
|
||
|
||
### Scope guard
|
||
|
||
- **Do not remove or rename any flag.** The backward-compatible shorthands
|
||
(`--hidden-dim`, `--n-blocks`, `--dropout`, `--mode`) exist because they predate the
|
||
per-stage flags and are in people's shell history and job scripts. Their surprising
|
||
stage-1-only scoping is documented behaviour, not a bug to fix.
|
||
- **Do not change precedence semantics.** Reproduce the current rules exactly, then
|
||
test them (Issue 4). Any intentional change is a separate commit.
|
||
- Preserve the explanatory comments at `cli.py:676-680` and `cli.py:713-719` — they
|
||
record *why* the precedence is the way it is.
|
||
|
||
---
|
||
|
||
## Issue 4 — `cli.py` sits at 35.8 % coverage and holds untested override-precedence logic
|
||
|
||
> **Status: Fixed for the override-precedence logic (Issue 3's scope); the
|
||
> `predict`/`rollout` inference-bootstrap portion described below is still open — that is
|
||
> Issue 5, deliberately not attempted here.** Commit `2bfb1ab` on
|
||
> `v0.3.0-stage2-autoregressive` adds direct, `CliRunner`-free unit tests for
|
||
> `overrides_from_flags` in `tests/test_config.py` — one per precedence rule, including
|
||
> several with previously **zero** coverage: both legs of `--stage{1,2}-generator`
|
||
> overriding `--mode` (only the stage1 leg had a test before), all three stage1
|
||
> shorthand-vs-`--stage1-*` pairs (previously only `hidden_dim`), all four WGAN knobs'
|
||
> shared-vs-per-stage precedence (previously only `n_critic`/`gp_weight`), the
|
||
> `--emb-dim`/`--conditioning` dual-axis fan-out (previously untested via `train` at all),
|
||
> and an explicit regression test that `router_config` only ever writes
|
||
> `stage1_model.router`. `tests/test_cli_train_overrides.py` keeps its original 4
|
||
> `CliRunner` smoke tests unmodified, plus 4 new ones covering the `--batch-size auto`
|
||
> parse-error and success paths and all three `out_dir` resolution branches
|
||
> (`--out`/`--resume`/default) — previously entirely uncovered. The percentage barely moves
|
||
> (36 % → 35 %), because the extraction *deleted* more statements from `cli.py` (539→478)
|
||
> than the new tests cover elsewhere in the file, but covered statements rose in absolute
|
||
> terms (157→169) and every override-precedence line the original issue called out by
|
||
> number is now covered. Coverage of `cli.py`'s other listed gap — the `predict`/
|
||
> `rollout` inference bootstrap (`cli.py:1104-1423`/`1528-1702` in the pre-fix numbering) —
|
||
> is unchanged, since fixing that requires the `load_for_inference` extraction described in
|
||
> Issue 5, which is explicitly out of scope for this fix (done separately, if at all).
|
||
> Everything below this point describes the pre-fix state and is kept for historical
|
||
> context.
|
||
|
||
**Severity: High. Effort: Medium.**
|
||
|
||
**Location:** `giant/cli.py` (1865 lines).
|
||
|
||
### What the code does today
|
||
|
||
Per-module line coverage from `coverage.xml`:
|
||
|
||
| Module | Coverage |
|
||
|---|---|
|
||
| `rollout.py` | 99.6 % |
|
||
| `analysis/catalog.py` | 99.6 % |
|
||
| `training/trainers.py` | 99.5 % |
|
||
| `model/network.py` | 98.6 % |
|
||
| `pipeline.py` | 97.1 % |
|
||
| `data/transforms.py` | 96.2 % |
|
||
| `config.py` | 91.8 % |
|
||
| **`cli.py`** | **35.8 %** |
|
||
|
||
Project total is 81.8 %. `cli.py` is not merely the lowest — it is a **60-point outlier**
|
||
against an otherwise uniformly high standard, and it is simultaneously the largest module
|
||
in the repo.
|
||
|
||
Existing CLI tests (`tests/test_cli_train_overrides.py`, `tests/test_cli_new_run.py`,
|
||
`tests/test_cli_predict.py`) are the right idea and well-written; they simply cover a
|
||
fraction of the surface.
|
||
|
||
### Why it's a problem
|
||
|
||
The uncovered code is not boilerplate. It includes:
|
||
|
||
- The entire flag → config override translation and its precedence rules (Issue 3). Every
|
||
"`--stage2-hidden-dim` beats `--hidden-dim`", "`--stage2-generator` beats `--mode`",
|
||
"`--n-critic` fans out to both stages unless `--stage1-n-critic` is given" rule is
|
||
currently **unverified by any test**.
|
||
- The complete inference bootstrap in `predict` and `rollout` — checkpoint loading,
|
||
normalizer reconstruction, conditioning-axis resolution, top-N map wiring (Issue 5).
|
||
This is the code where a divergence between the two commands produces silent
|
||
train/inference skew rather than a crash.
|
||
- The `--batch-size auto` parsing and estimation paths, and the six distinct
|
||
"retrain with the current code" checkpoint-compatibility guards.
|
||
|
||
A failure here is expensive in a way a unit-test failure is not: a mis-scoped flag means
|
||
a multi-hour GPU run trains a model that differs from the one the experiment log claims.
|
||
Given `CLAUDE.md` describes v0.3.0 as a sequence of architecture comparisons, this is the
|
||
worst possible place for silent divergence.
|
||
|
||
### How to verify
|
||
|
||
```bash
|
||
uv run pytest -q --cov=giant --cov-report=term-missing:skip-covered 2>&1 | grep "cli.py"
|
||
```
|
||
|
||
### Recommended fix
|
||
|
||
Coverage here is a *consequence* of Issue 3, not an independent goal. Sequence the work:
|
||
|
||
1. **First, extract the testable logic** (Issue 3): `overrides_from_flags` in
|
||
`config.py`, and `load_for_inference` in a new `giant/checkpoint_io.py` (Issue 5).
|
||
2. **Then test the extracted functions directly** — no Typer, no `CliRunner`, no
|
||
filesystem. One test per precedence rule:
|
||
|
||
```python
|
||
def test_stage1_hidden_dim_beats_hidden_dim_shorthand(): ...
|
||
def test_mode_sets_both_stage_generators(): ...
|
||
def test_stage2_generator_overrides_mode_for_stage2_only(): ...
|
||
def test_n_critic_fans_out_to_both_wgan_subtables(): ...
|
||
def test_stage1_n_critic_overrides_shared_n_critic_for_stage1_only(): ...
|
||
def test_emb_dim_sets_both_conditioning_axes(): ...
|
||
```
|
||
|
||
3. **Keep a thin layer of `CliRunner` smoke tests** over `train --help`, `predict`,
|
||
`rollout` to catch signature/wiring breakage, but do not try to reach high coverage
|
||
through the CLI surface — that is slow and brittle.
|
||
|
||
Target: get `cli.py` to roughly the repo norm by *moving code out of it*, not by writing
|
||
elaborate CLI-invocation tests. A `cli.py` that is genuinely just argument declaration
|
||
plus delegation can sit at modest coverage without concern, because there will be
|
||
nothing in it left to get wrong.
|
||
|
||
### Scope guard
|
||
|
||
Do not add `# pragma: no cover` to close the gap. The gap is a real signal and the fix is
|
||
extraction.
|
||
|
||
---
|
||
|
||
## Issue 5 — The inference bootstrap is duplicated verbatim between `predict` and `rollout`
|
||
|
||
> **Status: Fixed.** `giant/checkpoint_io.py` now holds a single
|
||
> `load_for_inference(checkpoint, device, command_name, weights="raw", require_stage2=True)`
|
||
> plus an `InferenceContext` dataclass (stage1/stage2 models, all three normalizers, both
|
||
> vocab maps, both top-N maps, both conditioning axes, `k_max`, both stages' ddpm step
|
||
> counts, `other_policy`, the raw `model_config`, and `epoch`/`best_val_loss` for
|
||
> `rollout`'s YAML sidecar) — exactly the design this issue proposed, verified against the
|
||
> current (not `55332db`-era) code before writing it. `predict`/`rollout` in `cli.py`
|
||
> each shrink to one `try/except CheckpointCompatibilityError` call plus a block of
|
||
> `ctx.<field>` unpacks; `cli.py` lost `_conditioning_axes`, `_stage_cfg`, `_ddpm_steps`,
|
||
> `_particle_type_other_policy`, `_load_pdg_topn_map`/`_load_mat_topn_map`, and
|
||
> `_load_model_weights` entirely (net ~180 lines off `cli.py`). The independent third copy
|
||
> in `giant/analysis/router_gating.py` was deleted in favour of a lazy
|
||
> `from giant.checkpoint_io import conditioning_axes` inside `load_router`'s existing
|
||
> lazy-import block, preserving that module's "polars/numpy only at module scope"
|
||
> contract (`checkpoint_io.py` imports torch eagerly, so it must never be imported at
|
||
> `router_gating.py` module scope). Two guard orderings from the two commands' drifted
|
||
> copies were consolidated into one (`warn_if_checkpoint_config_mismatch` now always
|
||
> runs right after the existence guards, and `predict`'s `--batch-size auto` estimate now
|
||
> reads `ctx.model_config` after the checkpoint loads rather than before) — both are
|
||
> console-output-order changes only, no error text or model behavior changed, confirmed by
|
||
> diffing `giant predict --help`/`giant rollout --help` byte-for-byte before and after (no
|
||
> flag touched) and re-reading both rewritten command bodies field-by-field against the
|
||
> original. `require_stage2` exists as a real parameter, exercised by a new test, even
|
||
> though both current callers pass the default `True`. New `tests/test_checkpoint_io.py`
|
||
> (17 tests: happy path, every guard individually with exact message-text assertions, the
|
||
> `require_stage2=False`/inactive-stage2 path, `conditioning_axes`/`stage_cfg` directly)
|
||
> plus thin `CliRunner` smoke tests in `tests/test_cli_predict.py` and the new
|
||
> `tests/test_cli_rollout.py` confirming `CheckpointCompatibilityError` actually surfaces
|
||
> as `typer.Exit(1)` through the CLI — previously this entire code path had zero test
|
||
> coverage. `uv run pytest -q` (803 passed, up from 784), `ruff check`, `ruff format
|
||
> --check`, and `ty check` all clean. Everything below this point describes the pre-fix
|
||
> state and is kept for historical context.
|
||
|
||
**Severity: High. Effort: Small. Risk if unfixed: silent train/inference skew.**
|
||
|
||
**Location:** `giant/cli.py:1121-1201` (`predict`), `giant/cli.py:1534-1593` (`rollout`),
|
||
plus a third partial copy at `giant/analysis/router_gating.py:86-120`.
|
||
|
||
### What the code does today
|
||
|
||
`predict` and `rollout` each contain ~65 lines that are near-identical line-for-line:
|
||
|
||
1. `ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)`
|
||
2. Guard: no `model_config` → "retrain with the current code" → `Exit(1)`
|
||
3. Guard: no `sec_decoder` → same
|
||
4. Guard: no `normalizer.sec_phys` → same
|
||
5. `_conditioning_axes(model_cfg)`
|
||
6. `_load_pdg_topn_map(ckpt)` / `_load_mat_topn_map(ckpt)`
|
||
7. Guard: `particle_conditioning == "onehot"` but no `pdg_topn_map` → `Exit(1)`
|
||
8. Guard: `material_conditioning == "onehot"` but no `mat_topn_map` → `Exit(1)`
|
||
9. `pdg_map` / `mat_map` key-type coercion (`{int(k): v ...}` / `{str(k): v ...}`)
|
||
10. Three `Normalizer.from_dict(ckpt["normalizer"][...])` calls
|
||
11. `build_models(model_cfg)`, unpack `stage1` / `stage2`
|
||
12. Guard: either stage `None` → "needs both" → `Exit(1)`
|
||
13. `_load_model_weights(...)`, `.to(device).eval()` on both
|
||
14. `typer.echo(f"loaded checkpoint: ...")`
|
||
15. `gconfig.warn_if_checkpoint_config_mismatch(checkpoint)`
|
||
|
||
Additionally, `_conditioning_axes` is **duplicated verbatim into a second module**.
|
||
`giant/analysis/router_gating.py:69` carries a byte-for-byte copy of
|
||
`giant/cli.py:74`, with the docstring openly acknowledging it: *"Mirrors
|
||
`giant.cli._conditioning_axes`."*
|
||
|
||
```bash
|
||
grep -rn "def _conditioning_axes" giant/
|
||
# giant/cli.py:74
|
||
# giant/analysis/router_gating.py:69
|
||
```
|
||
|
||
### Why it's a problem
|
||
|
||
This is not cosmetic duplication. This code decides **how input features are assembled at
|
||
inference time** — which conditioning mode is used, which top-N vocabulary maps are
|
||
applied, which normalizer statistics are restored. If `predict` and `rollout` ever
|
||
diverge on any of those, the result is not an exception. It is two commands producing
|
||
subtly different physics from the same checkpoint, with no error and no warning. That
|
||
class of bug is found by noticing that a plot looks wrong, weeks later.
|
||
|
||
The duplication is also **entirely untested** — it lives in the 35.8 %-covered region of
|
||
`cli.py` (Issue 4). And it is *already* growing: the third copy in `router_gating.py`
|
||
shows the pattern spreading into a package that is otherwise carefully isolated.
|
||
|
||
The self-aware "Mirrors `giant.cli._conditioning_axes`" comment is the tell. When a
|
||
developer documents a copy rather than removing it, the missing abstraction has been
|
||
identified but not yet built.
|
||
|
||
### Recommended fix
|
||
|
||
Create `giant/checkpoint_io.py` (name it whatever fits; the point is that it is **not**
|
||
`cli.py`) exposing one function and one result object:
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class InferenceContext:
|
||
"""Everything needed to run a trained checkpoint forward, resolved once."""
|
||
stage1: nn.Module
|
||
stage2: nn.Module
|
||
cond_norm: Normalizer
|
||
tgt_norm: Normalizer
|
||
sec_phys_norm: Normalizer
|
||
pdg_map: dict[int, int]
|
||
mat_map: dict[str, int]
|
||
pdg_topn_map: TopNMap | None
|
||
mat_topn_map: TopNMap | None
|
||
particle_conditioning: str
|
||
material_conditioning: str
|
||
k_max: int
|
||
stage1_ddpm_steps: int
|
||
stage2_ddpm_steps: int
|
||
other_policy: str
|
||
model_config: dict
|
||
|
||
def load_for_inference(
|
||
checkpoint: Path,
|
||
device: torch.device,
|
||
weights: str = "raw",
|
||
require_stage2: bool = True,
|
||
) -> InferenceContext: ...
|
||
```
|
||
|
||
Raise a dedicated `CheckpointCompatibilityError` (carrying the current, genuinely helpful
|
||
message text) instead of calling `typer.echo` + `typer.Exit`; the CLI catches it and does
|
||
the echo/exit. That keeps the module free of Typer and makes it unit-testable.
|
||
|
||
`predict` and `rollout` each shrink by ~60 lines to a single call. `router_gating.py`
|
||
drops its `_conditioning_axes` copy and imports from the new module — note that
|
||
`router_gating` deliberately imports torch **lazily inside the function**
|
||
(`router_gating.py:88`) to keep the analysis workers' "polars/numpy only" contract; the
|
||
new module must be imported the same way there, and `checkpoint_io.py` must not be
|
||
imported at `analysis/` module scope.
|
||
|
||
### Scope guard
|
||
|
||
- Preserve every error message verbatim. They are specific and actionable
|
||
("checkpoint's `conditioning.particle.type='onehot'` but has no `pdg_topn_map` — retrain
|
||
with the current code"), and users have seen them before.
|
||
- Preserve the `require_stage2` distinction: `giant predict` and `giant rollout` both need
|
||
both stages today, but `stage1_model.active = false` / `stage2_model.active = false` are
|
||
real config options (`DEFAULT_CONFIG`), so the parameter should exist rather than
|
||
hard-coding the requirement.
|
||
- Do not fold `giant/analysis/router_gating.py`'s router-specific loading
|
||
(`load_router`, which returns `None` for non-MoE checkpoints) into the shared function.
|
||
It has different semantics — absence is a normal outcome there, not an error.
|
||
|
||
---
|
||
|
||
## Issue 6 — Two independent v0.2→v0.3 migration surfaces encode the same knowledge
|
||
|
||
**Severity: Medium. Effort: Medium.**
|
||
|
||
**Location:** `giant/config.py:514` (`migrate_config`) and
|
||
`giant/model/network.py:1446` (`_migrate_legacy_model_config`).
|
||
|
||
### What the code does today
|
||
|
||
v0.3.0 broke the config format: the v0.2 single `[train]` + `[model]` layout became
|
||
`[conditioning]` / `[stage1_model]` / `[stage2_model]` / `[train]`. That break has to be
|
||
absorbed in two different places, and today it is absorbed by two unrelated functions:
|
||
|
||
- **`config.migrate_config`** translates a v0.2 **`config.toml`** on load.
|
||
- **`network._migrate_legacy_model_config`** translates a v0.2 **checkpoint's
|
||
`model_config` dict** on `build_models`.
|
||
|
||
The split is acknowledged as deferred work in `migrate_config`'s own docstring:
|
||
|
||
> *"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."*
|
||
|
||
Both functions independently encode the same translation facts:
|
||
|
||
| v0.2 concept | v0.3 destination | in `migrate_config` | in `_migrate_legacy_model_config` |
|
||
|---|---|---|---|
|
||
| `train.mode` / `model.mode` | `stage{1,2}_model.generator` | ✓ | ✓ |
|
||
| `model.emb_dim` | `conditioning.{particle,material}.emb_dim` | ✓ | ✓ |
|
||
| `model.conditioning` | `conditioning.{particle,material}.type` | ✓ | ✓ |
|
||
| `model.n_blocks` | `stage{1,2}_model.n_res_blocks` | ✓ | ✓ |
|
||
| `model.noise_dim` | `stage{1,2}_model.wgan.noise_dim` | ✓ | ✓ |
|
||
| conditioning MLP depth was always 2 | `conditioning.*.n_layers = 2` | ✓ | ✓ |
|
||
| `router.expert_hidden_dim` set → hard error | — | ✓ | ✓ (near-identical message) |
|
||
| n_sec head lived on stage 1 | `stage2_model.n_sec.legacy_owner` | ✗ | ✓ (only here) |
|
||
|
||
The `expert_hidden_dim` rejection is the clearest symptom: the same policy, the same
|
||
reasoning, two hand-maintained copies of a ~10-line error message
|
||
(`config.py:581-591` and `network.py:1475-1489`).
|
||
|
||
### Why it's a problem
|
||
|
||
1. **Drift.** A future correction to the v0.2 interpretation must be applied to both.
|
||
Applying it to one produces a checkpoint that loads with different architecture than
|
||
its own config file describes.
|
||
2. **`legacy_owner` leaks into the builder.** Because the checkpoint migration is
|
||
downstream of the config migration rather than sharing it, `build_models` has to carry
|
||
legacy-specific branching at four sites (`network.py:1612`, `1642`, `1663`, `1688`):
|
||
|
||
```python
|
||
legacy_owner = (s2cfg.get("n_sec") or {}).get("legacy_owner")
|
||
n_sec_head_k_max = s2cfg.get("k_max", K_MAX) if legacy_owner == "stage1" else None
|
||
...
|
||
build_n_sec_head=legacy_owner != "stage1",
|
||
```
|
||
|
||
A builder for the current architecture should not need to know where v0.2 put its
|
||
`n_sec` head.
|
||
3. **The retention policy is undeclared.** `tests/legacy/network_v02_snapshot.py` is
|
||
**1009 lines** of frozen v0.2 network code kept purely so migration can be tested
|
||
against it. That is a substantial maintenance surface with no stated expiry.
|
||
|
||
### Recommended fix
|
||
|
||
1. **Extract the shared translation into one table.** A single
|
||
`_V02_TO_V03_TRANSLATION` mapping (old dotted key → tuple of new dotted keys) plus one
|
||
set of "v0.2 architectural facts with no config key" constants, consumed by both
|
||
functions. `config.py:475-511` already has the beginnings of this
|
||
(`_V02_TRAIN_PASSTHROUGH`, `_V02_MODEL_TO_BOTH_STAGES`,
|
||
`_V02_TRAIN_TO_BOTH_STAGES_WGAN`) — extend that pattern and share it. Put the shared
|
||
table in a neutral module (e.g. `giant/_migration.py`) so neither `config.py` nor
|
||
`network.py` has to import the other.
|
||
2. **Move `legacy_owner` handling out of `build_models`.** The migration function should
|
||
emit a `model_config` that `build_models` can consume without legacy branching — for
|
||
example by emitting an explicit `stage2_model.n_sec.owner` key that the *current*
|
||
schema also carries (with value `"stage2"` for new runs), so the builder reads one key
|
||
with two valid values rather than a nullable legacy sentinel.
|
||
3. **Write down the retention policy.** Add to `CLAUDE.md`: which v0.2 checkpoints must
|
||
remain loadable, until when, and what triggers dropping the shim and the 1009-line
|
||
snapshot. Without that, nobody will ever feel authorised to delete it.
|
||
|
||
### Scope guard
|
||
|
||
- **v0.2 checkpoints must keep loading** until the policy above says otherwise. There are
|
||
trained models on `/ceph` that predate v0.3.0 and analysis runs referencing them.
|
||
- Keep `tests/legacy/network_v02_snapshot.py` as a **frozen snapshot** — do not "clean it
|
||
up", reformat it, or make it share code with current `network.py`. Its entire value is
|
||
that it is an independent, unchanging record of what v0.2 did. If ruff/ty complain about
|
||
it, exclude it rather than edit it (it is already excluded from coverage via
|
||
`pyproject.toml`'s `omit = ["*/legacy/*"]`).
|
||
|
||
---
|
||
|
||
## Issue 7 — Positional tuple contracts between the data, model and training layers
|
||
|
||
**Severity: Medium. Effort: Small.**
|
||
|
||
**Location:** `giant/data/transforms.py:863-890` (`build_features`),
|
||
`giant/data/dataset.py:229-237` (`StreamingStepsDataset` yield),
|
||
`giant/training/trainers.py:574-582` (unpack), `giant/cli.py:1233` (unpack).
|
||
|
||
### What the code does today
|
||
|
||
`build_features` returns a **bare 9-tuple**:
|
||
|
||
```python
|
||
) -> tuple[
|
||
np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray,
|
||
np.ndarray, np.ndarray, Normalizer | None, Normalizer | None,
|
||
]:
|
||
"""Assemble (cond_cont, cond_cat, target_s1, n_sec, sec_cont, proc_idx,
|
||
sec_type_idx) arrays. ..."""
|
||
```
|
||
|
||
The element *meanings* live only in the docstring. Call sites re-derive them positionally:
|
||
|
||
```python
|
||
# giant/cli.py:1233 — predict
|
||
cond_cont, cond_cat, target_raw, _, _, _, _, _, _ = build_features(...)
|
||
```
|
||
|
||
`StreamingStepsDataset` yields a **7-tuple** of tensors (`dataset.py:229`), unpacked
|
||
positionally in the trainer:
|
||
|
||
```python
|
||
# giant/training/trainers.py:574
|
||
(
|
||
cond_cont, cond_cat, x1_s1, n_sec, sec_cont, proc_idx, sec_type_idx,
|
||
) = _batch_to_device(batch, device)
|
||
```
|
||
|
||
and again, with a *different arity*, in the WGAN path:
|
||
|
||
```python
|
||
# giant/training/trainers.py:737
|
||
cond_cont, cond_cat, n_sec, sec_cont, sec_type_idx = batch_tensors
|
||
```
|
||
|
||
`_batch_to_device` is typed `(batch: tuple, device) -> tuple` — no element information at
|
||
all.
|
||
|
||
### Why it's a problem
|
||
|
||
1. **Invisible to the type checker.** `ty check` passes on all of it. Reordering two
|
||
`np.ndarray` elements — or inserting a new one in the middle — type-checks cleanly and
|
||
produces a model trained on scrambled features. Several of these arrays are
|
||
shape-compatible with each other (`n_sec`, `proc_idx` and `sec_type_idx` are all
|
||
integer arrays), so a swap may not even produce a shape error at runtime.
|
||
2. **This is the hottest contract in the codebase.** It crosses three layer boundaries
|
||
(`data` → `training`, `data` → `cli`) and is the mechanism by which the model receives
|
||
physics. A silent corruption here is the most expensive possible failure: it does not
|
||
crash, it just trains something wrong.
|
||
3. **The nine-underscore unpack** at `cli.py:1233` is unreadable and does not survive any
|
||
change to the tuple.
|
||
4. **Two different batch arities** for the same conceptual batch (7 in `_compute`, 5 in
|
||
`_stage2_real_and_fake`) means the reader must track which slice is in play.
|
||
|
||
### Recommended fix
|
||
|
||
Convert both to `NamedTuple`. Zero runtime cost, full `ty` visibility, tuple-unpacking
|
||
still works so migration is incremental:
|
||
|
||
```python
|
||
class StepFeatures(NamedTuple):
|
||
"""Output of build_features. Field order is load-bearing for existing
|
||
positional unpacking — append only, never insert or reorder."""
|
||
cond_cont: np.ndarray
|
||
cond_cat: np.ndarray
|
||
target_s1: np.ndarray
|
||
n_sec: np.ndarray
|
||
sec_cont: np.ndarray
|
||
proc_idx: np.ndarray
|
||
sec_type_idx: np.ndarray
|
||
cond_normalizer: Normalizer | None
|
||
target_normalizer: Normalizer | None
|
||
|
||
class StepBatch(NamedTuple):
|
||
cond_cont: torch.Tensor
|
||
cond_cat: torch.Tensor
|
||
target_s1: torch.Tensor
|
||
n_sec: torch.Tensor
|
||
sec_cont: torch.Tensor
|
||
proc_idx: torch.Tensor
|
||
sec_type_idx: torch.Tensor
|
||
```
|
||
|
||
Then `cli.py:1233` becomes `feats = build_features(...)` / `feats.cond_cont`, and
|
||
`_batch_to_device` gets the real signature `(batch: StepBatch, device) -> StepBatch`.
|
||
|
||
Precedent exists in this codebase: `RolloutSummary` (`rollout.py:276`, a `TypedDict`) and
|
||
`SetupStageResult` (`pipeline.py:39`, a dataclass) already do this correctly.
|
||
|
||
Note that `StreamingStepsDataset` passes batches through a PyTorch `DataLoader` with
|
||
`batch_size=None` and `num_workers > 0`, so the batch type must survive worker-process
|
||
pickling and `pin_memory`. `NamedTuple` does — it is a plain tuple subclass, and
|
||
`pin_memory` recurses into tuples — but **verify this with a real multi-worker run**, not
|
||
just the test suite, since the tests may run single-process.
|
||
|
||
### Scope guard
|
||
|
||
Do not reorder any existing fields while converting. The whole point of the change is to
|
||
make future reordering safe; performing one during the conversion, when nothing yet
|
||
protects against it, is the single riskiest version of this change.
|
||
|
||
---
|
||
|
||
## Issue 8 — `network.py` is 1745 lines holding three distinct modules
|
||
|
||
**Severity: Medium. Effort: Small (mechanical).**
|
||
|
||
**Location:** `giant/model/network.py`.
|
||
|
||
### What the code does today
|
||
|
||
One file contains six unrelated concerns:
|
||
|
||
| Lines (approx.) | Concern |
|
||
|---|---|
|
||
| 28-213 | Primitives: `SinusoidalEmbedding`, `cat_col_layout`, `_make_axis_mlp`, `ConditionEncoder`, `ContextAdapter`, `ResBlock` |
|
||
| 215-575 | **The entire router subsystem**: `Router` base, `register_router`/`build_router` registry, `EnergyRouter`, `PdgRouter`, `ProcessRouter`, `ComposedRouter`, `build_composed_router`, `_parse_composed_axes`, `_check_router_conditioning_compat`, `_build_router_from_cfg` |
|
||
| 577-727 | Trunks: `ExpertTrunk`, `_route_forward`, `Trunk`, `MonolithicTrunk`, `RoutedTrunk`, `build_trunk` |
|
||
| 729-881 | **History encoders**: `HistoryEncoder`, `MarkovHistory`, `_CausalAttnBlock`, `AttentionHistory` |
|
||
| 883-1443 | Top-level models: `Stage1Model`, `Stage2OneShot`, `Stage2Autoregressive`, `CriticModel` |
|
||
| 1446-1568 | **Legacy migration**: `_migrate_legacy_model_config`, `migrate_legacy_state_dict` |
|
||
| 1570-1745 | Builders: `build_models`, `build_critics` |
|
||
|
||
The v0.3.0 refactor described in `CLAUDE.md` — *"`network.py` is refactored from ten
|
||
permutation classes into composable parts (encoder × trunk × objective)"* — clearly
|
||
landed and is a genuine improvement. The composition is visible and clean. The file
|
||
simply was not split to match.
|
||
|
||
### Why it's a problem
|
||
|
||
- The seams are already there in the class layout; the file just does not honour them. A
|
||
reader looking for `AttentionHistory` has no reason to expect it 800 lines into a file
|
||
whose name suggests "the network".
|
||
- The router subsystem alone is ~370 lines with its own registry, its own config parsing,
|
||
and its own compatibility validation. It is a subsystem, not a section.
|
||
- Legacy migration (Issue 6) sitting in the same file as the current builders is exactly
|
||
what lets `legacy_owner` bleed into `build_models`.
|
||
- It is a merge-conflict magnet on a repo with parallel feature branches
|
||
(`condor-gpu-train-rollout`, `v0.3.0-stage2-autoregressive`).
|
||
|
||
### Recommended fix
|
||
|
||
Split along the existing seams into `giant/model/`:
|
||
|
||
```
|
||
giant/model/
|
||
layers.py # SinusoidalEmbedding, ResBlock, ContextAdapter, _make_axis_mlp
|
||
encoders.py # ConditionEncoder, cat_col_layout
|
||
routers.py # Router base + registry + all 4 router types + config parsing
|
||
trunks.py # Trunk, MonolithicTrunk, RoutedTrunk, ExpertTrunk, build_trunk
|
||
history.py # HistoryEncoder, MarkovHistory, AttentionHistory, _CausalAttnBlock
|
||
models.py # Stage1Model, Stage2OneShot, Stage2Autoregressive, CriticModel
|
||
builders.py # build_models, build_critics
|
||
_legacy.py # _migrate_legacy_model_config, migrate_legacy_state_dict
|
||
network.py # re-export shim: `from giant.model.layers import *` etc.
|
||
```
|
||
|
||
Keep `network.py` as a **re-export shim** so no import site outside `giant/model/` has to
|
||
change in the same commit. `network.py` is imported by `cli.py`, `sample.py`,
|
||
`trainers.py`, `pipeline.py`, `analysis/router_gating.py`, and heavily by the test suite —
|
||
`grep -rn "from giant.model.network import" giant/ tests/ | wc -l` before starting, to
|
||
size the blast radius if you do decide to update call sites (21 sites on `55332db`).
|
||
|
||
This is a pure file-move refactor: no logic changes, and the 725-test suite plus `ty
|
||
check` is a strong safety net for it.
|
||
|
||
### Scope guard
|
||
|
||
Do it as its own commit, containing **only** moves and imports. Do not combine with Issue 1
|
||
or Issue 6, both of which change behaviour in this file — a diff mixing moves with logic
|
||
changes is effectively unreviewable.
|
||
|
||
---
|
||
|
||
## Issue 9 — `scripts` is published as a top-level distribution package
|
||
|
||
**Severity: Medium. Effort: Small.**
|
||
|
||
**Location:** `pyproject.toml`:
|
||
|
||
```toml
|
||
[project.scripts]
|
||
giant = "giant.cli:app"
|
||
dwarf = "scripts.dwarf:app"
|
||
|
||
[tool.hatch.build.targets.wheel]
|
||
packages = ["giant", "scripts"]
|
||
```
|
||
|
||
### What the code does today
|
||
|
||
The repo's tooling CLI (`dwarf`) lives in a directory called `scripts/`, which is declared
|
||
as a wheel package and referenced by the console-script entry point as `scripts.dwarf:app`.
|
||
Installing `giant` therefore creates a top-level importable module named **`scripts`** in
|
||
`site-packages`.
|
||
|
||
`scripts/` holds ten real modules: `dwarf.py`, `bump_dataset_version.py`,
|
||
`create_root_files.py`, `geometry_oracle.py`, `hparam_scan.py`, `migrate_geant_steps.py`,
|
||
`profile_analysis_costs.py`, `steps_to_parquet.py`, `steps_to_parquet_parallel.py`,
|
||
`warm_setup_cache.py`.
|
||
|
||
### Why it's a problem
|
||
|
||
`scripts` is one of the most generic names possible in the Python ecosystem. Consequences:
|
||
|
||
1. **Collision.** Any other installed distribution that also ships a top-level `scripts`
|
||
package silently shadows or is shadowed by this one, depending on `sys.path` order.
|
||
The failure mode is an `ImportError` or — worse — importing someone else's `dwarf`-less
|
||
`scripts` and getting `AttributeError` at CLI startup.
|
||
2. **Environment-order fragility.** On the portal machines (`/work/lbogner`, shared with
|
||
other users, per `CLAUDE.md`), a stray `scripts/` directory in the CWD shadows the
|
||
installed package, because CWD precedes `site-packages` on `sys.path`. Running `dwarf`
|
||
from a directory that happens to contain a `scripts/` folder can break in a confusing
|
||
way.
|
||
3. **`scripts/` reads as "not part of the product"**, yet it is installed, has an entry
|
||
point, is covered by `[tool.coverage.run] source`, and has a full test suite
|
||
(`tests/test_dwarf.py` at 81.7 % coverage, plus `test_steps_to_parquet*.py`,
|
||
`test_create_root_files.py`, `test_bump_dataset_version.py`). Its name misrepresents
|
||
its status.
|
||
|
||
### Recommended fix
|
||
|
||
Move it under the `giant` namespace, where it cannot collide:
|
||
|
||
```
|
||
giant/tools/ # was scripts/
|
||
dwarf.py
|
||
...
|
||
```
|
||
|
||
```toml
|
||
[project.scripts]
|
||
giant = "giant.cli:app"
|
||
dwarf = "giant.tools.dwarf:app"
|
||
|
||
[tool.hatch.build.targets.wheel]
|
||
packages = ["giant"]
|
||
```
|
||
|
||
Then update:
|
||
|
||
- `[tool.coverage.run] source = ["giant"]` (drop the now-redundant `"scripts"`).
|
||
- All `from scripts.X import Y` in `tests/` (`grep -rn "scripts\." tests/`).
|
||
- Any `python scripts/foo.py` invocations in docs, `README.md`, `CLAUDE.md`, HTCondor
|
||
submit files, or shell history/job scripts on the portal machines.
|
||
|
||
The `dwarf` **command name does not change**, so anything invoking `dwarf ...` keeps
|
||
working — only Python-level imports and direct `python scripts/...` paths move.
|
||
|
||
### Scope guard
|
||
|
||
Check `giant/analysis/condor.py` and any generated HTCondor submit descriptions for
|
||
hard-coded `scripts/` paths before moving. Jobs submitted to the cluster may reference the
|
||
path as it exists on `/work` or `/ceph`, and a rename that lands mid-flight breaks queued
|
||
jobs. Search for the literal string `scripts/` across the repo, not just Python imports.
|
||
|
||
---
|
||
|
||
## Issue 10 — `torch.load(weights_only=False)`: checkpoints are arbitrary pickles
|
||
|
||
**Severity: Low (given the threat model). Effort: Medium.**
|
||
|
||
**Location:** `giant/cli.py:1122`, `giant/cli.py:1535`,
|
||
`giant/analysis/router_gating.py:93`, `giant/training/loop.py:153`.
|
||
|
||
### What the code does today
|
||
|
||
All four checkpoint loads pass `weights_only=False`:
|
||
|
||
```python
|
||
ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
|
||
```
|
||
|
||
This is *necessary* today, not careless: the checkpoint dict carries non-tensor objects
|
||
beyond raw state dicts — `model_config`, `pdg_map` / `mat_map`, `pdg_topn_map` /
|
||
`mat_topn_map`, and the `normalizer` sub-dict, assembled in
|
||
`giant/training/checkpoint.py:19` (`build_checkpoint`) from the `extras` argument.
|
||
`weights_only=True` would reject them.
|
||
|
||
### Why it's worth recording
|
||
|
||
`weights_only=False` means loading a checkpoint executes arbitrary pickle opcodes. The
|
||
practical threat model here is mild — checkpoints are produced by this codebase and live
|
||
on `/ceph/lbogner` — but the portal machines and `/ceph` are explicitly **shared with
|
||
other users** (`CLAUDE.md`, "Compute environment"), and `giant predict --checkpoint <path>`
|
||
will happily load a path someone else wrote. It is also the kind of thing that becomes a
|
||
blocker later, if a model is ever shared outside the group or published alongside a paper.
|
||
|
||
Secondary practical cost: a pickled `model_config` cannot be inspected without importing
|
||
torch and unpickling. `dwarf status`-style tooling, or a human answering "what conditioning
|
||
mode was this trained with?", has to load the whole checkpoint.
|
||
|
||
### Recommended fix
|
||
|
||
Split the checkpoint into a tensor part and a JSON sidecar:
|
||
|
||
- `ckpt.pt` — state dicts only, loadable with `weights_only=True`.
|
||
- `ckpt.meta.json` — `model_config`, vocab maps, top-N maps, normalizer statistics,
|
||
`epoch` / `global_step` / `best_val_loss`.
|
||
|
||
This has real secondary benefits: the sidecar is greppable and diffable, so comparing two
|
||
runs' architectures becomes `diff` rather than a Python session, and it composes well
|
||
with the config-provenance machinery already in `config.py`
|
||
(`warn_if_checkpoint_config_mismatch`, `git_hash`).
|
||
|
||
Note that `giant/data/setup_cache.py` already establishes the "JSON sidecar next to the
|
||
data" pattern for exactly this kind of non-tensor state — follow its conventions.
|
||
|
||
### Scope guard
|
||
|
||
This is a **format change**, so it needs a compatibility path: `load_for_inference`
|
||
(Issue 5) reads the sidecar if present and falls back to the pickled keys if not. Do not
|
||
attempt this before Issue 5 lands — with three separate copies of the loading code, a
|
||
format change means three separate compatibility paths. After Issue 5, it is one.
|
||
|
||
Given the mild threat model, this is correctly the **lowest-priority** item here. It is
|
||
recorded so the decision is deliberate rather than accidental.
|
||
|
||
---
|
||
|
||
## Issue 11 — Minor items
|
||
|
||
### 11a — `echo=print` threaded through the pipeline instead of `logging`
|
||
|
||
**Location:** `giant/pipeline.py:305` (`run_train_job(..., echo=print)`),
|
||
`giant/pipeline.py:86` (`run_setup_stage(..., echo=...)`); 52 `typer.echo` calls in
|
||
`cli.py`.
|
||
|
||
Passing the output function as a parameter is *better* than hard-coding `typer.echo` deep
|
||
in the pipeline — it keeps `pipeline.py` free of Typer and makes output capturable in
|
||
tests. But at this scale it has costs: no severity levels (the `--num-workers` quota
|
||
warning at `pipeline.py:329` and routine progress output are indistinguishable), no
|
||
timestamps on long training runs, no way to route to a file without wrapping, and the
|
||
parameter has to be threaded through every function that might print.
|
||
|
||
Suggested: standard `logging` with a Typer/rich handler configured once in `cli.py`.
|
||
Module-level `log = logging.getLogger(__name__)` replaces the threaded `echo`. Keep
|
||
`typer.echo` for genuine CLI output (the `device:` / `out_dir:` banners, error messages
|
||
before `Exit(1)`) — those are interface, not logs.
|
||
|
||
Low priority; the current approach is defensible. Worth doing opportunistically if
|
||
`pipeline.py` is being touched anyway.
|
||
|
||
### 11b — `giant.particles` imports from `giant.data.loader`
|
||
|
||
**Location:** `giant/particles.py:30`.
|
||
|
||
```python
|
||
if TYPE_CHECKING:
|
||
from giant.data.loader import TopNMap
|
||
```
|
||
|
||
`giant/particles.py` is a physics-domain module (PDG code decoding, mass/charge lookup).
|
||
`giant/data/loader.py` is data I/O. A domain module depending on an I/O module is a
|
||
layering inversion.
|
||
|
||
Mitigating factors, which is why this is minor: the import is `TYPE_CHECKING`-guarded, so
|
||
there is **no runtime cycle**, and `ty check` passes. It is a design smell, not a defect.
|
||
|
||
Suggested: move `TopNMap` to a neutral location — `giant/constants.py` or a new
|
||
`giant/types.py` — so both `particles.py` and `data/loader.py` depend on it rather than on
|
||
each other. Do this only if `TopNMap` is being touched for other reasons; it is not worth
|
||
a standalone commit.
|
||
|
||
---
|
||
|
||
## Recommended sequence
|
||
|
||
The issues are interdependent. This order minimises rework:
|
||
|
||
1. **Issue 5** — extract `load_for_inference` into `giant/checkpoint_io.py`. Small,
|
||
self-contained, removes the highest-risk duplication, and unblocks Issue 10.
|
||
2. **Issue 3** — extract the flag→config mapping table into `config.py`.
|
||
3. **Issue 4** — test the two extracted units directly. Issues 3 and 5 make this cheap;
|
||
attempting it first means testing through `CliRunner`, which is slow and brittle.
|
||
4. **Issue 1 + Issue 2 together** — typed config dataclasses. One migration solves both,
|
||
and doing them separately means touching the same 132 call sites twice.
|
||
5. **Issue 6** — unify the migration surfaces (easier once config is typed).
|
||
6. **Issue 8** — split `network.py`. Pure file moves; do it as an isolated commit, after
|
||
the logic changes in Issues 1 and 6 have settled, to keep both diffs readable.
|
||
7. **Issue 7** — `NamedTuple` the feature/batch contracts.
|
||
8. **Issue 9** — move `scripts/` → `giant/tools/`. Independent of everything else; can be
|
||
done at any point.
|
||
9. **Issue 10** — checkpoint format split, if the threat model or a publication makes it
|
||
worthwhile.
|
||
10. **Issue 11** — opportunistically.
|
||
|
||
## General guidance for whoever picks this up
|
||
|
||
- **Run the full suite on every change.** `uv run pytest -q` takes 18 seconds. There is no
|
||
reason to batch up unverified changes.
|
||
- **Run all three checks before committing:** `uv run ruff check .`,
|
||
`uv run ruff format .`, `uv run ty check .`. CI enforces all three.
|
||
- **Do not change model behaviour while refactoring.** Several issues touch code that
|
||
determines what network gets built (Issues 1, 6) or what features it receives (Issues 5,
|
||
7). A refactor that also changes a default silently invalidates every prior benchmark in
|
||
`/home/lars/knowledge-base/experiments/`. If a behaviour change is warranted, it is a
|
||
separate commit with a separate message saying so.
|
||
- **Preserve the comments.** This codebase's docstrings explain reasoning, trade-offs, and
|
||
known limitations to an unusually high standard. When code moves, the comments move with
|
||
it. When code is deleted, check whether the comment records a decision that still needs
|
||
recording elsewhere.
|
||
- **`giant/analysis/` is the template.** It is declarative where the rest of the codebase
|
||
is imperative (`PlotSpec` registry), explicit about its own contracts (the
|
||
`compute_partial` / `finalize` split, `chunkable=False`, the "polars/numpy only on
|
||
workers" boundary), and honest about its one exception (`router_gating.py`'s module
|
||
docstring explains precisely why it is allowed to import torch and why that is still
|
||
safe). When deciding how a refactored `config.py` or `cli.py` should look, look there.
|