Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb8d16caba | |||
| c8a1b4f25d | |||
| 23efd6d9ff | |||
| 9fa6420183 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.bumpversion]
|
||||
current_version = "0.3.10"
|
||||
current_version = "0.3.11"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
|
||||
@@ -28,9 +28,6 @@ jobs:
|
||||
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
|
||||
- run: uv sync --extra cpu --extra dev
|
||||
- run: uv run ruff check .
|
||||
# The uv cache is a persistent volume shared by every job on this
|
||||
# runner, so each job trims what it no longer needs before exiting.
|
||||
- run: uv cache prune --ci
|
||||
|
||||
ruff-format:
|
||||
name: Format (ruff format)
|
||||
@@ -49,7 +46,6 @@ jobs:
|
||||
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
|
||||
- run: uv sync --extra cpu --extra dev
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv cache prune --ci
|
||||
|
||||
type-check:
|
||||
name: Type check (ty)
|
||||
@@ -68,7 +64,6 @@ jobs:
|
||||
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
|
||||
- run: uv sync --extra cpu --extra dev
|
||||
- run: uv run ty check .
|
||||
- run: uv cache prune --ci
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
@@ -92,7 +87,6 @@ jobs:
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
- run: uv cache prune --ci
|
||||
|
||||
bump-version:
|
||||
name: Bump version, tag, and update changelog on merge to master
|
||||
@@ -179,10 +173,6 @@ jobs:
|
||||
git tag -a "$TAG" -m "$TAG"
|
||||
git push origin "refs/tags/$TAG"
|
||||
fi
|
||||
# Same guard as every other step here: on a non-merge push uv was never
|
||||
# set up, so there is nothing to prune.
|
||||
- run: uv cache prune --ci
|
||||
if: steps.merge_check.outputs.is_merge == 'true'
|
||||
|
||||
sync-version-on-tag:
|
||||
name: Sync project version with tag
|
||||
@@ -211,4 +201,3 @@ jobs:
|
||||
else
|
||||
echo "Tag version matches project version ($CURRENT_VERSION)"
|
||||
fi
|
||||
- run: uv cache prune --ci
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.11] - 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Feat(analysis): per-step secondary multiplicity plots
|
||||
|
||||
## [0.3.10] - 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -9,7 +9,6 @@ uv sync --extra cpu # install dependencies with CPU-only torch (s
|
||||
uv sync --extra cuda # install dependencies with CUDA 11.8 torch
|
||||
uv sync --extra cpu --extra dev # add dev extras (pytest, ruff, ty, bump-my-version, git-cliff, + all runtime extras)
|
||||
uv sync --extra cpu --extra geometry # add scikit-learn for the geometry oracle (giant rollout)
|
||||
uv sync --extra cpu --extra workflow # add b2luigi for `giant workflow` pipeline orchestration
|
||||
pytest # run tests
|
||||
giant new-run --hidden-dim 512 --lr 3e-4 # scaffold a config.toml + run dir ahead of training
|
||||
giant train path/to/steps.parquet # train (defaults: stage 1 flow, stage 2 wgan + autoregressive)
|
||||
@@ -19,9 +18,8 @@ giant train path/to/steps.parquet --router --router-type energy # MoE routing t
|
||||
giant model summary --config config.toml # build-only: parameter counts + which config keys actually bite
|
||||
giant predict path/to/steps.parquet --checkpoint ckpt/best.pt # per-step predictions
|
||||
giant rollout path/to/steps.parquet --checkpoint ckpt/best.pt --geometry oracle.pkl # full showers
|
||||
giant workflow run spec.toml --batch --workers 20 # whole pipeline (cache-warm -> train -> rollout -> analysis)
|
||||
giant analyze prep rollout.yaml --chunks 32 # lay out an analysis run dir (compute jobs come from the workflow)
|
||||
giant analyze render <run_dir> --gallery # render PDFs + HTML gallery (run_dir from prep)
|
||||
giant analyze submit rollout.yaml --accounting-group cms # parallel rollout-vs-reference analysis on HTCondor
|
||||
giant analyze render <run_dir> --gallery # render PDFs + HTML gallery (run_dir from prep/submit)
|
||||
giant analyze metrics <train_run_dir> # training-progress plots from metrics.csv
|
||||
dwarf --help # dataset/tooling CLI: convert, migrate, bump-gen,
|
||||
# bump-schema, status, update-manifest, create-manifest,
|
||||
@@ -53,7 +51,7 @@ Work on this repo happens across three kinds of machine:
|
||||
|
||||
- **Local dev machines** (laptop + desktop, identical): repo at `~/Programming/giant`, no access to `/ceph` — datasets, training results, and models aren't reachable here.
|
||||
- **Portal machines** (`portal1`, `deepthought`, `deepthought2`, `bms1`, `bms2`, `bms3`): repo lives under `/work`, and `/ceph` holds ROOT/parquet files and trained models. **These are shared with other users** — stay strictly within `/work/lbogner` and `/ceph/lbogner`, and keep resource usage to roughly a quarter of CPU/RAM and a single GPU so as not to disturb other users' jobs.
|
||||
- **HTCondor worker nodes**: never run or SSH onto these directly — the only sanctioned path is `giant workflow run <spec.toml> --batch` (b2luigi, see the Workflow section), which submits and polls every job. `/ceph` is available there; `/work` is only sometimes mounted, depending on the node.
|
||||
- **HTCondor worker nodes**: never run or SSH onto these directly — the only sanctioned path is submitting jobs through condor (`giant analyze submit`, and the in-progress remote-GPU train/rollout submission on `condor-gpu-train-rollout`). `/ceph` is available there; `/work` is only sometimes mounted, depending on the node.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -99,11 +97,9 @@ Secondary energies are a **stick-breaking partition of the `e_sec` budget** from
|
||||
|
||||
**Validation** (`giant/validate.py`): step-level marginal + KL-divergence comparisons during training (`--validate-every`).
|
||||
|
||||
**Analysis** (`giant/analysis/`, `giant analyze` CLI): a lean, streaming rollout-vs-reference plotting pipeline that compares one or more autoregressive `giant rollout` runs against a single held-out miniCaloSim reference steps file shared by all of them, and produces publication-styled PDFs assembled into an HTML gallery — one distinctly colored series per rollout, one reference line/panel. It exploits the fact that rollout output and a raw reference file share a world-frame physical column subset under identical names (`pre_*`/`post_*`/`edep`/`step_length`/`pdg`/`material`/`event_id`), so no ALR/local-frame decode is needed — everything is world-frame mm/MeV. Structure: `sources.py` (canonical LazyFrames + `RolloutSpec`/`Side` — a rollout's opened frames + per-checkpoint diagnostic inputs — + synthetic-termination-row filtering + the secondary view, which is `generation>0 & step_no==0` rollout tracks vs exploded `sec_*_list` reference columns), `variables.py` (the per-step value expressions shared by range sizing and the plot registry), `reduce.py` (the streaming primitives — a single `hist1d` `group_by([group,bin]).len()` pass, per-event scalars, edep-weighted depth/transverse profiles, species share, leakage), `grouping.py`/`context.py` (fixed bin edges + energy-quantile/pdg/material group sets resolved once by `prep` into `shared.json` over the union of the reference and every rollout, so every compute job is one pass with no range scan), `reduced.py` (`Partial`/`Reduced` — the compact self-describing JSON a compute job emits), `catalog.py` (the declarative `PlotSpec` registry — marginals × {overall,energy,pdg,material}, per-event totals, shower profiles/containment, species/leakage, secondaries, distance/confusion summaries, router and type-embedding diagnostics; `giant analyze list` prints every id), `runtime_estimate.py` (per-(plot, chunk) walltime estimates for the submit description), and `render.py` (the only module importing ETPlot's `plotstyle`/LaTeX; dispatches on `Reduced.kind`, writes PDFs + `metadata.yaml`; each rollout gets a stable `ps.get_color(i)` slot by its position in `series`, the reference always draws in one fixed dashed-ink style). `Bundle.rollouts` is a name-keyed dict of `Side`, and every `compute_partial`/`finalize` builds a `Reduced.payload["series"]` dict keyed the same way, with `payload["reference"]` as the one distinguished non-rollout entry. The heatmap-shaped specs (`marginal_distance_summary`, `n_sec_confusion`) and the checkpoint-bound diagnostics (`router_gating.py`, `type_embedding_distance.py`) are inherently one-matrix/one-checkpoint per rollout, so they render as one panel per rollout instead of one line/bar per rollout.
|
||||
**Analysis** (`giant/analysis/`, `giant analyze` CLI): a lean, streaming rollout-vs-reference plotting pipeline that compares one or more autoregressive `giant rollout` runs against a single held-out miniCaloSim reference steps file shared by all of them, and produces publication-styled PDFs assembled into an HTML gallery — one distinctly colored series per rollout, one reference line/panel. It exploits the fact that rollout output and a raw reference file share a world-frame physical column subset under identical names (`pre_*`/`post_*`/`edep`/`step_length`/`pdg`/`material`/`event_id`), so no ALR/local-frame decode is needed — everything is world-frame mm/MeV. Structure: `sources.py` (canonical LazyFrames + `RolloutSpec`/`Side` — a rollout's opened frames + per-checkpoint diagnostic inputs — + synthetic-termination-row filtering + the secondary view, which is `generation>0 & step_no==0` rollout tracks vs exploded `sec_*_list` reference columns), `variables.py` (the per-step value expressions shared by range sizing and the plot registry), `reduce.py` (the streaming primitives — a single `hist1d` `group_by([group,bin]).len()` pass, per-event scalars, edep-weighted depth/transverse profiles, species share, leakage), `grouping.py`/`context.py` (fixed bin edges + energy-quantile/pdg/material group sets resolved once by `prep` into `shared.json` over the union of the reference and every rollout, so every compute job is one pass with no range scan), `reduced.py` (`Partial`/`Reduced` — the compact self-describing JSON a compute job emits), `catalog.py` (the declarative `PlotSpec` registry — marginals × {overall,energy,pdg,material}, per-event totals, shower profiles/containment, species/leakage, secondaries, distance summaries, router and type-embedding diagnostics; `giant analyze list` prints every id), `runtime_estimate.py` (per-(plot, chunk) walltime estimates for the submit description), and `render.py` (the only module importing ETPlot's `plotstyle`/LaTeX; dispatches on `Reduced.kind`, writes PDFs + `metadata.yaml`; each rollout gets a stable `ps.get_color(i)` slot by its position in `series`, the reference always draws in one fixed dashed-ink style). `Bundle.rollouts` is a name-keyed dict of `Side`, and every `compute_partial`/`finalize` builds a `Reduced.payload["series"]` dict keyed the same way, with `payload["reference"]` as the one distinguished non-rollout entry. The heatmap-shaped specs (`marginal_distance_summary`, `sec_count_per_step_by_species` — the latter also drawing the reference as its own panel) and the checkpoint-bound diagnostics (`router_gating.py`, `type_embedding_distance.py`) are inherently one-matrix/one-checkpoint per rollout, so they render as one panel per rollout instead of one line/bar per rollout.
|
||||
|
||||
**Input is one or more `giant rollout` YAML sidecars** (`run.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 prep a.yaml [b.yaml ...] --chunks N` records `N` in `run_meta.json`, and the workflow's `AnalysisComputeTask` runs 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`.
|
||||
|
||||
**Workflow orchestration** (`giant/workflow/`, `giant workflow run` CLI): b2luigi is the **only sanctioned way to run a multi-step pipeline**; `giant`/`dwarf` are single-step primitives the tasks invoke. One workflow TOML (`configs/workflow_example.toml`) parameterises a whole experiment — `[workflow]`/`[condor]`/`[dataset]`/`[geometry]` plus repeated `[[train]]`/`[[rollout]]`/`[[analysis]]` tables, each cross-referenced by name — and `spec.py` parses it into frozen dataclasses, rejecting unknown keys and dangling references. Every task's output directory is `<result_dir>/<kind>/name=<name>/spec_hash=<hash>/…`, where the 8-hex `spec_hash` covers that task's resolved sub-spec **and its transitive parents**, so an edited spec re-runs exactly the affected subtree instead of silently reusing stale outputs. The DAG (`tasks.py`): `DatasetTask` (external, fails fast if `/ceph` isn't mounted) → `WarmCacheTask` / `GeometryOracleTask` → `TrainEpochTask(name, milestone)` → `TrainTask` → `RolloutTask` → `AnalysisPrepTask` → `AnalysisComputeTask(name, plot_id, chunk)` → `AnalysisRenderTask` → `WorkflowTask`. Training is fanned out into **one short GPU job per epoch** (`epochs_per_job` trades queue waits back), chained by `--resume` on the previous job's `last.pt` — the loop already handles that unchanged — and `TrainTask` republishes `best.pt`/`last.pt`/a concatenated `metrics.csv` so nothing downstream sees the fan-out. `StreamingStepsDataset.set_epoch` and `config.epoch_seed` (both applied per epoch by `training/loop.py`) derive the batch order and the global RNG state from `(seed, epoch)`, so epoch *k* is bit-identical either way — verified by diffing a chained run's `metrics.csv` against a single 3-epoch `giant train`. `AnalysisRenderTask` is always local (the only step importing plotstyle/LaTeX); `htcondor.py` holds the CPU/GPU submit settings, with the GPU requirement strings (`TARGET.ProvidesEtpCeph` + device/memory pins) ported from the `condor-gpu-train-rollout` branch. `run.py` is the script b2luigi re-executes on workers (`--spec` forwarded via `task_cmd_additional_args`, so a worker resolves the identical graph); `giant workflow run` is a thin exec of it. Needs `uv sync --extra cpu --extra workflow`.
|
||||
**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`.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -125,4 +121,4 @@ v0.2 configs and checkpoints are auto-migrated (`config.migrate_config`, `model.
|
||||
|
||||
A sampling-calorimeter (multi-material) dataset track is still open and unblocked, not yet started. See the knowledge base (`/home/lars/knowledge-base/meta/roadmap.md`).
|
||||
|
||||
**Condor-submitted GPU training/rollout (`condor-gpu-train-rollout` branch, superseded):** its goal — moving `giant train`/`giant rollout` off the shared portal GPU dev machines onto remote-GPU HTCondor submission — is now met by the b2luigi workflow above. Its `train-submit`/`rollout-submit` commands are deliberately **not** ported and must not be revived when that branch is eventually merged; the only part that survived is `_gpu_requirements`, which moved into `giant/workflow/htcondor.py`.
|
||||
**Condor-submitted GPU training/rollout (in progress, `condor-gpu-train-rollout` branch, not yet merged):** moves `giant train`/`giant rollout` off the shared portal GPU dev machines (see Compute environment) onto remote-GPU HTCondor submission on TOpAS/NEMO2. Partway between "needs major features" and feature-complete — not ready to merge yet.
|
||||
|
||||
@@ -110,15 +110,10 @@ giant/
|
||||
│ │ ├── reduced.py # Partial/Reduced — the compact JSON a compute job emits
|
||||
│ │ ├── catalog.py # declarative PlotSpec registry (`giant analyze list`)
|
||||
│ │ ├── router_gating.py / type_embedding_distance.py # checkpoint-bound diagnostics
|
||||
│ │ ├── runtime_estimate.py # per-(plot, chunk) walltime estimates for the job requests
|
||||
│ │ ├── run.py # prep / compute-one / merge plumbing
|
||||
│ │ ├── runtime_estimate.py # per-(plot, chunk) walltime estimates for submit
|
||||
│ │ ├── condor.py # prep / compute-one / merge / submit-description plumbing
|
||||
│ │ └── render.py # PDFs + HTML gallery (only module importing plotstyle/LaTeX)
|
||||
│ ├── workflow/ # b2luigi pipeline orchestration (`giant workflow run spec.toml`)
|
||||
│ │ ├── spec.py # workflow TOML -> frozen dataclasses, validation, per-task spec hashes
|
||||
│ │ ├── htcondor.py # CPU/GPU submit settings (docker image, +RemoteJob, GPU requirements)
|
||||
│ │ ├── tasks.py # the task graph: cache-warm -> train (one job/epoch) -> rollout -> analysis
|
||||
│ │ └── run.py # the script b2luigi re-executes on every worker
|
||||
│ └── cli.py # `giant train` / `new-run` / `model summary` / `predict` / `rollout` / `analyze` / `workflow`
|
||||
│ └── cli.py # `giant train` / `new-run` / `model summary` / `predict` / `rollout` / `analyze`
|
||||
├── giant/tools/ # dataset/tooling logic, unified under the `dwarf` CLI (`dwarf --help`)
|
||||
│ ├── dwarf.py # Typer app: convert, migrate, bump-gen, bump-schema, status,
|
||||
│ │ # update-manifest, create-manifest, make-root,
|
||||
@@ -145,10 +140,9 @@ uv sync --extra cpu --extra geometry # add scikit-learn, for `dwarf build-geome
|
||||
uv sync --extra cpu --extra analysis # matplotlib/polars/plotstyle, for `giant analyze render`
|
||||
uv sync --extra cpu --extra convert # uproot/awkward/polars, for `dwarf convert`
|
||||
uv sync --extra cpu --extra wandb # W&B logging (`giant train --wandb`)
|
||||
uv sync --extra cpu --extra workflow # b2luigi, for `giant workflow run`
|
||||
```
|
||||
|
||||
The `dev` extra pulls in `convert`, `analysis`, `geometry`, `wandb` and `workflow` as well.
|
||||
The `dev` extra pulls in `convert`, `analysis`, `geometry` and `wandb` as well.
|
||||
|
||||
`cpu` and `cuda` are mutually exclusive — pick one to select the torch build (pinned to 2.3.x). Plain `uv sync` installs no torch at all. See `CLAUDE.md` for details.
|
||||
|
||||
@@ -185,29 +179,17 @@ Config-file-only knobs (no CLI flag — use `--config config.toml`): `stage2_mod
|
||||
- `giant analyze` — deeper rollout-vs-reference diagnostics (marginals by energy/pdg/material, per-event totals, shower profiles, species share, leakage, secondaries):
|
||||
|
||||
```bash
|
||||
giant analyze prep rollout.yaml --chunks 8 # lay out the run directory
|
||||
giant analyze prep a.yaml b.yaml --label flow --label wgan # N rollouts vs one shared reference
|
||||
giant analyze submit rollout.yaml --accounting-group cms # prep + one HTCondor job per plot × chunk (compute only)
|
||||
giant analyze submit a.yaml b.yaml --accounting-group cms --label flow --label wgan # N rollouts vs one shared reference
|
||||
giant analyze render <run_dir> --gallery # local: merge chunks, then styled PDFs + HTML gallery (needs LaTeX)
|
||||
|
||||
giant analyze list # every catalog plot id
|
||||
giant analyze prep rollout.yaml --chunks 8 # just the run directory, no submission
|
||||
giant analyze compute-one --id marginal_edep --run-dir <run_dir> --chunk 0 # what a condor job runs
|
||||
giant analyze merge-one --id marginal_edep --run-dir <run_dir> # merge one plot's chunks (debugging)
|
||||
```
|
||||
|
||||
The per-(plot, chunk) compute jobs themselves are submitted by the workflow (below), not by `giant analyze` — these commands are the single-step primitives it calls. `<run_dir>` defaults to `<cwd>/analysis_runs/analysis_<id>` (`--run-dir` overrides it; `prep` prints it). Multiple rollout YAMLs must all name the same reference (`dataset`) file; each renders as its own colored series against one reference line/panel. Compute jobs are polars/numpy only; only `render` needs LaTeX, so it always runs locally.
|
||||
|
||||
## Workflow orchestration
|
||||
|
||||
Multi-step pipelines run through [b2luigi](https://github.com/belle2/b2luigi) — one spec file describes a whole experiment, and every step's outputs are files on `/ceph` that are only recomputed when their spec (or an upstream one) changes:
|
||||
|
||||
```bash
|
||||
uv sync --extra cpu --extra workflow
|
||||
giant workflow run configs/workflow_example.toml --mode dry-run # what would run
|
||||
giant workflow run configs/workflow_example.toml --mode show-output # where every output goes
|
||||
giant workflow run configs/workflow_example.toml --batch --workers 20 # submit to HTCondor and wait
|
||||
```
|
||||
|
||||
The spec holds `[workflow]`/`[condor]`/`[dataset]`/`[geometry]` plus repeated `[[train]]`, `[[rollout]]` and `[[analysis]]` tables cross-referenced by name (see `configs/workflow_example.toml`). The task graph is `DatasetTask → WarmCacheTask/GeometryOracleTask → TrainEpochTask… → TrainTask → RolloutTask → AnalysisPrepTask → AnalysisComputeTask(plot, chunk) → AnalysisRenderTask`. Training is split into one short GPU job per epoch (chained by `--resume`), which schedules better on a busy farm and survives preemption; `TrainTask` then publishes one `best.pt`/`last.pt`/`metrics.csv` for everything downstream. Rendering always runs locally, since it is the only step that needs LaTeX.
|
||||
`<run_dir>` defaults to `<cwd>/analysis_runs/analysis_<id>` (`--run-dir` overrides it; `prep`/`submit` print it). Multiple rollout YAMLs must all name the same reference (`dataset`) file; each renders as its own colored series against one reference line/panel. Compute jobs are polars/numpy only; only `render` needs LaTeX, so it always runs locally.
|
||||
|
||||
Separately, `giant analyze metrics <train_run_dir>` renders training-progress plots (loss/lr/accuracy/grad-norm/router/wgan/throughput) straight from a training run's `metrics.csv`.
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Example GIANT workflow spec — `giant workflow run configs/workflow_example.toml`.
|
||||
#
|
||||
# One file parameterises a whole experiment: the datasets, the geometry oracle,
|
||||
# N trainings, N rollouts, and the analyses comparing them. Every task's output
|
||||
# directory carries a hash of its resolved sub-spec (plus its parents), so
|
||||
# editing anything here re-runs exactly the affected subtree and nothing else.
|
||||
#
|
||||
# result_dir/log_dir must be visible from both the submit host and the workers
|
||||
# (i.e. on /ceph) — there is deliberately no HTCondor file transfer.
|
||||
|
||||
[workflow]
|
||||
name = "baseline-vs-router"
|
||||
result_dir = "/ceph/lbogner/workflows/baseline-vs-router"
|
||||
log_dir = "/ceph/lbogner/workflows/baseline-vs-router/logs"
|
||||
|
||||
[condor]
|
||||
accounting_group = "cms"
|
||||
repo_dir = "/work/lbogner/giant" # also b2luigi's working_dir
|
||||
env_script = "/work/lbogner/giant/condor_env.sh"
|
||||
docker_image_cpu = "cverstege/alma9-gridjob"
|
||||
docker_image_gpu = "mschnepf/slc7-condocker"
|
||||
remote = true
|
||||
|
||||
[dataset]
|
||||
steps = "/ceph/lbogner/geant_steps/train/" # training data
|
||||
reference = "/ceph/lbogner/geant_steps/holdout/" # rollout seeds + analysis truth
|
||||
|
||||
[geometry]
|
||||
method = "slab"
|
||||
subsample = 500_000
|
||||
|
||||
[[train]]
|
||||
name = "baseline"
|
||||
config = "configs/baseline.toml"
|
||||
epochs = 200
|
||||
epochs_per_job = 1 # one short GPU job per epoch, chained
|
||||
request_gpus = 1
|
||||
gpu_memory_mb = 20000
|
||||
overrides = { lr = 3e-4 } # `giant train` flag names
|
||||
|
||||
[[train]]
|
||||
name = "router-balanced"
|
||||
config = "configs/router.toml"
|
||||
epochs = 200
|
||||
epochs_per_job = 1
|
||||
request_gpus = 1
|
||||
gpu_memory_mb = 20000
|
||||
|
||||
[[rollout]]
|
||||
name = "baseline"
|
||||
train = "baseline" # -> [[train]].name
|
||||
n_events = 2000
|
||||
energy_cutoff = 0.1
|
||||
|
||||
[[rollout]]
|
||||
name = "router-balanced"
|
||||
train = "router-balanced"
|
||||
n_events = 2000
|
||||
energy_cutoff = 0.1
|
||||
|
||||
[[analysis]]
|
||||
name = "baseline-vs-router"
|
||||
rollouts = ["baseline", "router-balanced"]
|
||||
chunks = 32
|
||||
energy_bins = 4
|
||||
bins = 50
|
||||
top_pdg = 6
|
||||
gallery = true
|
||||
@@ -4,7 +4,7 @@ Compares one or more autoregressive ``giant rollout`` runs against a single
|
||||
held-out miniCaloSim reference file shared by all of them, producing
|
||||
publication-styled comparison plots (one colored series per rollout, one
|
||||
reference line) generated in parallel on HTCondor (one job per plot x data
|
||||
chunk, compute/merge/render split) — orchestrated by ``giant/workflow``.
|
||||
chunk, compute/merge/render split).
|
||||
|
||||
Only ``render`` (and the ``render`` CLI path) imports plotstyle/LaTeX; everything
|
||||
re-exported here is plotstyle-free so it runs on a compute worker. Import
|
||||
@@ -12,9 +12,10 @@ re-exported here is plotstyle-free so it runs on a compute worker. Import
|
||||
"""
|
||||
|
||||
from giant.analysis.catalog import build_catalog, catalog_ids, get_spec
|
||||
from giant.analysis.run import (
|
||||
from giant.analysis.condor import (
|
||||
LoadedRollout,
|
||||
RunMeta,
|
||||
SubmitConfig,
|
||||
compute_one,
|
||||
compute_reduced,
|
||||
derive_run_dir,
|
||||
@@ -23,6 +24,7 @@ from giant.analysis.run import (
|
||||
merge_all,
|
||||
merge_one,
|
||||
prep,
|
||||
write_submit,
|
||||
)
|
||||
from giant.analysis.context import Context, build_context
|
||||
from giant.analysis.reduced import Partial, Reduced
|
||||
@@ -35,6 +37,7 @@ __all__ = [
|
||||
"get_spec",
|
||||
"LoadedRollout",
|
||||
"RunMeta",
|
||||
"SubmitConfig",
|
||||
"compute_one",
|
||||
"compute_reduced",
|
||||
"derive_run_dir",
|
||||
@@ -43,6 +46,7 @@ __all__ = [
|
||||
"merge_all",
|
||||
"merge_one",
|
||||
"prep",
|
||||
"write_submit",
|
||||
"Context",
|
||||
"build_context",
|
||||
"Partial",
|
||||
|
||||
+157
-92
@@ -22,9 +22,9 @@ which is the order rollouts were given on the CLI) plus the single reference.
|
||||
``finalize`` merges each rollout's chunks independently and assembles a
|
||||
``Reduced.payload`` keyed the same way: ``"series": {name: ...}`` for the
|
||||
rollouts, ``"reference": ...`` as one distinguished entry (omitted on
|
||||
rollout-only plots like ``leakage_fraction``). The two heatmap-shaped specs
|
||||
(``marginal_distance_summary``, ``n_sec_confusion``) and the router
|
||||
diagnostics are inherently one-matrix/one-checkpoint per rollout, so their
|
||||
rollout-only plots like ``leakage_fraction``). The heatmap-shaped specs
|
||||
(``marginal_distance_summary``, ``sec_count_per_step_by_species``) and the
|
||||
router diagnostics are inherently one-matrix/one-checkpoint per rollout, so their
|
||||
``"series"`` entries are whole per-rollout artifacts (a matrix, a gating
|
||||
dict) rather than a single number/array — ``render.py`` draws those as one
|
||||
panel per rollout instead of one line/bar per rollout.
|
||||
@@ -60,7 +60,6 @@ from giant.analysis.reduce import (
|
||||
leakage_fraction,
|
||||
profile_finalize,
|
||||
profile_partial,
|
||||
sec_count_by_event,
|
||||
species_share,
|
||||
sum_merge,
|
||||
transverse_expr,
|
||||
@@ -72,7 +71,15 @@ from giant.analysis.router_gating import (
|
||||
compute_router_share_by_process,
|
||||
compute_router_specialization,
|
||||
)
|
||||
from giant.analysis.sources import RolloutSide, RolloutSpec, Side, open_side, physical_steps, secondaries
|
||||
from giant.analysis.sources import (
|
||||
RolloutSide,
|
||||
RolloutSpec,
|
||||
Side,
|
||||
open_side,
|
||||
physical_steps,
|
||||
secondaries,
|
||||
secondaries_by_step,
|
||||
)
|
||||
from giant.analysis.type_embedding_distance import compute_type_embedding_l1_distance
|
||||
from giant.analysis.variables import RANGED_VARS, cos_scatter_expr
|
||||
|
||||
@@ -211,31 +218,6 @@ def _ks_statistic(r_counts, t_counts) -> float:
|
||||
return float(np.max(np.abs(r_cdf - t_cdf)))
|
||||
|
||||
|
||||
def _integer_confusion(
|
||||
t: np.ndarray, r: np.ndarray, max_bins: int = 21, cap: int | None = None
|
||||
) -> tuple[list[str], np.ndarray]:
|
||||
"""Confusion matrix of two paired small-integer arrays (e.g. secondary counts).
|
||||
|
||||
Bins are consecutive integers ``0..cap``, with the last bin an overflow
|
||||
``"cap+"`` bucket, so an occasional pathological count doesn't blow up the
|
||||
heatmap. Returns ``(labels, matrix)`` with ``matrix[i, j]`` counting pairs
|
||||
with ``t == i`` and ``r == j`` (both clipped into ``[0, cap]``).
|
||||
|
||||
``cap``, if given, is used as-is instead of being derived from ``t``/``r``
|
||||
— lets a multi-rollout caller fix one shared cap (and so one shared label
|
||||
set) across every rollout's matrix rather than each panel picking its own.
|
||||
"""
|
||||
if cap is None:
|
||||
cap = min(max(int(t.max()) if len(t) else 0, int(r.max()) if len(r) else 0, 1), max_bins - 1)
|
||||
t_c = np.clip(t.astype(np.int64), 0, cap)
|
||||
r_c = np.clip(r.astype(np.int64), 0, cap)
|
||||
n = cap + 1
|
||||
mat = np.zeros((n, n), dtype=np.int64)
|
||||
np.add.at(mat, (t_c, r_c), 1)
|
||||
labels = [str(i) for i in range(cap)] + [f"{cap}+"]
|
||||
return labels, mat
|
||||
|
||||
|
||||
def _containment_depths(mat: np.ndarray, edges: np.ndarray, quantile: float) -> np.ndarray:
|
||||
"""Per-event depth containing ``quantile`` of that event's deposited energy.
|
||||
|
||||
@@ -802,6 +784,139 @@ def _sec_count_per_species_finalize(parts: list[dict], ctx: Context) -> Reduced:
|
||||
)
|
||||
|
||||
|
||||
# Per-step secondary multiplicity. Fixed integer edges (bin i == exactly i
|
||||
# secondaries, the top bin an overflow bucket) keep both plots sum-mergeable
|
||||
# across chunks — no shared-range pass needed. The species heatmap gets a
|
||||
# shorter row axis because a single step rarely emits many of *one* species.
|
||||
_N_SEC_STEP_CAP = 20
|
||||
_N_SEC_SPECIES_CAP = 10
|
||||
_OTHER_KEY = "other"
|
||||
|
||||
|
||||
def _n_sec_edges(cap: int) -> np.ndarray:
|
||||
return np.arange(-0.5, cap + 1.5)
|
||||
|
||||
|
||||
def _sec_step_key_lf(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
|
||||
"""Secondaries with their emitting-step key.
|
||||
|
||||
The rollout side reads *all* rows, not just physical ones: a secondary
|
||||
whose very first row is a synthetic termination row (born, then immediately
|
||||
escaped or cut) was still produced by its parent step, and dropping it would
|
||||
undercount that step's multiplicity.
|
||||
"""
|
||||
return secondaries_by_step(lf, side)
|
||||
|
||||
|
||||
def _n_steps(lf: pl.LazyFrame) -> int:
|
||||
"""Number of (physical) step rows — the denominator the zero rows come from."""
|
||||
return int(lf.select(pl.len()).collect(engine="streaming").item())
|
||||
|
||||
|
||||
def _sec_count_per_step_partial(b: Bundle) -> dict:
|
||||
edges = _n_sec_edges(_N_SEC_STEP_CAP)
|
||||
|
||||
def _side(sec_lf: pl.LazyFrame, steps_lf: pl.LazyFrame) -> dict:
|
||||
per_step = sec_lf.group_by("step_key").agg(pl.len().alias("n"))
|
||||
return {
|
||||
"h": _partial_hist(per_step, pl.col("n").clip(0, _N_SEC_STEP_CAP), edges),
|
||||
"n_steps": _n_steps(steps_lf),
|
||||
}
|
||||
|
||||
return {
|
||||
"r": _per_rollout(b, lambda rs: _side(_sec_step_key_lf(rs.all, Side.rollout), rs.phys)),
|
||||
"t": _side(_sec_step_key_lf(b.t_all, Side.reference), b.t_phys),
|
||||
}
|
||||
|
||||
|
||||
def _zero_filled(part_hists: list[dict], n_steps: int, key, nbins: int) -> list[int]:
|
||||
"""Merged counts for one series, with bin 0 (= steps that emitted none) filled in.
|
||||
|
||||
The reduction only ever sees steps that produced at least one secondary, so
|
||||
the empty ones are recovered by subtraction from the total step count.
|
||||
"""
|
||||
counts = _finalize_counts(sum_merge(part_hists), key, nbins)
|
||||
counts[0] = max(n_steps - int(sum(counts)), 0)
|
||||
return [int(c) for c in counts]
|
||||
|
||||
|
||||
def _sec_count_per_step_finalize(parts: list[dict], ctx: Context) -> Reduced:
|
||||
edges = _n_sec_edges(_N_SEC_STEP_CAP)
|
||||
nb = len(edges) - 1
|
||||
names = list(parts[0]["r"])
|
||||
series = {
|
||||
name: _zero_filled([p["r"][name]["h"] for p in parts], sum(p["r"][name]["n_steps"] for p in parts), 0, nb)
|
||||
for name in names
|
||||
}
|
||||
return Reduced(
|
||||
id="sec_count_per_step",
|
||||
family="secondaries",
|
||||
kind="overlay_hist",
|
||||
title="Number of secondaries per step",
|
||||
xlabel="secondaries per step",
|
||||
payload={
|
||||
"edges": edges.tolist(),
|
||||
"series": series,
|
||||
"reference": _zero_filled([p["t"]["h"] for p in parts], sum(p["t"]["n_steps"] for p in parts), 0, nb),
|
||||
"log_y": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _species_key_expr(top_pdgs: list[int]) -> pl.Expr:
|
||||
"""``pdg`` bucketed into the shared top-K columns plus one ``other`` bin."""
|
||||
return pl.when(pl.col("pdg").is_in(list(top_pdgs))).then(pl.col("pdg").cast(pl.Utf8)).otherwise(pl.lit(_OTHER_KEY))
|
||||
|
||||
|
||||
def _sec_count_per_step_by_species_partial(b: Bundle) -> dict:
|
||||
edges = _n_sec_edges(_N_SEC_SPECIES_CAP)
|
||||
group = _species_key_expr(b.ctx.top_pdgs)
|
||||
|
||||
def _side(sec_lf: pl.LazyFrame, steps_lf: pl.LazyFrame) -> dict:
|
||||
per_step_species = sec_lf.group_by("step_key", "pdg").agg(pl.len().alias("n"))
|
||||
return {
|
||||
"h": _partial_hist(per_step_species, pl.col("n").clip(0, _N_SEC_SPECIES_CAP), edges, group=group),
|
||||
"n_steps": _n_steps(steps_lf),
|
||||
}
|
||||
|
||||
return {
|
||||
"r": _per_rollout(b, lambda rs: _side(_sec_step_key_lf(rs.all, Side.rollout), rs.phys)),
|
||||
"t": _side(_sec_step_key_lf(b.t_all, Side.reference), b.t_phys),
|
||||
}
|
||||
|
||||
|
||||
def _sec_count_per_step_by_species_finalize(parts: list[dict], ctx: Context) -> Reduced:
|
||||
edges = _n_sec_edges(_N_SEC_SPECIES_CAP)
|
||||
nb = len(edges) - 1
|
||||
names = list(parts[0]["r"])
|
||||
keys = [str(p) for p in ctx.top_pdgs] + [_OTHER_KEY]
|
||||
|
||||
def _matrix(hists: list[dict], n_steps: int) -> list[list[int]]:
|
||||
# columns = species, rows = multiplicity; every species gets its own
|
||||
# zero row (steps that produced none of *that* species).
|
||||
cols = [_zero_filled(hists, n_steps, k, nb) for k in keys]
|
||||
return [[cols[j][i] for j in range(len(keys))] for i in range(nb)]
|
||||
|
||||
return Reduced(
|
||||
id="sec_count_per_step_by_species",
|
||||
family="secondaries",
|
||||
kind="heatmap",
|
||||
title="Per-step secondary multiplicity by species",
|
||||
xlabel="species",
|
||||
payload={
|
||||
"series": {
|
||||
n: _matrix([p["r"][n]["h"] for p in parts], sum(p["r"][n]["n_steps"] for p in parts)) for n in names
|
||||
},
|
||||
"reference": _matrix([p["t"]["h"] for p in parts], sum(p["t"]["n_steps"] for p in parts)),
|
||||
"row_labels": [str(i) for i in range(_N_SEC_SPECIES_CAP)] + [f"{_N_SEC_SPECIES_CAP}+"],
|
||||
"col_labels": [pdg_label(k) for k in ctx.top_pdgs] + [_OTHER_KEY],
|
||||
"ylabel": "secondaries of this species per step",
|
||||
"cbar_label": "step count",
|
||||
"log_color": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sec_energy_partial(b: Bundle) -> dict:
|
||||
edges = np.linspace(*b.ctx.sec_energy_range, b.ctx.n_sec_bins + 1)
|
||||
return {
|
||||
@@ -858,62 +973,6 @@ def _sec_cos_angle_finalize(parts: list[dict], ctx: Context) -> Reduced:
|
||||
)
|
||||
|
||||
|
||||
def _n_sec_confusion_partial(b: Bundle) -> dict:
|
||||
t_ids, t_n = sec_count_by_event(b.t_all, _t_sec(b))
|
||||
|
||||
def _r(rs: RolloutSide) -> dict:
|
||||
ids, n = sec_count_by_event(rs.phys, _r_sec(rs))
|
||||
return {"ids": ids.tolist(), "n": n.tolist()}
|
||||
|
||||
return {"r": _per_rollout(b, _r), "t": {"ids": t_ids.tolist(), "n": t_n.tolist()}}
|
||||
|
||||
|
||||
def _n_sec_confusion_finalize(parts: list[dict], ctx: Context) -> Reduced:
|
||||
names = list(parts[0]["r"])
|
||||
# event-disjoint chunking (see Bundle.open) means each event_id appears in
|
||||
# exactly one part on each side, so a plain dict build is a safe merge.
|
||||
t_ids = np.concatenate([np.asarray(p["t"]["ids"], dtype=np.int64) for p in parts])
|
||||
t_n = np.concatenate([np.asarray(p["t"]["n"], dtype=np.int64) for p in parts])
|
||||
t_map = dict(zip(t_ids.tolist(), t_n.tolist()))
|
||||
|
||||
pairs: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||
max_val = 0
|
||||
for name in names:
|
||||
r_ids = np.concatenate([np.asarray(p["r"][name]["ids"], dtype=np.int64) for p in parts])
|
||||
r_n = np.concatenate([np.asarray(p["r"][name]["n"], dtype=np.int64) for p in parts])
|
||||
r_map = dict(zip(r_ids.tolist(), r_n.tolist()))
|
||||
common = sorted(set(r_map) & set(t_map))
|
||||
true_n = np.array([t_map[e] for e in common], dtype=np.int64)
|
||||
pred_n = np.array([r_map[e] for e in common], dtype=np.int64)
|
||||
pairs[name] = (true_n, pred_n)
|
||||
if len(true_n):
|
||||
max_val = max(max_val, int(true_n.max()), int(pred_n.max()))
|
||||
|
||||
cap = min(max(max_val, 1), 20)
|
||||
matrices: dict[str, list[list[int]]] = {}
|
||||
labels: list[str] = []
|
||||
for name in names:
|
||||
true_n, pred_n = pairs[name]
|
||||
labels, mat = _integer_confusion(true_n, pred_n, cap=cap)
|
||||
matrices[name] = mat.tolist()
|
||||
|
||||
return Reduced(
|
||||
id="n_sec_confusion",
|
||||
family="secondaries",
|
||||
kind="heatmap",
|
||||
title="Predicted vs true secondary count per event",
|
||||
xlabel="predicted secondaries (rollout)",
|
||||
payload={
|
||||
"series": matrices,
|
||||
"row_labels": labels,
|
||||
"col_labels": labels,
|
||||
"ylabel": "true secondaries (reference)",
|
||||
"cbar_label": "event count",
|
||||
"vmin": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# router diagnostics (not chunked — already bounded/subsampled)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1077,6 +1136,18 @@ def build_catalog() -> list[PlotSpec]:
|
||||
compute_partial=_sec_count_per_species_partial,
|
||||
finalize=_sec_count_per_species_finalize,
|
||||
),
|
||||
PlotSpec(
|
||||
"sec_count_per_step",
|
||||
"secondaries",
|
||||
compute_partial=_sec_count_per_step_partial,
|
||||
finalize=_sec_count_per_step_finalize,
|
||||
),
|
||||
PlotSpec(
|
||||
"sec_count_per_step_by_species",
|
||||
"secondaries",
|
||||
compute_partial=_sec_count_per_step_by_species_partial,
|
||||
finalize=_sec_count_per_step_by_species_finalize,
|
||||
),
|
||||
PlotSpec(
|
||||
"sec_energy",
|
||||
"secondaries",
|
||||
@@ -1089,12 +1160,6 @@ def build_catalog() -> list[PlotSpec]:
|
||||
compute_partial=_sec_cos_angle_partial,
|
||||
finalize=_sec_cos_angle_finalize,
|
||||
),
|
||||
PlotSpec(
|
||||
"n_sec_confusion",
|
||||
"secondaries",
|
||||
compute_partial=_n_sec_confusion_partial,
|
||||
finalize=_n_sec_confusion_finalize,
|
||||
),
|
||||
PlotSpec(
|
||||
"router_gating",
|
||||
"model",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Analysis run directories: prep, per-(plot, chunk) compute, and merge.
|
||||
|
||||
Driven by one or more ``giant rollout`` YAML sidecars.
|
||||
"""HTCondor orchestration driven by one or more ``giant rollout`` YAML sidecars.
|
||||
|
||||
A rollout writes a YAML sidecar (``giant/cli.py:_write_prediction_ref`` +
|
||||
rollout extras) that already names both files we need and carries the run's
|
||||
@@ -24,16 +22,13 @@ everything out under it:
|
||||
<run_dir>/reduced/<id>.json merged, per plot
|
||||
<run_dir>/plots/<family>/<id>.pdf rendered locally
|
||||
|
||||
Job model (one job per (plot, chunk), compute/merge/render split). Job
|
||||
submission itself is b2luigi's (``giant/workflow/tasks.py`` — ``AnalysisPrepTask``
|
||||
/ ``AnalysisComputeTask`` / ``AnalysisRenderTask``); this module only provides
|
||||
the three steps they call:
|
||||
Job model (one condor job per (plot, chunk), compute/merge/render split):
|
||||
|
||||
1. ``prep`` runs once locally — reads the YAML, resolves the shared
|
||||
1. ``prep`` runs once on the submit node — reads the YAML, resolves the shared
|
||||
context from a subsample, writes ``shared.json`` + ``run_meta.json``
|
||||
(including the run's configured ``n_chunks``).
|
||||
2. one job per catalog id x chunk index runs ``giant analyze compute-one
|
||||
--run-dir`` (or ``compute_one`` in-process) on a worker — a single streaming pass over that
|
||||
--run-dir`` on a worker — a single streaming pass over that
|
||||
``event_id``-disjoint chunk, writing ``reduced_partial/<id>__<chunk>.json``
|
||||
(polars/numpy only, no LaTeX). Specs marked ``chunkable=False``
|
||||
(``PlotSpec``, ``catalog.py``) always run as a single chunk.
|
||||
@@ -43,15 +38,15 @@ the three steps they call:
|
||||
``reduced/<id>.json``, then renders those into the styled PDF + gallery tree
|
||||
(that step imports plotstyle/LaTeX).
|
||||
|
||||
Files on ``/ceph`` or ``/work`` are reached directly (see
|
||||
``giant/workflow/htcondor.py``); no HTCondor file transfer of the multi-GB
|
||||
inputs.
|
||||
Files on ``/ceph`` or ``/work`` are reached via ``ProvidesETPResources``; no
|
||||
HTCondor file transfer of the multi-GB inputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -62,6 +57,7 @@ import yaml
|
||||
from giant.analysis.catalog import Bundle, catalog_ids, get_spec
|
||||
from giant.analysis.context import Context, build_context
|
||||
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.
|
||||
@@ -433,3 +429,136 @@ def merge_one(spec_id: str, run_dir: str | Path) -> Path:
|
||||
def merge_all(run_dir: str | Path) -> list[Path]:
|
||||
"""Merge every catalog plot's chunk partials into ``reduced/<id>.json``."""
|
||||
return [merge_one(spec_id, run_dir) for spec_id in catalog_ids()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# submit description
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubmitConfig:
|
||||
run_dir: Path
|
||||
accounting_group: str
|
||||
repo_dir: Path
|
||||
docker_image: str = "cverstege/alma9-gridjob"
|
||||
request_memory_mb: int = 8192
|
||||
request_cpus: int = 1
|
||||
remote: bool = False # +RemoteJob (grid I/O) vs ProvidesETPResources (local files)
|
||||
n_chunks: int = 1 # per-plot data chunks; ignored for chunkable=False specs
|
||||
|
||||
|
||||
_WRAPPER = """#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd {repo_dir}
|
||||
exec {giant_exe} analyze compute-one --id "$1" --chunk "$2" --run-dir {run_dir}
|
||||
"""
|
||||
|
||||
|
||||
def _submit_description(cfg: SubmitConfig, wrapper: Path, jobs_file: Path) -> str:
|
||||
reqs_attrs = "+RemoteJob = True\n" if cfg.remote else "requirements = TARGET.ProvidesETPResources\n"
|
||||
return (
|
||||
"universe = docker\n"
|
||||
f"docker_image = {cfg.docker_image}\n"
|
||||
f"executable = {wrapper}\n"
|
||||
"arguments = $(plotid) $(chunk)\n"
|
||||
"should_transfer_files = YES\n"
|
||||
"when_to_transfer_output = ON_EXIT\n"
|
||||
f"request_memory = {cfg.request_memory_mb}\n"
|
||||
f"request_cpus = {cfg.request_cpus}\n"
|
||||
"+RequestWalltime = $(walltime)\n"
|
||||
f"accounting_group = {cfg.accounting_group}\n"
|
||||
f"{reqs_attrs}"
|
||||
f"output = {cfg.run_dir}/logs/$(plotid)__$(chunk).out\n"
|
||||
f"error = {cfg.run_dir}/logs/$(plotid)__$(chunk).err\n"
|
||||
f"log = {cfg.run_dir}/logs/condor.log\n"
|
||||
f"queue plotid,chunk,walltime from {jobs_file}\n"
|
||||
)
|
||||
|
||||
|
||||
def _job_walltimes(run_dir: Path, ids: list[str], n_chunks: int) -> list[tuple[str, int, int]]:
|
||||
"""``(spec_id, chunk, walltime_s)`` for every job, sized from ``run_meta.json``.
|
||||
|
||||
Row counts come from ``prep``'s ``RunMeta.rows_per_chunk``/``total_rows``;
|
||||
``chunkable=False`` specs (router diagnostics) always use the dataset
|
||||
total since they run as a single job regardless of ``n_chunks``.
|
||||
"""
|
||||
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||
jobs: list[tuple[str, int, int]] = []
|
||||
for spec_id in ids:
|
||||
chunkable = get_spec(spec_id).chunkable
|
||||
chunks = range(n_chunks) if chunkable else [0]
|
||||
for chunk in chunks:
|
||||
n_rows = meta.rows_per_chunk[chunk] if chunkable else meta.total_rows
|
||||
jobs.append((spec_id, chunk, estimate_runtime_s(spec_id, n_rows)))
|
||||
return jobs
|
||||
|
||||
|
||||
def _resolve_giant_executable(repo_dir: Path) -> Path:
|
||||
"""Path to the ``giant`` entry point to bake into the condor wrapper script.
|
||||
|
||||
Prefers the venv currently running this process (``sys.executable``'s
|
||||
sibling ``giant``) so a submit from a non-default venv (e.g. ``--extra
|
||||
cuda`` on a dev box) doesn't silently pick up a different one; falls back
|
||||
to ``repo_dir/.venv/bin/giant`` for the case this is invoked from outside
|
||||
any venv (e.g. a system Python).
|
||||
"""
|
||||
active = Path(sys.executable).parent / "giant"
|
||||
if active.exists():
|
||||
return active
|
||||
venv_giant = repo_dir / ".venv" / "bin" / "giant"
|
||||
if not venv_giant.exists():
|
||||
raise FileNotFoundError(
|
||||
f"no `giant` executable found next to {sys.executable} or at "
|
||||
f"{venv_giant} — condor jobs run it directly (no `uv` on the "
|
||||
f"worker image), so run `uv sync --extra cpu` in {repo_dir} "
|
||||
"before submitting."
|
||||
)
|
||||
return venv_giant
|
||||
|
||||
|
||||
def write_submit(cfg: SubmitConfig, ids: list[str] | None = None) -> Path:
|
||||
"""Write the wrapper script, (plot, chunk) job list, and HTCondor submit
|
||||
description.
|
||||
|
||||
Each catalog id gets ``cfg.n_chunks`` jobs, except ``chunkable=False``
|
||||
specs (the router diagnostics), which always get exactly one regardless of
|
||||
``cfg.n_chunks``. Every job's ``+RequestWalltime`` is estimated from its
|
||||
chunk's row count (``runtime_estimate.estimate_runtime_s``, requires
|
||||
``run_meta.json`` from ``prep`` to already carry ``rows_per_chunk``).
|
||||
Returns the submit description path (``<run_dir>/analyze.sub``). Does not
|
||||
submit — call ``condor_submit`` on the returned file.
|
||||
|
||||
``cfg.n_chunks`` and the run directory's own ``RunMeta.n_chunks`` (fixed by
|
||||
``prep``, and what ``RunMeta.rows_per_chunk`` was sized against) are two
|
||||
independent values — checked equal up front so a mismatch is a clear error
|
||||
here rather than an ``IndexError`` out of ``_job_walltimes``.
|
||||
"""
|
||||
giant_exe = _resolve_giant_executable(cfg.repo_dir)
|
||||
|
||||
ids = ids or catalog_ids()
|
||||
run_dir = cfg.run_dir
|
||||
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||
if cfg.n_chunks != meta.n_chunks:
|
||||
raise ValueError(
|
||||
f"SubmitConfig.n_chunks={cfg.n_chunks} does not match the "
|
||||
f"n_chunks this run directory was prepped with "
|
||||
f"(RunMeta.n_chunks={meta.n_chunks} in {run_dir}/run_meta.json) — "
|
||||
"re-run `prep` with the desired n_chunks, or fix cfg.n_chunks to "
|
||||
"match it."
|
||||
)
|
||||
(run_dir / "logs").mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "reduced").mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "reduced_partial").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
wrapper = run_dir / "run_compute.sh"
|
||||
wrapper.write_text(_WRAPPER.format(repo_dir=cfg.repo_dir, giant_exe=giant_exe, run_dir=run_dir))
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
jobs = _job_walltimes(run_dir, ids, cfg.n_chunks)
|
||||
jobs_file = run_dir / "jobs.txt"
|
||||
jobs_file.write_text("\n".join(f"{i},{k},{w}" for i, k, w in jobs) + "\n")
|
||||
|
||||
sub = run_dir / "analyze.sub"
|
||||
sub.write_text(_submit_description(cfg, wrapper, jobs_file))
|
||||
return sub
|
||||
@@ -271,20 +271,3 @@ def leakage_fraction(lf: pl.LazyFrame) -> np.ndarray:
|
||||
escaped = per_event["escaped"].fill_null(0.0).to_numpy()
|
||||
total = deposited + escaped
|
||||
return np.where(total > 0, escaped / total, 0.0)
|
||||
|
||||
|
||||
def sec_count_by_event(lf_all: pl.LazyFrame, sec_lf: pl.LazyFrame) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Per-event secondary count, zero-filled for events that produced none.
|
||||
|
||||
Two bounded per-event ``group_by``s — the full event set (from ``lf_all``)
|
||||
and the secondary counts (from ``sec_lf``, see ``sources.secondaries``) —
|
||||
merged in Python via a dict. Both results are event-granularity (not
|
||||
per-row), so this stays in the same bounded-memory budget as
|
||||
``event_scalars``; a plain ``group_by`` on ``sec_lf`` alone would silently
|
||||
drop zero-secondary events instead of zero-filling them.
|
||||
"""
|
||||
ev = lf_all.select("event_id").unique().collect(engine="streaming")["event_id"].to_numpy()
|
||||
cnt_df = sec_lf.group_by("event_id").agg(pl.len().alias("n")).collect(engine="streaming")
|
||||
cnt = dict(zip(cnt_df["event_id"].to_list(), cnt_df["n"].to_list()))
|
||||
counts = np.array([cnt.get(int(e), 0) for e in ev], dtype=np.int64)
|
||||
return ev, counts
|
||||
|
||||
@@ -27,7 +27,7 @@ from pathlib import Path
|
||||
# "router_specialization" max gate weight vs energy (one scalar trend line
|
||||
# summarizing "router_gating"), per rollout with an enabled router
|
||||
# "heatmap" row x col matrix + colorbar, one panel per rollout (a
|
||||
# distance scorecard or a predicted-vs-true confusion matrix)
|
||||
# distance scorecard)
|
||||
# "unavailable" plot not applicable to this run (e.g. no MoE checkpoint)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import plotstyle as ps
|
||||
from matplotlib.colors import LogNorm
|
||||
import yaml
|
||||
|
||||
from giant.analysis.reduced import Reduced
|
||||
@@ -376,10 +377,16 @@ def _render_router_specialization(r: Reduced, params: dict):
|
||||
|
||||
|
||||
def _render_heatmap(r: Reduced, params: dict):
|
||||
series = r.payload["series"]
|
||||
series = dict(r.payload["series"])
|
||||
row_labels = r.payload["row_labels"]
|
||||
col_labels = r.payload["col_labels"]
|
||||
# A heatmap-shaped plot is one matrix per rollout, so the reference (when the
|
||||
# comparison has one — the distance scorecard doesn't) becomes one more panel
|
||||
# rather than another line.
|
||||
if r.payload.get("reference") is not None:
|
||||
series["reference"] = r.payload["reference"]
|
||||
names = list(series)
|
||||
norm = LogNorm(vmin=1) if r.payload.get("log_color") else None
|
||||
fig, axes = ps.new_figure(
|
||||
"slide-16x9" if len(names) > 1 else "thesis-single",
|
||||
title=r.title,
|
||||
@@ -397,8 +404,9 @@ def _render_heatmap(r: Reduced, params: dict):
|
||||
origin="upper",
|
||||
aspect="auto",
|
||||
cmap=r.payload.get("cmap", "viridis"),
|
||||
vmin=r.payload.get("vmin"),
|
||||
vmax=r.payload.get("vmax"),
|
||||
norm=norm,
|
||||
vmin=None if norm else r.payload.get("vmin"),
|
||||
vmax=None if norm else r.payload.get("vmax"),
|
||||
)
|
||||
ax.set_xticks(range(len(col_labels)))
|
||||
ax.set_xticklabels(col_labels, rotation=45, ha="right")
|
||||
@@ -535,7 +543,7 @@ def render_run(run_dir: str | Path, *, run_gallery: bool = False) -> list[Path]:
|
||||
(checkpoint, paths, cutoffs) from ``run_meta.json`` into every plot's
|
||||
gallery metadata and renders.
|
||||
"""
|
||||
from giant.analysis.run import RunMeta, merge_all
|
||||
from giant.analysis.condor import RunMeta, merge_all
|
||||
|
||||
run_dir = Path(run_dir)
|
||||
merge_all(run_dir)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Per-(plot, chunk) HTCondor walltime estimates for the analysis compute jobs.
|
||||
"""Per-(plot, chunk) HTCondor walltime estimates for `giant analyze submit`.
|
||||
|
||||
Each catalog spec's compute cost is close to linear in the number of input
|
||||
rows a `compute-one` job streams over — every spec is one (or a couple of)
|
||||
|
||||
@@ -236,3 +236,34 @@ def secondaries(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
|
||||
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"))
|
||||
)
|
||||
|
||||
+82
-59
@@ -4,7 +4,6 @@ from enum import Enum
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional
|
||||
import uuid as uuid_mod
|
||||
|
||||
@@ -192,17 +191,8 @@ def _write_prediction_ref(
|
||||
out: Path,
|
||||
dataset_path: Path,
|
||||
comment: str | None = None,
|
||||
explicit_out: bool = False,
|
||||
) -> Path:
|
||||
"""Write the YAML sidecar and return its path.
|
||||
|
||||
With an explicit ``--out`` the sidecar sits next to the output file as
|
||||
``out.with_suffix(".yaml")`` — a *deterministic* path, which is what lets
|
||||
a workflow task (``giant/workflow/tasks.py``) declare it as a target.
|
||||
Without one, the historic uuid-named file under the checkpoint directory
|
||||
is kept, so ad-hoc runs and the ``/ceph`` predictions convention are
|
||||
unaffected.
|
||||
"""
|
||||
"""Write a YAML sidecar in the checkpoint directory and return its path."""
|
||||
ref = {
|
||||
"prediction_id": pred_uuid,
|
||||
"output": str(out),
|
||||
@@ -212,7 +202,7 @@ def _write_prediction_ref(
|
||||
}
|
||||
if comment is not None:
|
||||
ref["comment"] = comment
|
||||
ref_path = out.with_suffix(".yaml") if explicit_out else checkpoint.parent / f"{pred_uuid}.yaml"
|
||||
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
|
||||
|
||||
@@ -1086,7 +1076,6 @@ def predict(
|
||||
bs = batch_size_value
|
||||
|
||||
# --- Output path ---
|
||||
explicit_out = out is not None
|
||||
out, dataset_path, pred_uuid = _resolve_prediction_output(data, out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
typer.echo(f"output: {out}")
|
||||
@@ -1306,7 +1295,7 @@ def predict(
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
|
||||
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path, comment, explicit_out=explicit_out)
|
||||
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path, comment)
|
||||
typer.echo(f"reference: {ref_path}")
|
||||
|
||||
if skipped:
|
||||
@@ -1454,7 +1443,6 @@ def rollout(
|
||||
seeds = _seed_from_data(files, n_events)
|
||||
typer.echo(f"seeded {len(seeds['event_id']):,} shower(s)")
|
||||
|
||||
explicit_out = out is not None
|
||||
out, dataset_path, pred_uuid = _resolve_prediction_output(data, out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1515,7 +1503,7 @@ def rollout(
|
||||
|
||||
l1_summary = l1_dist_collector.summary()
|
||||
|
||||
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path, explicit_out=explicit_out)
|
||||
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path)
|
||||
ref = yaml.safe_load(ref_path.read_text())
|
||||
ref.update(
|
||||
{
|
||||
@@ -1559,49 +1547,6 @@ def rollout(
|
||||
typer.echo(f"reference: {ref_path}")
|
||||
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="b2luigi pipeline orchestration: one spec file -> cache-warm, train, rollout, analysis.",
|
||||
)
|
||||
app.add_typer(workflow_app, name="workflow")
|
||||
|
||||
|
||||
@workflow_app.command("run")
|
||||
def workflow_run(
|
||||
spec: Annotated[Path, typer.Argument(help="Workflow TOML (see configs/workflow_example.toml)")],
|
||||
batch: Annotated[
|
||||
bool,
|
||||
typer.Option("--batch/--local", help="Submit batch-system tasks to HTCondor, or run everything locally"),
|
||||
] = False,
|
||||
workers: Annotated[int, typer.Option("--workers", help="Concurrent luigi workers")] = 1,
|
||||
mode: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--mode",
|
||||
help="run | dry-run (print pending tasks) | show-output (print every target) | remove (delete outputs)",
|
||||
),
|
||||
] = "run",
|
||||
scheduler_host: Annotated[Optional[str], typer.Option("--scheduler-host", help="luigid host")] = None,
|
||||
scheduler_port: Annotated[Optional[int], typer.Option("--scheduler-port", help="luigid port")] = None,
|
||||
) -> None:
|
||||
"""Run a workflow spec end to end (the only sanctioned multi-step entry point).
|
||||
|
||||
A thin exec of `giant/workflow/run.py`, which b2luigi also re-executes on
|
||||
every worker — so there is one documented entry point and one code path.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
script = Path(__file__).resolve().parent / "workflow" / "run.py"
|
||||
cmd = [sys.executable, str(script), "--spec", str(spec), "--workers", str(workers), "--mode", mode]
|
||||
if batch:
|
||||
cmd.append("--batch")
|
||||
if scheduler_host:
|
||||
cmd += ["--scheduler-host", scheduler_host]
|
||||
if scheduler_port:
|
||||
cmd += ["--scheduler-port", str(scheduler_port)]
|
||||
raise typer.Exit(subprocess.run(cmd).returncode)
|
||||
|
||||
|
||||
analyze_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
help="Rollout-vs-reference analysis: parallel compute on HTCondor + local render.",
|
||||
@@ -1732,5 +1677,83 @@ def analyze_metrics(
|
||||
typer.echo(f"rendered {len(paths)} plots -> {paths[0].parent if paths else '(nothing to render)'}")
|
||||
|
||||
|
||||
@analyze_app.command("submit")
|
||||
def analyze_submit(
|
||||
rollout_yamls: Annotated[
|
||||
list[Path],
|
||||
typer.Argument(
|
||||
help="giant rollout YAML sidecar(s). Multiple compare N rollouts against one "
|
||||
"shared reference — every YAML must name the same `dataset`."
|
||||
),
|
||||
],
|
||||
accounting_group: Annotated[str, typer.Option("--accounting-group")],
|
||||
label: Annotated[
|
||||
Optional[list[str]],
|
||||
typer.Option(
|
||||
"--label",
|
||||
help="Series name for a rollout YAML, positionally matched to it — give none, "
|
||||
'or exactly one per YAML. Defaults to the YAML stem (or "rollout" for a '
|
||||
"single YAML).",
|
||||
),
|
||||
] = None,
|
||||
run_dir: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option(
|
||||
"--run-dir",
|
||||
"-o",
|
||||
help="Override the run directory (default: <cwd>/analysis_runs/analysis_<id>)",
|
||||
),
|
||||
] = None,
|
||||
docker_image: Annotated[str, typer.Option("--docker-image")] = "cverstege/alma9-gridjob",
|
||||
request_memory: Annotated[int, typer.Option("--request-memory", help="MB")] = 8192,
|
||||
remote: Annotated[
|
||||
bool,
|
||||
typer.Option("--remote/--local", help="+RemoteJob vs ProvidesETPResources"),
|
||||
] = False,
|
||||
chunks: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--chunks",
|
||||
help="Split each plot's data into this many event_id chunks/jobs",
|
||||
),
|
||||
] = 1,
|
||||
n_energy_bins: Annotated[int, typer.Option("--energy-bins")] = 4,
|
||||
n_marginal_bins: Annotated[int, typer.Option("--bins")] = 50,
|
||||
top_k_pdg: Annotated[int, typer.Option("--top-pdg")] = 6,
|
||||
dry_run: Annotated[bool, typer.Option("--dry-run", help="Write files but don't condor_submit")] = False,
|
||||
) -> None:
|
||||
"""prep + write the HTCondor submit description (one job per plot x chunk), then submit."""
|
||||
import subprocess
|
||||
|
||||
from giant.analysis import SubmitConfig, prep, write_submit
|
||||
|
||||
path = prep(
|
||||
rollout_yamls,
|
||||
run_dir,
|
||||
n_chunks=chunks,
|
||||
default_base=Path.cwd() / "analysis_runs",
|
||||
labels=label,
|
||||
n_energy_bins=n_energy_bins,
|
||||
n_marginal_bins=n_marginal_bins,
|
||||
top_k_pdg=top_k_pdg,
|
||||
)
|
||||
cfg = SubmitConfig(
|
||||
run_dir=path,
|
||||
accounting_group=accounting_group,
|
||||
repo_dir=Path.cwd(),
|
||||
docker_image=docker_image,
|
||||
request_memory_mb=request_memory,
|
||||
remote=remote,
|
||||
n_chunks=chunks,
|
||||
)
|
||||
sub = write_submit(cfg)
|
||||
typer.echo(f"run directory: {path}")
|
||||
typer.echo(f"wrote submit description: {sub}")
|
||||
if dry_run:
|
||||
typer.echo("dry-run: not submitting")
|
||||
return
|
||||
subprocess.run(["condor_submit", str(sub)], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
@@ -1769,19 +1769,6 @@ def resolve_default_out_dir(cfg: dict, base: Path = Path("checkpoints")) -> Path
|
||||
return out_dir
|
||||
|
||||
|
||||
def epoch_seed(seed: int, epoch: int) -> int:
|
||||
"""Per-epoch derivative of the run seed.
|
||||
|
||||
Reseeding the global RNGs from this at the top of every epoch makes epoch
|
||||
*k* draw the same noise whether it runs inside one long `giant train` or
|
||||
as its own resumed job in a per-epoch workflow chain
|
||||
(`giant/workflow/tasks.py:TrainEpochTask`) — without it, a fresh process
|
||||
would restart the stream at epoch 1's state. Mirrors what
|
||||
`StreamingStepsDataset.set_epoch` does for the batch order.
|
||||
"""
|
||||
return (int(seed) * 1_000_003 + int(epoch)) % (2**32)
|
||||
|
||||
|
||||
def seed_everything(seed: int) -> None:
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
|
||||
+3
-23
@@ -97,7 +97,6 @@ class StreamingStepsDataset(IterableDataset):
|
||||
mat_topn_map: dict[str, int] | None = None,
|
||||
sec_type_class_map: dict | None = None,
|
||||
k_max: int = K_MAX,
|
||||
seed: int = 0,
|
||||
) -> None:
|
||||
self.files = list(files)
|
||||
self._offsets = {path: event_id_offset(i) for i, path in enumerate(self.files)}
|
||||
@@ -118,35 +117,16 @@ class StreamingStepsDataset(IterableDataset):
|
||||
self.mat_topn_map = mat_topn_map
|
||||
self.sec_type_class_map = sec_type_class_map
|
||||
self.k_max = k_max
|
||||
self.seed = seed
|
||||
self.epoch = 0
|
||||
self._rng = np.random.default_rng()
|
||||
|
||||
def set_epoch(self, epoch: int) -> None:
|
||||
"""Select the shuffle stream for `epoch` (the DistributedSampler convention).
|
||||
|
||||
The training loop calls this at the top of every epoch. Shuffling is
|
||||
seeded from `(seed, epoch, worker_id)` rather than the global numpy
|
||||
state so epoch *k*'s batch order is the same whether it runs as epoch
|
||||
*k* of one long `giant train`, or as its own resumed job in a
|
||||
per-epoch workflow chain (`giant/workflow/tasks.py:TrainEpochTask`).
|
||||
Workers are re-forked from this object each epoch (no
|
||||
`persistent_workers`), so setting it here reaches them.
|
||||
"""
|
||||
self.epoch = int(epoch)
|
||||
|
||||
def __iter__(self):
|
||||
worker_info = torch.utils.data.get_worker_info()
|
||||
files = self.files
|
||||
worker_id = worker_info.id if worker_info is not None else 0
|
||||
if worker_info is not None:
|
||||
files = files[worker_id :: worker_info.num_workers]
|
||||
|
||||
self._rng = np.random.default_rng([self.seed, self.epoch, worker_id])
|
||||
files = files[worker_info.id :: worker_info.num_workers]
|
||||
|
||||
if self.shuffle:
|
||||
files = list(files)
|
||||
self._rng.shuffle(files)
|
||||
np.random.default_rng().shuffle(files)
|
||||
|
||||
buf_cont: list[np.ndarray] = []
|
||||
buf_cat: list[np.ndarray] = []
|
||||
@@ -242,7 +222,7 @@ class StreamingStepsDataset(IterableDataset):
|
||||
styp = np.concatenate(buf_type)
|
||||
|
||||
if self.shuffle:
|
||||
idx = self._rng.permutation(len(cont))
|
||||
idx = np.random.permutation(len(cont))
|
||||
cont, cat, tgt = cont[idx], cat[idx], tgt[idx]
|
||||
nsec, sec, proc, styp = nsec[idx], sec[idx], proc[idx], styp[idx]
|
||||
|
||||
|
||||
@@ -435,7 +435,6 @@ def run_train_job(
|
||||
mat_topn_map=cond_mat_topn,
|
||||
sec_type_class_map=sec_type_class_map,
|
||||
k_max=k_max,
|
||||
seed=t["seed"],
|
||||
)
|
||||
val_ds = StreamingStepsDataset(
|
||||
files=files,
|
||||
|
||||
@@ -26,7 +26,7 @@ import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from giant.analysis.catalog import catalog_ids, get_spec
|
||||
from giant.analysis.run import compute_reduced
|
||||
from giant.analysis.condor import compute_reduced
|
||||
from giant.analysis.context import build_context
|
||||
from giant.analysis.sources import RolloutSpec
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import torch
|
||||
from torch.utils.data import DataLoader
|
||||
from tqdm import tqdm
|
||||
|
||||
from giant import config
|
||||
from giant.data.loader import TopNMap
|
||||
from giant.data.setup_cache import topnmap_to_json
|
||||
from giant.training.checkpoint import build_checkpoint, init_stages_from_checkpoints, load_checkpoint
|
||||
@@ -185,17 +184,6 @@ def train(
|
||||
if device.type == "cuda":
|
||||
torch.cuda.reset_peak_memory_stats(device)
|
||||
collector.start_epoch(epoch)
|
||||
# Epoch-aware RNG: same noise (and, below, same batch order) for
|
||||
# epoch k whether the run is one process or a chain of per-epoch
|
||||
# jobs. See giant.config.epoch_seed.
|
||||
config.seed_everything(config.epoch_seed(t["seed"], epoch))
|
||||
# Epoch-aware shuffle stream (see StreamingStepsDataset.set_epoch):
|
||||
# keeps epoch k's batch order identical whether it runs here or as
|
||||
# its own resumed per-epoch job in a b2luigi workflow.
|
||||
# (tests hand `train` a plain list of batches, which has neither)
|
||||
set_epoch = getattr(getattr(train_loader, "dataset", None), "set_epoch", None)
|
||||
if callable(set_epoch):
|
||||
set_epoch(epoch)
|
||||
for trainer in trainers.values():
|
||||
trainer.train_mode()
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def derive_metrics_dir(
|
||||
|
||||
Precedence: an explicit `out_dir` always wins. Otherwise
|
||||
`default_base / f"metrics_{run_dir.name}"` (the CLI passes the repo's
|
||||
gitignored `analysis_runs/`, matching `giant.analysis.run.derive_run_dir`'s
|
||||
gitignored `analysis_runs/`, matching `giant.analysis.condor.derive_run_dir`'s
|
||||
convention) — training-progress plots live alongside rollout-vs-reference
|
||||
analysis runs, not inside the training run directory itself.
|
||||
"""
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""b2luigi orchestration of the full GIANT pipeline.
|
||||
|
||||
One workflow TOML (``spec.py``) parameterises an entire experiment — dataset,
|
||||
geometry oracle, N trainings, N rollouts, N analyses — and ``giant workflow
|
||||
run <spec.toml>`` turns it into a b2luigi task graph (``tasks.py``) whose
|
||||
targets are files on ``/ceph``: nothing is recomputed that already exists,
|
||||
every step waits for its inputs, and HTCondor submission/polling is b2luigi's
|
||||
job rather than a hand-rolled submit-file generator.
|
||||
|
||||
This is the only sanctioned way to run a multi-step pipeline; ``giant`` and
|
||||
``dwarf`` stay single-step primitives that these tasks invoke.
|
||||
|
||||
``tasks``/``run`` import b2luigi, so they are *not* imported here — a plain
|
||||
``import giant.workflow`` (or ``giant.workflow.spec``) works without the
|
||||
``workflow`` extra installed.
|
||||
"""
|
||||
|
||||
from giant.workflow.spec import (
|
||||
AnalysisSpec,
|
||||
CondorSpec,
|
||||
DatasetSpec,
|
||||
GeometrySpec,
|
||||
RolloutSpec,
|
||||
TrainSpec,
|
||||
WorkflowSpec,
|
||||
load_spec,
|
||||
spec_hash,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnalysisSpec",
|
||||
"CondorSpec",
|
||||
"DatasetSpec",
|
||||
"GeometrySpec",
|
||||
"RolloutSpec",
|
||||
"TrainSpec",
|
||||
"WorkflowSpec",
|
||||
"load_spec",
|
||||
"spec_hash",
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""HTCondor job descriptions for the workflow tasks.
|
||||
|
||||
b2luigi writes every key of a task's ``htcondor_settings`` dict straight into
|
||||
that job's submit description, so these helpers are just the ETP-specific
|
||||
resource/requirement conventions in one place:
|
||||
|
||||
* **CPU jobs** (setup cache, geometry oracle, analysis compute) keep what
|
||||
the deleted ``giant analyze submit`` used: ``+RemoteJob`` for grid I/O, or
|
||||
``TARGET.ProvidesETPResources`` when the files are local to the cluster.
|
||||
* **GPU jobs** (training epochs, rollout) are remote-only, so they always
|
||||
carry ``+RemoteJob`` and reach ``/ceph`` through
|
||||
``TARGET.ProvidesEtpCeph`` — the requirement strings are ported from the
|
||||
``condor-gpu-train-rollout`` branch's ``giant/condor.py`` rather than
|
||||
rewritten, since they encode what the ETP HTCondor wiki documents for
|
||||
TOpAS/NEMO2 GPU workers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from giant.workflow.spec import CondorSpec
|
||||
|
||||
__all__ = ["cpu_settings", "gpu_settings", "gpu_requirements"]
|
||||
|
||||
|
||||
def cpu_settings(
|
||||
condor: CondorSpec,
|
||||
*,
|
||||
request_memory_mb: int | None = None,
|
||||
request_cpus: int | None = None,
|
||||
walltime_s: int | None = None,
|
||||
) -> dict:
|
||||
settings: dict = {
|
||||
"universe": "docker",
|
||||
"docker_image": condor.docker_image_cpu,
|
||||
"request_memory": request_memory_mb if request_memory_mb is not None else condor.request_memory_mb,
|
||||
"request_cpus": request_cpus if request_cpus is not None else condor.request_cpus,
|
||||
"accounting_group": condor.accounting_group,
|
||||
"should_transfer_files": "YES",
|
||||
"when_to_transfer_output": "ON_EXIT",
|
||||
}
|
||||
if condor.remote:
|
||||
settings["+RemoteJob"] = "True"
|
||||
else:
|
||||
settings["requirements"] = "TARGET.ProvidesETPResources"
|
||||
if walltime_s is not None:
|
||||
settings["+RequestWalltime"] = int(walltime_s)
|
||||
return settings
|
||||
|
||||
|
||||
def gpu_requirements(gpu_type: str | None = None, gpu_memory_mb: int | None = None) -> str:
|
||||
"""``TARGET.ProvidesEtpCeph`` (remote /ceph access) ANDed with any GPU pin."""
|
||||
clauses = ["TARGET.ProvidesEtpCeph =?= True"]
|
||||
if gpu_type is not None:
|
||||
clauses.append(f'TARGET.GPUs_DeviceName =?= "{gpu_type}"')
|
||||
if gpu_memory_mb is not None:
|
||||
clauses.append(f"TARGET.GPUs_GlobalMemoryMb >= {gpu_memory_mb}")
|
||||
return " && ".join(clauses)
|
||||
|
||||
|
||||
def gpu_settings(
|
||||
condor: CondorSpec,
|
||||
*,
|
||||
request_gpus: int = 1,
|
||||
gpu_type: str | None = None,
|
||||
gpu_memory_mb: int | None = None,
|
||||
request_memory_mb: int = 16384,
|
||||
request_cpus: int = 4,
|
||||
walltime_s: int = 86400,
|
||||
) -> dict:
|
||||
return {
|
||||
"universe": "docker",
|
||||
"docker_image": condor.docker_image_gpu,
|
||||
"request_memory": request_memory_mb,
|
||||
"request_cpus": request_cpus,
|
||||
"RequestGPUs": request_gpus,
|
||||
"+RequestWalltime": int(walltime_s),
|
||||
"accounting_group": condor.accounting_group,
|
||||
"should_transfer_files": "YES",
|
||||
"when_to_transfer_output": "ON_EXIT",
|
||||
"+RemoteJob": "True",
|
||||
"requirements": f"({gpu_requirements(gpu_type, gpu_memory_mb)})",
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Entry point b2luigi re-executes on every worker.
|
||||
|
||||
Locally this is what ``giant workflow run <spec.toml>`` execs; on a batch
|
||||
worker it is what the generated wrapper script runs (after ``cd repo_dir`` and
|
||||
sourcing ``env_script``), with ``--spec`` forwarded via the
|
||||
``task_cmd_additional_args`` setting so the worker resolves exactly the same
|
||||
spec — and therefore the same task graph and output paths — as the submitter.
|
||||
|
||||
b2luigi needs a real script path for that re-execution, which is why this is a
|
||||
script rather than a ``python -m`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow `python giant/workflow/run.py` from a checkout that isn't installed.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import b2luigi # noqa: E402
|
||||
|
||||
from giant.workflow.spec import WorkflowSpec, load_spec # noqa: E402
|
||||
from giant.workflow.tasks import WorkflowTask, set_spec # noqa: E402
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run a GIANT workflow spec with b2luigi.")
|
||||
parser.add_argument("--spec", required=True, help="Workflow TOML (see configs/workflow_example.toml)")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Concurrent luigi workers")
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
action="store_true",
|
||||
help="Submit batch-system tasks to HTCondor (otherwise everything runs locally)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("run", "dry-run", "show-output", "remove"),
|
||||
default="run",
|
||||
help="run (default), dry-run (print pending tasks), show-output (print every target), remove (delete outputs)",
|
||||
)
|
||||
parser.add_argument("--scheduler-host", default=None, help="luigid host (default: local scheduler)")
|
||||
parser.add_argument("--scheduler-port", type=int, default=None, help="luigid port")
|
||||
return parser
|
||||
|
||||
|
||||
def configure(spec: WorkflowSpec, spec_path: Path, batch: bool) -> None:
|
||||
"""Wire b2luigi's settings from the spec.
|
||||
|
||||
``/ceph`` is shared between submit host and workers, so there is
|
||||
deliberately no ``transfer_files``: ``result_dir``/``log_dir`` must live
|
||||
somewhere both sides can see.
|
||||
"""
|
||||
set_spec(spec)
|
||||
|
||||
b2luigi.set_setting("result_dir", spec.result_dir)
|
||||
b2luigi.set_setting("log_dir", spec.log_dir)
|
||||
b2luigi.set_setting("task_file_dir", str(Path(spec.result_dir) / "task_files"))
|
||||
b2luigi.set_setting("use_parameter_name_in_output", True)
|
||||
b2luigi.set_setting("batch_system", "htcondor" if batch else "local")
|
||||
b2luigi.set_setting("working_dir", spec.condor.repo_dir)
|
||||
b2luigi.set_setting("job_name", spec.name)
|
||||
if spec.condor.env_script:
|
||||
b2luigi.set_setting("env_script", spec.condor.env_script)
|
||||
# The worker command is `<executable> [<basename of this file>] --batch-runner
|
||||
# --task-id ...`, run after `cd working_dir`. Only the *basename* would be
|
||||
# used, so the filename is dropped and the repo-relative script path is
|
||||
# made part of the executable instead.
|
||||
b2luigi.set_setting("add_filename_to_cmd", False)
|
||||
b2luigi.set_setting("executable", [".venv/bin/python", "giant/workflow/run.py"])
|
||||
b2luigi.set_setting("task_cmd_additional_args", ["--spec", str(spec_path)])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args, _ = build_parser().parse_known_args(argv)
|
||||
spec_path = Path(args.spec).resolve()
|
||||
spec = load_spec(spec_path)
|
||||
configure(spec, spec_path, batch=args.batch)
|
||||
|
||||
kwargs: dict = {}
|
||||
if args.scheduler_host:
|
||||
kwargs["scheduler_host"] = args.scheduler_host
|
||||
if args.scheduler_port:
|
||||
kwargs["scheduler_port"] = args.scheduler_port
|
||||
|
||||
b2luigi.process(
|
||||
WorkflowTask(workflow_name=spec.name),
|
||||
workers=args.workers,
|
||||
batch=args.batch,
|
||||
dry_run=args.mode == "dry-run",
|
||||
show_output=args.mode == "show-output",
|
||||
remove=args.mode == "remove",
|
||||
auto_confirm=args.mode == "remove",
|
||||
# run.py owns --spec/--mode/...; b2luigi must not choke on them.
|
||||
ignore_additional_command_line_args=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,383 +0,0 @@
|
||||
"""Workflow TOML -> frozen dataclasses, validation, and per-task spec hashes.
|
||||
|
||||
One spec file is the only place a pipeline is parameterised (see
|
||||
``configs/workflow_example.toml``):
|
||||
|
||||
[workflow] name / result_dir / log_dir
|
||||
[condor] accounting group, repo dir, env script, docker images
|
||||
[dataset] steps (training) + reference (rollout seeds & analysis truth)
|
||||
[geometry] geometry-oracle build options
|
||||
[[train]] one per training run (name, config, epochs, overrides, ...)
|
||||
[[rollout]] one per rollout (name, train = <a [[train]].name>, ...)
|
||||
[[analysis]] one per comparison (name, rollouts = [<[[rollout]].name>, ...])
|
||||
|
||||
Every task carries its ``name`` plus a short ``spec_hash`` — 8 hex of the
|
||||
canonical JSON of its own resolved sub-spec **including its transitive
|
||||
parents**. That is what makes an edited spec produce a fresh result directory
|
||||
instead of silently reusing outputs computed under different settings: change
|
||||
the dataset and every hash downstream of it changes too.
|
||||
|
||||
Unknown keys are rejected (with the valid ones listed), in the same spirit as
|
||||
``giant.config.validate_config_keys`` — a typo in a workflow spec would
|
||||
otherwise be a silently ignored setting on a multi-day pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tomllib
|
||||
from dataclasses import MISSING, dataclass, field, fields, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"AnalysisSpec",
|
||||
"CondorSpec",
|
||||
"DatasetSpec",
|
||||
"GeometrySpec",
|
||||
"RolloutSpec",
|
||||
"TrainSpec",
|
||||
"WorkflowSpec",
|
||||
"load_spec",
|
||||
"spec_hash",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowSpecError(ValueError):
|
||||
"""Raised for any malformed workflow spec (unknown key, bad reference, ...)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sub-specs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CondorSpec:
|
||||
"""Where and how jobs run — the batch-system half of the spec.
|
||||
|
||||
``repo_dir`` doubles as b2luigi's ``working_dir`` (jobs ``cd`` there before
|
||||
running ``giant/workflow/run.py``), and ``env_script`` is sourced first,
|
||||
since submit and worker machines don't share an environment.
|
||||
"""
|
||||
|
||||
accounting_group: str
|
||||
repo_dir: str
|
||||
env_script: str = ""
|
||||
docker_image_cpu: str = "cverstege/alma9-gridjob"
|
||||
docker_image_gpu: str = "mschnepf/slc7-condocker"
|
||||
remote: bool = True
|
||||
request_cpus: int = 1
|
||||
request_memory_mb: int = 8192
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatasetSpec:
|
||||
"""The two datasets every pipeline needs.
|
||||
|
||||
``steps`` is what training reads; ``reference`` is the held-out file
|
||||
rollouts are seeded from and the analysis compares against (the "one
|
||||
ground truth" premise of ``giant.analysis``).
|
||||
"""
|
||||
|
||||
steps: str
|
||||
reference: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeometrySpec:
|
||||
"""``dwarf build-geometry-oracle`` options (see giant/tools/geometry_oracle.py)."""
|
||||
|
||||
method: str = "slab"
|
||||
k: int = 1
|
||||
subsample: int = 500_000
|
||||
escape_factor: float = 5.0
|
||||
seed: int = 0
|
||||
depth_axis: int = 2
|
||||
n_bins: int = 2000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrainSpec:
|
||||
"""One training run, fanned out into ``ceil(epochs / epochs_per_job)`` jobs.
|
||||
|
||||
``overrides`` are ``[train]``/model config keys merged on top of ``config``
|
||||
exactly as ``giant train``'s flags are (``giant.config.merge_cli_overrides``),
|
||||
so anything expressible on the CLI is expressible here.
|
||||
"""
|
||||
|
||||
name: str
|
||||
config: str | None = None
|
||||
epochs: int = 1
|
||||
epochs_per_job: int = 1
|
||||
overrides: dict[str, Any] = field(default_factory=dict)
|
||||
request_gpus: int = 1
|
||||
gpu_type: str | None = None
|
||||
gpu_memory_mb: int | None = None
|
||||
request_memory_mb: int = 16384
|
||||
request_cpus: int = 4
|
||||
walltime_s: int = 86400
|
||||
num_workers: int = 4
|
||||
shuffle_buffer: int = 65536
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RolloutSpec:
|
||||
"""One ``giant rollout`` run against the checkpoint of ``train``."""
|
||||
|
||||
name: str
|
||||
train: str
|
||||
n_events: int | None = None
|
||||
energy_cutoff: float = 0.1
|
||||
max_steps: int = 1000
|
||||
steps: int = 10
|
||||
batch_size: int = 4096
|
||||
max_tracks_per_event: int | None = None
|
||||
escape_threshold: float | None = None
|
||||
weights: str = "raw"
|
||||
seed: int | None = None
|
||||
request_gpus: int = 1
|
||||
gpu_type: str | None = None
|
||||
gpu_memory_mb: int | None = None
|
||||
request_memory_mb: int = 16384
|
||||
request_cpus: int = 2
|
||||
walltime_s: int = 86400
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalysisSpec:
|
||||
"""One rollout-vs-reference comparison (N rollout series, one reference)."""
|
||||
|
||||
name: str
|
||||
rollouts: tuple[str, ...]
|
||||
chunks: int = 1
|
||||
energy_bins: int = 4
|
||||
bins: int = 50
|
||||
top_pdg: int = 6
|
||||
gallery: bool = False
|
||||
request_memory_mb: int = 8192
|
||||
request_cpus: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowSpec:
|
||||
"""A whole pipeline: the parsed spec file plus name-keyed lookups."""
|
||||
|
||||
name: str
|
||||
result_dir: str
|
||||
log_dir: str
|
||||
condor: CondorSpec
|
||||
dataset: DatasetSpec
|
||||
geometry: GeometrySpec
|
||||
trains: tuple[TrainSpec, ...]
|
||||
rollouts: tuple[RolloutSpec, ...]
|
||||
analyses: tuple[AnalysisSpec, ...]
|
||||
path: str = ""
|
||||
|
||||
# -- lookups ----------------------------------------------------------
|
||||
def train(self, name: str) -> TrainSpec:
|
||||
return _lookup(self.trains, name, "train")
|
||||
|
||||
def rollout(self, name: str) -> RolloutSpec:
|
||||
return _lookup(self.rollouts, name, "rollout")
|
||||
|
||||
def analysis(self, name: str) -> AnalysisSpec:
|
||||
return _lookup(self.analyses, name, "analysis")
|
||||
|
||||
# -- hashes -----------------------------------------------------------
|
||||
# Each one folds in everything upstream of it, so a change anywhere in a
|
||||
# task's ancestry moves its result directory (and only the affected
|
||||
# subtree's).
|
||||
def dataset_hash(self) -> str:
|
||||
return spec_hash(self.dataset)
|
||||
|
||||
def warm_cache_hash(self, train_name: str) -> str:
|
||||
# The setup cache depends on the dataset and on what this training's
|
||||
# config asks of it (val split, conditioning, router) — not on how
|
||||
# many epochs it runs for, so epochs/resources are deliberately left
|
||||
# out and two trainings sharing a config share one warm-cache job.
|
||||
t = self.train(train_name)
|
||||
return spec_hash(self.dataset, t.config, t.overrides)
|
||||
|
||||
def geometry_hash(self) -> str:
|
||||
return spec_hash(self.dataset, self.geometry)
|
||||
|
||||
def train_hash(self, name: str) -> str:
|
||||
return spec_hash(self.dataset, self.train(name))
|
||||
|
||||
def rollout_hash(self, name: str) -> str:
|
||||
ro = self.rollout(name)
|
||||
return spec_hash(self.dataset, self.geometry, self.train(ro.train), ro)
|
||||
|
||||
def analysis_hash(self, name: str) -> str:
|
||||
an = self.analysis(name)
|
||||
parents = [self.rollout(r) for r in an.rollouts]
|
||||
train_parents = [self.train(r.train) for r in parents]
|
||||
return spec_hash(self.dataset, self.geometry, train_parents, parents, an)
|
||||
|
||||
|
||||
def _lookup(items, name: str, kind: str):
|
||||
for item in items:
|
||||
if item.name == name:
|
||||
return item
|
||||
known = ", ".join(sorted(i.name for i in items)) or "(none defined)"
|
||||
raise WorkflowSpecError(f"no [[{kind}]] named {name!r} in this workflow — defined: {known}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def spec_hash(*parts: Any, length: int = 8) -> str:
|
||||
"""Short stable hash of one or more (sub-)specs.
|
||||
|
||||
Canonical JSON (sorted keys, dataclasses expanded) so the value depends
|
||||
only on the resolved settings — not on key order in the TOML, nor on
|
||||
which defaults were written out explicitly.
|
||||
"""
|
||||
payload = json.dumps([_canonical(p) for p in parts], sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode()).hexdigest()[:length]
|
||||
|
||||
|
||||
def _canonical(value: Any) -> Any:
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {f.name: _canonical(getattr(value, f.name)) for f in fields(value)}
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _canonical(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_canonical(v) for v in value]
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build(cls, data: dict, where: str):
|
||||
"""Instantiate a frozen sub-spec, rejecting unknown/missing keys loudly."""
|
||||
valid = {f.name for f in fields(cls)}
|
||||
unknown = sorted(set(data) - valid)
|
||||
if unknown:
|
||||
raise WorkflowSpecError(f"{where}: unknown key(s) {unknown} — valid keys: {sorted(valid)}")
|
||||
required = {f.name for f in fields(cls) if f.default is MISSING and f.default_factory is MISSING}
|
||||
missing = sorted(required - set(data))
|
||||
if missing:
|
||||
raise WorkflowSpecError(f"{where}: missing required key(s) {missing}")
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def load_spec(path: str | Path) -> WorkflowSpec:
|
||||
"""Parse and validate a workflow TOML file."""
|
||||
path = Path(path)
|
||||
try:
|
||||
raw = tomllib.loads(path.read_text())
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise WorkflowSpecError(f"{path}: not valid TOML — {exc}") from exc
|
||||
return parse_spec(raw, path=path)
|
||||
|
||||
|
||||
def parse_spec(raw: dict, path: str | Path = "") -> WorkflowSpec:
|
||||
"""Validate an already-parsed workflow spec mapping."""
|
||||
top_valid = {"workflow", "condor", "dataset", "geometry", "train", "rollout", "analysis"}
|
||||
unknown = sorted(set(raw) - top_valid)
|
||||
if unknown:
|
||||
raise WorkflowSpecError(
|
||||
f"{path or '<spec>'}: unknown top-level table(s) {unknown} — valid: {sorted(top_valid)}"
|
||||
)
|
||||
|
||||
for required in ("workflow", "condor", "dataset"):
|
||||
if required not in raw:
|
||||
raise WorkflowSpecError(f"{path or '<spec>'}: missing required [{required}] table")
|
||||
|
||||
wf = dict(raw["workflow"])
|
||||
wf_valid = {"name", "result_dir", "log_dir"}
|
||||
wf_unknown = sorted(set(wf) - wf_valid)
|
||||
if wf_unknown:
|
||||
raise WorkflowSpecError(f"[workflow]: unknown key(s) {wf_unknown} — valid keys: {sorted(wf_valid)}")
|
||||
if "name" not in wf or "result_dir" not in wf:
|
||||
raise WorkflowSpecError("[workflow]: 'name' and 'result_dir' are required")
|
||||
result_dir = str(Path(wf["result_dir"]).expanduser())
|
||||
log_dir = str(Path(wf.get("log_dir", Path(result_dir) / "logs")).expanduser())
|
||||
|
||||
condor = _build(CondorSpec, dict(raw["condor"]), "[condor]")
|
||||
dataset = _build(DatasetSpec, dict(raw["dataset"]), "[dataset]")
|
||||
geometry = _build(GeometrySpec, dict(raw.get("geometry", {})), "[geometry]")
|
||||
|
||||
trains = tuple(_build(TrainSpec, dict(t), f"[[train]] #{i}") for i, t in enumerate(raw.get("train", [])))
|
||||
rollouts = tuple(_build(RolloutSpec, dict(r), f"[[rollout]] #{i}") for i, r in enumerate(raw.get("rollout", [])))
|
||||
analyses = tuple(
|
||||
_build(AnalysisSpec, {**a, "rollouts": tuple(a.get("rollouts", ()))}, f"[[analysis]] #{i}")
|
||||
for i, a in enumerate(raw.get("analysis", []))
|
||||
)
|
||||
|
||||
_check_unique(trains, "train")
|
||||
_check_unique(rollouts, "rollout")
|
||||
_check_unique(analyses, "analysis")
|
||||
|
||||
train_names = {t.name for t in trains}
|
||||
for ro in rollouts:
|
||||
if ro.train not in train_names:
|
||||
raise WorkflowSpecError(
|
||||
f"[[rollout]] {ro.name!r}: train={ro.train!r} names no [[train]] — defined: {sorted(train_names)}"
|
||||
)
|
||||
rollout_names = {r.name for r in rollouts}
|
||||
for an in analyses:
|
||||
if not an.rollouts:
|
||||
raise WorkflowSpecError(f"[[analysis]] {an.name!r}: 'rollouts' must name at least one [[rollout]]")
|
||||
for r in an.rollouts:
|
||||
if r not in rollout_names:
|
||||
raise WorkflowSpecError(
|
||||
f"[[analysis]] {an.name!r}: rollout {r!r} is not defined — "
|
||||
f"defined: {sorted(rollout_names) or '(none)'}"
|
||||
)
|
||||
if len(set(an.rollouts)) != len(an.rollouts):
|
||||
raise WorkflowSpecError(f"[[analysis]] {an.name!r}: repeated rollout name(s) in 'rollouts'")
|
||||
if an.chunks < 1:
|
||||
raise WorkflowSpecError(f"[[analysis]] {an.name!r}: chunks must be >= 1, got {an.chunks}")
|
||||
|
||||
for t in trains:
|
||||
if t.epochs < 1:
|
||||
raise WorkflowSpecError(f"[[train]] {t.name!r}: epochs must be >= 1, got {t.epochs}")
|
||||
if t.epochs_per_job < 1:
|
||||
raise WorkflowSpecError(f"[[train]] {t.name!r}: epochs_per_job must be >= 1, got {t.epochs_per_job}")
|
||||
|
||||
return WorkflowSpec(
|
||||
name=wf["name"],
|
||||
result_dir=result_dir,
|
||||
log_dir=log_dir,
|
||||
condor=condor,
|
||||
dataset=dataset,
|
||||
geometry=geometry,
|
||||
trains=trains,
|
||||
rollouts=rollouts,
|
||||
analyses=analyses,
|
||||
path=str(path),
|
||||
)
|
||||
|
||||
|
||||
def _check_unique(items, kind: str) -> None:
|
||||
names = [i.name for i in items]
|
||||
dupes = sorted({n for n in names if names.count(n) > 1})
|
||||
if dupes:
|
||||
raise WorkflowSpecError(f"[[{kind}]] names must be unique — repeated: {dupes}")
|
||||
|
||||
|
||||
def epoch_milestones(train: TrainSpec) -> list[int]:
|
||||
"""Cumulative epoch counts, one per chained ``TrainEpochTask``.
|
||||
|
||||
``epochs_per_job`` trades queue waits against job length: with
|
||||
``epochs=10, epochs_per_job=3`` this is ``[3, 6, 9, 10]``, i.e. job *k*
|
||||
resumes job *k-1*'s ``last.pt`` and trains up to its own milestone.
|
||||
"""
|
||||
step = train.epochs_per_job
|
||||
milestones = list(range(step, train.epochs + 1, step))
|
||||
if not milestones or milestones[-1] != train.epochs:
|
||||
milestones.append(train.epochs)
|
||||
return milestones
|
||||
@@ -1,647 +0,0 @@
|
||||
"""The b2luigi task graph: cache-warm -> train -> rollout -> analysis.
|
||||
|
||||
DatasetTask (external) ─┬─> WarmCacheTask(train) ─> TrainEpochTask(train, 1..N) ─> TrainTask(train) ─┐
|
||||
└─> GeometryOracleTask ──┐ │
|
||||
└──> RolloutTask(rollout) <────────────────────────┘
|
||||
│
|
||||
AnalysisPrepTask(analysis) ─> AnalysisComputeTask(analysis, plot, chunk) ─> AnalysisRenderTask(analysis)
|
||||
^
|
||||
WorkflowTask (wrapper) ─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Every task's output directory is ``<result_dir>/<kind>/name=<name>/spec_hash=
|
||||
<hash>/…`` — the hash covers the task's resolved sub-spec *and its transitive
|
||||
parents* (``giant/workflow/spec.py``), so editing the spec produces a fresh
|
||||
directory for exactly the affected subtree instead of silently reusing stale
|
||||
outputs.
|
||||
|
||||
Task bodies never reimplement anything: they call the same entry points the
|
||||
CLIs do (``run_warm_setup_cache``, ``run_build_geometry_oracle``,
|
||||
``run_train_job``, ``giant.analysis.prep``/``compute_one``/``merge_all``,
|
||||
``render_run``), or shell out to ``giant rollout``, which has no library-level
|
||||
entry point of its own.
|
||||
|
||||
Training is fanned out into **one short GPU job per epoch** (or per
|
||||
``epochs_per_job`` epochs): job *k* runs ``run_train_job`` with ``epochs = k``
|
||||
and ``resume = <job k-1>/last.pt``, which the training loop already handles
|
||||
(``giant/training/loop.py`` sets ``start_epoch = ckpt["epoch"] + 1`` and
|
||||
returns early when the checkpoint already covers ``epochs``). A 200-epoch run
|
||||
then becomes 200 schedulable jobs that survive preemption and give luigi a
|
||||
real progress signal, at the cost of one (cache-warmed) setup scan and one
|
||||
queue wait per job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import b2luigi
|
||||
|
||||
from giant.workflow.htcondor import cpu_settings, gpu_settings
|
||||
from giant.workflow.spec import WorkflowSpec, epoch_milestones
|
||||
|
||||
__all__ = [
|
||||
"AnalysisComputeTask",
|
||||
"AnalysisPrepTask",
|
||||
"AnalysisRenderTask",
|
||||
"DatasetTask",
|
||||
"GeometryOracleTask",
|
||||
"RolloutTask",
|
||||
"TrainEpochTask",
|
||||
"TrainTask",
|
||||
"WarmCacheTask",
|
||||
"WorkflowTask",
|
||||
"analysis_dir",
|
||||
"analysis_jobs",
|
||||
"get_spec",
|
||||
"set_spec",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the active spec
|
||||
# ---------------------------------------------------------------------------
|
||||
# luigi parameters must be simple scalars, so tasks carry only `name` +
|
||||
# `spec_hash` and read the rest out of the one spec this process was started
|
||||
# with. Batch workers re-execute `run.py --spec <same file>` (see
|
||||
# `task_cmd_additional_args` there), so they resolve the identical spec.
|
||||
|
||||
_SPEC: WorkflowSpec | None = None
|
||||
|
||||
|
||||
def set_spec(spec: WorkflowSpec) -> None:
|
||||
global _SPEC
|
||||
_SPEC = spec
|
||||
|
||||
|
||||
def get_spec() -> WorkflowSpec:
|
||||
if _SPEC is None:
|
||||
raise RuntimeError("no workflow spec loaded — call giant.workflow.tasks.set_spec() first")
|
||||
return _SPEC
|
||||
|
||||
|
||||
def _result_dir(*parts: str) -> Path:
|
||||
return Path(get_spec().result_dir).joinpath(*parts)
|
||||
|
||||
|
||||
def _task_dir(kind: str, name: str, spec_hash: str) -> Path:
|
||||
"""``<result_dir>/<kind>/name=<name>/spec_hash=<hash>``."""
|
||||
return _result_dir(kind, f"name={name}", f"spec_hash={spec_hash}")
|
||||
|
||||
|
||||
def analysis_dir(spec: WorkflowSpec, name: str) -> Path:
|
||||
"""The analysis run directory — what ``prep`` lays out and every later step reads."""
|
||||
return Path(spec.result_dir) / "analysis" / f"name={name}" / f"spec_hash={spec.analysis_hash(name)}"
|
||||
|
||||
|
||||
def analysis_jobs(spec: WorkflowSpec, name: str) -> list[tuple[str, int]]:
|
||||
"""Every ``(plot_id, chunk)`` compute job of one analysis.
|
||||
|
||||
``chunkable=False`` specs (the checkpoint-bound diagnostics, already
|
||||
bounded/subsampled) always run as a single chunk — the same rule the
|
||||
deleted ``_job_walltimes`` applied.
|
||||
"""
|
||||
from giant.analysis.catalog import catalog_ids, get_spec as get_plot_spec
|
||||
|
||||
chunks = spec.analysis(name).chunks
|
||||
jobs: list[tuple[str, int]] = []
|
||||
for plot_id in catalog_ids():
|
||||
n = chunks if get_plot_spec(plot_id).chunkable else 1
|
||||
jobs.extend((plot_id, chunk) for chunk in range(n))
|
||||
return jobs
|
||||
|
||||
|
||||
def _giant_cmd() -> list[str]:
|
||||
"""How to invoke the ``giant`` CLI from inside a task (worker or locally)."""
|
||||
return [sys.executable, "-m", "giant.cli"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DatasetTask(b2luigi.ExternalTask):
|
||||
"""A steps parquet file or directory that must already exist.
|
||||
|
||||
Nothing produces it, so a missing path is a hard, immediate error rather
|
||||
than a job that fails hours later — the usual cause being ``/ceph`` not
|
||||
mounted on the machine the workflow was started from.
|
||||
"""
|
||||
|
||||
path = b2luigi.Parameter()
|
||||
|
||||
def output(self):
|
||||
return b2luigi.LocalTarget(str(self.path))
|
||||
|
||||
def complete(self):
|
||||
if not Path(str(self.path)).exists():
|
||||
raise FileNotFoundError(
|
||||
f"dataset {self.path!r} does not exist — is /ceph mounted on this machine? "
|
||||
"(see CLAUDE.md's Compute environment section)"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# setup stage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WarmCacheTask(b2luigi.Task):
|
||||
"""Precompute one training's setup-stage sidecar (vocab maps, event split,
|
||||
normalizer stats) so every per-epoch job is a cache hit instead of a
|
||||
full rescan.
|
||||
|
||||
The real product (``<data>.giant_train_cache.json``) lives next to the
|
||||
dataset, not under ``result_dir``, so the target here is a small stamp
|
||||
recording that sidecar's path/mtime/size.
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
|
||||
@property
|
||||
def htcondor_settings(self):
|
||||
spec = get_spec()
|
||||
return cpu_settings(spec.condor, request_memory_mb=32768, request_cpus=4, walltime_s=21600)
|
||||
|
||||
def requires(self):
|
||||
yield DatasetTask(path=get_spec().dataset.steps)
|
||||
|
||||
def output(self):
|
||||
return b2luigi.LocalTarget(str(_task_dir("warm_cache", str(self.name), str(self.spec_hash)) / "stamp.json"))
|
||||
|
||||
def run(self):
|
||||
from giant.data.setup_cache import sidecar_path
|
||||
from giant.tools.warm_setup_cache import run_warm_setup_cache
|
||||
|
||||
spec = get_spec()
|
||||
train = spec.train(str(self.name))
|
||||
run_warm_setup_cache(
|
||||
data=spec.dataset.steps,
|
||||
config_path=Path(train.config) if train.config else None,
|
||||
)
|
||||
sidecar = Path(sidecar_path(spec.dataset.steps))
|
||||
stamp = {
|
||||
"sidecar": str(sidecar),
|
||||
"mtime": sidecar.stat().st_mtime if sidecar.exists() else None,
|
||||
"size": sidecar.stat().st_size if sidecar.exists() else None,
|
||||
}
|
||||
out = Path(self.output().path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(stamp, indent=2))
|
||||
|
||||
|
||||
class GeometryOracleTask(b2luigi.Task):
|
||||
"""Build the position -> (material, layer_id) oracle every rollout needs."""
|
||||
|
||||
spec_hash = b2luigi.Parameter()
|
||||
|
||||
@property
|
||||
def htcondor_settings(self):
|
||||
spec = get_spec()
|
||||
return cpu_settings(spec.condor, request_memory_mb=32768, request_cpus=4, walltime_s=21600)
|
||||
|
||||
def requires(self):
|
||||
yield DatasetTask(path=get_spec().dataset.steps)
|
||||
|
||||
def output(self):
|
||||
return b2luigi.LocalTarget(
|
||||
str(_result_dir("geometry", f"spec_hash={self.spec_hash}") / "oracle.pkl"),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
from giant.tools.geometry_oracle import run_build_geometry_oracle
|
||||
|
||||
spec = get_spec()
|
||||
g = spec.geometry
|
||||
out = Path(self.output().path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
run_build_geometry_oracle(
|
||||
data=Path(spec.dataset.steps),
|
||||
out=out,
|
||||
method=g.method,
|
||||
k=g.k,
|
||||
subsample=g.subsample,
|
||||
escape_factor=g.escape_factor,
|
||||
seed=g.seed,
|
||||
depth_axis=g.depth_axis,
|
||||
n_bins=g.n_bins,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# training
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _train_cfg(spec: WorkflowSpec, name: str, epochs: int) -> dict:
|
||||
"""The merged config one training job runs, resolved exactly as `giant train` does."""
|
||||
from giant import config as gconfig
|
||||
|
||||
train = spec.train(name)
|
||||
flags = {**train.overrides, "epochs": epochs}
|
||||
overrides = gconfig.overrides_from_flags(flags)
|
||||
cfg = gconfig.merge_cli_overrides(
|
||||
gconfig.DEFAULT_CONFIG,
|
||||
Path(train.config) if train.config else None,
|
||||
overrides,
|
||||
)
|
||||
gconfig.validate_config(cfg, resume=True)
|
||||
return cfg
|
||||
|
||||
|
||||
class TrainEpochTask(b2luigi.Task):
|
||||
"""Epochs up to ``milestone`` of one training, resuming the previous job.
|
||||
|
||||
Target is ``last.pt``. ``best.pt`` is written by the loop *only when that
|
||||
epoch improved*, and ``best_val_loss`` travels inside the checkpoint, so
|
||||
the global best comparison stays correct across jobs: "``best.pt`` exists
|
||||
in milestone dir *k*" means exactly "one of that job's epochs was the best
|
||||
so far".
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
milestone = b2luigi.IntParameter()
|
||||
|
||||
@property
|
||||
def htcondor_settings(self):
|
||||
spec = get_spec()
|
||||
train = spec.train(str(self.name))
|
||||
return gpu_settings(
|
||||
spec.condor,
|
||||
request_gpus=train.request_gpus,
|
||||
gpu_type=train.gpu_type,
|
||||
gpu_memory_mb=train.gpu_memory_mb,
|
||||
request_memory_mb=train.request_memory_mb,
|
||||
request_cpus=train.request_cpus,
|
||||
walltime_s=train.walltime_s,
|
||||
)
|
||||
|
||||
@property
|
||||
def _dir(self) -> Path:
|
||||
return _task_dir("train_epoch", str(self.name), str(self.spec_hash)) / f"epochs={int(self.milestone)}"
|
||||
|
||||
def _previous_milestone(self) -> int | None:
|
||||
spec = get_spec()
|
||||
milestones = epoch_milestones(spec.train(str(self.name)))
|
||||
index = milestones.index(int(self.milestone))
|
||||
return milestones[index - 1] if index > 0 else None
|
||||
|
||||
def requires(self):
|
||||
previous = self._previous_milestone()
|
||||
if previous is None:
|
||||
yield WarmCacheTask(name=self.name, spec_hash=get_spec().warm_cache_hash(str(self.name)))
|
||||
else:
|
||||
yield TrainEpochTask(name=self.name, spec_hash=self.spec_hash, milestone=previous)
|
||||
|
||||
def output(self):
|
||||
return b2luigi.LocalTarget(str(self._dir / "last.pt"))
|
||||
|
||||
def run(self):
|
||||
import torch
|
||||
|
||||
from giant import config as gconfig
|
||||
from giant.pipeline import run_train_job
|
||||
|
||||
spec = get_spec()
|
||||
train = spec.train(str(self.name))
|
||||
cfg = _train_cfg(spec, str(self.name), int(self.milestone))
|
||||
|
||||
previous = self._previous_milestone()
|
||||
resume = None
|
||||
if previous is not None:
|
||||
resume = _task_dir("train_epoch", str(self.name), str(self.spec_hash)) / f"epochs={previous}" / "last.pt"
|
||||
|
||||
device = torch.device(train.device) if train.device else gconfig.auto_device()
|
||||
out_dir = self._dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
run_train_job(
|
||||
data=Path(spec.dataset.steps),
|
||||
cfg=cfg,
|
||||
out_dir=out_dir,
|
||||
device=device,
|
||||
shuffle_buffer=train.shuffle_buffer,
|
||||
num_workers=train.num_workers,
|
||||
resume=resume,
|
||||
cache_setup=True,
|
||||
)
|
||||
|
||||
|
||||
class TrainTask(b2luigi.Task):
|
||||
"""Publish one training's canonical outputs, hiding the epoch fan-out.
|
||||
|
||||
Everything downstream (``RolloutTask``, humans, ``giant analyze metrics``)
|
||||
points here and never has to know which milestone directory happened to
|
||||
hold the best checkpoint.
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
batch_system = "local"
|
||||
|
||||
@property
|
||||
def _milestones(self) -> list[int]:
|
||||
return epoch_milestones(get_spec().train(str(self.name)))
|
||||
|
||||
def requires(self):
|
||||
yield TrainEpochTask(name=self.name, spec_hash=self.spec_hash, milestone=self._milestones[-1])
|
||||
|
||||
@property
|
||||
def _dir(self) -> Path:
|
||||
return _task_dir("train", str(self.name), str(self.spec_hash))
|
||||
|
||||
def output(self):
|
||||
d = self._dir
|
||||
return {
|
||||
"best.pt": b2luigi.LocalTarget(str(d / "best.pt")),
|
||||
"last.pt": b2luigi.LocalTarget(str(d / "last.pt")),
|
||||
"metrics.csv": b2luigi.LocalTarget(str(d / "metrics.csv")),
|
||||
}
|
||||
|
||||
def run(self):
|
||||
epoch_base = _task_dir("train_epoch", str(self.name), str(self.spec_hash))
|
||||
milestone_dirs = [epoch_base / f"epochs={m}" for m in self._milestones]
|
||||
|
||||
best_dirs = [d for d in milestone_dirs if (d / "best.pt").exists()]
|
||||
if not best_dirs:
|
||||
raise FileNotFoundError(
|
||||
f"no best.pt in any milestone directory under {epoch_base} — "
|
||||
"did every epoch job run with a validation split?"
|
||||
)
|
||||
out = self._dir
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(best_dirs[-1] / "best.pt", out / "best.pt")
|
||||
shutil.copy2(milestone_dirs[-1] / "last.pt", out / "last.pt")
|
||||
for extra in ("config.toml", "run_meta.json"):
|
||||
src = milestone_dirs[-1] / extra
|
||||
if src.exists():
|
||||
shutil.copy2(src, out / extra)
|
||||
|
||||
# One metrics.csv for the whole run: the first job's header, then
|
||||
# every job's rows in epoch order, so `giant analyze metrics` sees a
|
||||
# single continuous training curve.
|
||||
lines: list[str] = []
|
||||
header: str | None = None
|
||||
for d in milestone_dirs:
|
||||
csv = d / "metrics.csv"
|
||||
if not csv.exists():
|
||||
continue
|
||||
rows = csv.read_text().splitlines()
|
||||
if not rows:
|
||||
continue
|
||||
if header is None:
|
||||
header = rows[0]
|
||||
lines.extend(rows[1:])
|
||||
(out / "metrics.csv").write_text("\n".join([header or ""] + lines) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rollout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RolloutTask(b2luigi.Task):
|
||||
"""Roll one trained checkpoint forward into full showers.
|
||||
|
||||
``giant rollout`` has no library-level entry point, so this shells out to
|
||||
the CLI — with an explicit ``--out``, which puts the YAML sidecar at the
|
||||
deterministic ``rollout.yaml`` next to the parquet (see
|
||||
``giant/cli.py:_write_prediction_ref``).
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
|
||||
@property
|
||||
def htcondor_settings(self):
|
||||
spec = get_spec()
|
||||
ro = spec.rollout(str(self.name))
|
||||
return gpu_settings(
|
||||
spec.condor,
|
||||
request_gpus=ro.request_gpus,
|
||||
gpu_type=ro.gpu_type,
|
||||
gpu_memory_mb=ro.gpu_memory_mb,
|
||||
request_memory_mb=ro.request_memory_mb,
|
||||
request_cpus=ro.request_cpus,
|
||||
walltime_s=ro.walltime_s,
|
||||
)
|
||||
|
||||
@property
|
||||
def _dir(self) -> Path:
|
||||
return _task_dir("rollout", str(self.name), str(self.spec_hash))
|
||||
|
||||
def requires(self):
|
||||
spec = get_spec()
|
||||
ro = spec.rollout(str(self.name))
|
||||
yield TrainTask(name=ro.train, spec_hash=spec.train_hash(ro.train))
|
||||
yield GeometryOracleTask(spec_hash=spec.geometry_hash())
|
||||
yield DatasetTask(path=spec.dataset.reference)
|
||||
|
||||
def output(self):
|
||||
d = self._dir
|
||||
return {
|
||||
"rollout.parquet": b2luigi.LocalTarget(str(d / "rollout.parquet")),
|
||||
"rollout.yaml": b2luigi.LocalTarget(str(d / "rollout.yaml")),
|
||||
}
|
||||
|
||||
def run(self):
|
||||
spec = get_spec()
|
||||
ro = spec.rollout(str(self.name))
|
||||
out = self._dir / "rollout.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
checkpoint = _task_dir("train", ro.train, spec.train_hash(ro.train)) / "best.pt"
|
||||
oracle = _result_dir("geometry", f"spec_hash={spec.geometry_hash()}") / "oracle.pkl"
|
||||
|
||||
cmd = [
|
||||
*_giant_cmd(),
|
||||
"rollout",
|
||||
spec.dataset.reference,
|
||||
"--checkpoint",
|
||||
str(checkpoint),
|
||||
"--geometry",
|
||||
str(oracle),
|
||||
"--out",
|
||||
str(out),
|
||||
"--energy-cutoff",
|
||||
str(ro.energy_cutoff),
|
||||
"--max-steps",
|
||||
str(ro.max_steps),
|
||||
"--steps",
|
||||
str(ro.steps),
|
||||
"--batch-size",
|
||||
str(ro.batch_size),
|
||||
"--weights",
|
||||
ro.weights,
|
||||
]
|
||||
for flag, value in (
|
||||
("--n-events", ro.n_events),
|
||||
("--max-tracks-per-event", ro.max_tracks_per_event),
|
||||
("--escape-threshold", ro.escape_threshold),
|
||||
("--seed", ro.seed),
|
||||
("--device", ro.device),
|
||||
):
|
||||
if value is not None:
|
||||
cmd += [flag, str(value)]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# analysis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnalysisPrepTask(b2luigi.Task):
|
||||
"""Resolve the shared bin edges/group sets once, for every compute job.
|
||||
|
||||
Cheap and streaming, so it runs locally: everything after it needs
|
||||
``shared.json``/``run_meta.json`` to already exist.
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
batch_system = "local"
|
||||
|
||||
def requires(self):
|
||||
spec = get_spec()
|
||||
for rollout_name in spec.analysis(str(self.name)).rollouts:
|
||||
yield RolloutTask(name=rollout_name, spec_hash=spec.rollout_hash(rollout_name))
|
||||
|
||||
@property
|
||||
def _dir(self) -> Path:
|
||||
return analysis_dir(get_spec(), str(self.name))
|
||||
|
||||
def output(self):
|
||||
d = self._dir
|
||||
return {
|
||||
"shared.json": b2luigi.LocalTarget(str(d / "shared.json")),
|
||||
"run_meta.json": b2luigi.LocalTarget(str(d / "run_meta.json")),
|
||||
}
|
||||
|
||||
def run(self):
|
||||
from giant.analysis import prep
|
||||
|
||||
spec = get_spec()
|
||||
an = spec.analysis(str(self.name))
|
||||
yamls = [_task_dir("rollout", r, spec.rollout_hash(r)) / "rollout.yaml" for r in an.rollouts]
|
||||
prep(
|
||||
yamls,
|
||||
run_dir=self._dir,
|
||||
n_chunks=an.chunks,
|
||||
labels=list(an.rollouts),
|
||||
n_energy_bins=an.energy_bins,
|
||||
n_marginal_bins=an.bins,
|
||||
top_k_pdg=an.top_pdg,
|
||||
)
|
||||
|
||||
|
||||
class AnalysisComputeTask(b2luigi.Task):
|
||||
"""One (plot, chunk) streaming reduction — the replaced ``jobs.txt`` row.
|
||||
|
||||
The output path is the on-disk contract ``compute-one``/``merge_one``
|
||||
already share (``reduced_partial/<id>__<chunk>.json``), declared
|
||||
explicitly rather than through b2luigi's own output naming so that
|
||||
contract is untouched.
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
plot_id = b2luigi.Parameter()
|
||||
chunk = b2luigi.IntParameter()
|
||||
|
||||
@property
|
||||
def htcondor_settings(self):
|
||||
# A property, so it is evaluated at submit time — i.e. after prep has
|
||||
# written run_meta.json, whose row counts size the walltime request.
|
||||
from giant.analysis import RunMeta
|
||||
from giant.analysis.runtime_estimate import estimate_runtime_s
|
||||
|
||||
spec = get_spec()
|
||||
an = spec.analysis(str(self.name))
|
||||
walltime = None
|
||||
meta_path = analysis_dir(spec, str(self.name)) / "run_meta.json"
|
||||
if meta_path.exists():
|
||||
from giant.analysis.catalog import get_spec as get_plot_spec
|
||||
|
||||
meta = RunMeta.load(meta_path)
|
||||
chunkable = get_plot_spec(str(self.plot_id)).chunkable
|
||||
n_rows = meta.rows_per_chunk[int(self.chunk)] if chunkable and meta.rows_per_chunk else meta.total_rows
|
||||
walltime = estimate_runtime_s(str(self.plot_id), n_rows)
|
||||
return cpu_settings(
|
||||
spec.condor,
|
||||
request_memory_mb=an.request_memory_mb,
|
||||
request_cpus=an.request_cpus,
|
||||
walltime_s=walltime,
|
||||
)
|
||||
|
||||
def requires(self):
|
||||
yield AnalysisPrepTask(name=self.name, spec_hash=self.spec_hash)
|
||||
|
||||
def output(self):
|
||||
run_dir = analysis_dir(get_spec(), str(self.name))
|
||||
return b2luigi.LocalTarget(str(run_dir / "reduced_partial" / f"{self.plot_id}__{int(self.chunk)}.json"))
|
||||
|
||||
def run(self):
|
||||
from giant.analysis import compute_one
|
||||
|
||||
compute_one(str(self.plot_id), analysis_dir(get_spec(), str(self.name)), chunk_index=int(self.chunk))
|
||||
|
||||
|
||||
class AnalysisRenderTask(b2luigi.Task):
|
||||
"""Merge every plot's chunk partials, then render the PDFs + gallery.
|
||||
|
||||
Always local — this is the only step that imports plotstyle/LaTeX, which
|
||||
the compute worker images don't have.
|
||||
"""
|
||||
|
||||
name = b2luigi.Parameter()
|
||||
spec_hash = b2luigi.Parameter()
|
||||
batch_system = "local"
|
||||
|
||||
def requires(self):
|
||||
spec = get_spec()
|
||||
for plot_id, chunk in analysis_jobs(spec, str(self.name)):
|
||||
yield AnalysisComputeTask(
|
||||
name=self.name,
|
||||
spec_hash=self.spec_hash,
|
||||
plot_id=plot_id,
|
||||
chunk=chunk,
|
||||
)
|
||||
|
||||
def output(self):
|
||||
run_dir = analysis_dir(get_spec(), str(self.name))
|
||||
return b2luigi.LocalTarget(str(run_dir / "plots" / "metadata.yaml"))
|
||||
|
||||
def run(self):
|
||||
# render_run joins every plot's chunk partials (merge_all) before
|
||||
# rendering, so this one call is the whole merge+render step.
|
||||
from giant.analysis.render import render_run
|
||||
|
||||
spec = get_spec()
|
||||
render_run(analysis_dir(spec, str(self.name)), run_gallery=spec.analysis(str(self.name)).gallery)
|
||||
|
||||
|
||||
class WorkflowTask(b2luigi.WrapperTask):
|
||||
"""The whole pipeline: every analysis in the spec, rendered."""
|
||||
|
||||
workflow_name = b2luigi.Parameter()
|
||||
|
||||
def requires(self):
|
||||
spec = get_spec()
|
||||
if not spec.analyses:
|
||||
# A spec with no [[analysis]] still has work to do — fall back to
|
||||
# the deepest tasks it does define.
|
||||
for ro in spec.rollouts:
|
||||
yield RolloutTask(name=ro.name, spec_hash=spec.rollout_hash(ro.name))
|
||||
if not spec.rollouts:
|
||||
for tr in spec.trains:
|
||||
yield TrainTask(name=tr.name, spec_hash=spec.train_hash(tr.name))
|
||||
return
|
||||
for an in spec.analyses:
|
||||
yield AnalysisRenderTask(name=an.name, spec_hash=spec.analysis_hash(an.name))
|
||||
+2
-16
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "giant"
|
||||
version = "0.3.10"
|
||||
version = "0.3.11"
|
||||
description = "Geant4 step-function surrogate via conditional flow matching"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -28,7 +28,7 @@ dev = [
|
||||
"ty>=0.0.50,<0.1",
|
||||
"bump-my-version>=1.2,<2",
|
||||
"git-cliff>=2,<3",
|
||||
"giant[convert,analysis,geometry,wandb,workflow]",
|
||||
"giant[convert,analysis,geometry,wandb]",
|
||||
]
|
||||
geometry = [
|
||||
"scikit-learn>=1.4,<2",
|
||||
@@ -49,11 +49,6 @@ analysis = [
|
||||
# `giant analyze render` step imports it; compute workers never do.
|
||||
"plotstyle>=1.0.0",
|
||||
]
|
||||
# b2luigi pulls luigi + tenacity; the only sanctioned way to chain a
|
||||
# multi-step pipeline (see giant/workflow/).
|
||||
workflow = [
|
||||
"b2luigi>=1.0,<2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
giant = "giant.cli:app"
|
||||
@@ -108,12 +103,3 @@ explicit = true
|
||||
name = "larsbogner"
|
||||
url = "https://git.larsbogner.de/api/packages/lars/pypi/simple/"
|
||||
explicit = true
|
||||
|
||||
# luigi builds task constructors from class-level Parameter descriptors, so a
|
||||
# static checker sees no keyword parameters at all on `Task(name=..., ...)`.
|
||||
# The workflow code is written against that API; nothing else in the repo is.
|
||||
[[tool.ty.overrides]]
|
||||
include = ["giant/workflow/**", "tests/test_workflow_tasks.py"]
|
||||
|
||||
[tool.ty.overrides.rules]
|
||||
unknown-argument = "ignore"
|
||||
|
||||
@@ -13,6 +13,7 @@ from giant.analysis.sources import (
|
||||
open_side,
|
||||
physical_steps,
|
||||
secondaries,
|
||||
secondaries_by_step,
|
||||
)
|
||||
from giant.data.loader import EVENT_ID_FILE_STRIDE
|
||||
|
||||
@@ -159,18 +160,17 @@ def test_secondaries_rollout_vs_reference_align():
|
||||
assert t["pdg"].to_list() == [22, 22]
|
||||
|
||||
|
||||
def test_sec_count_by_event_zero_fills_events_with_no_secondaries():
|
||||
r_phys = physical_steps(_rollout_frame(), Side.rollout)
|
||||
r_sec = secondaries(_rollout_frame(), Side.rollout)
|
||||
ev, n = R.sec_count_by_event(r_phys, r_sec)
|
||||
# event 1 has one secondary track; event 2 has none and must still appear (as 0),
|
||||
# not silently drop out of a plain group_by on the secondaries frame alone.
|
||||
assert dict(zip(ev.tolist(), n.tolist())) == {1: 1, 2: 0}
|
||||
def test_secondaries_by_step_keys_each_secondary_to_its_emitting_step():
|
||||
r = secondaries_by_step(_rollout_frame(), Side.rollout).collect()
|
||||
assert r["pdg"].to_list() == [22]
|
||||
# the rollout key is (event_id, parent_id, birth position) — the parent
|
||||
# step's post_pos, copied verbatim onto the child's birth row.
|
||||
assert r["step_key"][0] == {"event_id": 1, "parent_id": 0, "pre_x": 0.0, "pre_y": 0.0, "pre_z": 1.0}
|
||||
|
||||
t_all = _reference_frame()
|
||||
t_sec = secondaries(t_all, Side.reference)
|
||||
ev, n = R.sec_count_by_event(t_all, t_sec)
|
||||
assert dict(zip(ev.tolist(), n.tolist())) == {1: 1, 2: 1}
|
||||
t = secondaries_by_step(_reference_frame(), Side.reference).collect()
|
||||
assert t["pdg"].to_list() == [22, 22]
|
||||
# one row per emitting step; the empty-list step drops out entirely
|
||||
assert [k["_row"] for k in t["step_key"]] == [0, 2]
|
||||
|
||||
|
||||
def test_leakage_fraction():
|
||||
|
||||
+26
-38
@@ -10,10 +10,10 @@ from giant.analysis.catalog import (
|
||||
Bundle,
|
||||
PlotSpec,
|
||||
_containment_depths,
|
||||
_integer_confusion,
|
||||
_ks_statistic,
|
||||
)
|
||||
from giant.analysis.context import Context, build_context
|
||||
from giant.analysis.grouping import pdg_label
|
||||
from giant.analysis.sources import RolloutSpec
|
||||
from tests.test_analysis_reduce import _reference_frame, _rollout_frame
|
||||
|
||||
@@ -160,8 +160,9 @@ def _validate_payload(r, names: list[str]) -> None:
|
||||
# data-dependent edges (event_total_edep), concat-then-mean/std (shower_
|
||||
# longitudinal), concat-then-max-edge (leakage_fraction), pdg-keyed sum with a
|
||||
# ratio (species_edep_share), a chunkable=False passthrough (router_gating),
|
||||
# nested sum-merge into a scorecard (marginal_distance_summary), concat-then-
|
||||
# event-id-join (n_sec_confusion), and concat-then-per-event-derived-quantity
|
||||
# sum-mergeable-with-a-zero-fill-denominator (sec_count_per_step{,_by_species}),
|
||||
# nested sum-merge into a scorecard (marginal_distance_summary), and
|
||||
# concat-then-per-event-derived-quantity
|
||||
# (shower_containment_depth_90, reusing the profile matrix's own merge shape).
|
||||
_CHUNK_EQUIVALENCE_IDS = [
|
||||
"marginal_edep",
|
||||
@@ -170,9 +171,10 @@ _CHUNK_EQUIVALENCE_IDS = [
|
||||
"shower_longitudinal",
|
||||
"leakage_fraction",
|
||||
"sec_count_per_species",
|
||||
"sec_count_per_step",
|
||||
"sec_count_per_step_by_species",
|
||||
"router_gating",
|
||||
"marginal_distance_summary",
|
||||
"n_sec_confusion",
|
||||
"shower_containment_depth_90",
|
||||
]
|
||||
|
||||
@@ -217,7 +219,7 @@ def test_chunked_matches_unchunked(two_ctx: Context, spec_id: str):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new (gitea #76) reductions: KS distance, confusion matrix, containment depth
|
||||
# new (gitea #76) reductions: KS distance and containment depth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -228,28 +230,6 @@ def test_ks_statistic():
|
||||
assert _ks_statistic([10, 0], [0, 0]) == 1.0 # one side empty, other isn't -> maximal mismatch
|
||||
|
||||
|
||||
def test_integer_confusion_matches_event_pairing():
|
||||
# true (reference) n_sec = [1, 1]; predicted (rollout) n_sec = [1, 0]
|
||||
labels, mat = _integer_confusion(np.array([1, 1]), np.array([1, 0]))
|
||||
assert labels == ["0", "1+"]
|
||||
assert mat.tolist() == [[0, 0], [1, 1]] # row=true, col=pred
|
||||
|
||||
|
||||
def test_integer_confusion_caps_pathological_outliers():
|
||||
labels, mat = _integer_confusion(np.array([0, 500]), np.array([0, 0]), max_bins=5)
|
||||
assert labels[-1] == "4+"
|
||||
assert mat.shape == (5, 5)
|
||||
assert mat.sum() == 2
|
||||
|
||||
|
||||
def test_integer_confusion_explicit_cap_overrides_local_range():
|
||||
# Even though this pair's own max is 1, an explicit shared cap forces a
|
||||
# wider (and so cross-rollout-consistent) label set.
|
||||
labels, mat = _integer_confusion(np.array([1, 1]), np.array([0, 1]), cap=3)
|
||||
assert labels == ["0", "1", "2", "3+"]
|
||||
assert mat.shape == (4, 4)
|
||||
|
||||
|
||||
def test_containment_depths_simple_ramp():
|
||||
# one event, edep concentrated in the first bin -> 90%/95% containment
|
||||
# depth is the first bin's right edge; a zero-energy event is dropped.
|
||||
@@ -259,17 +239,25 @@ def test_containment_depths_simple_ramp():
|
||||
assert depths.tolist() == [1.0]
|
||||
|
||||
|
||||
def test_n_sec_confusion_spec(bundle):
|
||||
spec = get_spec("n_sec_confusion")
|
||||
def test_sec_count_per_step_counts_empty_steps(bundle):
|
||||
spec = get_spec("sec_count_per_step")
|
||||
r = spec.finalize([spec.compute_partial(bundle)], bundle.ctx)
|
||||
assert r.payload["row_labels"] == r.payload["col_labels"] == ["0", "1+"]
|
||||
assert r.payload["series"]["rollout"] == [[0, 0], [1, 1]]
|
||||
# reference: 3 steps, two of which emit exactly one secondary
|
||||
assert r.payload["reference"][:2] == [1, 2]
|
||||
# rollout: 4 physical steps, one of which emits a single secondary
|
||||
assert r.payload["series"]["rollout"][:2] == [3, 1]
|
||||
assert sum(r.payload["reference"]) == 3
|
||||
|
||||
|
||||
def test_n_sec_confusion_shares_one_cap_across_rollouts(two_bundle):
|
||||
spec = get_spec("n_sec_confusion")
|
||||
r = spec.finalize([spec.compute_partial(two_bundle)], two_bundle.ctx)
|
||||
assert list(r.payload["series"]) == ["flow", "wgan"]
|
||||
# both rollouts share the same fixture data here, so their matrices (and
|
||||
# the shared label set) must be identical.
|
||||
assert r.payload["series"]["flow"] == r.payload["series"]["wgan"]
|
||||
def test_sec_count_per_step_by_species_zero_row_is_per_species(bundle):
|
||||
spec = get_spec("sec_count_per_step_by_species")
|
||||
r = spec.finalize([spec.compute_partial(bundle)], bundle.ctx)
|
||||
cols = r.payload["col_labels"]
|
||||
ref = r.payload["reference"]
|
||||
g = cols.index(pdg_label(22))
|
||||
# two reference steps emit one photon each; the third emits none
|
||||
assert [row[g] for row in ref][:2] == [1, 2]
|
||||
# every other species column is "no such secondary" on all 3 steps
|
||||
for j, _ in enumerate(cols):
|
||||
if j != g:
|
||||
assert ref[0][j] == 3 and sum(row[j] for row in ref[1:]) == 0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Tests for the rollout-YAML(s) → run-directory flow, compute, and merge."""
|
||||
"""Tests for the rollout-YAML(s) → run-directory flow, compute, and submit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
@@ -10,6 +11,8 @@ import yaml
|
||||
|
||||
from giant.analysis import (
|
||||
RunMeta,
|
||||
SubmitConfig,
|
||||
catalog_ids,
|
||||
compute_one,
|
||||
compute_reduced,
|
||||
derive_run_dir,
|
||||
@@ -17,8 +20,10 @@ from giant.analysis import (
|
||||
load_rollout_yamls,
|
||||
merge_one,
|
||||
prep,
|
||||
write_submit,
|
||||
)
|
||||
from giant.analysis.run import Context
|
||||
from giant.analysis.catalog import get_spec
|
||||
from giant.analysis.condor import Context
|
||||
from giant.analysis.reduced import Partial, Reduced
|
||||
from giant.constants import PREDICT_COORD_METADATA_KEY, ROLLOUT_COORD_VALUE
|
||||
from tests.test_analysis_reduce import _reference_frame, _rollout_frame
|
||||
@@ -81,6 +86,14 @@ def _write_two_inputs(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return paths[0], paths[1]
|
||||
|
||||
|
||||
def _fake_venv(repo_dir: Path) -> None:
|
||||
"""Stand in for a `uv sync`'d venv: write_submit checks `.venv/bin/giant` exists."""
|
||||
giant = repo_dir / ".venv" / "bin" / "giant"
|
||||
giant.parent.mkdir(parents=True, exist_ok=True)
|
||||
giant.write_text("#!/bin/bash\n")
|
||||
giant.chmod(0o755)
|
||||
|
||||
|
||||
def _prep(rollout_yamls, run_dir: str | Path | None = None, chunks: int = 1, labels=None) -> Path:
|
||||
"""``prep`` with small test-sized context bins/sampling."""
|
||||
return prep(
|
||||
@@ -300,6 +313,73 @@ def test_compute_reduced_rejects_out_of_range_chunk(tmp_path: Path):
|
||||
compute_one("marginal_edep", run_dir, chunk_index=1)
|
||||
|
||||
|
||||
def test_write_submit_description(tmp_path: Path):
|
||||
run_dir = _prep([_write_inputs(tmp_path)])
|
||||
_fake_venv(tmp_path)
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path)
|
||||
txt = write_submit(cfg).read_text()
|
||||
assert "universe = docker" in txt
|
||||
assert "docker_image = cverstege/alma9-gridjob" in txt
|
||||
assert "requirements = TARGET.ProvidesETPResources" in txt
|
||||
assert "accounting_group = cms" in txt
|
||||
assert "+RequestWalltime = $(walltime)" in txt
|
||||
assert "queue plotid,chunk,walltime from" in txt
|
||||
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
||||
assert [i for i, _, _ in jobs] == catalog_ids()
|
||||
assert all(k == "0" for _, k, _ in jobs) # n_chunks=1 default
|
||||
assert all(int(w) > 0 for _, _, w in jobs)
|
||||
wrapper = run_dir / "run_compute.sh"
|
||||
assert wrapper.exists() and (wrapper.stat().st_mode & 0o111)
|
||||
body = wrapper.read_text()
|
||||
assert "giant analyze compute-one --id" in body
|
||||
assert "--chunk" in body and "--run-dir" in body
|
||||
|
||||
|
||||
def test_write_submit_requires_synced_venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
run_dir = _prep([_write_inputs(tmp_path)])
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path)
|
||||
# No `giant` next to the (fake) active interpreter, so this falls through
|
||||
# to repo_dir/.venv/bin/giant, which _write_inputs/_prep also didn't create.
|
||||
monkeypatch.setattr(sys, "executable", str(tmp_path / "not-a-venv" / "bin" / "python"))
|
||||
with pytest.raises(FileNotFoundError, match="uv sync"):
|
||||
write_submit(cfg)
|
||||
|
||||
|
||||
def test_write_submit_remote_flag(tmp_path: Path):
|
||||
run_dir = _prep([_write_inputs(tmp_path)])
|
||||
_fake_venv(tmp_path)
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, remote=True)
|
||||
txt = write_submit(cfg).read_text()
|
||||
assert "+RemoteJob = True" in txt
|
||||
assert "ProvidesETPResources" not in txt
|
||||
|
||||
|
||||
def test_write_submit_chunks_respect_chunkable(tmp_path: Path):
|
||||
assert get_spec("router_gating").chunkable is False
|
||||
run_dir = _prep([_write_inputs(tmp_path)], chunks=4)
|
||||
_fake_venv(tmp_path)
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=4)
|
||||
write_submit(cfg)
|
||||
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
||||
counts: dict[str, int] = {}
|
||||
for spec_id, _, _ in jobs:
|
||||
counts[spec_id] = counts.get(spec_id, 0) + 1
|
||||
assert counts["marginal_edep"] == 4
|
||||
assert counts["router_gating"] == 1 # chunkable=False, ignores n_chunks
|
||||
|
||||
|
||||
def test_write_submit_rejects_n_chunks_mismatch_with_run_meta(tmp_path: Path):
|
||||
"""cfg.n_chunks must match the n_chunks the run_dir was actually prepped
|
||||
with — RunMeta.rows_per_chunk is sized to the prepped value, so a
|
||||
mismatch would otherwise surface as a confusing IndexError deep inside
|
||||
_job_walltimes instead of a clear error here."""
|
||||
run_dir = _prep([_write_inputs(tmp_path)], chunks=2)
|
||||
_fake_venv(tmp_path)
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=4)
|
||||
with pytest.raises(ValueError, match="n_chunks"):
|
||||
write_submit(cfg)
|
||||
|
||||
|
||||
def test_estimate_runtime_s_scales_with_rows_and_margin():
|
||||
from giant.analysis import RUNTIME_SAFETY_MARGIN, estimate_runtime_s
|
||||
from giant.analysis.runtime_estimate import _FIXED_OVERHEAD_S
|
||||
@@ -309,3 +389,18 @@ def test_estimate_runtime_s_scales_with_rows_and_margin():
|
||||
large = estimate_runtime_s("marginal_edep", 100_000_000)
|
||||
assert small >= (1 + RUNTIME_SAFETY_MARGIN) * _FIXED_OVERHEAD_S
|
||||
assert large > small # bigger chunk -> longer estimate
|
||||
|
||||
|
||||
def test_write_submit_walltime_grows_with_chunk_rows(tmp_path: Path):
|
||||
"""A chunked run's later job walltimes track that chunk's row count."""
|
||||
from giant.analysis.runtime_estimate import estimate_runtime_s
|
||||
|
||||
run_dir = _prep([_write_inputs(tmp_path)], chunks=2)
|
||||
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||
_fake_venv(tmp_path)
|
||||
cfg = SubmitConfig(run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=2)
|
||||
write_submit(cfg)
|
||||
jobs = {(i, int(k)): int(w) for i, k, w in (line.split(",") for line in (run_dir / "jobs.txt").read_text().split())}
|
||||
for chunk in range(2):
|
||||
expected = estimate_runtime_s("marginal_edep", meta.rows_per_chunk[chunk])
|
||||
assert jobs[("marginal_edep", chunk)] == expected
|
||||
@@ -206,21 +206,21 @@ def test_render_all_run_gallery_invokes_subprocess(tmp_path: Path, monkeypatch):
|
||||
assert kwargs == {"check": True}
|
||||
|
||||
|
||||
def test_render_run_glues_run_meta_into_render_all(tmp_path: Path, monkeypatch):
|
||||
from giant.analysis import run as run_mod
|
||||
def test_render_run_glues_condor_run_meta_into_render_all(tmp_path: Path, monkeypatch):
|
||||
from giant.analysis import condor as condor_mod
|
||||
|
||||
run_dir = tmp_path / "run"
|
||||
(run_dir / "reduced").mkdir(parents=True)
|
||||
|
||||
merge_calls = []
|
||||
monkeypatch.setattr(run_mod, "merge_all", lambda rd: merge_calls.append(Path(rd)))
|
||||
meta = run_mod.RunMeta(
|
||||
monkeypatch.setattr(condor_mod, "merge_all", lambda rd: merge_calls.append(Path(rd)))
|
||||
meta = condor_mod.RunMeta(
|
||||
rollouts=[{"name": "rollout", "path": "rollout.parquet", "plot_meta": {"checkpoint": "ckpt/best.pt"}}],
|
||||
reference="reference.parquet",
|
||||
run_dir=str(run_dir),
|
||||
title="my-run",
|
||||
)
|
||||
monkeypatch.setattr(run_mod.RunMeta, "load", classmethod(lambda cls, p: meta))
|
||||
monkeypatch.setattr(condor_mod.RunMeta, "load", classmethod(lambda cls, p: meta))
|
||||
|
||||
Reduced("s", "species", "single_hist", "Single", "x", {"edges": [0, 1], "series": {"rollout": [1]}}).save(
|
||||
run_dir / "reduced" / "s.json"
|
||||
@@ -304,13 +304,15 @@ def test_render_one_of_each_kind(tmp_path: Path):
|
||||
"hm1",
|
||||
"secondaries",
|
||||
"heatmap",
|
||||
"Confusion (single rollout)",
|
||||
"Heatmap (single rollout)",
|
||||
"predicted",
|
||||
{
|
||||
"series": {"flow": [[1, 0], [0, 1]]},
|
||||
"reference": [[2, 0], [0, 1]],
|
||||
"row_labels": ["0", "1+"],
|
||||
"col_labels": ["0", "1+"],
|
||||
"cbar_label": "count",
|
||||
"log_color": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Workflow spec parsing, validation, and spec hashes (gitea #83)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from giant.workflow.spec import (
|
||||
WorkflowSpecError,
|
||||
epoch_milestones,
|
||||
load_spec,
|
||||
parse_spec,
|
||||
spec_hash,
|
||||
)
|
||||
|
||||
MINIMAL = {
|
||||
"workflow": {"name": "wf", "result_dir": "/tmp/wf"},
|
||||
"condor": {"accounting_group": "cms", "repo_dir": "/work/lbogner/giant"},
|
||||
"dataset": {"steps": "/data/train", "reference": "/data/holdout"},
|
||||
"train": [{"name": "a", "epochs": 3}],
|
||||
"rollout": [{"name": "a", "train": "a"}],
|
||||
"analysis": [{"name": "cmp", "rollouts": ["a"], "chunks": 4}],
|
||||
}
|
||||
|
||||
|
||||
def _spec(**patch):
|
||||
raw = {k: (v.copy() if isinstance(v, dict) else list(v)) for k, v in MINIMAL.items()}
|
||||
raw.update(patch)
|
||||
return parse_spec(raw)
|
||||
|
||||
|
||||
def test_parses_minimal_spec():
|
||||
spec = _spec()
|
||||
assert spec.name == "wf"
|
||||
assert spec.log_dir == "/tmp/wf/logs" # derived from result_dir
|
||||
assert spec.train("a").epochs == 3
|
||||
assert spec.rollout("a").train == "a"
|
||||
assert spec.analysis("cmp").rollouts == ("a",)
|
||||
# defaults come from the dataclasses, not the file
|
||||
assert spec.geometry.method == "slab"
|
||||
assert spec.condor.docker_image_gpu == "mschnepf/slc7-condocker"
|
||||
|
||||
|
||||
def test_example_config_is_valid():
|
||||
spec = load_spec("configs/workflow_example.toml")
|
||||
assert {t.name for t in spec.trains} == {"baseline", "router-balanced"}
|
||||
assert spec.analysis("baseline-vs-router").rollouts == ("baseline", "router-balanced")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch, message",
|
||||
[
|
||||
({"train": [{"name": "a"}, {"name": "a"}]}, "unique"),
|
||||
({"rollout": [{"name": "r", "train": "nope"}]}, "names no"),
|
||||
({"analysis": [{"name": "c", "rollouts": ["nope"]}]}, "not defined"),
|
||||
({"analysis": [{"name": "c", "rollouts": []}]}, "at least one"),
|
||||
({"analysis": [{"name": "c", "rollouts": ["a"], "chunks": 0}]}, "chunks must be"),
|
||||
({"train": [{"name": "a", "epochs": 0}]}, "epochs must be"),
|
||||
({"train": [{"name": "a", "epchs": 3}]}, "unknown key"),
|
||||
({"geometry": {"methd": "slab"}}, "unknown key"),
|
||||
],
|
||||
)
|
||||
def test_validation_errors(patch, message):
|
||||
with pytest.raises(WorkflowSpecError, match=message):
|
||||
_spec(**patch)
|
||||
|
||||
|
||||
def test_unknown_top_level_table_rejected():
|
||||
with pytest.raises(WorkflowSpecError, match="unknown top-level"):
|
||||
_spec(nonsense={})
|
||||
|
||||
|
||||
def test_missing_required_table_rejected():
|
||||
raw = {k: v for k, v in MINIMAL.items() if k != "dataset"}
|
||||
with pytest.raises(WorkflowSpecError, match=r"missing required \[dataset\]"):
|
||||
parse_spec(raw)
|
||||
|
||||
|
||||
def test_unknown_lookup_names_are_explicit():
|
||||
spec = _spec()
|
||||
with pytest.raises(WorkflowSpecError, match="no \\[\\[train\\]\\] named 'zzz'"):
|
||||
spec.train("zzz")
|
||||
|
||||
|
||||
def test_hash_is_stable_and_order_independent():
|
||||
a = _spec()
|
||||
b = parse_spec(
|
||||
{
|
||||
"dataset": MINIMAL["dataset"],
|
||||
"condor": MINIMAL["condor"],
|
||||
"workflow": MINIMAL["workflow"],
|
||||
"train": MINIMAL["train"],
|
||||
"rollout": MINIMAL["rollout"],
|
||||
"analysis": MINIMAL["analysis"],
|
||||
}
|
||||
)
|
||||
assert a.train_hash("a") == b.train_hash("a")
|
||||
assert a.analysis_hash("cmp") == b.analysis_hash("cmp")
|
||||
assert len(a.train_hash("a")) == 8
|
||||
|
||||
|
||||
def test_hash_changes_with_own_settings():
|
||||
base = _spec()
|
||||
changed = _spec(train=[{"name": "a", "epochs": 4}])
|
||||
assert base.train_hash("a") != changed.train_hash("a")
|
||||
|
||||
|
||||
def test_hash_propagates_from_parents():
|
||||
"""A dataset change must move every downstream task's directory."""
|
||||
base = _spec()
|
||||
changed = _spec(dataset={"steps": "/data/other", "reference": "/data/holdout"})
|
||||
assert base.train_hash("a") != changed.train_hash("a")
|
||||
assert base.rollout_hash("a") != changed.rollout_hash("a")
|
||||
assert base.analysis_hash("cmp") != changed.analysis_hash("cmp")
|
||||
|
||||
# ... and so must a change to a training the analysis transitively uses.
|
||||
retrained = _spec(train=[{"name": "a", "epochs": 9}])
|
||||
assert retrained.analysis_hash("cmp") != base.analysis_hash("cmp")
|
||||
# while an unrelated knob on the analysis leaves the training alone
|
||||
rebinned = _spec(analysis=[{"name": "cmp", "rollouts": ["a"], "chunks": 4, "bins": 99}])
|
||||
assert rebinned.train_hash("a") == base.train_hash("a")
|
||||
assert rebinned.analysis_hash("cmp") != base.analysis_hash("cmp")
|
||||
|
||||
|
||||
def test_warm_cache_hash_ignores_epochs():
|
||||
"""Epoch count doesn't change the setup cache, so it must not re-warm it."""
|
||||
base = _spec()
|
||||
longer = _spec(train=[{"name": "a", "epochs": 50}])
|
||||
assert base.warm_cache_hash("a") == longer.warm_cache_hash("a")
|
||||
other_cfg = _spec(train=[{"name": "a", "epochs": 3, "config": "configs/router.toml"}])
|
||||
assert base.warm_cache_hash("a") != other_cfg.warm_cache_hash("a")
|
||||
|
||||
|
||||
def test_spec_hash_expands_dataclasses():
|
||||
spec = _spec()
|
||||
assert spec_hash(spec.dataset) == spec_hash(spec.dataset)
|
||||
assert spec_hash(spec.dataset) != spec_hash(spec.geometry)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"epochs, per_job, expected",
|
||||
[
|
||||
(3, 1, [1, 2, 3]),
|
||||
(10, 3, [3, 6, 9, 10]),
|
||||
(9, 3, [3, 6, 9]),
|
||||
(1, 5, [1]),
|
||||
],
|
||||
)
|
||||
def test_epoch_milestones(epochs, per_job, expected):
|
||||
spec = _spec(train=[{"name": "a", "epochs": epochs, "epochs_per_job": per_job}])
|
||||
assert epoch_milestones(spec.train("a")) == expected
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Workflow task graph: dependencies, output paths, condor settings (gitea #83)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from giant.analysis.catalog import catalog_ids, get_spec as get_plot_spec
|
||||
from giant.workflow import tasks
|
||||
from giant.workflow.spec import parse_spec
|
||||
|
||||
CONDOR = {
|
||||
"accounting_group": "cms",
|
||||
"repo_dir": "/work/lbogner/giant",
|
||||
"env_script": "/work/lbogner/giant/condor_env.sh",
|
||||
}
|
||||
|
||||
RAW = {
|
||||
"workflow": {"name": "wf", "result_dir": "/results/wf"},
|
||||
"condor": CONDOR,
|
||||
"dataset": {"steps": "/data/train", "reference": "/data/holdout"},
|
||||
"train": [
|
||||
{"name": "base", "epochs": 3, "gpu_memory_mb": 20000},
|
||||
{"name": "router", "epochs": 2},
|
||||
],
|
||||
"rollout": [
|
||||
{"name": "base", "train": "base"},
|
||||
{"name": "router", "train": "router"},
|
||||
],
|
||||
"analysis": [{"name": "cmp", "rollouts": ["base", "router"], "chunks": 4}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec():
|
||||
s = parse_spec(RAW)
|
||||
tasks.set_spec(s)
|
||||
return s
|
||||
|
||||
|
||||
def _requires(task):
|
||||
return list(task.requires() or [])
|
||||
|
||||
|
||||
def test_epoch_chain_is_linear_and_rooted_at_warm_cache(spec):
|
||||
h = spec.train_hash("base")
|
||||
third = tasks.TrainEpochTask(name="base", spec_hash=h, milestone=3)
|
||||
second = _requires(third)
|
||||
assert [type(t) for t in second] == [tasks.TrainEpochTask]
|
||||
assert second[0].milestone == 2
|
||||
first = _requires(second[0])[0]
|
||||
assert first.milestone == 1
|
||||
root = _requires(first)
|
||||
assert [type(t) for t in root] == [tasks.WarmCacheTask]
|
||||
# the warm cache is keyed by its own hash, not the training's
|
||||
assert root[0].spec_hash == spec.warm_cache_hash("base")
|
||||
|
||||
|
||||
def test_epoch_task_outputs_last_pt_per_milestone(spec):
|
||||
h = spec.train_hash("base")
|
||||
path = tasks.TrainEpochTask(name="base", spec_hash=h, milestone=2).output().path
|
||||
assert path == f"/results/wf/train_epoch/name=base/spec_hash={h}/epochs=2/last.pt"
|
||||
|
||||
|
||||
def test_train_task_requires_final_epoch_and_publishes_canonical_outputs(spec):
|
||||
h = spec.train_hash("base")
|
||||
train = tasks.TrainTask(name="base", spec_hash=h)
|
||||
(dep,) = _requires(train)
|
||||
assert isinstance(dep, tasks.TrainEpochTask) and dep.milestone == 3
|
||||
out = train.output()
|
||||
assert set(out) == {"best.pt", "last.pt", "metrics.csv"}
|
||||
assert out["best.pt"].path == f"/results/wf/train/name=base/spec_hash={h}/best.pt"
|
||||
# local: it only copies files around, no reason to queue a job for it
|
||||
assert train.batch_system == "local"
|
||||
|
||||
|
||||
def test_rollout_requires_training_geometry_and_reference(spec):
|
||||
ro = tasks.RolloutTask(name="base", spec_hash=spec.rollout_hash("base"))
|
||||
deps = _requires(ro)
|
||||
assert [type(d) for d in deps] == [tasks.TrainTask, tasks.GeometryOracleTask, tasks.DatasetTask]
|
||||
assert deps[0].name == "base"
|
||||
assert deps[2].path == "/data/holdout"
|
||||
out = ro.output()
|
||||
assert out["rollout.yaml"].path.endswith("rollout.yaml")
|
||||
# the sidecar sits next to the parquet — the deterministic path
|
||||
# `giant rollout --out` now produces
|
||||
assert out["rollout.yaml"].path[: -len(".yaml")] == out["rollout.parquet"].path[: -len(".parquet")]
|
||||
|
||||
|
||||
def test_analysis_prep_requires_every_named_rollout(spec):
|
||||
prep = tasks.AnalysisPrepTask(name="cmp", spec_hash=spec.analysis_hash("cmp"))
|
||||
deps = _requires(prep)
|
||||
assert [d.name for d in deps] == ["base", "router"]
|
||||
assert all(isinstance(d, tasks.RolloutTask) for d in deps)
|
||||
assert prep.batch_system == "local"
|
||||
|
||||
|
||||
def test_compute_job_enumeration_collapses_non_chunkable_specs(spec):
|
||||
jobs = tasks.analysis_jobs(spec, "cmp")
|
||||
non_chunkable = [i for i in catalog_ids() if not get_plot_spec(i).chunkable]
|
||||
expected = (len(catalog_ids()) - len(non_chunkable)) * 4 + len(non_chunkable)
|
||||
assert len(jobs) == expected
|
||||
assert non_chunkable, "expected some chunkable=False specs in the catalog"
|
||||
for spec_id in non_chunkable:
|
||||
assert [c for i, c in jobs if i == spec_id] == [0]
|
||||
|
||||
|
||||
def test_compute_output_matches_the_on_disk_contract(spec):
|
||||
h = spec.analysis_hash("cmp")
|
||||
task = tasks.AnalysisComputeTask(name="cmp", spec_hash=h, plot_id="event_mean_length", chunk=2)
|
||||
assert task.output().path == (
|
||||
f"/results/wf/analysis/name=cmp/spec_hash={h}/reduced_partial/event_mean_length__2.json"
|
||||
)
|
||||
(dep,) = _requires(task)
|
||||
assert isinstance(dep, tasks.AnalysisPrepTask)
|
||||
|
||||
|
||||
def test_render_requires_every_compute_job_and_runs_locally(spec):
|
||||
render = tasks.AnalysisRenderTask(name="cmp", spec_hash=spec.analysis_hash("cmp"))
|
||||
deps = _requires(render)
|
||||
assert len(deps) == len(tasks.analysis_jobs(spec, "cmp"))
|
||||
assert render.batch_system == "local" # the only step importing plotstyle/LaTeX
|
||||
assert render.output().path.endswith("/plots/metadata.yaml")
|
||||
|
||||
|
||||
def test_workflow_task_wraps_every_analysis(spec):
|
||||
deps = _requires(tasks.WorkflowTask(workflow_name="wf"))
|
||||
assert [(type(d), d.name) for d in deps] == [(tasks.AnalysisRenderTask, "cmp")]
|
||||
|
||||
|
||||
def test_workflow_without_analysis_falls_back_to_rollouts():
|
||||
raw = {k: v for k, v in RAW.items() if k != "analysis"}
|
||||
tasks.set_spec(parse_spec(raw))
|
||||
deps = _requires(tasks.WorkflowTask(workflow_name="wf"))
|
||||
assert [type(d) for d in deps] == [tasks.RolloutTask, tasks.RolloutTask]
|
||||
|
||||
|
||||
def test_gpu_settings_carry_remote_ceph_and_pins(spec):
|
||||
settings = tasks.TrainEpochTask(name="base", spec_hash=spec.train_hash("base"), milestone=1).htcondor_settings
|
||||
assert settings["+RemoteJob"] == "True"
|
||||
assert settings["RequestGPUs"] == 1
|
||||
assert "TARGET.ProvidesEtpCeph =?= True" in settings["requirements"]
|
||||
assert "TARGET.GPUs_GlobalMemoryMb >= 20000" in settings["requirements"]
|
||||
assert settings["accounting_group"] == "cms"
|
||||
assert settings["docker_image"] == "mschnepf/slc7-condocker"
|
||||
|
||||
|
||||
def test_cpu_settings_used_for_analysis_compute(spec):
|
||||
task = tasks.AnalysisComputeTask(
|
||||
name="cmp", spec_hash=spec.analysis_hash("cmp"), plot_id="event_mean_length", chunk=0
|
||||
)
|
||||
settings = task.htcondor_settings
|
||||
assert settings["docker_image"] == "cverstege/alma9-gridjob"
|
||||
assert "RequestGPUs" not in settings
|
||||
# no run_meta.json yet (prep hasn't run), so no walltime is claimed
|
||||
assert "+RequestWalltime" not in settings
|
||||
|
||||
|
||||
def test_cpu_settings_local_files_use_provides_etp_resources():
|
||||
raw = {**RAW, "condor": {**CONDOR, "remote": False}}
|
||||
spec = parse_spec(raw)
|
||||
tasks.set_spec(spec)
|
||||
settings = tasks.GeometryOracleTask(spec_hash=spec.geometry_hash()).htcondor_settings
|
||||
assert settings["requirements"] == "TARGET.ProvidesETPResources"
|
||||
assert "+RemoteJob" not in settings
|
||||
|
||||
|
||||
def test_missing_dataset_fails_immediately(spec):
|
||||
with pytest.raises(FileNotFoundError, match="/ceph"):
|
||||
tasks.DatasetTask(path="/data/train").complete()
|
||||
|
||||
|
||||
def test_dataset_that_exists_is_complete(tmp_path, spec):
|
||||
(tmp_path / "steps.parquet").write_text("")
|
||||
assert tasks.DatasetTask(path=str(tmp_path / "steps.parquet")).complete()
|
||||
@@ -147,26 +147,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a1/70ebfffd6c6edc6034a547838ee46287c65ed89f710592ddc39c76b4a5a8/awkward_cpp-53-cp314-cp314t-win_arm64.whl", hash = "sha256:1be0c1d87d9f4fdf94b767a061df849f1bb21579d302b2996fb101527fc80a97", size = 551257, upload-time = "2026-06-08T12:31:56.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "b2luigi"
|
||||
version = "1.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "colorama" },
|
||||
{ name = "gitpython" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "luigi" },
|
||||
{ name = "parse" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "webdavclient3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/0c3e0602b6cf80a2cfebbe54c330f3623b4de227c2b0f8cbff437dc8d62c/b2luigi-1.2.9.tar.gz", hash = "sha256:3f6734b06970cd5bf6bb62c45c095e9c02b6e283a45b76a29ffabdeaa8fce0c0", size = 786252, upload-time = "2026-04-17T16:38:23.316Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/82/1a8d3c20bc235e665300c42783eb8bf9f2aac49c7eae644273191e5e01e8/b2luigi-1.2.9-py3-none-any.whl", hash = "sha256:9ca28b4203f5946394b609da432e53b3c9e35540c840bdc6497cce6ab4c82e2e", size = 115103, upload-time = "2026-04-17T16:38:21.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bracex"
|
||||
version = "3.0.1"
|
||||
@@ -196,15 +176,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/0b/5885530f79d4400368b9d4dcb9b39274c0d52e633f7871e7fc6feceea1e3/bump_my_version-1.5.1-py3-none-any.whl", hash = "sha256:df3e2989d0d7fe704718feb24a5880f089b6b6369e427a4445b89c3adebfcff1", size = 65090, upload-time = "2026-08-06T14:26:37.083Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
@@ -634,15 +605,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "executing"
|
||||
version = "2.2.1"
|
||||
@@ -713,7 +675,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "giant"
|
||||
version = "0.3.10"
|
||||
version = "0.3.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
@@ -766,14 +728,10 @@ geometry = [
|
||||
wandb = [
|
||||
{ name = "wandb" },
|
||||
]
|
||||
workflow = [
|
||||
{ name = "b2luigi" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "awkward", marker = "extra == 'convert'", specifier = ">=2.6,<3" },
|
||||
{ name = "b2luigi", marker = "extra == 'workflow'", specifier = ">=1.0,<2" },
|
||||
{ name = "bump-my-version", marker = "extra == 'dev'", specifier = ">=1.2,<2" },
|
||||
{ name = "giant", extras = ["convert", "analysis", "geometry", "wandb"], marker = "extra == 'dev'" },
|
||||
{ name = "git-cliff", marker = "extra == 'dev'", specifier = ">=2,<3" },
|
||||
@@ -799,7 +757,7 @@ requires-dist = [
|
||||
{ name = "uproot", marker = "extra == 'convert'", specifier = ">=5.3,<6" },
|
||||
{ name = "wandb", marker = "extra == 'wandb'", specifier = ">=0.16,<1" },
|
||||
]
|
||||
provides-extras = ["cpu", "cuda", "dev", "geometry", "wandb", "convert", "analysis", "workflow"]
|
||||
provides-extras = ["cpu", "cuda", "dev", "geometry", "wandb", "convert", "analysis"]
|
||||
|
||||
[[package]]
|
||||
name = "git-cliff"
|
||||
@@ -821,30 +779,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/b2/99fac50978b9a90bfec0f1b89354a667ec83f4990301f6c708abce05484e/git_cliff-2.13.1-py3-none-win_amd64.whl", hash = "sha256:856d831a0bede9c258229dbd4d4c2b1c0810d8fce3d3882729669e8dc09c72bf", size = 7714969, upload-time = "2026-04-26T10:33:40.163Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitdb"
|
||||
version = "4.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smmap" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.60"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/14/e6b1a48d831755a53c2029351fcef82e70db4a08f338daefe29d8d0cf31c/gitpython-3.1.60.tar.gz", hash = "sha256:e936431879fa85581b4311fa63492ea52251909e2d655b6529c704c904ddcc24", size = 230793, upload-time = "2026-08-25T18:33:46.102Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/63/ba28697918b7c190af9f3f21940d03e8814e25dd4ddd39d6929f3a553995/gitpython-3.1.60-py3-none-any.whl", hash = "sha256:39548bffb8fa0f3a548133348868bb4838e79d73283052207dc97781a569b6b4", size = 221893, upload-time = "2026-08-25T18:33:44.75Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -1139,141 +1073,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lockfile"
|
||||
version = "0.12.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "luigi"
|
||||
version = "3.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-daemon" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tornado" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/df/5235521bd30402e19e469b79c9e91757046014a3d9bc2c91fd341b0edcb6/luigi-3.7.3.tar.gz", hash = "sha256:82e3a5d482e9738f4dcae5acb631ee7ffa748896cb1a1d5d4922c2cac61903fe", size = 1295810, upload-time = "2026-02-12T01:49:34.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/42/a311f89099c5714dc9c69368d4197a99d453d5f3764713eb5c09b8a8cf18/luigi-3.7.3-py3-none-any.whl", hash = "sha256:f6aaac2417e28a833d30e6b1cd03fe53b2254024dd861fa104ab342cc4f3b843", size = 1186711, upload-time = "2026-02-12T01:49:32.78Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/a9/970b8fa0ecc4fbf1dfaed0d89bbc1fc1421b25ec26a2038c91e872dc6c8e/lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18", size = 4210626, upload-time = "2026-08-19T04:58:15.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/55eb54507073089ab27743c5da2113c84f0d0b1715b33175fdd943c9652d/lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237", size = 8602111, upload-time = "2026-08-19T04:58:28.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bf/6332f45d78da385bb01d5cac3fe4acda19f025d1307cbc7ad538355fecbb/lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3", size = 4638376, upload-time = "2026-08-19T04:58:41.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e0/21fba0fe74d417fbe976903ae6bc77e92cdce01aae7b636abd87756f4588/lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40", size = 4939689, upload-time = "2026-08-19T04:58:48.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/e5/ce3e885264fdd0bdcb6b49c1ea1842f94281b39e4ff956099e8d57532c60/lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd", size = 5105185, upload-time = "2026-08-19T04:59:15.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b6/990a8446c488c70fa25681e150de94b7bf2eaaf387e374d195ab3c8faafb/lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99", size = 5011863, upload-time = "2026-08-19T04:59:50.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6a/f70f41363dae27e3bfd6224b128f5ba150874bd32ca4938552930ffa33b0/lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9", size = 5638234, upload-time = "2026-08-19T05:00:00.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/a65b64f34d556925faef2c4f14167d58c571bc15a3e1f2bba71138830562/lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960", size = 5244532, upload-time = "2026-08-19T05:00:07.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a9/471552e015e954fc9d960aa27c3d67ebf489683d03f033399a790417c67c/lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5", size = 5358194, upload-time = "2026-08-19T05:00:22.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0f/bc6248fbec2cc416f102b1267f1567e07510f6fa909bbe8cd2a22d6fb78e/lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185", size = 4704432, upload-time = "2026-08-19T05:00:51.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3f/cec859f50e63f1fa338fab43d2362d7543e1237f2475960d8ab0769de0eb/lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9", size = 5255038, upload-time = "2026-08-19T05:00:58.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/d9/2ced0cf2967115f92a1b8b3ae6bd18763abc3ebef88c98cf25145fda396c/lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003", size = 5054481, upload-time = "2026-08-19T05:01:10.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f5/4f07386d3c88673daeec3b8cc09a2a4d39fa01c1fc49009791b0746d97fa/lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42", size = 4785535, upload-time = "2026-08-19T05:01:18.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5a/f4fe3ecbc189f48fba2547c5db5c940a10151d3e86b856a60a533a77e816/lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70", size = 5655337, upload-time = "2026-08-19T05:01:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/f586aa1bf27bfbace2dfdbb704da5c52f0bdece8ee440c8fb4946c940b2e/lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f", size = 5245778, upload-time = "2026-08-19T05:01:45.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/a1/677494bbaef4d6db5e4633af817414f478865850b55c03ae4bf70fa7b8ca/lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313", size = 5267274, upload-time = "2026-08-19T05:01:57.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/71/b71425b8764d4cb7c92eb970483be7d5610dce2a6316242b5aaae7d260be/lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3", size = 3602563, upload-time = "2026-08-19T05:02:01.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/fb/909584e16d2148c1a252cc2c32dd99fe0e2682459c586d3d7a192e74a0ae/lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f", size = 4005965, upload-time = "2026-08-19T05:02:07.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8d/41207c9212caad0b52749e34739fb9bfab67486729f52a8fe9bd9266fee6/lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49", size = 3666641, upload-time = "2026-08-19T05:02:11.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2a/e9651f47a31a60b5cae031abc23391ed9aa30c8fc07571d1a38f58d6d770/lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4", size = 8590165, upload-time = "2026-08-19T04:58:40.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/87/a8098abaf35118767d1703b84c98940a5d833064e0eca39a00ecfe9840ab/lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0", size = 4632474, upload-time = "2026-08-19T04:58:47.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cc/fe74d1def7f4fb967c4a825608a074d4dbdbb871b0d6bd59c6ed07d67868/lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c", size = 4936196, upload-time = "2026-08-19T04:59:03.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ad/b96e6ca926e26726a99aa643602aac7411ecc1731ddb1b25af8cc57edfcd/lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a", size = 5093290, upload-time = "2026-08-19T04:59:17.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/84/616f5d3b7cd086fcfba3e5add6fccda67f976c1c753ae9ed7bbd317cb9be/lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871", size = 4998767, upload-time = "2026-08-19T04:59:28.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/88/d5b453a8d083483c9442ad7f5ac5c560796022eb5c80d60b65d75e449236/lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9", size = 5626717, upload-time = "2026-08-19T04:59:40.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/45/31e5aa4d4bae024908ba1d03480c7425cf027a28b7e5c88d1b7202bd80cc/lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f", size = 5232330, upload-time = "2026-08-19T04:59:46.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5a/2627912420df8b2d31ba3014da5539f15ec85add01d42048864ffefda516/lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372", size = 5347054, upload-time = "2026-08-19T04:59:52.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/86/54ac0f529b22a8f12313726dd49e12961bb46471d9028cc28d2a29408f0b/lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c", size = 4707275, upload-time = "2026-08-19T05:00:04.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/42/ffcdc6e4519be90df907cdae7e88409efb25d823ae4de8846f737dae1884/lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b", size = 5240071, upload-time = "2026-08-19T05:00:19.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/5b1d7ab35f013f1127ec48f3108319f58b65b00d5cb26f215adbe86eadfb/lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a", size = 5050356, upload-time = "2026-08-19T05:00:27.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/57/1cf049d054189b55c8fe8012269234f6602256949b69cd3ba80608a88219/lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa", size = 4780394, upload-time = "2026-08-19T05:00:39.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ad/064488a8fa60e639fd773e421a18bf17541d02a95fbf36238ad7c65f69d4/lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1", size = 5645854, upload-time = "2026-08-19T05:03:42.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/bb/120e56f3cf1c149bb3b014278fb86d0a6dd552403981081f0ee0a0a57be7/lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20", size = 5231132, upload-time = "2026-08-19T05:03:45.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2c/7d49aab893c128671a3276580074cce4c002896145b8dd2893da79633bca/lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881", size = 5256076, upload-time = "2026-08-19T05:03:48.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/28/ddea3aa1fa9acfd384fe34d4a2a93eecc07541dd2d922fa9b140c60d8014/lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c", size = 3602177, upload-time = "2026-08-19T05:03:50.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/7a/96bac167538748cae2544335855f812fa33e49a9a67bc8b8520dcbd592bd/lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5", size = 4004117, upload-time = "2026-08-19T05:03:53.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/24/9498fa3c84135956e5ef55ea4d8bd11e999e381f7f210fb6f8c6a980ef03/lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73", size = 3665412, upload-time = "2026-08-19T05:03:55.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b5/728b0578791b397ace8d1b101c8b3fe10f36043542f7bb85f82d8bdc3f50/lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29", size = 8609651, upload-time = "2026-08-19T04:58:42.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/6b/49209fa6225c15c48a30061f03d3aba75e3c19634813b88bf83b88c525ed/lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b", size = 4639588, upload-time = "2026-08-19T04:59:01.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/86/80bae4e8bc2eed9d6f017701a3d86fdea56936218efa738911d0b76aa7f4/lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324", size = 4964846, upload-time = "2026-08-19T04:59:08.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ce/4782caee7a22959c1ac67cb46495e03912c22a4ba7d20c163496a519e815/lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd", size = 5099288, upload-time = "2026-08-19T04:59:18.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/21/f120967cc43b54e05512dff0c39726b832c836195d30f41f88733ef36ac8/lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a", size = 5036837, upload-time = "2026-08-19T04:59:47.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ba/8005e9f47598e3ec5c18312c77f94e889580027616678848405c6aeba5de/lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03", size = 5658569, upload-time = "2026-08-19T04:59:54.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ba/add33b3c7ce51462cf7a4637bcfec2eaa258364d6015b989dd7d1216e6a6/lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999", size = 5246003, upload-time = "2026-08-19T04:59:59.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b3/a43012748fb861c914c5eac1c1a3bad44282e767499cd02280d4d1edf092/lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645", size = 5354047, upload-time = "2026-08-19T05:00:21.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cb/813021d9a445713b8d758b9e5eae2ed392cd598d9f119d9b053b37c2ab93/lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6", size = 4704382, upload-time = "2026-08-19T05:00:47.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c9/1155299f4577bebf3c280497534a73e4b8ad8cab3b96074731ad10949d4e/lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935", size = 5258530, upload-time = "2026-08-19T05:01:14.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6e/d76e58384b378b877e140e25b9a9835da00035f81ff70cbe943a3749bf27/lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b", size = 5089919, upload-time = "2026-08-19T05:01:33.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b7/898013c0f8891481d0624ab3bd5dd8c8ff827232dfee2a5d1f8bf970a7cc/lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a", size = 4741972, upload-time = "2026-08-19T05:01:38.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/47/efb53c4d7b655831c03317a450d9da439b0829c61f34d9d4fe7c863445d6/lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a", size = 5683241, upload-time = "2026-08-19T05:02:00.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0a/0ff36a584cbba14a71326ee8a5300694400f0b97927d1f90a87d95b17d4a/lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68", size = 5245892, upload-time = "2026-08-19T05:02:06.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/9e/303717a1aa56d4bd775c91936717d3c9e8d999a8e8b68b00979c4c1f93d0/lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b", size = 5269528, upload-time = "2026-08-19T05:02:09.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/c2/2ae7cb97089eb86bf0689516db3cf280a007b6145853d2a0235a1f01683d/lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605", size = 3662743, upload-time = "2026-08-19T05:03:02.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/13/a3d483230a09201e211ceb1aa208b1374d27d23b8b180d74dba14b30f6b3/lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d", size = 4073942, upload-time = "2026-08-19T05:03:04.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/f1/c1445d4b6ad7c51e39d4e2ebbf015a4880f5b297a4ab0e77e4d0e5b70110/lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63", size = 3749235, upload-time = "2026-08-19T05:03:07.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/eb/598c76f4ce19a67c635e86a46d880cc854f308f39a6f1fdf13bbb01813ec/lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222", size = 8860315, upload-time = "2026-08-19T05:02:14.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/c7/1f9fac7b566a86ad0da13dcc0259164266469c0ad86744c740ccd5c2a081/lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd", size = 4755176, upload-time = "2026-08-19T05:02:18.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/1b/cfda9307388d496e7eeb7493d9455896b8137ed95f51f3d6ae6ddcc14a47/lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e", size = 4979444, upload-time = "2026-08-19T05:02:21.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/71/f732c8919c45b7f29acf443288c6e90036877a67bfeeb1acceb0fffa011b/lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923", size = 5115887, upload-time = "2026-08-19T05:02:23.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/00/121d52b944f41e33ea86c62875f902d24982842dc7231ab154ac5a6c6593/lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595", size = 5032418, upload-time = "2026-08-19T05:02:26.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/19/cadb73c7fe48c7563dc8ab62ea53d5b920c8911bfb808507a6daa82e78d2/lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e", size = 5603304, upload-time = "2026-08-19T05:02:28.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/32/9de126a14d5a5db8c371c5ec869178417db226707b62a47273a95ae6df7f/lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88", size = 5228938, upload-time = "2026-08-19T05:02:30.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9b/22dd9e843629ed04652591fb220eb2bf2394d97be3be377d60d8083405d7/lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118", size = 5317790, upload-time = "2026-08-19T05:02:33.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/2c/b12a1dc121f81c280635c721c7bcaa341441fcbe37397f60b8915048aece/lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68", size = 4646468, upload-time = "2026-08-19T05:02:35.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/41/fd87a41edc531e7969c25ab1d6b52b5b041eb108b88f6394d6afb4374396/lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852", size = 5240607, upload-time = "2026-08-19T05:02:37.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/30/713ba813b6e6673c6dc34733746516017efcd17949b767b154cc50bccf20/lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a", size = 5086495, upload-time = "2026-08-19T05:02:40.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f8/6532ce0fecd9c326d06b08274ee075cc28dbc9f5e9285355db8504689114/lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b", size = 4758801, upload-time = "2026-08-19T05:02:45.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b6/5a1f7833ebaa0dd33c28f6f9755ec6ff3891bf63f097634b44e6da1bb65e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1", size = 5626977, upload-time = "2026-08-19T05:02:48.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/20/6ae0fc1b45e20877cdcfb1168ceeaf9abb0fba5ed36bd639a260e7b2101e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d", size = 5235036, upload-time = "2026-08-19T05:02:50.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b4/2bc7b37fbb990ccfb7d30393660741592177224a94e07d842c8da70638e8/lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45", size = 5252270, upload-time = "2026-08-19T05:02:53.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0b/07fb8e1dee29a78e2c5fa5c6c914218be76a6406baff27907429566e90ec/lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b", size = 3902666, upload-time = "2026-08-19T05:02:55.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ab/3371527bd9820aae6f511697c93032ed197b0d8dab0f17818f18d3099637/lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c", size = 4401011, upload-time = "2026-08-19T05:02:57.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/bb/e6de9b2546a4e6df4fb52fb18921906a8b7a041aba06570995759a4d6d8b/lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d", size = 3823384, upload-time = "2026-08-19T05:03:00.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/83/7ff98683e14a148191278728d11ba782c3d5137886d49fd95ab4036efa1b/lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e", size = 8609183, upload-time = "2026-08-19T04:58:32.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/39/c39f05e8240e98009dd3d4ceb248319d0f36467babc5f90a909ed0c5b68a/lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a", size = 4639898, upload-time = "2026-08-19T04:58:39.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/bf/25e26b089510940a0777ab334357874569255e50930224c8159cd649e754/lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef", size = 5037527, upload-time = "2026-08-19T04:58:46.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/6d/aed3a58a3d662f7367a537fabe8c549f1446dbd043719e0ae8cd53f47819/lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19", size = 5661918, upload-time = "2026-08-19T04:59:02.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ca/706d32b6957c0c2e005a9833e8fc528449196b38d5cfcf9e0fd86a96fb00/lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f", size = 5249359, upload-time = "2026-08-19T05:04:01.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/e9/445ff43f56fcffa06f6f3a7189920c216f3eacef68ef834d4111cdbd86ba/lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79", size = 4704548, upload-time = "2026-08-19T05:04:04.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/78/20b8b7e79a1b1d9cd4465c332d62962858562b446692f16a27068fa54b85/lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537", size = 5261170, upload-time = "2026-08-19T05:04:07.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ca/84a0e1148bf511e12e0d99732a4e136a3bf1b91622f0a1b197796e2ff984/lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453", size = 5090576, upload-time = "2026-08-19T05:04:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/f8/1ef6fc7070bed8753315f2e4ea66bc0d37620e1444d014db7f0267b8faaf/lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9", size = 4744614, upload-time = "2026-08-19T05:04:12.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f6/3a4824cd1c1b81d996d2d75bbd176ba13fbe9b5d89489290d93ff9558486/lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00", size = 5685792, upload-time = "2026-08-19T05:04:15.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/9a/f133bf16a67149e00ca5d8a8f1ae662c30a86c303aa242693b67f8e19856/lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7", size = 5248972, upload-time = "2026-08-19T05:04:18.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/63/273e7e8a73a5d183d8552dfdaa131dfda0292ddab7bcddc5a66a0ae525d8/lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060", size = 5271809, upload-time = "2026-08-19T05:04:21.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/eb/614117c36a28909e79ff7cdec87008f0bd996478f35cf72309189cf398b1/lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f", size = 3662854, upload-time = "2026-08-19T05:05:07.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e9/06aee6107cf8e7b870f10f82539f366cba10dc6053144cca80e838caf8c8/lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b", size = 4074590, upload-time = "2026-08-19T05:05:09.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/bf/dad9b6baf9b26d79584834e15cef2a5dd0a13c7b1df08831e8f18244b494/lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa", size = 3749336, upload-time = "2026-08-19T05:05:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9d/cd0c43d45e2eb52df7735c6558f24054ca633499191899b0cb9040fbbc3c/lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86", size = 8857688, upload-time = "2026-08-19T05:04:24.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/26/27093dc1a9edbdd8a54652f237a387f7e63ec0192efe708bc2576d8a1383/lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111", size = 4754422, upload-time = "2026-08-19T05:04:27.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ee/502f7c93507f57eb496744a64da8f4ca86855cf88e48d14584342f1bfd92/lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41", size = 5033396, upload-time = "2026-08-19T05:04:30.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/72/c4cbbe72f951650f2afe43a70e51687e111d82b9bec46e3310ea76419d46/lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715", size = 5615298, upload-time = "2026-08-19T05:04:32.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/83/a3df966d6d7b6513e9dfb6fbfb041c0619642170359c1b36ab20a83e59eb/lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818", size = 5236282, upload-time = "2026-08-19T05:04:35.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/85/8692ec8173c9f8d295735b9bf410d202317e7b3ed11141e80a30f421f409/lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5", size = 4650647, upload-time = "2026-08-19T05:04:38.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/e7/dbe3cece28a5bf82997a091d9dbb0fc49e725a5fa86550897ee2cf6412e6/lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb", size = 5243387, upload-time = "2026-08-19T05:04:41.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a9/81a2d27640db0d27200b2f32339a54e74c36d58feb5ad528b87d52a59ecc/lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9", size = 5092624, upload-time = "2026-08-19T05:04:43.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f4/0b0304c70c087f618d95b0306738b070bd556afd09c2c92589b78dbe5eb0/lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1", size = 4758742, upload-time = "2026-08-19T05:04:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ec/f9fc45f1d01b632b673e11880e75292dff9953db9f426d1a38201b8eb5f5/lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e", size = 5649540, upload-time = "2026-08-19T05:04:49.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0b/d65e0458c2bcce0df68d5cc29ad0006e76446f02d9e50caf188fd1fb8bae/lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c", size = 5234869, upload-time = "2026-08-19T05:04:52.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/62/1fee828238badd3bfe9544f5cc9ce6ded421ef38e9634030445dedd78b36/lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088", size = 5259992, upload-time = "2026-08-19T05:04:56.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/18/35fb14dd6baccbffa6daeb2369802f04a94e3f73db3c7bb405dbab009729/lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6", size = 3901151, upload-time = "2026-08-19T05:04:58.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b6/07530896ca062bc3d2f09d5cb8a48e799c05b12c496205db03159ba13b6c/lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0", size = 4395355, upload-time = "2026-08-19T05:05:01.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a8/237d8de1d77085cfd41d0c6049a044d8d01886f3afb7f1eda2f43d900a96/lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b", size = 3822823, upload-time = "2026-08-19T05:05:04.63Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
@@ -1719,15 +1518,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse"
|
||||
version = "1.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911", size = 36970, upload-time = "2026-05-26T03:44:52.624Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32", size = 20925, upload-time = "2026-05-26T03:44:51.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parso"
|
||||
version = "0.8.7"
|
||||
@@ -2166,20 +1956,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-daemon"
|
||||
version = "2.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docutils" },
|
||||
{ name = "lockfile" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/fb/a280d65f81e9d69989c8d6c4e0bb18d7280cdcd6d406a2cc3f4eb47d4402/python-daemon-2.1.2.tar.gz", hash = "sha256:261c859be5c12ae7d4286dc6951e87e9e1a70a882a8b41fd926efc1ec4214f73", size = 76176, upload-time = "2016-10-26T10:16:01.909Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/59/816004688f8e8602526553cd96226f34657ce4a86daa2240c3eebb0568a3/python_daemon-2.1.2-py2.py3-none-any.whl", hash = "sha256:53da55aec3bb67b576e13a8091a2181f99b395c2eec32a5a0d91d347a5c420a7", size = 19425, upload-time = "2016-10-26T10:16:05.441Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -2472,15 +2248,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "84.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@@ -2499,15 +2266,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smmap"
|
||||
version = "5.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stack-data"
|
||||
version = "0.6.3"
|
||||
@@ -2545,15 +2303,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/24/84ce997e8ae6296168a74d0d9c4dde572d90fb23fd7c0b219c30ff71e00e/tbb-2021.13.1-py3-none-win_amd64.whl", hash = "sha256:cbf024b2463fdab3ebe3fa6ff453026358e6b903839c80d647e08ad6d0796ee9", size = 286908, upload-time = "2024-08-07T15:09:05.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpoolctl"
|
||||
version = "3.6.0"
|
||||
@@ -2847,20 +2596,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webdavclient3"
|
||||
version = "3.14.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/d8/ca3981053ed553363322f71745f543186b93439b6417f5d6ca91d4b4fec7/webdavclient3-3.14.7.tar.gz", hash = "sha256:6c04252b579bc015cec78081480c63eadf1030f382768248777c6203f059b3f5", size = 30836, upload-time = "2026-02-06T17:54:15.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/5e/0b1c2f494d03c4acbc44567fa68b954cd0fa3f21eb3f9528011da371f9b1/webdavclient3-3.14.7-py3-none-any.whl", hash = "sha256:a904381da8e3ae77b4ca9e11e05058d91a07704254d71c193c797f7c2fb15025", size = 22887, upload-time = "2026-02-06T17:54:14.068Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash"
|
||||
version = "3.7.0"
|
||||
|
||||
Reference in New Issue
Block a user