Files
giant/giant/analysis/sources.py
T
lars 2358a75ee1
CI / Sync project version with tag (pull_request) Has been skipped
CI / Format (ruff format) (pull_request) Successful in 43s
CI / Lint (ruff check) (pull_request) Successful in 54s
CI / Type check (ty) (pull_request) Successful in 53s
CI / Tests (pull_request) Successful in 2m33s
CI / Bump version, tag, and update changelog on merge to master (pull_request) Has been skipped
CI / Publish package to Gitea package registry (pull_request) Has been skipped
feat: add eval-cost benchmark — Geant4 reference vs surrogate rollout timing
Closes the roadmap's long-standing "no eval-latency number exists for any
configuration" gap. Instruments `giant rollout` to record per-physical-step
wall-clock cost in its YAML sidecar, adds a measured Geant4/miniCaloSim
per-step reference (giant/analysis/geant4_reference.py, from a 3-energy,
4-event-count-per-energy local benchmark), and wires both into a new
eval_cost_per_step PlotSpec in the giant analyze gallery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:52:20 +02:00

275 lines
11 KiB
Python

"""Canonical world-frame LazyFrame builders for the two kinds of comparison input.
The analysis compares one or more autoregressive ``giant rollout`` runs (the
*generated* side — one named series each, see ``RolloutSpec``) against a single
raw miniCaloSim steps file shared by all of them (the *reference* / real side).
Every rollout is the same *kind* of file regardless of how many there are, so
``Side`` stays binary: it describes a file's schema (rollout column layout +
synthetic-termination rows + per-track secondary view, vs. reference
``sec_*_list`` columns), not series identity. Both kinds carry a
**shared world-frame physical column subset** under identical names, so no
renaming or coordinate decode is needed — everything is already in world-frame
mm / MeV:
event_id, track_id, step_no, pdg,
pre_x, pre_y, pre_z, pre_E, pre_dx, pre_dy, pre_dz,
post_x, post_y, post_z, post_E, post_dx, post_dy, post_dz,
edep, step_length, material, layer_id
(rollout: ``rollout.py:_RECORD_KEYS``; reference: minicalosim ``RunAction.cc``
Steps ntuple passed through by ``dwarf convert``.)
The two files differ in their *extra* columns — the rollout adds ``parent_id``,
``generation``, ``n_sec_pred``, ``termination_reason``; the reference adds
``process``, field columns, ``child_track_ids`` and the ``sec_*_list`` secondary
birth-state lists. Those are only touched by the side-specific helpers here
(synthetic-row filtering, the secondary view).
Nothing in this module (or ``reduce.py``) imports plotstyle — compute runs on
HTCondor workers that have no LaTeX toolchain.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import polars as pl
import pyarrow.parquet as pq
from giant.constants import (
PREDICT_COORD_METADATA_KEY,
ROLLOUT_COORD_VALUE,
TERM_ENERGY_CUTOFF,
TERM_ESCAPED,
TERM_MAX_STEPS,
TERM_UNKNOWN_PDG,
)
from giant.data.loader import event_id_offset, find_parquet_files
# Helper column name for the per-shard offset join in open_side; dropped before
# the LazyFrame is returned, so it never leaks into a caller's schema.
_SOURCE_PATH_COL = "__source_path"
# The world-frame physical columns both sides share under identical names.
PHYS_COLS: tuple[str, ...] = (
"event_id",
"pdg",
"pre_x",
"pre_y",
"pre_z",
"pre_E",
"pre_dx",
"pre_dy",
"pre_dz",
"post_x",
"post_y",
"post_z",
"post_E",
"post_dx",
"post_dy",
"post_dz",
"edep",
"step_length",
"material",
"layer_id",
)
# Rollout rows written purely for bookkeeping (a track's forced stop): they carry
# step_length=0, post_pos=pre_pos, and — for every reason but escaped — the
# track's whole remaining pre_E dumped into edep so the shower still conserves
# energy. They are not physical steps (the reference has no equivalent), so a
# per-step marginal comparison must drop them; a per-event energy total must keep
# them. See rollout.py's terminal-row handling.
SYNTHETIC_TERMINATION_REASONS: frozenset[str] = frozenset(
{TERM_ESCAPED, TERM_UNKNOWN_PDG, TERM_ENERGY_CUTOFF, TERM_MAX_STEPS}
)
class Side(str, Enum):
"""Which of the two comparison-input *kinds* a file is."""
rollout = "rollout"
reference = "reference"
@dataclass
class RolloutSpec:
"""One named rollout input, as fed to ``build_context``/``Bundle.open``.
``name`` is the series' identity throughout the rest of the pipeline (a
plot's ``payload["series"]`` key, a figure's legend label, its color) —
resolved once in ``condor.load_rollout_yamls`` from ``--label`` or the
YAML stem, then threaded through unchanged. ``checkpoint`` /
``type_embedding_l1_dist`` are only used by the router/type-embedding
diagnostics (``catalog.py``'s ``chunkable=False`` specs).
"""
name: str
source: str | Path | pl.LazyFrame
checkpoint: str | None = None
type_embedding_l1_dist: dict | None = None
timing: dict | None = None
@dataclass
class RolloutSide:
"""One rollout's opened frames + per-checkpoint diagnostic inputs (``catalog.Bundle.rollouts`` value)."""
all: pl.LazyFrame # rollout, all rows (incl. synthetic termination rows)
phys: pl.LazyFrame # rollout, physical steps only
checkpoint: str | None = None # from the rollout YAML; router_gating only
# Diagnostic pre-aggregated at rollout time (giant.rollout.
# L1DistCollector.summary()) — from the rollout YAML, type_embedding_l1_distance
# only. Unlike checkpoint, this needs no live model: it's already a
# finished histogram, just passed through.
type_embedding_l1_dist: dict | None = None
# Wall-clock cost of this rollout run (giant.cli's rollout command),
# from the rollout YAML — eval_cost_per_step only. None on rollout runs
# that predate timing instrumentation.
timing: dict | None = None
def _check_rollout_metadata(path: Path) -> None:
"""Raise if ``path`` carries coord metadata that isn't the rollout tag.
A missing tag (older rollout output, predating tagging) is allowed through,
matching ``giant rollout``'s own leniency; a tag that is present but wrong is
a real mismatch and worth failing on before the column layout is trusted.
"""
metadata = pq.read_schema(path).metadata or {}
coord = metadata.get(PREDICT_COORD_METADATA_KEY.encode())
if coord is not None and coord.decode() != ROLLOUT_COORD_VALUE:
raise ValueError(f"{path} is not a rollout file (coord={coord.decode()!r}); expected `giant rollout` output")
def open_side(source: str | Path | pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Lazily scan one side's file, verifying the rollout tag when applicable.
Returns the *full* lazy scan (no column projection) so downstream reductions
can push their own narrow projection into the parquet read — the single
biggest lever on a larger-than-RAM file. ``pl.LazyFrame`` inputs pass straight
through (used by tests).
``pdg`` is cast to a canonical ``Int64`` here: the rollout writer and the
reference file's upstream ROOT→parquet conversion don't agree on integer
width, and an uncast mismatch only surfaces later as a ``pl.concat``
``SchemaError`` (e.g. in ``build_context``'s pdg-count merge).
The reference (a rollout's seed ``dataset``) may be a directory of parquet
shards, or a ``.manifest`` naming a subset, rather than a single file — each
such shard is a separate Geant4 job whose own ``event_id`` numbering
restarts from 0, so a multi-shard load offsets every shard's ids by
``giant.data.loader.event_id_offset(file_index)`` to keep them globally
unique, exactly as the training/rollout data pipeline already does
(``giant/data/loader.py``). ``file_index`` comes from
``find_parquet_files``'s deterministic ordering — the same list and
ordering ``giant rollout`` used (via ``_seed_from_data``) to offset the
rollout side's own ``event_id``s, so both sides agree on what an
``event_id`` means. There is no overflow guard here (unlike
``loader._offset_event_id``): checking it would cost an eager
``event_id``-column read per shard in every condor compute job, and
``giant rollout`` already ran that check over this exact file list when it
produced the seed.
"""
if isinstance(source, pl.LazyFrame):
return source.with_columns(pl.col("pdg").cast(pl.Int64))
path = Path(source)
if side is Side.rollout:
_check_rollout_metadata(path)
lf = pl.scan_parquet(path)
else:
files = find_parquet_files(path)
if len(files) == 1:
lf = pl.scan_parquet(files[0])
else:
offsets = {str(p): event_id_offset(i) for i, p in enumerate(files)}
lf = (
pl.scan_parquet(files, include_file_paths=_SOURCE_PATH_COL)
.with_columns(
pl.col("event_id") + pl.col(_SOURCE_PATH_COL).replace_strict(offsets, return_dtype=pl.Int64)
)
.drop(_SOURCE_PATH_COL)
)
return lf.with_columns(pl.col("pdg").cast(pl.Int64))
def physical_steps(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Real, physical steps only — drops the rollout's synthetic termination rows.
The predicate is pushed down so the dropped rows are never decoded. The
reference has no such rows, so it is returned unchanged.
"""
if side is Side.reference:
return lf
return lf.filter(~pl.col("termination_reason").is_in(list(SYNTHETIC_TERMINATION_REASONS)))
def secondaries(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Per-secondary birth state, one row per produced secondary.
Canonical columns: ``event_id, energy, pdg, sdx, sdy, sdz`` (birth energy in
MeV, PDG code, birth unit direction in the world frame). The two sides encode
secondaries differently:
- rollout: each secondary is its own track, so its birth state is the row with
``generation > 0`` and ``step_no == 0`` (``pre_E`` / ``pre_dir`` there).
- reference: secondaries live in per-parent-step ``sec_*_list`` columns; the
lists are exploded together and empty (no-secondary) steps drop out.
"""
if side is Side.rollout:
return lf.filter((pl.col("generation") > 0) & (pl.col("step_no") == 0)).select(
"event_id",
pl.col("pre_E").alias("energy"),
"pdg",
pl.col("pre_dx").alias("sdx"),
pl.col("pre_dy").alias("sdy"),
pl.col("pre_dz").alias("sdz"),
)
lists = ["sec_E_list", "sec_pdg_list", "sec_dx_list", "sec_dy_list", "sec_dz_list"]
return (
lf.select("event_id", *lists)
.explode(lists)
.drop_nulls("sec_E_list")
.select(
"event_id",
pl.col("sec_E_list").alias("energy"),
pl.col("sec_pdg_list").alias("pdg"),
pl.col("sec_dx_list").alias("sdx"),
pl.col("sec_dy_list").alias("sdy"),
pl.col("sec_dz_list").alias("sdz"),
)
)
def secondaries_by_step(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""One row per produced secondary, tagged with the step that produced it.
Canonical columns: ``step_key`` (an opaque struct identifying the emitting
step) and ``pdg``. ``secondaries`` deliberately drops that link; the
per-step multiplicity plots need it, so this is a separate view rather than
extra columns every other consumer would pay for.
- rollout: a secondary's birth row carries ``parent_id`` and a birth
position copied verbatim from the parent step's ``post_pos``, so
``(event_id, parent_id, pre_pos)`` identifies the emitting step exactly —
no join against the (large) step frame is needed.
- reference: secondaries already live on their parent step's row, so the
row index *is* the step key. It is only ever used as a group key inside
one chunk's own aggregation, so indices repeating across chunks is
harmless.
"""
if side is Side.rollout:
return lf.filter((pl.col("generation") > 0) & (pl.col("step_no") == 0)).select(
pl.struct("event_id", "parent_id", "pre_x", "pre_y", "pre_z").alias("step_key"),
"pdg",
)
return (
lf.select("sec_pdg_list")
.with_row_index("_row")
.explode("sec_pdg_list")
.drop_nulls("sec_pdg_list")
.select(pl.struct("_row").alias("step_key"), pl.col("sec_pdg_list").cast(pl.Int64).alias("pdg"))
)