feat(predict): enrich YAML sidecar with provenance and timing
CI / Sync project version with tag (hand-pushed tags only) (pull_request) Has been skipped
CI / Publish package to Gitea package registry (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 48s
CI / Lint (ruff check) (pull_request) Successful in 49s
CI / Format (ruff format) (pull_request) Successful in 49s
CI / Tests (pull_request) Successful in 3m14s
CI / Release (bump, changelog, badges, tag) on merge to master (pull_request) Has been skipped

`giant predict`'s sidecar previously stopped at kind/prediction_id/
output/dataset/checkpoint/timestamp, unlike `giant rollout`'s, which
carries full run provenance (model_config, training_epoch,
training_config, timing, ...) that flows into analysis gallery
metadata. `analyze --prediction` consumed the same thin sidecar, so a
prediction series in an analysis run was nearly unlabeled compared to
its rollout counterparts.

- `_write_prediction_ref` takes an `extra: dict | None` merged into
  the sidecar; `giant rollout` now uses it instead of a
  load/update/rewrite round trip (identical output).
- New `_build_predict_timing`, key-compatible with
  `_build_rollout_timing`, from timers now wrapping predict's setup/
  sample/write phases.
- `giant predict` writes coord, has_truth, schema_version, steps,
  weights, device, batch_size(+auto), row/skip/unknown-pdg counts,
  timing, and the checkpoint's model_config/config_overrides/
  training_epoch/best_val_loss/training_config/training_meta.
- `giant/analysis/condor.py`'s `_PLOT_META_KEYS` forwards the new
  predict-only keys (plus rollout's previously-unforwarded
  config_overrides) into each plot's gallery metadata.yaml.
- Fixes a `ty` regression from the prior commit in
  tests/test_cli_predict.py (Command has no static `.commands`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpxE9nij3ujg9XcuzvQ26q
This commit is contained in:
2026-09-07 11:52:45 +02:00
parent 51f9dad3b0
commit 51790d3e0a
5 changed files with 247 additions and 14 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ Secondary energies are a **stick-breaking partition of the `e_sec` budget** from
**Input is one or more `giant rollout` YAML sidecars** (`condor.py:load_rollout_yamls`): each YAML's `output`/`dataset` keys name its rollout parquet and seed file (= the reference truth); every supplied YAML must resolve to the same `dataset`, checked up front with a clear error otherwise (the premise is "N candidates vs one ground truth"). Each rollout's series name comes from a repeated `--label` CLI flag, else the YAML stem (N>1), else `"rollout"` (a single YAML). `prep` creates a **run directory** (`<cwd>/analysis_runs/analysis_<id>/` by default, `--run-dir` to override) holding `shared.json`, `run_meta.json` (`RunMeta.rollouts: list[{name,path,plot_meta}]`, insertion order = CLI order = every plot's series order), `reduced_partial/`, `reduced/`, `plots/`. **Compute/merge/render split:** `giant analyze submit a.yaml [b.yaml ...] --chunks N` runs `prep` (recording `N` in `run_meta.json`) then submits one HTCondor job per (plot, chunk) pair (`compute-one --id --chunk --run-dir`, polars/numpy only — no LaTeX on workers), each streaming over an `event_id`-disjoint slice (`event_id % N == chunk`) of the reference **and every rollout** and writing a small `reduced_partial/<id>__<chunk>.json`; every `PlotSpec` splits into a `compute_partial`/`finalize` pair so chunks can be summed/concatenated back per rollout (`chunkable=False` specs — the checkpoint-bound diagnostics, already bounded/subsampled — always run as a single chunk). The local `giant analyze render <run_dir>` first joins every plot's chunk partials into `reduced/<id>.json` (`merge_all`, a no-op join when `N=1`; `merge-one` does a single plot for debugging), then turns those into the styled PDF/gallery tree. `giant analyze metrics <train_run_dir>` is a separate, unrelated entry point: training-progress plots straight from a run's `metrics.csv`.
**`prediction` family (paired truth/pred, `giant/analysis/prediction.py`):** an optional add-on to the rollout comparison, driven by `--prediction`/`--prediction-label` on `analyze prep`/`submit` (repeatable, same convention as `--label`/rollout YAMLs; series name defaults to the YAML stem for N>1 or `"prediction"` for one). Unlike a rollout (freely generated, no row-level correspondence to truth), a `giant predict` output has a matching truth row for every prediction — a paired, not distributional, comparison. `giant predict --coord global` (schema v3, `--truth` on by default) writes both `pred_*` and `true_*` physical columns plus truth/predicted secondary lists; `--coord local` is the older, always-paired 9D model-space output (`pred_{name}`/`true_{name}` for `LOCAL_TARGET_NAMES`, no secondaries — stage 2 doesn't run there). `paired_frame()` normalizes either coord into one canonical `true_<var>`/`pred_<var>` frame over `PAIRED_VARS` (`step_length`, `edep`, `delta_e`, `post_E`, `cos_scatter`, `cos_travel`), decoding local coord's ALR energy logits the same way `energy_simplex_decode` does. Every prediction in one run must share one `--coord` and the rollouts' `dataset` (`condor.load_prediction_yamls`). The catalog's `prediction` family (`catalog.py`, ids prefixed `pred_`) covers per-variable marginals (new `paired_hist` kind: true dashed / pred solid) and truth-vs-pred 2D histograms (new `heatmap2d` kind, with a y=x guide), residuals/relative-residuals/residual-vs-truth profiles, KS/bias/RMSE scorecards (reusing `heatmap`), `n_sec` and secondary-species confusion matrices (row-normalised `heatmap`), direction-alignment and physical-constraint-violation checks, and a pred/true correlation-matrix delta. Every spec degrades to `kind="unavailable"` when no `--prediction` was given, so a rollout-only run is unaffected.
**`prediction` family (paired truth/pred, `giant/analysis/prediction.py`):** an optional add-on to the rollout comparison, driven by `--prediction`/`--prediction-label` on `analyze prep`/`submit` (repeatable, same convention as `--label`/rollout YAMLs; series name defaults to the YAML stem for N>1 or `"prediction"` for one). Unlike a rollout (freely generated, no row-level correspondence to truth), a `giant predict` output has a matching truth row for every prediction — a paired, not distributional, comparison. `giant predict --coord global` (schema v3, `--truth` on by default) writes both `pred_*` and `true_*` physical columns plus truth/predicted secondary lists; `--coord local` is the older, always-paired 9D model-space output (`pred_{name}`/`true_{name}` for `LOCAL_TARGET_NAMES`, no secondaries — stage 2 doesn't run there). `paired_frame()` normalizes either coord into one canonical `true_<var>`/`pred_<var>` frame over `PAIRED_VARS` (`step_length`, `edep`, `delta_e`, `post_E`, `cos_scatter`, `cos_travel`), decoding local coord's ALR energy logits the same way `energy_simplex_decode` does. Every prediction in one run must share one `--coord` and the rollouts' `dataset` (`condor.load_prediction_yamls`). The catalog's `prediction` family (`catalog.py`, ids prefixed `pred_`) covers per-variable marginals (new `paired_hist` kind: true dashed / pred solid) and truth-vs-pred 2D histograms (new `heatmap2d` kind, with a y=x guide), residuals/relative-residuals/residual-vs-truth profiles, KS/bias/RMSE scorecards (reusing `heatmap`), `n_sec` and secondary-species confusion matrices (row-normalised `heatmap`), direction-alignment and physical-constraint-violation checks, and a pred/true correlation-matrix delta. Every spec degrades to `kind="unavailable"` when no `--prediction` was given, so a rollout-only run is unaffected. `giant predict` also writes a YAML sidecar next to the checkpoint (`cli.py:_write_prediction_ref`, mirroring `giant rollout`'s) carrying the run's provenance and timing — coord/weights/steps/batch size, row/skip/unknown-PDG counts, a `timing` block, and the checkpoint's `model_config`/`training_epoch`/`training_config` — which `--prediction` consumes the same way `--label` rollout YAMLs are consumed, surfacing those keys into each plot's gallery `metadata.yaml` (`condor.py:_PLOT_META_KEYS`).
**Shower rollout** (`giant/rollout.py`, `giant rollout` CLI): autoregressively steps the two-stage model into a full shower, advancing tracks breadth-first (every sweep steps all active tracks once, in `batch_size` chunks, so many tracks share each forward pass). Each primary post-step becomes the next pre-step, secondaries are pushed as new tracks, and per-step `material`/`layer_id` come from a `GeometryOracle` (`giant/geometry.py`, built via `dwarf build-geometry-oracle`) that learns position → (material, layer_id) from data and flags detector escape by nearest-neighbour distance. Tracks terminate on one of the `TERM_*` reasons in `constants.py` (energy cutoff, max steps, escape, natural end, unknown pdg, max tracks); energy is deposited locally on every stop except escape (leakage), so showers conserve energy by construction. `giant/checkpoint_io.py` is the shared checkpoint → ready-to-run-models path used by both `predict` and `rollout`.
+13 -1
View File
@@ -61,7 +61,9 @@ from giant.analysis.reduced import Partial
from giant.analysis.runtime_estimate import estimate_runtime_s
from giant.analysis.sources import RolloutSpec, Side, open_side
# Keys copied verbatim from a rollout YAML into each plot's gallery metadata.
# Keys copied verbatim from a rollout or prediction YAML into each plot's
# gallery metadata. Rollout-only and predict-only keys both live here —
# `_plot_meta` copies only whichever of these are present in a given YAML.
_PLOT_META_KEYS = (
"prediction_id",
"checkpoint",
@@ -85,10 +87,20 @@ _PLOT_META_KEYS = (
"termination_reason_counts",
"timing",
"model_config",
"config_overrides",
"training_epoch",
"best_val_loss",
"training_config",
"training_meta",
# giant predict only (giant/cli.py's predict command).
"coord",
"has_truth",
"schema_version",
"n_input_rows",
"n_files",
"n_skipped_rows",
"unknown_pdg_counts",
"batch_size_auto",
# Diagnostic — only present when giant rollout ran under
# stage2_model.particle_type.target="embedding" (see giant/cli.py's
# rollout command and giant.rollout.L1DistCollector); absent otherwise,
+111 -10
View File
@@ -187,8 +187,14 @@ def _write_prediction_ref(
out: Path,
dataset_path: Path,
comment: str | None = None,
extra: dict | None = None,
) -> Path:
"""Write a YAML sidecar in the checkpoint directory and return its path."""
"""Write a YAML sidecar in the checkpoint directory and return its path.
``extra`` is merged in after the base fields (e.g. `giant rollout`'s
provenance/timing block, or `giant predict`'s) — callers that don't pass
it get exactly today's thin sidecar.
"""
import yaml
ref = {
@@ -201,6 +207,8 @@ def _write_prediction_ref(
}
if comment is not None:
ref["comment"] = comment
if extra:
ref.update(extra)
ref_path = checkpoint.parent / f"{pred_uuid}.yaml"
ref_path.write_text(yaml.dump(ref, default_flow_style=False, sort_keys=False))
return ref_path
@@ -245,6 +253,41 @@ def _build_rollout_timing(
}
def _build_predict_timing(
*,
setup_s: float,
predict_s: float,
write_s: float,
n_rows: int,
device: str,
torch_threads: int,
) -> dict:
"""Assemble ``giant predict``'s ``timing`` sidecar block.
Keys are deliberately compatible with ``_build_rollout_timing``'s (same
names for the quantities both commands have) so a gallery's ``timing``
metadata renders the same way whether the series came from a rollout or
a prediction. There's no ``n_physical_rows``/``ms_per_event`` here:
unlike a rollout, `giant predict` never emits synthetic termination rows
(one output row per input step) and doesn't work in whole showers/events
— so ``us_per_step`` is already directly comparable to a rollout's and to
``giant.analysis.geant4_reference``'s per-step Geant4 measurement.
"""
sample_s = predict_s - write_s
return {
"setup_s": setup_s,
"predict_s": predict_s,
"write_s": write_s,
"sample_s": sample_s,
"n_rows": n_rows,
"us_per_step": (sample_s / n_rows * 1e6) if n_rows else None,
"write_us_per_step": (write_s / n_rows * 1e6) if n_rows else None,
"rows_per_s": (n_rows / predict_s) if predict_s else None,
"device": device,
"torch_threads": torch_threads,
}
@app.callback()
def _main() -> None:
"""GIANT — Geant4 step-function surrogate."""
@@ -1085,6 +1128,8 @@ def predict(
] = None,
) -> None:
"""Run trained model on a parquet file and save predictions."""
import time
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
@@ -1104,6 +1149,8 @@ def predict(
from giant.rollout import decode_secondary_identity
from giant.sample import resolve_n_sec, sample_stage1, sample_stage2
_t_setup_start = time.perf_counter()
batch_size_auto = False
batch_size_value: int | None = None
if batch_size.strip().lower() == "auto":
@@ -1175,6 +1222,10 @@ def predict(
unknown_pdg_counts: Counter[int] = Counter()
total_rows = sum(pq.ParquetFile(path).metadata.num_rows for path in files)
training_cfg = gconfig.load_checkpoint_config(checkpoint)
_write_s = 0.0
_setup_s = time.perf_counter() - _t_setup_start
# --coord local always needs full row-groups (it's paired against the 9D
# target); --coord global only needs them when --truth is requested —
# otherwise the cheaper conditioning-only read is used.
@@ -1196,7 +1247,7 @@ def predict(
return {k: np.concatenate([a[k], b[k]], axis=0) for k in a}
def _process(piece: dict[str, np.ndarray]) -> None:
nonlocal writer, total
nonlocal writer, total, _write_s
if coord == Coord.local:
feats = build_features(
@@ -1386,15 +1437,18 @@ def predict(
}
)
_t0 = time.perf_counter()
if writer is None:
writer = pq.ParquetWriter(out, table.schema)
writer.write_table(table)
_write_s += time.perf_counter() - _t0
total += len(piece["event_id"])
# Buffer rows across row-group boundaries so the inference batch size
# isn't capped by however the source file happens to be chunked.
buffer: dict[str, np.ndarray] | None = None
_t_predict_start = time.perf_counter()
bar = tqdm(total=total_rows, desc="predict", unit="row", dynamic_ncols=True)
for i, path in enumerate(files):
for chunk in chunk_iter(path, offset=event_id_offset(i)):
@@ -1422,9 +1476,56 @@ def predict(
bar.close()
if writer is not None:
writer.close()
_predict_s = time.perf_counter() - _t_predict_start
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path, comment)
timing = _build_predict_timing(
setup_s=_setup_s,
predict_s=_predict_s,
write_s=_write_s,
n_rows=total,
device=str(_device),
torch_threads=torch.get_num_threads(),
)
ref_path = _write_prediction_ref(
checkpoint,
pred_uuid,
out,
dataset_path,
comment,
extra={
"coord": coord.value,
"has_truth": coord == Coord.local or write_truth,
"schema_version": PREDICT_SCHEMA_VERSION,
"steps": steps,
"weights": weights.value,
"device": str(_device),
"batch_size": bs,
"batch_size_auto": batch_size_auto,
"n_input_rows": total_rows,
"n_files": len(files),
"n_rows": total,
"n_skipped_rows": skipped,
"unknown_pdg_counts": {str(pdg): count for pdg, count in unknown_pdg_counts.items()},
"timing": timing,
# Full architecture spec baked into the checkpoint — see the
# matching comment in `rollout`.
"model_config": dict(ctx.model_config),
"config_overrides": dict(ctx.config_overrides),
"training_epoch": ctx.epoch,
"best_val_loss": ctx.best_val_loss,
# [train]/[meta] from the sibling config.toml (giant.config.save_config)
# — empty dicts if the checkpoint has no config.toml next to it.
"training_config": dict(training_cfg.get("train", {})),
"training_meta": dict(training_cfg.get("meta", {})),
},
)
typer.echo(f"reference: {ref_path}")
if timing["us_per_step"] is not None:
typer.echo(
f"timing: {_predict_s:.1f}s total ({timing['sample_s']:.1f}s sample + {_write_s:.1f}s write), "
f"{timing['us_per_step']:.1f} us/step over {total:,} step(s)"
)
if skipped:
codes = ", ".join(f"{pdg} ({count})" for pdg, count in sorted(unknown_pdg_counts.items()))
@@ -1556,7 +1657,6 @@ def rollout(
import pyarrow as pa
import pyarrow.parquet as pq
import torch
import yaml
from giant.checkpoint_io import CheckpointCompatibilityError, load_for_inference
from giant.data.loader import find_parquet_files
@@ -1685,10 +1785,12 @@ def rollout(
l1_summary = l1_dist_collector.summary()
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path)
ref = yaml.safe_load(ref_path.read_text())
ref.update(
{
ref_path = _write_prediction_ref(
checkpoint,
pred_uuid,
out,
dataset_path,
extra={
"kind": "rollout",
"geometry_oracle": str(geometry.resolve()),
"energy_cutoff": energy_cutoff,
@@ -1725,9 +1827,8 @@ def rollout(
# — empty dicts if the checkpoint has no config.toml next to it.
"training_config": dict(training_cfg.get("train", {})),
"training_meta": dict(training_cfg.get("meta", {})),
}
},
)
ref_path.write_text(yaml.dump(ref, default_flow_style=False, sort_keys=False))
typer.echo(f"wrote {summary['n_rows']:,} step rows → {out}")
typer.echo(f"terminations: {summary['termination_reason_counts']}")
+82 -2
View File
@@ -6,6 +6,7 @@ from typer.testing import CliRunner
from giant.cli import (
_CEPH_PREDICTIONS,
_build_predict_timing,
_resolve_prediction_output,
_write_prediction_ref,
app,
@@ -145,6 +146,46 @@ def test_ref_timestamp_is_iso_format(tmp_path):
assert ts.tzinfo is not None
def test_ref_yaml_merges_extra_after_base_fields(tmp_path):
ckpt_dir = tmp_path / "checkpoints"
ckpt_dir.mkdir()
checkpoint = ckpt_dir / "best.pt"
checkpoint.touch()
out = tmp_path / "pred.parquet"
dataset = tmp_path / "full.manifest"
pred_uuid = str(uuid.uuid4())
ref_path = _write_prediction_ref(
checkpoint,
pred_uuid,
out,
dataset,
extra={"coord": "global", "n_rows": 42, "timing": {"setup_s": 1.0}},
)
data = yaml.safe_load(ref_path.read_text())
# Base fields untouched, extras layered on top.
assert data["kind"] == "prediction"
assert data["prediction_id"] == pred_uuid
assert data["coord"] == "global"
assert data["n_rows"] == 42
assert data["timing"] == {"setup_s": 1.0}
def test_ref_yaml_without_extra_matches_today(tmp_path):
ckpt_dir = tmp_path / "checkpoints"
ckpt_dir.mkdir()
checkpoint = ckpt_dir / "best.pt"
checkpoint.touch()
pred_uuid = str(uuid.uuid4())
ref_path = _write_prediction_ref(checkpoint, pred_uuid, tmp_path / "p.parquet", tmp_path / "d")
data = yaml.safe_load(ref_path.read_text())
assert set(data) == {"kind", "prediction_id", "output", "dataset", "checkpoint", "timestamp"}
def test_ref_checkpoint_path_is_absolute(tmp_path):
ckpt_dir = tmp_path / "checkpoints"
ckpt_dir.mkdir()
@@ -232,13 +273,52 @@ def test_predict_truth_metadata_key_exists():
assert PREDICT_TRUTH_METADATA_KEY == "giant.predict.has_truth"
# ---------------------------------------------------------------------------
# _build_predict_timing
# ---------------------------------------------------------------------------
def test_build_predict_timing_computes_per_step_cost():
timing = _build_predict_timing(
setup_s=1.0,
predict_s=10.0,
write_s=2.0,
n_rows=100,
device="cpu",
torch_threads=4,
)
assert timing["n_rows"] == 100
assert timing["sample_s"] == 8.0 # predict_s - write_s
assert timing["us_per_step"] == 8.0 / 100 * 1e6
assert timing["write_us_per_step"] == 2.0 / 100 * 1e6
assert timing["rows_per_s"] == 10.0
assert timing["device"] == "cpu" and timing["torch_threads"] == 4
def test_build_predict_timing_handles_zero_rows():
timing = _build_predict_timing(
setup_s=1.0,
predict_s=0.0,
write_s=0.0,
n_rows=0,
device="cpu",
torch_threads=1,
)
assert timing["us_per_step"] is None
assert timing["write_us_per_step"] is None
assert timing["rows_per_s"] is None
def test_predict_has_truth_flag_default_on():
# Inspecting rendered --help text is brittle across terminal
# widths/color settings (wraps or re-colors mid-flag); go straight to
# the underlying click command's registered option instead.
import typer
from typing import cast
predict_cmd = typer.main.get_command(app).commands["predict"]
import typer
from click import Group
predict_cmd = cast(Group, typer.main.get_command(app)).commands["predict"]
truth_param = next(p for p in predict_cmd.params if p.name == "truth")
assert truth_param.opts == ["--truth"]
assert truth_param.secondary_opts == ["--no-truth"]
+40
View File
@@ -256,6 +256,46 @@ def test_prep_with_prediction_writes_run_meta(tmp_path: Path):
assert partial.data["available"]
def test_prep_forwards_predict_only_metadata_keys(tmp_path: Path):
"""A rich `giant predict` sidecar's provenance/timing keys reach
run_meta.json's plot_meta, same as a rollout's do a thin legacy
sidecar (no such keys) still loads fine (see _write_prediction_yaml)."""
rollout_yaml = _write_inputs(tmp_path)
reference = load_rollout_yaml(rollout_yaml)["dataset"]
pred = tmp_path / "pred_rich.parquet"
_write_prediction(pred, coord="global")
yaml_path = tmp_path / "pred_rich.yaml"
yaml_path.write_text(
yaml.safe_dump(
{
"kind": "prediction",
"prediction_id": "richpred12",
"output": str(pred),
"dataset": str(reference),
"checkpoint": "/ckpt/rich.pt",
"coord": "global",
"has_truth": True,
"schema_version": "3",
"n_input_rows": 1000,
"n_files": 1,
"n_skipped_rows": 3,
"unknown_pdg_counts": {"999999": 3},
"batch_size_auto": False,
"timing": {"us_per_step": 12.5},
}
)
)
run_dir = _prep([rollout_yaml], prediction_yamls=[yaml_path])
meta = RunMeta.load(run_dir / "run_meta.json")
plot_meta = meta.predictions[0]["plot_meta"]
assert plot_meta["coord"] == "global"
assert plot_meta["has_truth"] is True
assert plot_meta["n_input_rows"] == 1000
assert plot_meta["n_skipped_rows"] == 3
assert plot_meta["unknown_pdg_counts"] == {"999999": 3}
assert plot_meta["timing"] == {"us_per_step": 12.5}
def test_derive_run_dir_next_to_rollout():
y = {"output": "/data/roll.parquet", "prediction_id": "abcd1234ef", "dataset": "d"}
assert derive_run_dir([y]) == Path("/data/analysis_abcd1234")