Initial release: E1 genus probe package moved out of the E01 workflow

- src/custom_models torch modules, store, training, metrics, runner and CLI
  (package-relative imports for pip installability)
- test suite (49 tests) + conftest with fast synthetic-store fixtures
- uv-managed dev env (pyproject + CPU torch index), hatchling build
- README: uv for the package, pip install into conda envs for the workflow
This commit is contained in:
Tom Kasper
2026-09-22 13:22:50 +01:00
commit 88e98effad
18 changed files with 5988 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
dist/
build/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
+261
View File
@@ -0,0 +1,261 @@
# custom_models — E1 genus probe CLI
Torch models and a generalised train/validate/test wrapper for the E1
genus-information-ceiling experiment (GATE ZERO / torch model spec).
`custom-models` is its own pip-installable package (this repo, `src/`
layout, built with hatchling).
## Install
Dev/build environment is **uv** (much faster solver + lockfile):
```bash
uv sync --extra dev # venv at .venv with CPU torch from the uv index
uv run pytest # run the test suite
uv run custom-models build --arch cnn --budget 1000000 --n-classes 11
```
Import into the main workflow's **conda** envs — torch/numpy/polars come
from conda there and already satisfy the requirements, so pip adds only
this package:
```bash
conda run -n e1-torch-cpu pip install /path/to/custom_models
conda run -n e1-torch-cpu python -m custom_models <subcommand> ...
# or with pod5 support for the `store` subcommand:
conda run -n e1-torch-cpu pip install "/path/to/custom_models[pod5]"
```
Run as:
```bash
python -m custom_models <subcommand> ...
```
---
## Subcommands
| Subcommand | Purpose |
|-------------|---------|
| `build` | Resolve a nominal parameter budget into a realised probe geometry; prints JSON. |
| `store` | Build a signal store either from pod5 + labels parquet or a synthetic duty-cycle corpus. |
| `train` | One training run on a store; optionally a shuffled-label control. Writes a run directory. |
| `validate` | Evaluate a run's checkpoint on its validation split (bootstrap CIs). |
| `test` | Evaluate a run's checkpoint on its test split; optionally attach a trap (out-of-bank) store. |
| `gate` | E1-v2 gate verdict: compare candidate run dirs against a control run. |
| `configs` | Round-trip helper: load a config JSON and re-save it (`load_config(save_config(c)) == c`). |
Every subcommand seeds before doing anything; all exit 0 on success, 1 on
usage/runtime errors (message on stderr).
---
## Input: the labels parquet
`store` (pod5 mode) and `extract_store` require a labels table (parquet)
with these columns (order-free; `LABEL_COLUMNS` in `store.py`):
| Column | Type | Role |
|--------------|--------|------|
| `read_id` | str | ONT read id — the join key against the pod5 files. **Must be unique** (duplicates raise `ValueError`). |
| `genus` | str | Class label used by the probe. |
| `species` | str | Fine-grained secondary label (carried through to the manifest; unused by training). |
| `ref_name` | str | Reference sequence name the read was aligned to. |
| `identity` | float | Alignment identity (e.g. minimap2 `id%`), for provenance/filtering elsewhere. |
| `aligned_len`| int | Aligned reference length. |
| `query_len` | int | Read length (basis for the coverage-style ratios). |
| `source_run` | str | Sequencing run the read came from — enables the `holdout_runs` split mode. |
Extra columns are ignored. The genus set actually trained on is chosen at
run time with `train --genera A,B,C` (default: every genus in the store).
## Input: the pod5 files
`store --pod5 FILE [FILE ...] --labels LABELS.parquet --out STORE_DIR`.
Windows are cut per read: skip `--skip-head` samples (adapter/mux
artefacts), then take up to `--max-windows-per-read` contiguous fixed
windows of `--window-samples` samples (default 12,000 ≈ 2.4 s at 5 kHz).
Reads shorter than `--min-read-samples` are dropped. Each window is
normalised (`--norm median_iqr`, the amplitude-erasing default) and
written float16 (`--dtype`).
## Output: the signal store layout
```text
STORE_DIR/
├── store_meta.json # extraction stats, counts, dropped-read accounting
├── manifest.parquet # per-window table:
│ # window_id, read_id, genus, species, source_run,
│ # window_index, n_samples
└── shard_%05d.npy # float16 windows, one file per --shard-windows windows
```
Both training paths (real + synthetic) read through `SignalStore`, which
validates the manifest columns on open.
---
## `train` and the option model
`train` accepts a previous run's `config.json` as `--config` base;
explicit flags override it. Resolution order per field:
```
explicit flag > base config value > spec default
```
This makes variants (ablations, holdout robustness, retraining,
relocation of the run dir) reproducible by editing one or two flags.
### Model parameters (`models.py` role)
| Flag | Default | Role |
|------|---------|------|
| `--arch` | `cnn` | Encoder family: `cnn` (patch-embed + residual conv blocks + gated mean pool) or `linatt` (same conv stack tokenised, linear attention blocks, mean pool). |
| `--budget` | 1,000,000 | Nominal trainable-parameter budget. `build_probe` searches layer widths up to `budget * (1 + --budget-tol)` for ceilings well above the arch's table count. `build`, then the basis for the deterministic search over deeper/wider stacks. |
| `--stride` | 4 | Patch stride in signal samples (token pitch), 4 or 8; also fixes the patch length (4 samples / patch). Smaller stride → more tokens → finer temporal resolution, more compute. |
| `--d-embed` | 256 | Pooled embedding width the encoder terminates in. |
| `--budget-tol` | 0.10 | Relative tolerance; realised params must be `<= (1+tol) × budget`. |
| `--n-heads` | 4 | Attention head count for `linatt` (recorded for exact checkpoint rehydration). |
Realised geometry (`d_model`, `n_layers`, `params_realised`) is filled by
`build_probe` and written into the run's `config.json`; it is
rehydrated, never guessed, when a saved checkpoint is loaded.
### Data parameters
| Flag | Default | Role |
|------|---------|------|
| `--genera` | all in store | Comma-separated class whitelist (e.g. `Bacillus,Listeria`). Genera absent from the store drop out with a warning — the run id records the realised count `gN`. |
| `--val-frac` | 0.10 | Fraction of reads (per genus) used for validation (splits are read-level so windows of one read never straddle a split). |
| `--test-frac` | 0.10 | Fraction of reads for test. |
| `--holdout-runs` | off | Split by `source_run` instead of by read (robustness row: unseen instrument/run). |
| `--min-test-windows` | 300 | Per-genus floor for test windows; `make_splits` refuses to build a split that would starve a genus rather than silently evaluating on nothing. |
| `--data-seed` | 0 | Split permutation seed (independent of the training seed). |
### Training parameters
| Flag | Default | Role |
|------|---------|------|
| `--batch` | 256 | Optimiser batch size (read windows). Train loader uses `drop_last=True`. |
| `--lr` | 3e-4 | Peak AdamW learning rate; the warmup-cosine schedule holds returns to 0 by `--epochs`. |
| `--weight-decay` | 0.01 | Decoupled decay; not applied to norm weights or biases. |
| `--epochs` | 40 | Maximum epochs (early stopping may exit sooner). |
| `--warmup` | 0.05 | Fraction of total optimiser steps spent ramping LR 0→peak. |
| `--smoothing` | 0.0 | Cross-entropy label smoothing. |
| `--amp` | `bf16` | Autocast policy: `bf16` where supported, `fp16` (CUDA + GradScaler) or `off` (fp32; also forced on CPU). Falls back silently to fp32 when unsupported. |
| `--grad-clip` | 1.0 | L2 gradient-norm clip. |
| `--metric` | `val_recall_macro` | Early-stop selection metric (the headline gate metric; `val_loss` is the alternative). |
| `--patience` | 5 | Consecutive epochs without improvement before stopping. |
| `--workers` | 4 | DataLoader worker processes (workers are seeded; Linux pins the `fork` start method because 3.14 default `forkserver` is broken here). |
| `--device` | auto | `cuda` → `mps` → `cpu` resolution; or an explicit torch device string. |
| `--seed` | 0 | Training seed; every stochastic step is seeded from it. |
| `--deterministic` | on | Ensure kernels choose inverse-deterministic ops. |
### Run bookkeeping
| Flag | Default | Role |
|------|---------|------|
| `--store` / `--out-dir` | required (or via base config) | Where the signal store lives and where the run directory is created. |
| `--config` | — | Base `RunConfig` JSON (a previous run's `config.json`). |
| `--run-id` | derived | If empty: `{arch}_{budget_M}M_s{stride}_g{n_genera}` (control adds `-shuf`). |
| `--stage` | `arch_ladder` | Stage tag: `arch_ladder`, `windows_ablation`, `robustness`, `control`, `extra`. |
| `--control` | off | Marks the run as a shuffled-label control (overrides `--stage`). |
| `--notes` | — | Free-text note stored verbatim in `config.json`. |
### Run directory layout
```text
OUT_DIR/
└── RUN_ID/
├── config.json # realised RunConfig (round-trips exactly)
├── ckpt.pt # best-epoch weights (CPU tensors)
├── history.parquet # per-epoch: epoch, lr, train/val loss, val acc, recall, seconds
├── val_report.json # written by `validate`
├── report.json # written by `test`
├── trap_report.json # written by `test` (when --trap-store is given)
└── gate_verdict.json # written by `gate` (common parent of the runs)
```
### Control runs
`train --control` permutes the read-to-genus mapping across the selected
pool (seeded, preserving the genus multiset exactly); every window
inherits its read's new label. Applied *before* splitting, so the
control's splits and labels live consistently in the permuted world and
a trained control should land at chance accuracy (1/n_classes). Control
runs exist so the gate has a leakage-litmus baseline.
---
## `validate` / `test` / `gate`
```bash
python -m custom_models validate --run-dir OUT_DIR/RUN_ID [--store S] [--n-boot 10000]
python -m custom_models test --run-dir OUT_DIR/RUN_ID [--store S] [--trap-store T] [--n-boot]
python -m custom_models gate --runs RUN_A RUN_B ... --control OUT_DIR/CNN...shuf [--chance-tol 1.5]
```
`--store` points at a different signal store than the one recorded in
`config.json` (e.g. evaluating a synthetic-trained probe on real data —
cross-run transfer); by default the recorded store is re-used.
`--trap-store` runs the probe against an out-of-class "trap" set during
`test`: the closed-set open-set probe reports max-softmax-probability
(msp) calibration, threshold `tau`, and trap-vs-known msp separation
(FPR/FNR at the chosen `tau`).
`gate` evaluates the closed- and open-set reports of each candidate run
against the E1-v2 gate rules (chance = 1/n_classes from the control's
config):
- The **control** must itself be at chance (recall ≤ `--chance-tol` ×
chance); a control above that is INVALID (suspected label leakage) and
halts the interpretation.
- **PASS**: test recall-macro ≥ 0.90 with ≤ 5M realised params and trap
FPR at tau ≤ 0.05.
- **MARGINAL-TRAP**: recall ≥ 0.90 at ≤ 5M but failing trap FPR — adopt
per-class/margin thresholding.
- **MARGINAL**: recall in [0.70, 0.90) at ≤ 5M, or 0.90 only at ≤ 20M
with the trap criterion satisfied.
- **FAIL**: anything else. Missing trap evaluation degrades PASS to
MARGINAL (the open-set criterion cannot be confirmed).
The verdict (`gate_verdict.json`) is written to the runs' common parent
directory.
---
## Typical workflow
```bash
# 1. store
python -m custom_models store --pod5 run1.pod5 run2.pod5 \
--labels reads.parquet --out data/store_v1
# 2. build (check geometry before training)
python -m custom_models build --arch cnn --budget 1000000 --n-classes 6
# 3. tune the class ladder (train each stage on the same store)
for g in 3 4 5 6; do
python -m custom_models train --store data/store_v1 --out-dir runs \
--arch cnn --budget 1000000 --genera $(head -n $g genera.txt | paste -sd,) \
--stage arch_ladder --notes "information ceiling"
done
# 4. shuffled-label control
python -m custom_models train --store data/store_v1 --out-dir runs \
--arch cnn --budget 1000000 --control
# 5. evaluate + gate (candidates need a `report.json` from `test`)
python -m custom_models test --run-dir runs/cnn_1.0M_s4_g6 --trap-store data/trap
python -m custom_models gate --runs runs/cnn_1.0M_s4_g6 \
--control runs/cnn_1.0M_s4_g6-shuf
```
Run a subset via:
```bash
conda run -n e1-torch-cpu python -m custom_models ...
```
+61
View File
@@ -0,0 +1,61 @@
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "custom-models"
version = "0.1.0"
description = "E1 genus probe: torch models and train/validate/test wrapper"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Tom Kasper" }]
dependencies = [
"numpy>=2.0,<3",
"polars>=1.0",
"torch>=2.6",
]
[project.optional-dependencies]
pod5 = ["pod5"]
dev = ["pytest>=8", "ruff>=0.8", "pod5"]
[project.scripts]
custom-models = "custom_models.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/custom_models"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "D", "B", "RUF", "TRY", "I"]
ignore = [
"TRY003", # long messages are the run-artifact contract here
"TRY004", # ValueError (not TypeError) on config/type misuse: deliberate
"TRY300",
"B905", # explicit strict in zip() is noisy for internal iteration
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D103", "D401"]
# UV: dev/build environment resolves torch from the CPU wheel index so the
# venv stays small. pip installs into the workflow's conda envs ignore this
# (torch comes from conda there and satisfies the requirement).
[tool.uv]
index-strategy = "unsafe-best-match"
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = { index = "pytorch-cpu" }
+78
View File
@@ -0,0 +1,78 @@
"""E1 genus probe: torch models, training wrapper and evaluation (v0.1.0).
Public API re-exports the pieces a run needs: configs (``config``),
determinism (``seed``), the signal store (``store``), splits and loaders
(``data``), the torch modules and budget resolver (``models``), the
training loop and inference (``train``), metrics (``metrics``), run
orchestration and the gate (``runner``) and the CLI (``cli``). The
normative contracts live in ``planning/design_docs/preliminary_
experiments/E1_torch_model_spec.md`` and ``E1_v2_eleven_taxa_redesign.md``.
"""
from __future__ import annotations
from .config import (
DataConfig,
ExtractConfig,
ModelConfig,
RunConfig,
TrainConfig,
load_config,
run_id_for,
save_config,
)
from .data import ProbeDataset, Splits, make_splits, permute_read_labels
from .models import (
ConvEncoder,
GenusProbe,
LinearAttentionEncoder,
SignalPatchEmbed,
build_probe,
count_params,
)
from .seed import make_generator, seed_everything, worker_init_fn
from .store import SignalStore, extract_store, load_labels, synthetic_store
from .train import (
TrainResult,
build_optimizer,
embed_probe,
predict,
select_device,
train_probe,
)
__version__ = "0.1.0"
__all__ = [
"ConvEncoder",
"DataConfig",
"ExtractConfig",
"GenusProbe",
"LinearAttentionEncoder",
"ModelConfig",
"ProbeDataset",
"RunConfig",
"SignalPatchEmbed",
"SignalStore",
"Splits",
"TrainConfig",
"TrainResult",
"build_optimizer",
"build_probe",
"count_params",
"embed_probe",
"extract_store",
"load_config",
"load_labels",
"make_generator",
"make_splits",
"permute_read_labels",
"predict",
"run_id_for",
"save_config",
"seed_everything",
"select_device",
"synthetic_store",
"train_probe",
"worker_init_fn",
]
+8
View File
@@ -0,0 +1,8 @@
"""Module entry point: ``python -m custom_models <subcommand>``."""
from __future__ import annotations
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+571
View File
@@ -0,0 +1,571 @@
"""Generalised train/validate/test CLI for the E1 genus probe.
Subcommands: ``build`` (resolve a budget), ``store`` (build a signal
store from pod5+labels or a synthetic corpus), ``train`` (a full run,
optionally a shuffled-label control), ``validate`` / ``test`` (evaluate a
run directory's checkpoint, ``test`` optionally attaching a trap store),
``gate`` (verdict over run dirs) and ``configs`` (config round-trip).
Every subcommand seeds first (GATE ZERO §2.3); each returns exit code 0
on success and 1 on usage/runtime errors. ``train`` accepts a previous
run's ``config.json`` as a base with any flag overriding it, so variants
(windows ablation, holdout robustness, retraining) stay reproducible.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from .config import (
DataConfig,
ExtractConfig,
ModelConfig,
RunConfig,
TrainConfig,
load_config,
save_config,
)
from .runner import evaluate_gate, evaluate_test, evaluate_val, train_run
def _add_model_args(parser: argparse.ArgumentParser, required: bool) -> None:
"""Register the model-request flags on a subparser.
Args:
parser: Target subparser.
required: Whether ``--arch``/``--budget`` must be given (the
``build`` command demands them; ``train`` falls back to
defaults/base-config values).
"""
parser.add_argument("--arch", choices=["cnn", "linatt"], required=required)
parser.add_argument("--budget", type=int, required=required)
parser.add_argument("--stride", type=int, default=None)
parser.add_argument("--d-embed", type=int, default=None)
parser.add_argument("--budget-tol", type=float, default=None)
parser.add_argument("--n-heads", type=int, default=None)
def _add_data_args(parser: argparse.ArgumentParser) -> None:
"""Register the split-configuration flags on a subparser.
All default to ``None`` so only explicitly passed flags override a
base config (tri-state booleans use ``default=None``).
Args:
parser: Target subparser.
"""
parser.add_argument("--genera", default=None)
parser.add_argument("--val-frac", type=float, default=None)
parser.add_argument("--test-frac", type=float, default=None)
parser.add_argument("--holdout-runs", action="store_true", default=None)
parser.add_argument("--min-test-windows", type=int, default=None)
parser.add_argument("--data-seed", type=int, default=None)
def _add_train_args(parser: argparse.ArgumentParser) -> None:
"""Register the training-semantics flags on a subparser.
Args:
parser: Target subparser.
"""
parser.add_argument("--batch", type=int, default=None)
parser.add_argument("--lr", type=float, default=None)
parser.add_argument("--weight-decay", type=float, default=None)
parser.add_argument("--epochs", type=int, default=None)
parser.add_argument("--warmup", type=float, default=None)
parser.add_argument("--smoothing", type=float, default=None)
parser.add_argument("--amp", choices=["off", "bf16", "fp16"], default=None)
parser.add_argument("--grad-clip", type=float, default=None)
parser.add_argument(
"--metric", choices=["val_recall_macro", "val_loss"], default=None
)
parser.add_argument("--patience", type=int, default=None)
parser.add_argument("--workers", type=int, default=None)
parser.add_argument("--device", default=None)
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--deterministic", action="store_true", default=None)
def _pick(value: Any, base: Any, default: Any) -> Any:
"""Three-way option resolution: flag > base config > default.
Args:
value: The parsed CLI flag (``None`` when not passed).
base: The corresponding value from a base config (``None``
without one).
default: The spec default used last.
Returns:
The first non-``None`` of the three.
"""
if value is not None:
return value
if base is not None:
return base
return default
def _parse_genera(raw: str | None) -> tuple[str, ...]:
"""Parse a comma-separated genus list.
Args:
raw: e.g. ``"Bacillus,Listeria,Staphylococcus"``; ``None`` means
no restriction (use every genus in the store).
Returns:
The trimmed non-empty names as a tuple.
Raises:
ValueError: If the string contains no non-empty names.
"""
if raw is None:
return ()
genera = tuple(g.strip() for g in raw.split(",") if g.strip())
if not genera:
raise ValueError("--genera parsed to an empty list")
return genera
def _run_config_from_args(args: argparse.Namespace) -> RunConfig:
"""Assemble a :class:`RunConfig` from flags plus optional base config.
Resolution order per field: explicit flag, then the base config's
value (when ``--config`` was given), then the spec default.
``--control`` or ``--stage control`` marks the run as the
shuffled-label control.
Args:
args: Parsed ``train`` arguments.
Returns:
The fully resolved run configuration.
Raises:
ValueError: If the base file is not a ``RunConfig`` or neither
flags nor base supply ``--store`` / ``--out-dir``.
"""
base: RunConfig | None = None
if args.config:
base = load_config(args.config)
if not isinstance(base, RunConfig):
raise ValueError(f"{args.config} does not hold a RunConfig")
base_model = base.model if base else None
base_data = base.data if base else None
base_train = base.train if base else None
model = ModelConfig(
arch=_pick(args.arch, base_model.arch if base_model else None, "cnn"),
param_budget=_pick(
args.budget, base_model.param_budget if base_model else None, 1_000_000
),
stride=_pick(args.stride, base_model.stride if base_model else None, 4),
d_embed=_pick(args.d_embed, base_model.d_embed if base_model else None, 256),
budget_tol=_pick(
args.budget_tol, base_model.budget_tol if base_model else None, 0.10
),
n_heads=_pick(args.n_heads, base_model.n_heads if base_model else None, 4),
)
data = DataConfig(
genera=(
_parse_genera(args.genera)
if args.genera is not None
else (base_data.genera if base_data else ())
),
val_fraction=_pick(
args.val_frac, base_data.val_fraction if base_data else None, 0.10
),
test_fraction=_pick(
args.test_frac, base_data.test_fraction if base_data else None, 0.10
),
holdout_runs=_pick(
args.holdout_runs, base_data.holdout_runs if base_data else None, False
),
min_test_windows_per_genus=_pick(
args.min_test_windows,
base_data.min_test_windows_per_genus if base_data else None,
300,
),
seed=_pick(args.data_seed, base_data.seed if base_data else None, 0),
)
stage = base.stage if base else "arch_ladder"
if args.control:
stage = "control"
elif args.stage:
stage = args.stage
device = _pick(args.device, base_train.device if base_train else None, None)
if device in ("auto", ""):
device = None
train = TrainConfig(
batch_size=_pick(args.batch, base_train.batch_size if base_train else None, 256),
lr=_pick(args.lr, base_train.lr if base_train else None, 3e-4),
weight_decay=_pick(
args.weight_decay, base_train.weight_decay if base_train else None, 0.01
),
max_epochs=_pick(
args.epochs, base_train.max_epochs if base_train else None, 40
),
warmup_frac=_pick(
args.warmup, base_train.warmup_frac if base_train else None, 0.05
),
label_smoothing=_pick(
args.smoothing, base_train.label_smoothing if base_train else None, 0.0
),
amp=_pick(args.amp, base_train.amp if base_train else None, "bf16"),
grad_clip=_pick(
args.grad_clip, base_train.grad_clip if base_train else None, 1.0
),
early_stop_metric=_pick(
args.metric, base_train.early_stop_metric if base_train else None, "val_recall_macro"
),
patience=_pick(args.patience, base_train.patience if base_train else None, 5),
num_workers=_pick(
args.workers, base_train.num_workers if base_train else None, 4
),
device=device,
seed=_pick(args.seed, base_train.seed if base_train else None, 0),
deterministic=_pick(
args.deterministic, base_train.deterministic if base_train else None, True
),
)
store_dir = args.store or (base.store_dir if base else None)
if store_dir is None:
raise ValueError("--store (or a base --config with a store_dir) is required")
out_dir = args.out_dir or (base.out_dir if base else None)
if out_dir is None:
raise ValueError("--out-dir (or a base --config with an out_dir) is required")
run_id = args.run_id or (base.run_id if base else "") or ""
return RunConfig(
run_id=run_id,
stage=stage,
store_dir=Path(store_dir),
data=data,
model=model,
train=train,
out_dir=Path(out_dir),
notes=args.notes or (base.notes if base else "") or "",
)
def _cmd_build(args: argparse.Namespace) -> int:
"""Handle ``build``: resolve a budget and print the realised geometry.
Args:
args: Parsed arguments (arch, budget, stride, d-embed,
budget-tol, n-heads, n-classes, seed).
Returns:
Exit code 0 on success.
"""
from .models import build_probe, count_params
from .seed import seed_everything
seed_everything(args.seed)
cfg = ModelConfig(
arch=args.arch,
param_budget=args.budget,
stride=args.stride if args.stride is not None else 4,
d_embed=args.d_embed if args.d_embed is not None else 256,
budget_tol=args.budget_tol if args.budget_tol is not None else 0.10,
n_heads=args.n_heads if args.n_heads is not None else 4,
)
probe, realised = build_probe(cfg, args.n_classes)
payload = {
"arch": realised.arch,
"param_budget": realised.param_budget,
"params_realised": realised.params_realised,
"stride": realised.stride,
"patch_len": min(4, realised.stride),
"d_model": realised.d_model,
"n_layers": realised.n_layers,
"n_heads": realised.n_heads,
"d_embed": realised.d_embed,
"n_classes": args.n_classes,
"count_params": count_params(probe),
}
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
def _cmd_store(args: argparse.Namespace) -> int:
"""Handle ``store``: build a signal store and print its meta.
Args:
args: Parsed arguments; either ``--synthetic`` or both ``--pod5``
and ``--labels`` must be given, plus windowing flags.
Returns:
Exit code 0 on success.
Raises:
ValueError: On an incomplete mode selection (no ``--synthetic``
and no pod5/labels pair).
"""
from .store import extract_store, load_labels, synthetic_store
out = Path(args.out)
if args.synthetic:
meta = synthetic_store(
out_dir=out,
n_genera=args.n_genera,
reads_per_genus=args.reads_per_genus,
window_samples=args.window_samples,
period=args.period,
noise=args.noise,
seed=args.seed,
shard_windows=args.shard_windows,
genus_prefix=args.genus_prefix,
)
elif args.pod5 and args.labels:
cfg = ExtractConfig(
pod5_paths=tuple(Path(p) for p in args.pod5),
window_samples=args.window_samples,
skip_head_samples=args.skip_head,
max_windows_per_read=args.max_windows_per_read,
min_read_samples=args.min_read_samples,
norm=args.norm,
dtype=args.dtype,
shard_windows=args.shard_windows,
seed=args.seed,
)
labels = load_labels(Path(args.labels))
meta = extract_store(cfg, labels, out)
else:
raise ValueError(
"store needs either --synthetic or both --pod5 PATHS... and --labels PARQUET"
)
print(json.dumps(meta, indent=2, sort_keys=True, default=str))
return 0
def _cmd_train(args: argparse.Namespace) -> int:
"""Handle ``train``: run one training run and print its summary.
Args:
args: Parsed arguments (see :func:`_run_config_from_args`).
Returns:
Exit code 0 on success.
"""
cfg = _run_config_from_args(args)
realised, result, _ = train_run(cfg)
tail = result.history.row(result.history.height - 1, named=True)
metric = (
tail["val_recall_macro"]
if realised.train.early_stop_metric == "val_recall_macro"
else tail["val_loss"]
)
run_dir = realised.out_dir / realised.run_id
print(
json.dumps(
{
"run_id": realised.run_id,
"run_dir": str(run_dir),
"arch": realised.model.arch,
"d_model": realised.model.d_model,
"n_layers": realised.model.n_layers,
"params_realised": realised.model.params_realised,
"best_epoch": result.best_epoch,
f"best_{realised.train.early_stop_metric}": metric,
"seconds": round(result.seconds, 1),
},
indent=2,
sort_keys=True,
)
)
return 0
def _cmd_validate(args: argparse.Namespace) -> int:
"""Handle ``validate``: evaluate a run checkpoint on its val split.
Args:
args: Parsed arguments (``--run-dir``, optional ``--store``,
``--n-boot``).
Returns:
Exit code 0 on success.
"""
payload = evaluate_val(
Path(args.run_dir), store_dir=Path(args.store) if args.store else None, n_boot=args.n_boot
)
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
def _cmd_test(args: argparse.Namespace) -> int:
"""Handle ``test``: evaluate a run checkpoint on its test split.
Args:
args: Parsed arguments (``--run-dir``, optional ``--store`` and
``--trap-store``, ``--n-boot``).
Returns:
Exit code 0 on success.
"""
payload = evaluate_test(
Path(args.run_dir),
store_dir=Path(args.store) if args.store else None,
trap_store_dir=Path(args.trap_store) if args.trap_store else None,
n_boot=args.n_boot,
)
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
def _cmd_gate(args: argparse.Namespace) -> int:
"""Handle ``gate``: compute and print the E1-v2 gate verdict.
Args:
args: Parsed arguments (``--runs``, ``--control``,
``--chance-tol``).
Returns:
Exit code 0 on success.
"""
verdict = evaluate_gate([Path(r) for r in args.runs], Path(args.control), args.chance_tol)
print(json.dumps(verdict.__dict__, indent=2, sort_keys=True))
return 0
def _cmd_configs(args: argparse.Namespace) -> int:
"""Handle ``configs``: load a config JSON and re-save it elsewhere.
A round-trip helper proving ``load_config(save_config(c)) == c`` and
letting users materialise a base config from an existing run.
Args:
args: Parsed arguments (``--config`` source, ``--out``
destination).
Returns:
Exit code 0 on success.
"""
cfg = load_config(args.config)
save_config(cfg, Path(args.out))
print(f"wrote {args.out}")
return 0
def build_parser() -> argparse.ArgumentParser:
"""Construct the full CLI parser with all subcommands.
Returns:
The configured ``argparse.ArgumentParser`` (``--help`` lists
every subcommand and flag).
"""
parser = argparse.ArgumentParser(
prog="custom_models",
description="E1 genus probe: torch models and train/validate/test wrapper",
)
sub = parser.add_subparsers(dest="command", required=True)
p_build = sub.add_parser("build", help="resolve a param budget into a realised probe")
_add_model_args(p_build, required=True)
p_build.add_argument("--n-classes", type=int, default=11)
p_build.add_argument("--seed", type=int, default=0)
p_build.set_defaults(func=_cmd_build)
p_store = sub.add_parser("store", help="build a signal store (pod5+labels or synthetic)")
p_store.add_argument("--out", required=True)
p_store.add_argument("--synthetic", action="store_true")
p_store.add_argument("--n-genera", type=int, default=11)
p_store.add_argument("--reads-per-genus", type=int, default=200)
p_store.add_argument("--period", type=int, default=None)
p_store.add_argument("--noise", type=float, default=0.15)
p_store.add_argument("--genus-prefix", default="genus_")
p_store.add_argument("--pod5", nargs="+", default=None)
p_store.add_argument("--labels", default=None)
p_store.add_argument("--window-samples", type=int, default=12_000)
p_store.add_argument("--skip-head", type=int, default=500)
p_store.add_argument("--min-read-samples", type=int, default=12_500)
p_store.add_argument("--max-windows-per-read", type=int, default=1)
p_store.add_argument("--norm", choices=["median_iqr", "median_mad"], default="median_iqr")
p_store.add_argument("--dtype", choices=["float16", "float32"], default="float16")
p_store.add_argument("--shard-windows", type=int, default=8_192)
p_store.add_argument("--seed", type=int, default=0)
p_store.set_defaults(func=_cmd_store)
p_train = sub.add_parser("train", help="train a probe on a signal store")
p_train.add_argument("--store", default=None)
p_train.add_argument("--out-dir", default=None)
p_train.add_argument("--config", default=None)
p_train.add_argument("--run-id", default=None)
p_train.add_argument(
"--stage",
choices=["arch_ladder", "windows_ablation", "robustness", "control", "extra"],
default=None,
)
p_train.add_argument("--control", action="store_true", default=None)
p_train.add_argument("--notes", default=None)
_add_model_args(p_train, required=False)
_add_data_args(p_train)
_add_train_args(p_train)
p_train.set_defaults(func=_cmd_train)
p_validate = sub.add_parser("validate", help="evaluate a run checkpoint on its val split")
p_validate.add_argument("--run-dir", required=True)
p_validate.add_argument("--store", default=None)
p_validate.add_argument("--n-boot", type=int, default=10_000)
p_validate.set_defaults(func=_cmd_validate)
p_test = sub.add_parser("test", help="evaluate a run checkpoint on its test split")
p_test.add_argument("--run-dir", required=True)
p_test.add_argument("--store", default=None)
p_test.add_argument("--trap-store", default=None)
p_test.add_argument("--n-boot", type=int, default=10_000)
p_test.set_defaults(func=_cmd_test)
p_gate = sub.add_parser("gate", help="gate verdict over run dirs against a control run")
p_gate.add_argument("--runs", nargs="+", required=True)
p_gate.add_argument("--control", required=True)
p_gate.add_argument("--chance-tol", type=float, default=1.5)
p_gate.set_defaults(func=_cmd_gate)
p_configs = sub.add_parser("configs", help="round-trip a config JSON (save_config/load_config)")
p_configs.add_argument("--config", required=True)
p_configs.add_argument("--out", required=True)
p_configs.set_defaults(func=_cmd_configs)
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entry point (also ``python -m custom_models``).
Args:
argv: Argument list without the program name; ``None`` uses
``sys.argv``.
Returns:
0 on success; 1 on usage or runtime errors (message printed to
stderr).
"""
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
try:
return int(args.func(args))
except (ValueError, RuntimeError, FileNotFoundError, ImportError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+273
View File
@@ -0,0 +1,273 @@
"""Frozen configuration dataclasses and JSON (de)serialisation.
The dataclasses mirror the GATE ZERO config contract (``E1_genus_information_
ceiling.md`` §5.1) with the E1-v2 deltas (fixed 11-taxa membership, no
class-ladder machinery) and the binding deviations recorded in its §12:
``ModelConfig`` carries the realised fields ``d_model`` / ``n_layers`` /
``params_realised`` that ``build_probe`` fills in, and ``RunConfig`` records
the realised genera so a run directory round-trips deterministically.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, is_dataclass
from pathlib import Path
from typing import Any, Literal
Arch = Literal["cnn", "linatt"]
Stage = Literal["arch_ladder", "windows_ablation", "robustness", "control", "extra"]
AmpPolicy = Literal["off", "bf16", "fp16"]
EarlyStopMetric = Literal["val_recall_macro", "val_loss"]
NormMethod = Literal["median_iqr", "median_mad"]
@dataclass(frozen=True)
class DataConfig:
"""Split-time data selection and read-level split parameters."""
genera: tuple[str, ...] = ()
val_fraction: float = 0.10
test_fraction: float = 0.10
holdout_runs: bool = False
min_test_windows_per_genus: int = 300
seed: int = 0
@dataclass(frozen=True)
class ModelConfig:
"""Probe architecture request; realised geometry is filled by ``build_probe``.
Attributes:
arch: Encoder family, ``"cnn"`` or ``"linatt"``.
param_budget: Nominal trainable-parameter budget in params (e.g.
``1_000_000``). Only the nominal budget enters the run id; the
gate arithmetic always uses ``params_realised``.
stride: Patch stride in signal samples, 4 or 8 (token pitch).
d_embed: Pooled embedding width the encoder terminates in.
budget_tol: Relative tolerance around the budget; the realised count
must satisfy ``count_params <= (1 + budget_tol) * param_budget``.
n_heads: Attention head count for the ``"linatt"`` arch (recorded so
checkpoint rehydration rebuilds the exact geometry).
d_model: realised token width (filled by ``build_probe``, ``None``
on a request config).
n_layers: realised block count (filled by ``build_probe``).
params_realised: realised trainable parameter count (filled by
``build_probe``; the only source of truth for reporting).
"""
arch: Arch
param_budget: int
stride: int
d_embed: int = 256
budget_tol: float = 0.10
n_heads: int = 4
d_model: int | None = None
n_layers: int | None = None
params_realised: int | None = None
@dataclass(frozen=True)
class TrainConfig:
"""Training semantics (GATE ZERO §5.1; restated in the torch model spec §5)."""
batch_size: int = 256
lr: float = 3e-4
weight_decay: float = 0.01
max_epochs: int = 40
warmup_frac: float = 0.05
label_smoothing: float = 0.0
amp: AmpPolicy = "bf16"
grad_clip: float = 1.0
early_stop_metric: EarlyStopMetric = "val_recall_macro"
patience: int = 5
num_workers: int = 4
device: str | None = None
seed: int = 0
deterministic: bool = True
@dataclass(frozen=True)
class ExtractConfig:
"""Pod5-to-store extraction parameters for ``extract_store``.
Attributes:
pod5_paths: Pod5 files to scan, processed in sorted path order.
window_samples: Fixed window length in samples (12,000 ≈ 2.4 s at
5 kHz ≈ ~1 kb) — the model-input contract.
skip_head_samples: Head samples skipped before the first window
(adapter / mux artefacts).
max_windows_per_read: Windows taken per read; 1 is the headline, 4 is
the Stage-A' ablation row. Windows are contiguous from the first.
min_read_samples: Reads shorter than this are dropped and counted in
the store meta (must fit skip-head plus one full window).
norm: Per-window normaliser applied before storing.
dtype: On-disk shard dtype; fp16 halves the store at negligible cost.
shard_windows: Windows per ``shard_%05d.npy`` file.
seed: Unused by extraction itself (window choice is deterministic);
kept so every config in the pipeline is seed-carrying.
"""
pod5_paths: tuple[Path, ...]
window_samples: int = 12_000
skip_head_samples: int = 500
max_windows_per_read: int = 1
min_read_samples: int = 12_500
norm: NormMethod = "median_iqr"
dtype: Literal["float16", "float32"] = "float16"
shard_windows: int = 8_192
seed: int = 0
@dataclass(frozen=True)
class RunConfig:
"""One experiment run: a store, data/model/train sub-configs and outputs.
``train_run`` fills ``run_id`` (if empty) and ``genera`` (from the realised
splits) before the realised config is serialised into the run directory.
"""
run_id: str
stage: Stage
store_dir: Path
data: DataConfig
model: ModelConfig
train: TrainConfig
out_dir: Path
genera: tuple[str, ...] = ()
notes: str = ""
def run_id_for(arch: str, param_budget: int, stride: int, n_genera: int, stage: str) -> str:
"""Build the deterministic, filesystem-safe run identifier.
Format: ``{arch}_{budget in M:.1f}M_s{stride}_g{n_genera}``, e.g.
``cnn_1.0M_s4_g11``; the control stage appends ``-shuf``. Only the
nominal budget and class count enter the id — realised geometry never
does — so ids are unique within the run matrix and stable across
rehydration.
Args:
arch: Encoder family name (``"cnn"`` or ``"linatt"``).
param_budget: Nominal parameter budget (divided by 1e6 for the tag).
stride: Token stride in samples.
n_genera: Number of selected classes (the ``g`` component).
stage: Run stage; ``"control"`` triggers the ``-shuf`` suffix.
Returns:
The run id string.
"""
run_id = f"{arch}_{param_budget / 1_000_000:.1f}M_s{stride}_g{n_genera}"
if stage == "control":
return f"{run_id}-shuf"
return run_id
_TYPE_KEY = "__type__"
_DATACLASSES: dict[str, type] = {
cls.__name__: cls
for cls in (DataConfig, ModelConfig, TrainConfig, ExtractConfig, RunConfig)
}
def _encode(obj: Any) -> Any:
"""Recursively convert a config object into a JSON-friendly payload.
``Path`` becomes ``{"__type__": "path", "value": str}``, tuples become
tagged sequences (so they round-trip as tuples), and dataclass instances
become dicts tagged with their class name. All other values pass
through unchanged.
Args:
obj: A config dataclass, ``Path``, tuple/list, or plain JSON value.
Returns:
The JSON-serialisable encoding of ``obj``.
"""
if isinstance(obj, Path):
return {_TYPE_KEY: "path", "value": str(obj)}
if isinstance(obj, (tuple, list)):
return {_TYPE_KEY: "seq", "value": [_encode(v) for v in obj]}
if is_dataclass(obj) and not isinstance(obj, type):
payload: dict[str, Any] = {_TYPE_KEY: type(obj).__name__}
for field in obj.__dataclass_fields__:
payload[field] = _encode(getattr(obj, field))
return payload
return obj
def _decode(obj: Any) -> Any:
"""Invert :func:`_encode` back into config objects.
Tagged dicts rebuild ``Path`` values, tuples and registered dataclasses;
anything else is returned as-is. Unknown ``__type__`` tags fall through
as plain dicts so foreign JSON never crashes decoding.
Args:
obj: A payload produced by :func:`_encode` (or nested JSON value).
Returns:
The reconstructed config object.
"""
if isinstance(obj, dict):
kind = obj.get(_TYPE_KEY)
if kind == "path":
return Path(obj["value"])
if kind == "seq":
return tuple(_decode(v) for v in obj["value"])
if kind in _DATACLASSES:
cls = _DATACLASSES[kind]
kwargs = {}
for name, field in cls.__dataclass_fields__.items():
if name in obj:
kwargs[name] = _decode(obj[name])
elif field.default is not None:
pass
return cls(**kwargs)
return obj
def save_config(cfg: Any, path: Path) -> None:
"""Serialise a config dataclass to pretty JSON.
Parent directories are created as needed. The payload uses the
``"__type__"`` discriminator scheme (GATE ZERO §12.7) so
``load_config(save_config(c)) == c`` holds exactly.
Args:
cfg: A config dataclass (or nested structure of them).
path: Destination JSON file path.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(_encode(cfg), indent=2, sort_keys=True) + "\n")
def load_config(path: Path) -> Any:
"""Load a config dataclass written by :func:`save_config`.
Args:
path: JSON config file path.
Returns:
The reconstructed config object (type depends on the file).
Raises:
FileNotFoundError: If ``path`` does not exist.
ValueError: If the file is not valid JSON or does not decode into
the recorded dataclass schema.
"""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"config file not found: {path}")
try:
return _decode(json.loads(path.read_text()))
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError(f"malformed config file: {path}: {exc}") from exc
+406
View File
@@ -0,0 +1,406 @@
"""Splits, label permutation for control runs, datasets and data loaders.
Read-level stratified splitting (all windows of a read share a split),
the v2 near-clade safety minimum on per-genus test windows, whole-run
holdout mode, the seeded shuffled-label permutation used by control runs,
and the torch ``Dataset``/``DataLoader`` plumbing with device-appropriate
worker/pinning behaviour.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
import numpy as np
import polars as pl
import torch
from torch.utils.data import DataLoader
from .config import DataConfig, TrainConfig
from .seed import make_generator, worker_init_fn
from .store import SignalStore
@dataclass(frozen=True)
class Splits:
"""Window-index splits plus the full-store label vector.
Attributes:
train: Sorted window ids assigned to training.
val: Sorted window ids assigned to validation.
test: Sorted window ids assigned to testing.
genera: Sorted selected genera; class id = position in this list
everywhere downstream.
labels: int32 class id for every store window (length = store
size), ``-1`` for windows outside the selected genera.
"""
train: np.ndarray
val: np.ndarray
test: np.ndarray
genera: list[str]
labels: np.ndarray
def make_splits(manifest: pl.DataFrame, cfg: DataConfig) -> Splits:
"""Compute deterministic read-level stratified splits from a manifest.
Selection: ``cfg.genera`` if given (sorted, must all exist in the
store — the fixed owner list in E1 v2), else every genus present.
Splitting: per genus, reads are shuffled with the seeded generator and
assigned by read counts (test first, then val, remainder train), so
all windows of a read land in one split. ``holdout_runs`` instead
assigns whole ``source_run`` groups to test (seeded shuffle until the
test fraction is met) and splits the remaining reads into
train/val, requiring every genus to retain both train and test
presence. The v2 near-clade guard enforces
``min_test_windows_per_genus`` test windows per genus.
Args:
manifest: Store manifest (one row per window; needs ``window_id``,
``read_id``, ``genus``, ``source_run``).
cfg: Split parameters (genera, fractions, holdout mode, minimum,
seed).
Returns:
The splits (see :class:`Splits`).
Raises:
ValueError: On missing manifest columns, an empty manifest,
requested genera absent from the store, an empty selection or
train split, holdout presence failure, or a per-genus test
count below ``min_test_windows_per_genus`` (v2: raise instead
of gating on a noisy plan).
"""
for column in ("window_id", "read_id", "genus", "source_run"):
if column not in manifest.columns:
raise ValueError(f"manifest missing column: {column}")
n = manifest.height
if n == 0:
raise ValueError("manifest is empty")
genus_arr = manifest["genus"].to_numpy()
read_arr = manifest["read_id"].to_numpy()
wid_arr = manifest["window_id"].to_numpy()
run_arr = manifest["source_run"].to_numpy()
all_genera = sorted(set(genus_arr.tolist()))
selected = sorted(set(cfg.genera)) if cfg.genera else list(all_genera)
if not selected:
raise ValueError("no genera available for splitting")
missing = [g for g in selected if g not in set(all_genera)]
if missing:
raise ValueError(f"genera absent from the store: {missing}")
lookup = {g: i for i, g in enumerate(selected)}
n_classes = len(selected)
labels = np.full(n, -1, dtype=np.int32)
for genus, class_id in lookup.items():
labels[genus_arr == genus] = class_id
selected_mask = labels >= 0
if not selected_mask.any():
raise ValueError("no windows belong to the selected genera")
rng = make_generator(cfg.seed)
windows_by_read: dict[str, list[int]] = {}
genus_by_read: dict[str, str] = {}
run_by_read: dict[str, str] = {}
for wid, read_id, genus, source_run in zip(
wid_arr[selected_mask], read_arr[selected_mask], genus_arr[selected_mask],
run_arr[selected_mask]
):
windows_by_read.setdefault(read_id, []).append(int(wid))
genus_by_read[read_id] = genus
run_by_read[read_id] = source_run
reads_by_genus: dict[str, list[str]] = {g: [] for g in selected}
for read_id, genus in genus_by_read.items():
reads_by_genus[genus].append(read_id)
for reads in reads_by_genus.values():
reads.sort()
train_ids: list[int] = []
val_ids: list[int] = []
test_ids: list[int] = []
if cfg.holdout_runs:
runs = sorted(set(run_by_read.values()))
windows_per_run: dict[str, int] = {}
for read_id, source_run in run_by_read.items():
windows_per_run[source_run] = windows_per_run.get(source_run, 0) + len(
windows_by_read[read_id]
)
order = rng.permutation(len(runs))
shuffled_runs = [runs[i] for i in order]
target_test = round(cfg.test_fraction * selected_mask.sum())
chosen: set[str] = set()
accrued = 0
for source_run in shuffled_runs:
if accrued >= target_test:
break
chosen.add(source_run)
accrued += windows_per_run[source_run]
for read_id, source_run in run_by_read.items():
if source_run in chosen:
test_ids.extend(windows_by_read[read_id])
for genus in selected:
reads = [r for r in reads_by_genus[genus] if run_by_read[r] not in chosen]
order = rng.permutation(len(reads))
shuffled = [reads[i] for i in order]
n_val = round(cfg.val_fraction * len(shuffled))
val_reads = shuffled[:n_val]
train_reads = shuffled[n_val:]
for r in val_reads:
val_ids.extend(windows_by_read[r])
for r in train_reads:
train_ids.extend(windows_by_read[r])
for genus in selected:
has_train = any(run_by_read[r] not in chosen for r in reads_by_genus[genus])
has_test = any(run_by_read[r] in chosen for r in reads_by_genus[genus])
if not (has_train and has_test):
raise ValueError(
f"holdout_runs split leaves genus {genus!r} without train and test presence"
)
else:
for genus in selected:
reads = reads_by_genus[genus]
order = rng.permutation(len(reads))
shuffled = [reads[i] for i in order]
n_test = round(cfg.test_fraction * len(shuffled))
n_val = round(cfg.val_fraction * len(shuffled))
test_reads = shuffled[:n_test]
val_reads = shuffled[n_test : n_test + n_val]
train_reads = shuffled[n_test + n_val :]
for r in test_reads:
test_ids.extend(windows_by_read[r])
for r in val_reads:
val_ids.extend(windows_by_read[r])
for r in train_reads:
train_ids.extend(windows_by_read[r])
if not train_ids:
raise ValueError("train split is empty after splitting")
train = np.array(sorted(train_ids), dtype=np.int64)
val = np.array(sorted(val_ids), dtype=np.int64)
test = np.array(sorted(test_ids), dtype=np.int64)
if cfg.min_test_windows_per_genus > 0:
if len(test) == 0:
raise ValueError(
"test split is empty; per-genus minimum "
f"min_test_windows_per_genus={cfg.min_test_windows_per_genus} cannot be met"
)
counts = np.bincount(labels[test], minlength=n_classes)
short = {
selected[i]: int(c)
for i, c in enumerate(counts)
if 0 < c < cfg.min_test_windows_per_genus
}
absent = [g for g in selected if not np.any(labels[test] == lookup[g])]
if short or absent:
raise ValueError(
"per-genus test windows below the near-clade safety minimum "
f"(min_test_windows_per_genus={cfg.min_test_windows_per_genus}): "
f"short={short} absent={absent}"
)
return Splits(train=train, val=val, test=test, genera=selected, labels=labels)
def permute_read_labels(
manifest: pl.DataFrame, genera: tuple[str, ...], seed: int
) -> pl.DataFrame:
"""Shuffle genus labels across reads for the Stage-C control run.
The read-to-genus mapping of the selected pool is permuted (seeded,
preserving the genus multiset exactly); every window of a read
inherits its read's new label. Applied *before* splitting so the
control run's splits and labels live in the same permuted world as
its training (evaluation re-permutes identically).
Args:
manifest: Store manifest to relabel.
genera: Restriction of the permutation pool; empty means all
genera present.
seed: Seed for the permutation.
Returns:
A new manifest with the ``genus`` column permuted.
Raises:
ValueError: If the pool holds fewer than two reads.
"""
genus_arr = manifest["genus"].to_numpy()
read_arr = manifest["read_id"].to_numpy()
genus_by_read: dict[str, str] = {}
for read_id, genus in zip(read_arr, genus_arr):
genus_by_read.setdefault(read_id, genus)
pool = sorted(genus_by_read)
if genera:
pool = [r for r in pool if genus_by_read[r] in set(genera)]
if len(pool) < 2:
raise ValueError("control run needs at least two reads in the selected pool")
values = [genus_by_read[r] for r in pool]
rng = make_generator(seed)
order = rng.permutation(len(values))
permuted = {read_id: values[i] for read_id, i in zip(pool, order)}
new_genus = [permuted[read_id] for read_id in read_arr]
return manifest.with_columns(pl.Series("genus", new_genus, dtype=pl.String))
class ProbeDataset(torch.utils.data.Dataset):
"""Window dataset over a subset of store window ids."""
def __init__(self, store: SignalStore, indices: np.ndarray, labels: np.ndarray) -> None:
"""Bind a store, window ids and the full-store label vector.
Args:
store: Open signal store serving the windows.
indices: Window ids to serve (a split, or all ids).
labels: Full-store label vector indexed by window id
(from :class:`Splits`).
"""
self.store = store
self.indices = np.asarray(indices, dtype=np.int64)
self.labels = np.asarray(labels, dtype=np.int64)
def __len__(self) -> int:
"""Return the number of windows in this dataset."""
return len(self.indices)
def __getitem__(self, index: int) -> tuple[np.ndarray, int]:
"""Fetch one window and its class id.
Args:
index: Dataset row index (not the window id).
Returns:
``(float32 window of shape (L,), class id)``.
"""
window_id = int(self.indices[index])
return self.store.get(window_id), int(self.labels[window_id])
def collate_windows(
batch: list[tuple[np.ndarray, int]],
) -> tuple[torch.Tensor, torch.Tensor]:
"""Stack dataset samples into model-ready tensors.
Args:
batch: List of ``(window, class_id)`` pairs from
:class:`ProbeDataset`.
Returns:
``(windows (B, L) float32, labels (B,) int64)`` — the probe's
input contract.
"""
windows = torch.from_numpy(np.stack([np.asarray(b[0], dtype=np.float32) for b in batch]))
labels = torch.tensor([int(b[1]) for b in batch], dtype=torch.int64)
return windows, labels
def _loader_mp_context():
"""Pick a DataLoader multiprocessing start context for this platform.
Linux pins ``"fork"`` (torch's historical default) because conda's
Python 3.14 builds ship a broken ``forkserver``; other platforms keep
the interpreter default (spawn on macOS/Windows).
Returns:
A start-method name or ``None`` for the platform default.
"""
if sys.platform == "linux":
return "fork"
return None
def make_loaders(
store: SignalStore, splits: Splits, cfg: TrainConfig
) -> tuple[DataLoader, DataLoader, DataLoader]:
"""Build the train/val/test loaders for one run.
Train: shuffled over a generator seeded from ``cfg.seed`` with
``drop_last=True`` (stable step counts) and per-worker seeding.
Val/test: unshuffled and complete. ``pin_memory`` only when the
resolved device is CUDA; ``persistent_workers`` only when
``num_workers > 0``.
Args:
store: The signal store both splits read from.
splits: Split window ids and labels.
cfg: Training config (batch size, workers, seed, device).
Returns:
``(train_loader, val_loader, test_loader)``.
"""
from .train import select_device
device = select_device(cfg.device)
pin = device.type == "cuda"
generator = torch.Generator()
generator.manual_seed(cfg.seed)
common: dict = {
"batch_size": cfg.batch_size,
"collate_fn": collate_windows,
"num_workers": cfg.num_workers,
"pin_memory": pin,
"persistent_workers": cfg.num_workers > 0,
"worker_init_fn": worker_init_fn,
"multiprocessing_context": _loader_mp_context() if cfg.num_workers > 0 else None,
}
train_loader = DataLoader(
ProbeDataset(store, splits.train, splits.labels),
shuffle=True,
generator=generator,
drop_last=True,
**common,
)
val_loader = DataLoader(
ProbeDataset(store, splits.val, splits.labels),
shuffle=False,
drop_last=False,
**common,
)
test_loader = DataLoader(
ProbeDataset(store, splits.test, splits.labels),
shuffle=False,
drop_last=False,
**common,
)
return train_loader, val_loader, test_loader
def make_full_loader(store: SignalStore, cfg: TrainConfig) -> DataLoader:
"""Build an unshuffled loader over *every* window of a store.
Used for trap (TP2) evaluation where the store's genera are not
classes of the probe; labels are placeholder ``-1`` values.
Args:
store: Store to evaluate end-to-end.
cfg: Training config supplying batching/worker/device behaviour.
Returns:
A DataLoader yielding ``(windows, -1 labels)`` in window order.
"""
from .train import select_device
device = select_device(cfg.device)
labels = np.full(len(store), -1, dtype=np.int64)
return DataLoader(
ProbeDataset(store, np.arange(len(store), dtype=np.int64), labels),
batch_size=cfg.batch_size,
shuffle=False,
drop_last=False,
collate_fn=collate_windows,
num_workers=cfg.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=cfg.num_workers > 0,
worker_init_fn=worker_init_fn,
multiprocessing_context=_loader_mp_context() if cfg.num_workers > 0 else None,
)
+494
View File
@@ -0,0 +1,494 @@
"""Hand-rolled metrics on CPU numpy (no scikit-learn, no device-dependent math).
Closed-set reporting with seeded bootstrap CIs, the max-softmax-probability
(MSP) open-set diagnostic with val-calibrated tau, and the E1-v2 trap
report (false-accept rates at tau, family-collapsed impostor rates, MSP /
margin histograms, top-3 retrieval and embedding centroids for the E3
hook). Conventions: per-genus recall is NaN for genera absent from the
evaluated split; precision and F1 use the zero-division=0 convention so
reports stay JSON-clean.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
import polars as pl
DEFAULT_TRAP_ADJACENCY: dict[str, set[str]] = {
"Paenibacillus": {"Bacillus"},
"Escherichia": {
"Cronobacter",
"Citrobacter",
"Enterobacter",
"Klebsiella",
"Salmonella",
"Shigella",
},
"Aeromonas": {"Vibrio"},
}
def _softmax(logits: np.ndarray) -> np.ndarray:
"""Row-wise softmax, numerically stable (max-shifted).
Args:
logits: ``(N, C)`` score matrix.
Returns:
``(N, C)`` probability matrix (rows sum to 1).
"""
z = logits - logits.max(axis=1, keepdims=True)
e = np.exp(z)
return e / e.sum(axis=1, keepdims=True)
def msp(logits: np.ndarray) -> np.ndarray:
"""Max-softmax-probability per row — the open-set confidence score.
Args:
logits: Logit matrix of shape ``(N, C)`` with ``C >= 2``.
Returns:
``(N,)`` float64 MSP values in ``(0, 1]``.
Raises:
ValueError: On non-2-D input or fewer than two classes.
"""
logits = np.asarray(logits, dtype=np.float64)
if logits.ndim != 2 or logits.shape[1] < 2:
raise ValueError("msp expects logits of shape (N, C) with C >= 2")
return _softmax(logits).max(axis=1)
def _confusion(y_true: np.ndarray, y_pred: np.ndarray, n_classes: int) -> np.ndarray:
"""Build a ``(C, C)`` count matrix (rows = true, cols = predicted).
Args:
y_true: ``(N,)`` int true class ids.
y_pred: ``(N,)`` int predicted class ids.
n_classes: Class count C (matrix is always C x C even for absent
classes).
Returns:
int64 confusion-count matrix.
"""
codes = y_true.astype(np.int64) * n_classes + y_pred.astype(np.int64)
return np.bincount(codes, minlength=n_classes * n_classes).reshape(n_classes, n_classes)
def macro_recall(y_true: np.ndarray, y_pred: np.ndarray, n_classes: int) -> float:
"""Macro genus recall — the headline gate metric.
Mean over genera of ``correct / windows of that genus``; genera absent
from ``y_true`` are skipped (NaN-safe). This is the cheap per-epoch
variant used during training.
Args:
y_true: ``(N,)`` int true class ids.
y_pred: ``(N,)`` int predicted class ids.
n_classes: Class count C.
Returns:
Macro recall in ``[0, 1]``; NaN for an empty input pair.
"""
y_true = np.asarray(y_true, dtype=np.int64)
y_pred = np.asarray(y_pred, dtype=np.int64)
if y_true.size == 0:
return float("nan")
cm = _confusion(y_true, y_pred, n_classes)
rows = cm.sum(axis=1)
with np.errstate(invalid="ignore", divide="ignore"):
recall = np.where(rows > 0, np.diag(cm) / rows, np.nan)
return float(np.nanmean(recall))
@dataclass(frozen=True)
class ClosedSetReport:
"""Closed-set evaluation summary.
Attributes:
recall_macro: Mean per-genus recall (headline gate metric).
recall_micro: Top-1 accuracy (== micro recall).
precision_macro: Mean per-class precision (zero-division = 0).
f1_macro: Mean per-class F1 (zero-division = 0).
per_genus: DataFrame of genus, n_test, recall, precision, f1 and a
``trap`` boolean (False for closed-set rows; v2 schema).
confusion: ``(C, C)`` count matrix, rows = true, cols = pred,
ordered like ``genera``.
ci: ``"recall_macro"`` / ``"recall_micro"`` -> 95% bootstrap CI
(empty when ``n_boot == 0``).
"""
recall_macro: float
recall_micro: float
precision_macro: float
f1_macro: float
per_genus: pl.DataFrame
confusion: np.ndarray
ci: dict[str, tuple[float, float]]
def closed_set_report(
y_true: np.ndarray,
y_pred: np.ndarray,
genera: list[str],
n_boot: int = 10_000,
seed: int = 0,
) -> ClosedSetReport:
"""Full closed-set report over predicted labels, with bootstrap CIs.
The bootstrap resamples evaluation windows with replacement
``n_boot`` times (seeded) and recomputes macro/micro recall; the 2.5 /
97.5 percentiles give the 95% CIs. E1 fixes ``n_boot=10_000`` and
``seed=0`` for cross-run comparability.
Args:
y_true: ``(N,)`` int true class ids in ``[0, C)``.
y_pred: ``(N,)`` int predicted class ids in ``[0, C)``.
genera: Class names; position = class id (the split's genera
list).
n_boot: Bootstrap resample count (0 disables CIs).
seed: Seed for the bootstrap generator.
Returns:
The :class:`ClosedSetReport`.
Raises:
ValueError: On length mismatch, fewer than 2 classes, an empty
evaluation set, or labels outside ``[0, C)``.
"""
y_true = np.asarray(y_true, dtype=np.int64)
y_pred = np.asarray(y_pred, dtype=np.int64)
if len(y_true) != len(y_pred):
raise ValueError("y_true and y_pred must have equal length")
n_classes = len(genera)
if n_classes < 2:
raise ValueError("closed-set report needs at least 2 classes")
if y_true.size == 0:
raise ValueError("cannot report on an empty evaluation set")
lo_ok = y_true.min() >= 0 and y_pred.min() >= 0
hi_ok = y_true.max() < n_classes and y_pred.max() < n_classes
if not (lo_ok and hi_ok):
raise ValueError("labels fall outside [0, n_classes)")
confusion = _confusion(y_true, y_pred, n_classes)
row_sums = confusion.sum(axis=1)
col_sums = confusion.sum(axis=0)
diag = np.diag(confusion)
with np.errstate(invalid="ignore", divide="ignore"):
recall = np.where(row_sums > 0, diag / row_sums, np.nan)
precision = np.where(col_sums > 0, diag / col_sums, 0.0)
f1 = np.zeros(n_classes, dtype=np.float64)
valid = (row_sums > 0) & (np.nan_to_num(recall) + precision > 0)
f1[valid] = (
2 * recall[valid] * precision[valid] / (recall[valid] + precision[valid])
)
recall_macro = float(np.nanmean(recall)) if not np.isnan(recall).all() else 0.0
recall_micro = float(diag.sum() / confusion.sum())
precision_macro = float(precision.mean())
f1_macro = float(f1.mean())
per_genus = pl.DataFrame(
{
"genus": genera,
"n_test": row_sums.astype(np.int64),
"recall": recall,
"precision": precision,
"f1": f1,
"trap": np.zeros(n_classes, dtype=bool),
}
)
ci: dict[str, tuple[float, float]] = {}
if n_boot > 0:
rng = np.random.default_rng(seed)
n = len(y_true)
pair_codes = y_true * n_classes + y_pred
macro_samples = np.empty(n_boot, dtype=np.float64)
micro_samples = np.empty(n_boot, dtype=np.float64)
for b in range(n_boot):
idx = rng.integers(0, n, size=n)
cm = np.bincount(pair_codes[idx], minlength=n_classes * n_classes).reshape(
n_classes, n_classes
)
rows = cm.sum(axis=1)
with np.errstate(invalid="ignore", divide="ignore"):
rec = np.where(rows > 0, np.diag(cm) / rows, np.nan)
macro_samples[b] = np.nanmean(rec)
micro_samples[b] = np.diag(cm).sum() / cm.sum()
ci["recall_macro"] = (
float(np.quantile(macro_samples, 0.025)),
float(np.quantile(macro_samples, 0.975)),
)
ci["recall_micro"] = (
float(np.quantile(micro_samples, 0.025)),
float(np.quantile(micro_samples, 0.975)),
)
return ClosedSetReport(
recall_macro=recall_macro,
recall_micro=recall_micro,
precision_macro=precision_macro,
f1_macro=f1_macro,
per_genus=per_genus,
confusion=confusion,
ci=ci,
)
def choose_tau_msp(
val_correct_msp: np.ndarray, target_known_recall: float = 0.95
) -> float:
"""Calibrate the open-set MSP threshold tau on correct validation reads.
``tau`` is the ``(1 - target)`` quantile of the MSP values of
*correctly classified* validation windows, so at least ``target`` of
correct known reads survive ``msp >= tau``.
Args:
val_correct_msp: MSP values of correct validation windows.
target_known_recall: Fraction of correct known reads to keep
(default 0.95, GATE ZERO §5.9).
Returns:
The threshold ``tau`` in ``[0, 1)``.
Raises:
ValueError: On empty input or a target outside ``(0, 1)``.
"""
arr = np.asarray(val_correct_msp, dtype=np.float64)
if arr.size == 0:
raise ValueError("choose_tau_msp needs at least one correctly classified window")
if not 0.0 < target_known_recall < 1.0:
raise ValueError("target_known_recall must lie in (0, 1)")
return float(np.quantile(arr, 1.0 - target_known_recall))
@dataclass(frozen=True)
class OpenSetProbeReport:
"""E1's simple open-set diagnostic (full calibration is E3's job).
Attributes:
tau: Val-calibrated MSP threshold.
false_unknown_rate: Fraction of ALL test windows with
``msp < tau`` (routed to "unknown").
known_recall_at_tau: Macro recall restricted to windows with
``msp >= tau``.
kept_fraction: Fraction of test windows with ``msp >= tau``.
"""
tau: float
false_unknown_rate: float
known_recall_at_tau: float
kept_fraction: float
def open_set_probe_report(
val_logits: np.ndarray,
val_y: np.ndarray,
test_logits: np.ndarray,
test_y: np.ndarray,
target_known_recall: float = 0.95,
) -> OpenSetProbeReport:
"""Compute the open-set probe report from val and test logits.
Tau comes from the correct-validation MSP quantile
(:func:`choose_tau_msp`); the rates are measured on the test split.
Args:
val_logits: ``(N_val, C)`` validation logits.
val_y: ``(N_val,)`` validation class ids.
test_logits: ``(N_test, C)`` test logits.
test_y: ``(N_test,)`` test class ids.
target_known_recall: Known-recall target for tau calibration.
Returns:
The :class:`OpenSetProbeReport`.
Raises:
ValueError: On empty validation/test inputs or a validation pass
with zero correct windows (tau undefined).
"""
val_logits = np.asarray(val_logits, dtype=np.float64)
val_y = np.asarray(val_y, dtype=np.int64)
if val_logits.size == 0:
raise ValueError("open-set probe needs a non-empty validation set")
val_pred = val_logits.argmax(axis=1)
correct = val_pred == val_y
if not correct.any():
raise ValueError("open-set probe needs at least one correct validation window")
tau = choose_tau_msp(msp(val_logits)[correct], target_known_recall)
test_logits = np.asarray(test_logits, dtype=np.float64)
test_y = np.asarray(test_y, dtype=np.int64)
if test_logits.size == 0:
raise ValueError("open-set probe needs a non-empty test set")
test_msp = msp(test_logits)
test_pred = test_logits.argmax(axis=1)
kept = test_msp >= tau
kept_fraction = float(kept.mean())
false_unknown_rate = float((~kept).mean())
n_classes = test_logits.shape[1]
known_recall_at_tau = (
macro_recall(test_y[kept], test_pred[kept], n_classes) if kept.any() else float("nan")
)
return OpenSetProbeReport(
tau=tau,
false_unknown_rate=false_unknown_rate,
known_recall_at_tau=known_recall_at_tau,
kept_fraction=kept_fraction,
)
def _quantiles(values: np.ndarray) -> dict[str, float]:
"""Summarise a value vector with the 5/25/50/75/95 percentiles.
Args:
values: 1-D array (assumed within a known range).
Returns:
Dict with keys ``p05`` .. ``p95``.
"""
q = np.quantile(values, [0.05, 0.25, 0.5, 0.75, 0.95])
return {
"p05": float(q[0]),
"p25": float(q[1]),
"p50": float(q[2]),
"p75": float(q[3]),
"p95": float(q[4]),
}
def _histogram(values: np.ndarray, bins: int = 20) -> dict[str, Any]:
"""Histogram of a value vector over the unit interval.
Args:
values: 1-D array in ``[0, 1]`` (MSP values, margins).
bins: Bin count.
Returns:
``{"edges": [...], "counts": [...]}`` (JSON-friendly).
"""
counts, edges = np.histogram(values, bins=bins, range=(0.0, 1.0))
return {"edges": [float(e) for e in edges], "counts": [int(c) for c in counts]}
def trap_report(
trap_manifest: pl.DataFrame,
trap_logits: np.ndarray,
tau: float,
genera: list[str],
adjacency: dict[str, set[str]] | None = None,
embeddings: np.ndarray | None = None,
) -> dict[str, Any]:
"""Build the TP2 trap report for eval-only impostor windows.
Trap genera are never trained classes; every window is pushed through
the frozen probe's 11-way head and scored post-hoc. Per trap genus
this reports: window/read counts and provenance runs, the false-accept
rate at the val-calibrated ``tau`` (``fpr_at_tau``), the
family-collapsed impostor rate (``fpr_family`` — prediction in the
biologically adjacent classes), MSP and top1-top2 margin quantiles and
unit histograms, per-rank top-3 predicted-class counts, and the mean
embedding centroid (the E3 hook). Nothing is calibrated *on* traps —
tau always comes from the main run's validation split.
Args:
trap_manifest: The trap store's manifest (row order = logits
order; genus column names the trap genus per window).
trap_logits: ``(N, C)`` logits from the frozen probe.
tau: Open-set threshold from :func:`open_set_probe_report`.
genera: The probe's class list (position = class id).
adjacency: Map trap genus -> adjacent class-genera set; defaults
to the owner-list mapping from the v2 design (Paenibacillus ->
Bacillus, Escherichia -> the six Enterobacteriaceae classes,
Aeromonas -> Vibrio). Unknown trap genera simply get an empty
family set.
embeddings: Optional ``(N, d_embed)`` pooled embeddings (from
:func:`~custom_models.train.embed_probe`) enabling centroid
output.
Returns:
JSON-serialisable dict: overall ``trap_fpr_at_tau`` /
``trap_fpr_family`` plus a ``per_trap`` breakdown.
Raises:
ValueError: If manifest rows and logits disagree.
"""
adjacency = adjacency if adjacency is not None else DEFAULT_TRAP_ADJACENCY
trap_logits = np.asarray(trap_logits, dtype=np.float64)
n = trap_logits.shape[0]
if len(trap_manifest) != n:
raise ValueError("trap manifest rows and logits disagree")
probs = _softmax(trap_logits)
trap_msp = probs.max(axis=1)
trap_pred = trap_logits.argmax(axis=1)
order = np.argsort(-probs, axis=1)
top1 = order[:, 0]
top2 = order[:, 1]
margins = probs[np.arange(n), top1] - probs[np.arange(n), top2]
genus_arr = trap_manifest["genus"].to_numpy()
run_arr = trap_manifest["source_run"].to_numpy()
read_arr = trap_manifest["read_id"].to_numpy()
accepted = trap_msp >= tau
family_ids_by_genus: dict[str, list[int]] = {}
for trap_genus in set(genus_arr.tolist()):
family = adjacency.get(trap_genus, set())
family_ids_by_genus[trap_genus] = [
genera.index(g) for g in family if g in genera
]
in_family = np.array(
[
trap_pred[i] in family_ids_by_genus.get(genus_arr[i], [])
for i in range(n)
],
dtype=bool,
)
per_trap: dict[str, Any] = {}
for trap_genus in sorted(set(genus_arr.tolist())):
idx = np.flatnonzero(genus_arr == trap_genus)
family_ids = family_ids_by_genus[trap_genus]
pred_g = trap_pred[idx]
fpr_family = float(np.isin(pred_g, sorted(family_ids)).mean()) if len(idx) else 0.0
ranks = {"rank1": {}, "rank2": {}, "rank3": {}}
for rank, col in ((0, "rank1"), (1, "rank2"), (2, "rank3")):
counts: dict[str, int] = {}
for c in order[idx, rank]:
name = genera[int(c)]
counts[name] = counts.get(name, 0) + 1
ranks[col] = dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
entry: dict[str, Any] = {
"n_eval_windows": len(idx),
"source_runs": sorted(set(run_arr[idx].tolist())),
"n_reads": len(set(read_arr[idx].tolist())),
"fpr_at_tau": float(accepted[idx].mean()),
"fpr_family": fpr_family,
"adjacent_classes": sorted(family),
"msp_quantiles": _quantiles(trap_msp[idx]),
"margin_quantiles": _quantiles(margins[idx]),
"msp_hist": _histogram(trap_msp[idx]),
"margin_hist": _histogram(margins[idx]),
"top3_by_rank": ranks,
}
if embeddings is not None and len(idx):
entry["centroid"] = [float(v) for v in embeddings[idx].mean(axis=0)]
else:
entry["centroid"] = None
per_trap[trap_genus] = entry
return {
"tau": float(tau),
"n_eval_windows": int(n),
"trap_fpr_at_tau": float(accepted.mean()) if n else 0.0,
"trap_fpr_family": float(in_family.mean()) if n else 0.0,
"per_trap": per_trap,
}
+600
View File
@@ -0,0 +1,600 @@
"""Torch modules for the E1 supervised genus probe.
Implements the normative model spec (``E1_torch_model_spec.md`` §3): a
``SignalPatchEmbed`` conv patchifier (no positional information), the
``ConvEncoder`` (depthwise-separable blocks, dilation capped at 16,
zero-initialised attention-weighted pooling) and the
``LinearAttentionEncoder`` (pre-norm "fast transformer" blocks with the
elu+1 kernel, plain mean pooling), both terminating in a pooled
``d_embed`` vector, plus the linear-headed ``GenusProbe`` and the
deterministic budget resolver ``build_probe``. No batch-norm anywhere
(MPS/AMP-hostile, device-identical semantics required); no softmax or
log-softmax inside the model.
"""
from __future__ import annotations
from dataclasses import replace
import torch
import torch.nn.functional as F
from torch import nn
from .config import ModelConfig
D_MODEL_LATTICE: tuple[int, ...] = (
32,
48,
64,
96,
128,
192,
256,
384,
512,
768,
1024,
1536,
2048,
3072,
4096,
)
N_LAYERS_LATTICE: tuple[int, ...] = (2, 3, 4, 6, 8, 12, 16)
DILATION_CAP = 16
LINEAR_ATTENTION_EPS = 1e-6
def count_params(module: nn.Module) -> int:
"""Count trainable parameters of a module (buffers excluded).
This is the single source of truth for budget checks and reporting —
closed-form parameter arithmetic is design intuition only (spec §4).
Args:
module: Any ``torch.nn.Module`` (typically a ``GenusProbe``).
Returns:
Total ``numel`` over parameters with ``requires_grad=True``.
"""
return sum(p.numel() for p in module.parameters() if p.requires_grad)
class SignalPatchEmbed(nn.Module):
"""Patchify raw signal into tokens with a strided 1-D convolution.
``Conv1d(1 -> d_model, kernel=patch_len, stride=stride)`` followed by a
transpose to token layout ``(B, T, d_model)``. Bias is kept (it is part
of the parameter count). No positional information is added: absolute
position within a read is biologically irrelevant for a genus signal,
and ~3k-token positional tables would eat a large fraction of the 1M
budget point (spec §3.1).
"""
def __init__(self, d_model: int, patch_len: int, stride: int) -> None:
"""Create the patch embedding.
Args:
d_model: Token width produced by the convolution.
patch_len: Kernel size in signal samples (E1 uses
``min(4, stride)``).
stride: Token pitch in signal samples (4 or 8).
Raises:
ValueError: If any argument is < 1.
"""
super().__init__()
if d_model < 1:
raise ValueError("d_model must be >= 1")
if patch_len < 1:
raise ValueError("patch_len must be >= 1")
if stride < 1:
raise ValueError("stride must be >= 1")
self.patch_len = patch_len
self.stride = stride
self.proj = nn.Conv1d(1, d_model, kernel_size=patch_len, stride=stride)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Map a raw-signal batch to tokens.
Args:
x: Signal batch of shape ``(B, L)`` float32; ``L`` must be
divisible by ``stride`` (guaranteed by the store, validated
defensively per the API contract).
Returns:
Tokens of shape ``(B, T, d_model)`` with
``T = (L - patch_len) // stride + 1`` (3,000 for L=12,000 and
stride 4).
Raises:
ValueError: If ``x`` is not 2-D or ``L % stride != 0``.
"""
if x.dim() != 2:
raise ValueError(f"expected signal batch of shape (B, L), got {tuple(x.shape)}")
if x.shape[1] % self.stride != 0:
raise ValueError(
f"window length {x.shape[1]} is not divisible by stride {self.stride}"
)
tokens = self.proj(x.unsqueeze(1))
return tokens.transpose(1, 2)
class ConvBlock(nn.Module):
"""One depthwise-separable conv block: depthwise -> pointwise -> LN -> GELU.
Padding ``dilation * (k - 1) // 2`` preserves the token count so
``T`` is invariant through the stack. Post-norm ordering follows the
GATE ZERO block sketch (spec §3.2).
"""
def __init__(self, d_model: int, kernel_size: int, dilation: int, dropout: float) -> None:
"""Create the block.
Args:
d_model: Token width (channels in/Out).
kernel_size: Depthwise kernel size (7 anchors layer 0's
receptive field, 5 afterwards).
dilation: Depthwise dilation (growth capped at
``DILATION_CAP`` by the caller).
dropout: Dropout probability applied after activation; 0.0
installs an identity (E1 matrices run at 0.0).
"""
super().__init__()
padding = dilation * (kernel_size - 1) // 2
self.depthwise = nn.Conv1d(
d_model,
d_model,
kernel_size=kernel_size,
padding=padding,
dilation=dilation,
groups=d_model,
)
self.pointwise = nn.Conv1d(d_model, d_model, kernel_size=1)
self.norm = nn.LayerNorm(d_model)
self.act = nn.GELU()
self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Run the block over a token tensor.
Args:
x: Tokens of shape ``(B, T, d_model)``.
Returns:
Tokens of the same shape (length-preserving padding).
"""
h = x.transpose(1, 2)
h = self.pointwise(self.depthwise(h))
h = h.transpose(1, 2)
return self.drop(self.act(self.norm(h)))
class ConvEncoder(nn.Module):
"""Depthwise-separable 1-D CNN encoder with attention-weighted pooling.
Stack of ``n_layers`` :class:`ConvBlock` instances — kernel 7 in layer
0 (receptive-field anchor) then 5, dilation ``min(2**l, 16)`` (GATE
ZERO §12.3 cap) — followed by ``weighted_mean_pool``: a zero-init
``Linear(d_model -> 1)`` score head (uniform pooling at init, plain
mean behaviour) softmaxed over tokens, applied to a
``Linear(d_model -> d_embed)`` projection.
"""
def __init__(
self,
d_model: int,
n_layers: int,
patch_len: int,
stride: int,
d_embed: int,
dropout: float = 0.0,
) -> None:
"""Create the encoder.
Args:
d_model: Token width inside the stack.
n_layers: Number of conv blocks.
patch_len: Patch length passed to the patch embed.
stride: Token pitch passed to the patch embed.
d_embed: Output pooled-embedding width.
dropout: Block dropout (E1 uses 0.0; do not tune inside E1 —
that changes the ladder semantics).
Raises:
ValueError: If ``n_layers`` or ``d_embed`` < 1 (patch-embed
args are validated in :class:`SignalPatchEmbed`).
"""
super().__init__()
if n_layers < 1:
raise ValueError("n_layers must be >= 1")
if d_embed < 1:
raise ValueError("d_embed must be >= 1")
self.embed = SignalPatchEmbed(d_model, patch_len, stride)
self.blocks = nn.ModuleList(
[
ConvBlock(
d_model,
kernel_size=7 if layer == 0 else 5,
dilation=min(2**layer, DILATION_CAP),
dropout=dropout,
)
for layer in range(n_layers)
]
)
self.score = nn.Linear(d_model, 1)
nn.init.zeros_(self.score.weight)
nn.init.zeros_(self.score.bias)
self.proj = nn.Linear(d_model, d_embed)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Encode raw signal to a pooled embedding.
Args:
x: Signal batch of shape ``(B, L)`` float32 with ``L``
divisible by ``stride``.
Returns:
Pooled embedding of shape ``(B, d_embed)``.
"""
h = self.embed(x)
for block in self.blocks:
h = block(h)
weights = torch.softmax(self.score(h), dim=1)
return (weights * self.proj(h)).sum(dim=1)
class LinearAttention(nn.Module):
"""Non-causal linear attention with the elu+1 feature map.
Computes ``out[t] = phi(q_t) @ (sum_s phi(k_s) v_s^T) / (phi(q_t) @
sum_s phi(k_s))`` per head via the associative form — ``O(T*d^2)``
compute and ``O(d^2)`` memory per layer, no ``T x T`` matrix — which
is what keeps ~3k-token sequences trainable inside the 1-3 GPU-h/run
budget. ``phi(x) = elu(x) + 1`` is strictly positive, so the
denominator is positive; a small floor guards degenerate cases.
"""
def __init__(self, d_model: int, n_heads: int) -> None:
"""Create the attention mixer.
Args:
d_model: Token width (queries/keys/values are full-width
linears reshaped to heads, so param count is head-count
invariant).
n_heads: Number of heads (default 4; recorded in the realised
``ModelConfig``).
Raises:
ValueError: If ``d_model`` is not divisible by ``n_heads`` or
``n_heads`` < 1.
"""
super().__init__()
if n_heads < 1:
raise ValueError("n_heads must be >= 1")
if d_model % n_heads != 0:
raise ValueError(f"d_model {d_model} not divisible by n_heads {n_heads}")
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.q = nn.Linear(d_model, d_model)
self.k = nn.Linear(d_model, d_model)
self.v = nn.Linear(d_model, d_model)
self.out = nn.Linear(d_model, d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Mix tokens with linear attention.
Args:
x: Tokens of shape ``(B, T, d_model)``.
Returns:
Mixed tokens of the same shape.
"""
b, t, d = x.shape
q = self.q(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
k = self.k(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
v = self.v(x).view(b, t, self.n_heads, self.head_dim).transpose(1, 2)
phi_q = F.elu(q) + 1.0
phi_k = F.elu(k) + 1.0
kv = torch.einsum("bhti,bhtj->bhij", phi_k, v)
k_sum = phi_k.sum(dim=2)
numerator = torch.einsum("bhti,bhij->bhtj", phi_q, kv)
denominator = torch.einsum("bhti,bhi->bht", phi_q, k_sum)
out = numerator / denominator.clamp_min(LINEAR_ATTENTION_EPS).unsqueeze(-1)
out = out.transpose(1, 2).reshape(b, t, d)
return self.out(out)
class LinearAttentionBlock(nn.Module):
"""Pre-norm transformer block: attention residual, then MLP residual."""
def __init__(self, d_model: int, n_heads: int, dropout: float) -> None:
"""Create the block.
Args:
d_model: Token width.
n_heads: Attention head count.
dropout: Residual dropout (0.0 in E1 matrices).
"""
super().__init__()
self.norm1 = nn.LayerNorm(d_model)
self.attn = LinearAttention(d_model, n_heads)
self.norm2 = nn.LayerNorm(d_model)
self.mlp = nn.Sequential(
nn.Linear(d_model, d_model),
nn.GELU(),
nn.Linear(d_model, d_model),
)
self.drop1 = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
self.drop2 = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Run both mixer stages with pre-norm residuals.
Args:
x: Tokens of shape ``(B, T, d_model)``.
Returns:
Tokens of the same shape.
"""
x = x + self.drop1(self.attn(self.norm1(x)))
x = x + self.drop2(self.mlp(self.norm2(x)))
return x
class LinearAttentionEncoder(nn.Module):
"""Patch embed + pre-norm linear-attention blocks + mean pooling.
Terminates in the same interchangeable pooled representation as
:class:`ConvEncoder`: plain mean over tokens followed by a
``Linear(d_model -> d_embed)`` projection (no score head — the pooling
is deliberately not attention-weighted here, spec §3.3).
"""
def __init__(
self,
d_model: int,
n_layers: int,
patch_len: int,
stride: int,
d_embed: int,
dropout: float = 0.0,
n_heads: int = 4,
) -> None:
"""Create the encoder.
Args:
d_model: Token width inside the stack.
n_layers: Number of attention blocks.
patch_len: Patch length for the (separately weighted) patch
embed.
stride: Token pitch for the patch embed.
d_embed: Output pooled-embedding width.
dropout: Residual dropout (0.0 in E1 matrices).
n_heads: Attention head count.
Raises:
ValueError: If ``n_layers`` or ``d_embed`` < 1; head
divisibility is enforced in :class:`LinearAttention`.
"""
super().__init__()
if n_layers < 1:
raise ValueError("n_layers must be >= 1")
if d_embed < 1:
raise ValueError("d_embed must be >= 1")
self.embed = SignalPatchEmbed(d_model, patch_len, stride)
self.blocks = nn.ModuleList(
[LinearAttentionBlock(d_model, n_heads, dropout) for _ in range(n_layers)]
)
self.proj = nn.Linear(d_model, d_embed)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Encode raw signal to a pooled embedding.
Args:
x: Signal batch of shape ``(B, L)`` float32 with ``L``
divisible by ``stride``.
Returns:
Pooled embedding of shape ``(B, d_embed)``.
"""
h = self.embed(x)
for block in self.blocks:
h = block(h)
return self.proj(h.mean(dim=1))
class GenusProbe(nn.Module):
"""The supervised probe: an encoder plus a bare linear classifier.
The head is exactly ``Linear(d_embed, n_classes)`` — no hidden MLP, no
temperature, no bias tricks — because E1 measures the *encoder's*
information content; a linear head on a pooled representation is the
cleanest statement of "the information is (or is not) in the
embedding". Raw logits only; cross-entropy supplies the softmax.
"""
def __init__(self, encoder: nn.Module, d_embed: int, n_classes: int) -> None:
"""Assemble the probe.
Args:
encoder: An encoder (``ConvEncoder`` or
``LinearAttentionEncoder``) mapping ``(B, L)`` to
``(B, d_embed)``.
d_embed: Width of the encoder's pooled output; must match or
the first forward raises on the head's shape mismatch.
n_classes: Number of genera (C = 11 for E1 v2).
Raises:
ValueError: If ``n_classes`` < 2 (empty classification head) or
``d_embed`` < 1.
"""
super().__init__()
if n_classes < 2:
raise ValueError("n_classes must be >= 2")
if d_embed < 1:
raise ValueError("d_embed must be >= 1")
self.encoder = encoder
self.head = nn.Linear(d_embed, n_classes)
def embed(self, x: torch.Tensor) -> torch.Tensor:
"""Return the pooled embedding (E3/E4 reuse path).
Args:
x: Signal batch of shape ``(B, L)`` float32.
Returns:
Embeddings of shape ``(B, d_embed)`` — not L2-normalised;
cosine machinery belongs to E3, not the probe.
"""
return self.encoder(x)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Classify raw signal.
Args:
x: Signal batch of shape ``(B, L)`` float32.
Returns:
Logits of shape ``(B, n_classes)``.
"""
return self.head(self.embed(x))
def _make_encoder(
arch: str,
d_model: int,
n_layers: int,
patch_len: int,
stride: int,
d_embed: int,
n_heads: int,
) -> nn.Module:
"""Instantiate an encoder of the requested family.
Args:
arch: ``"cnn"`` or ``"linatt"``.
d_model: Token width.
n_layers: Block count.
patch_len: Patch length in samples.
stride: Token pitch in samples.
d_embed: Pooled output width.
n_heads: Head count (ignored by ``"cnn"``).
Returns:
A fresh encoder module (default torch init; draws from the global
RNG so callers must seed first).
Raises:
ValueError: On an unknown arch.
"""
if arch == "cnn":
return ConvEncoder(d_model, n_layers, patch_len, stride, d_embed)
if arch == "linatt":
return LinearAttentionEncoder(
d_model, n_layers, patch_len, stride, d_embed, n_heads=n_heads
)
raise ValueError(f"unknown arch: {arch!r}")
def build_probe(cfg: ModelConfig, n_classes: int) -> tuple[GenusProbe, ModelConfig]:
"""Resolve a parameter budget into a concrete probe (deterministic).
Searches the ``(d_model, n_layers)`` lattice — ``D_MODEL_LATTICE`` x
``N_LAYERS_LATTICE``, pruning by monotonicity (parameter counts grow
with both axes) so oversized candidates are never constructed — and
among candidates with ``count_params <= (1 + budget_tol) * budget``
picks the *largest* (ceiling-cap reading of GATE ZERO §12.3; candidates
above the cap are disqualified even if closer in absolute distance).
Ties break by ``(n_layers asc, d_model asc)``. The returned realised
config is the value serialised into ``config.json``; re-running the
search on it reproduces the same geometry (checkpoint rehydration
path). Construction draws only from torch's global RNG: callers must
call ``seed_everything`` first, and identical (cfg, n_classes, seed)
yields bit-identical weights.
Args:
cfg: Model request (arch, nominal budget, stride, d_embed,
tolerance, head count); realised fields are overwritten.
n_classes: Number of genera (C = 11 for E1 v2).
Returns:
``(probe, realised_cfg)`` where ``realised_cfg`` fills ``d_model``,
``n_layers`` and ``params_realised``.
Raises:
ValueError: If ``n_classes`` < 2, ``stride``/``d_embed`` invalid,
or no lattice candidate lands within tolerance (the ladder
matrix then drops that arch/budget cell — fail fast).
"""
if n_classes < 2:
raise ValueError(f"n_classes must be >= 2, got {n_classes}")
if cfg.stride < 1:
raise ValueError("stride must be >= 1")
if cfg.d_embed < 1:
raise ValueError("d_embed must be >= 1")
patch_len = min(4, cfg.stride)
ceiling = int((1.0 + cfg.budget_tol) * cfg.param_budget)
best: tuple[int, int, int, GenusProbe] | None = None
for d_model in D_MODEL_LATTICE:
probe = GenusProbe(
_make_encoder(
cfg.arch,
d_model,
N_LAYERS_LATTICE[0],
patch_len,
cfg.stride,
cfg.d_embed,
cfg.n_heads,
),
cfg.d_embed,
n_classes,
)
params = count_params(probe)
if params > ceiling:
break
local: tuple[int, int, int, GenusProbe] = (params, N_LAYERS_LATTICE[0], d_model, probe)
for n_layers in N_LAYERS_LATTICE[1:]:
probe = GenusProbe(
_make_encoder(
cfg.arch, d_model, n_layers, patch_len, cfg.stride, cfg.d_embed, cfg.n_heads
),
cfg.d_embed,
n_classes,
)
params = count_params(probe)
if params > ceiling:
break
local = (params, n_layers, d_model, probe)
if best is None or (local[0], -local[1], -local[2]) > (best[0], -best[1], -best[2]):
best = local
if best is None:
raise ValueError(
f"no ({cfg.arch}) candidate within {cfg.budget_tol:.0%} of budget {cfg.param_budget}"
)
params, n_layers, d_model, probe = best
realised = replace(
cfg,
d_model=d_model,
n_layers=n_layers,
params_realised=params,
n_heads=cfg.n_heads,
)
return probe, realised
+611
View File
@@ -0,0 +1,611 @@
"""Run orchestration: artifact writing, checkpoint rehydration and the gate.
``train_run`` executes one run end to end (seed, split, build, train,
artifacts); ``evaluate_val`` / ``evaluate_test`` rehydrate a run directory
deterministically (same config + same store -> same splits, control runs
re-permute identically) and write the evaluation artifact set; and
``evaluate_gate`` implements the E1-v2 four-line verdict table over run
directories plus the shuffled-label control.
"""
from __future__ import annotations
import json
import os
from collections.abc import Sequence
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Any
import numpy as np
import polars as pl
import torch
from .config import RunConfig, load_config, run_id_for, save_config
from .data import (
Splits,
make_full_loader,
make_loaders,
make_splits,
permute_read_labels,
)
from .metrics import (
ClosedSetReport,
closed_set_report,
msp,
open_set_probe_report,
trap_report,
)
from .models import build_probe
from .store import SignalStore
from .train import (
TrainResult,
embed_probe,
predict,
resolve_amp,
select_device,
train_probe,
)
CONFIG_NAME = "config.json"
CKPT_NAME = "ckpt.pt"
HISTORY_NAME = "history.parquet"
REPORT_NAME = "report.json"
VAL_REPORT_NAME = "val_report.json"
PER_GENUS_NAME = "per_genus.parquet"
CONFUSION_NAME = "confusion.npz"
OPEN_SET_NAME = "open_set.json"
ERRORS_NAME = "errors.parquet"
TRAP_REPORT_NAME = "trap_report.json"
GATE_NAME = "gate_verdict.json"
def _write_json(path: Path, payload: Any) -> None:
"""Write a JSON artifact atomically enough for run directories.
Args:
path: Destination file (parents created as needed).
payload: JSON-serialisable object (dicts/dataclasses already
converted by callers).
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def train_run(cfg: RunConfig) -> tuple[RunConfig, TrainResult, Splits]:
"""Execute one training run and write its core artifacts.
Order of operations (all stochastic steps seeded from the config):
``seed_everything`` -> open store -> permute labels if the stage is
``"control"`` -> deterministic splits -> ``build_probe`` budget
resolution (run id defaults to ``run_id_for`` when unset) -> write the
realised ``config.json`` -> ``train_probe`` (writes
``history.parquet``) -> save ``ckpt.pt`` holding the *best* state.
Re-runs of the same config overwrite artifacts (idempotent).
Args:
cfg: The run configuration. ``cfg.run_id`` may be empty (then
derived from arch/budget/stride/class count/stage).
Returns:
``(realised_cfg, result, splits)`` — the realised config that was
serialised, the training outcome and the splits used.
Raises:
Whatever ``make_splits``, ``build_probe`` and ``train_probe``
raise on invalid data or configuration (ValueError /
RuntimeError / FileNotFoundError).
"""
from .seed import seed_everything
seed_everything(cfg.train.seed, cfg.train.deterministic)
store = SignalStore(cfg.store_dir)
manifest = store.manifest()
if cfg.stage == "control":
manifest = permute_read_labels(manifest, cfg.data.genera, cfg.data.seed)
splits = make_splits(manifest, cfg.data)
n_classes = len(splits.genera)
probe, realised_model = build_probe(cfg.model, n_classes)
run_id = cfg.run_id
if not run_id:
run_id = run_id_for(
cfg.model.arch, cfg.model.param_budget, cfg.model.stride, n_classes, cfg.stage
)
realised = replace(
cfg,
run_id=run_id,
model=realised_model,
genera=tuple(splits.genera),
)
run_dir = realised.out_dir / run_id
run_dir.mkdir(parents=True, exist_ok=True)
save_config(realised, run_dir / CONFIG_NAME)
result = train_probe(probe, store, splits, realised.train, run_dir)
torch.save(
{
"best_state": result.best_state,
"best_epoch": result.best_epoch,
"run_id": run_id,
"params_realised": realised_model.params_realised,
},
run_dir / CKPT_NAME,
)
return realised, result, splits
def load_run(run_dir: Path) -> RunConfig:
"""Load a run directory's realised ``RunConfig``.
Args:
run_dir: Directory holding ``config.json`` (as written by
:func:`train_run`).
Returns:
The realised run configuration.
Raises:
FileNotFoundError: If the config file is missing.
ValueError: If the file does not decode into a ``RunConfig``.
"""
cfg = load_config(Path(run_dir) / CONFIG_NAME)
if not isinstance(cfg, RunConfig):
raise ValueError(f"{Path(run_dir) / CONFIG_NAME} does not hold a RunConfig")
return cfg
def load_probe(run_dir: Path, cfg: RunConfig) -> torch.nn.Module:
"""Rebuild a run's probe and load its best checkpoint weights.
The geometry comes from re-running the deterministic
``build_probe`` search on the realised config (a pure function of
config + class count, so the shapes always match), then
``load_state_dict`` fills the weights.
Args:
run_dir: Run directory holding ``ckpt.pt``.
cfg: The run's realised config (model section + genera).
Returns:
The probe (on CPU) with best-epoch weights loaded.
Raises:
FileNotFoundError: If the checkpoint is missing.
"""
run_dir = Path(run_dir)
ckpt_path = run_dir / CKPT_NAME
if not ckpt_path.is_file():
raise FileNotFoundError(f"checkpoint not found: {ckpt_path}")
n_classes = len(cfg.genera)
probe, _ = build_probe(cfg.model, n_classes)
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=True)
probe.load_state_dict(ckpt["best_state"])
return probe
def _rehydrate(
run_dir: Path, store_dir: Path | None = None
) -> tuple[RunConfig, SignalStore, pl.DataFrame, Splits, torch.nn.Module, torch.device, str]:
"""Rebuild everything an evaluation needs from a run directory.
Recomputes the splits from the run's config (deterministic: same
store + same seeds -> the training-time splits; control stages
re-apply their seeded label permutation first), reloads the probe,
and resolves device/AMP. The returned manifest is the *working*
manifest (permuted for control runs) so error tables agree with the
labels the run was trained on.
Args:
run_dir: Run directory (config + checkpoint).
store_dir: Optional store override; defaults to the recorded
``store_dir``.
Returns:
``(cfg, store, manifest, splits, probe, device, amp)`` with the
probe already moved to ``device``.
Raises:
ValueError: If the recomputed genera disagree with the recorded
genera (the store changed since training — evaluation would
not be apples-to-apples).
"""
cfg = load_run(run_dir)
store = SignalStore(store_dir if store_dir is not None else cfg.store_dir)
manifest = store.manifest()
if cfg.stage == "control":
manifest = permute_read_labels(manifest, cfg.data.genera, cfg.data.seed)
splits = make_splits(manifest, cfg.data)
if list(cfg.genera) != splits.genera:
raise ValueError(
"store genera disagree with the recorded run genera; "
"the store must be the one the run was trained on"
)
probe = load_probe(run_dir, cfg)
device = select_device(cfg.train.device)
amp = resolve_amp(cfg.train.amp, device)
probe = probe.to(device)
return cfg, store, manifest, splits, probe, device, amp
def _closed_payload(report: ClosedSetReport) -> dict[str, Any]:
"""Convert a closed-set report into its JSON payload.
Args:
report: The report to serialise.
Returns:
Dict of the scalar metrics plus CIs as plain lists.
"""
return {
"recall_macro": report.recall_macro,
"recall_micro": report.recall_micro,
"precision_macro": report.precision_macro,
"f1_macro": report.f1_macro,
"ci": {k: [float(v[0]), float(v[1])] for k, v in report.ci.items()},
}
def evaluate_val(
run_dir: Path, store_dir: Path | None = None, n_boot: int = 10_000
) -> dict[str, Any]:
"""Evaluate a run's checkpoint on its validation split.
Writes ``val_report.json`` (closed-set scalars + bootstrap CIs) into
the run directory and returns the same payload.
Args:
run_dir: Run directory (config + checkpoint).
store_dir: Optional store override (default: recorded store).
n_boot: Bootstrap resample count for CIs.
Returns:
The val-report payload (run id, split, class/param counts,
``"closed"`` block).
Raises:
ValueError: If the validation split is empty or the store drifted
(via :func:`_rehydrate`).
"""
run_dir = Path(run_dir)
cfg, store, _manifest, splits, probe, device, amp = _rehydrate(run_dir, store_dir)
if len(splits.val) == 0:
raise ValueError("validation split is empty")
_, val_loader, _ = make_loaders(store, splits, cfg.train)
val_logits, val_y = predict(probe, val_loader, device, amp)
val_pred = val_logits.argmax(axis=1)
report = closed_set_report(
val_y, val_pred, splits.genera, n_boot=n_boot, seed=cfg.data.seed
)
payload = {
"run_id": cfg.run_id,
"split": "val",
"n_classes": len(splits.genera),
"params_realised": cfg.model.params_realised,
"n_eval_windows": len(val_y),
"closed": _closed_payload(report),
}
_write_json(run_dir / VAL_REPORT_NAME, payload)
return payload
def evaluate_test(
run_dir: Path,
store_dir: Path | None = None,
trap_store_dir: Path | None = None,
n_boot: int = 10_000,
) -> dict[str, Any]:
"""Evaluate a run's checkpoint on its test split (full artifact set).
Predicts val (for tau calibration) and test; writes
``per_genus.parquet``, ``confusion.npz`` (matrix / genera / y_true /
y_pred / msp), ``errors.parquet`` (per-error rows), ``open_set.json``,
``report.json`` and — when a trap store is given —
``trap_report.json`` (TP2: false-accept rates at tau, family-collapsed
rates, MSP/margin histograms, top-3 retrieval, centroids).
Args:
run_dir: Run directory (config + checkpoint).
store_dir: Optional store override (default: recorded store).
trap_store_dir: Optional eval-only trap store (its genera are
never classes of the probe).
n_boot: Bootstrap resample count for CIs.
Returns:
The report payload: run id, class/param counts, ``"closed"`` and
``"open_set"`` blocks, and ``"trap"`` (null when no trap store
was supplied).
Raises:
ValueError: On empty val/test splits or store drift (via
:func:`_rehydrate`); open-set reporting errors propagate from
:func:`~custom_models.metrics.open_set_probe_report` (e.g. no
correct validation window — tau undefined).
"""
run_dir = Path(run_dir)
cfg, store, manifest, splits, probe, device, amp = _rehydrate(run_dir, store_dir)
if len(splits.val) == 0 or len(splits.test) == 0:
raise ValueError("val and test splits must both be non-empty")
_, val_loader, test_loader = make_loaders(store, splits, cfg.train)
val_logits, val_y = predict(probe, val_loader, device, amp)
test_logits, test_y = predict(probe, test_loader, device, amp)
test_pred = test_logits.argmax(axis=1)
report = closed_set_report(
test_y, test_pred, splits.genera, n_boot=n_boot, seed=cfg.data.seed
)
open_set = open_set_probe_report(val_logits, val_y, test_logits, test_y)
test_msp = msp(test_logits)
run_dir.mkdir(parents=True, exist_ok=True)
report.per_genus.write_parquet(run_dir / PER_GENUS_NAME)
np.savez_compressed(
run_dir / CONFUSION_NAME,
matrix=report.confusion,
genera=np.array(splits.genera),
y_true=test_y,
y_pred=test_pred,
msp=test_msp,
)
read_arr = manifest["read_id"].to_numpy()
genus_arr = manifest["genus"].to_numpy()
wid_arr = manifest["window_id"].to_numpy()
read_by_wid = dict(zip(wid_arr.tolist(), read_arr.tolist()))
genus_by_wid = dict(zip(wid_arr.tolist(), genus_arr.tolist()))
wrong = np.flatnonzero(test_pred != test_y)
errors = pl.DataFrame(
{
"window_id": splits.test[wrong],
"read_id": [read_by_wid[int(w)] for w in splits.test[wrong]],
"genus": [genus_by_wid[int(w)] for w in splits.test[wrong]],
"pred": [splits.genera[int(p)] for p in test_pred[wrong]],
"true_id": test_y[wrong],
"pred_id": test_pred[wrong],
"msp": test_msp[wrong],
}
)
errors.write_parquet(run_dir / ERRORS_NAME)
_write_json(run_dir / OPEN_SET_NAME, asdict(open_set))
trap_payload: dict[str, Any] | None = None
if trap_store_dir is not None:
trap_store = SignalStore(trap_store_dir)
trap_loader = make_full_loader(trap_store, cfg.train)
trap_logits, _ = predict(probe, trap_loader, device, amp)
trap_embeddings = embed_probe(probe, trap_loader, device, amp)
trap_payload = trap_report(
trap_store.manifest(),
trap_logits,
open_set.tau,
splits.genera,
embeddings=trap_embeddings,
)
_write_json(run_dir / TRAP_REPORT_NAME, trap_payload)
payload = {
"run_id": cfg.run_id,
"split": "test",
"n_classes": len(splits.genera),
"params_realised": cfg.model.params_realised,
"n_test_windows": len(test_y),
"closed": _closed_payload(report),
"open_set": asdict(open_set),
"trap": (
{
"tau": trap_payload["tau"],
"trap_fpr_at_tau": trap_payload["trap_fpr_at_tau"],
"trap_fpr_family": trap_payload["trap_fpr_family"],
"per_trap": trap_payload["per_trap"],
}
if trap_payload is not None
else None
),
}
_write_json(run_dir / REPORT_NAME, payload)
return payload
@dataclass(frozen=True)
class GateVerdict:
"""E1-v2 gate outcome over a set of runs plus the control run.
Attributes:
verdict: One of ``PASS`` / ``MARGINAL`` / ``MARGINAL-TRAP`` /
``FAIL`` / ``INVALID`` (v2 §6 four-line table).
best_run: Run id of the run the verdict was decided on.
best_recall_macro: That run's macro recall.
ci: Its 95% bootstrap CI on macro recall.
min_params_achieving_90: Smallest realised parameter count among
runs with recall >= 0.90 (None if none reached it).
trap_fpr: The deciding run's ``trap_fpr_at_tau`` (None when no
trap evaluation was attached — the PASS branch then degrades
to MARGINAL with an explanatory rationale).
chance: ``1 / n_classes`` from the control run's config.
control_recall_macro: The control run's macro recall (must sit at
``<= chance_tol * chance`` or the verdict is INVALID).
rationale: Human-readable verdict justification quoting the gate
table.
"""
verdict: str
best_run: str
best_recall_macro: float
ci: tuple[float, float]
min_params_achieving_90: int | None
trap_fpr: float | None
chance: float
control_recall_macro: float
rationale: str
def _read_run_summary(run_dir: Path) -> dict[str, Any]:
"""Summarise one run directory for the gate.
Args:
run_dir: Directory with ``config.json`` and ``report.json``.
Returns:
Dict with ``run_id``, ``params`` (realised, falling back to the
nominal budget), ``recall_macro``, ``ci`` and ``trap_fpr``.
Raises:
FileNotFoundError: If either file is missing (the test command
must run first).
"""
run_dir = Path(run_dir)
cfg = load_run(run_dir)
report_path = run_dir / REPORT_NAME
if not report_path.is_file():
raise FileNotFoundError(f"run {run_dir} has no {REPORT_NAME}; run the test command first")
report = json.loads(report_path.read_text())
params = cfg.model.params_realised
if params is None:
params = cfg.model.param_budget
trap_fpr = None
if isinstance(report.get("trap"), dict):
trap_fpr = report["trap"].get("trap_fpr_at_tau")
ci = report["closed"].get("ci", {}).get("recall_macro")
return {
"run_id": cfg.run_id,
"dir": str(run_dir),
"params": int(params),
"recall_macro": float(report["closed"]["recall_macro"]),
"ci": (float(ci[0]), float(ci[1])) if ci else (float("nan"), float("nan")),
"trap_fpr": trap_fpr,
}
def evaluate_gate(
runs: Sequence[Path], control: Path, chance_tol: float = 1.5
) -> GateVerdict:
"""Decide the E1-v2 gate over run directories and write the verdict.
Logic (v2 §6, chance = 1/n_classes from the control run's config):
the control exceeding ``chance_tol * chance`` is INVALID (suspected
label leakage — fix before reading anything else). Otherwise, over
the runs' *realised* params: PASS needs recall >= 0.90 at <= 5M with
``trap_fpr_at_tau <= 0.05``; recall >= 0.90 at <= 5M with failing
trap FPR is MARGINAL-TRAP (adopt per-class/margin thresholding);
recall in [0.70, 0.90) at <= 5M, or 0.90 only reached at <= 20M with
the trap criterion satisfied, is MARGINAL; anything else is FAIL.
Missing trap evaluation degrades PASS to MARGINAL (cannot confirm the
trap criterion). ``gate_verdict.json`` is written to the runs'
common parent directory.
Args:
runs: Run directories (each needs ``config.json`` +
``report.json`` from the test command).
control: The shuffled-label control run directory.
chance_tol: Multiple of chance the control may not exceed.
Returns:
The :class:`GateVerdict` (also written as JSON).
Raises:
FileNotFoundError: If a run directory lacks its report.
ValueError: If ``runs`` is empty.
"""
control_dir = Path(control)
control_summary = _read_run_summary(control_dir)
control_cfg = load_run(control_dir)
n_classes = len(control_cfg.genera)
chance = 1.0 / n_classes
control_recall = control_summary["recall_macro"]
if control_recall > chance_tol * chance:
verdict = GateVerdict(
verdict="INVALID",
best_run=control_summary["run_id"],
best_recall_macro=control_recall,
ci=control_summary["ci"],
min_params_achieving_90=None,
trap_fpr=None,
chance=chance,
control_recall_macro=control_recall,
rationale=(
f"control run recall {control_recall:.3f} exceeds {chance_tol}x chance "
f"({chance:.3f}): suspected label leakage; fix before reading the ladder"
),
)
else:
entries = [_read_run_summary(Path(r)) for r in runs]
if not entries:
raise ValueError("no runs passed to the gate")
by_5m = [e for e in entries if e["params"] <= 5_000_000]
by_20m = [e for e in entries if e["params"] <= 20_000_000]
best5 = max(by_5m, key=lambda e: e["recall_macro"]) if by_5m else None
best20 = max(by_20m, key=lambda e: e["recall_macro"]) if by_20m else None
min_params_90 = min(
(e["params"] for e in entries if e["recall_macro"] >= 0.90), default=None
)
if best5 is not None and best5["recall_macro"] >= 0.90:
chosen = best5
if chosen["trap_fpr"] is not None and chosen["trap_fpr"] <= 0.05:
verdict_str = "PASS"
rationale = (
f"best <= 5M run {chosen['run_id']} reaches recall_macro "
f"{chosen['recall_macro']:.3f} with trap FPR {chosen['trap_fpr']:.3f} <= 0.05"
)
elif chosen["trap_fpr"] is not None:
verdict_str = "MARGINAL-TRAP"
rationale = (
f"recall_macro {chosen['recall_macro']:.3f} at <= 5M params but trap FPR "
f"{chosen['trap_fpr']:.3f} > 0.05: adopt per-class or margin thresholding "
"before pod5-first is closed-set-safe"
)
else:
verdict_str = "MARGINAL"
rationale = (
f"recall_macro {chosen['recall_macro']:.3f} at <= 5M params but no trap "
"evaluation attached: cannot confirm the trap criterion"
)
elif best5 is not None and best5["recall_macro"] >= 0.70:
chosen = best5
verdict_str = "MARGINAL"
rationale = (
f"best <= 5M recall_macro {chosen['recall_macro']:.3f} in [0.70, 0.90): "
"Stage-1 expectations drop a rank"
)
elif best20 is not None and best20["recall_macro"] >= 0.90:
chosen = best20
if chosen["trap_fpr"] is not None and chosen["trap_fpr"] <= 0.05:
verdict_str = "MARGINAL"
rationale = (
f"0.90 only reached at <= 20M params ({chosen['run_id']}, "
f"{chosen['params']} params) with trap FPR satisfied"
)
elif chosen["trap_fpr"] is not None:
verdict_str = "MARGINAL-TRAP"
rationale = (
f"0.90 only at <= 20M params and trap FPR {chosen['trap_fpr']:.3f} > 0.05"
)
else:
verdict_str = "MARGINAL"
rationale = "0.90 only at <= 20M params; trap FPR unevaluated"
else:
best_overall = max(entries, key=lambda e: e["recall_macro"])
chosen = best_overall
verdict_str = "FAIL"
rationale = (
f"best recall_macro {chosen['recall_macro']:.3f} < 0.70 at <= 5M params and "
"0.90 not reached at <= 20M: pod5-as-primary dead, demote to triage"
)
verdict = GateVerdict(
verdict=verdict_str,
best_run=chosen["run_id"],
best_recall_macro=chosen["recall_macro"],
ci=chosen["ci"],
min_params_achieving_90=min_params_90,
trap_fpr=chosen["trap_fpr"],
chance=chance,
control_recall_macro=control_recall,
rationale=rationale,
)
dirs = [Path(r) for r in runs] + [Path(control)]
common = os.path.commonpath([str(p.parent) for p in dirs])
_write_json(Path(common) / GATE_NAME, asdict(verdict))
return verdict
+68
View File
@@ -0,0 +1,68 @@
"""Determinism helpers: seeding, RNG factories and DataLoader worker init."""
from __future__ import annotations
import os
import random
import numpy as np
import torch
def seed_everything(seed: int, deterministic: bool = True) -> None:
"""Seed every RNG the experiment draws from.
Seeds ``random``, ``numpy`` and ``torch`` (CPU plus all CUDA devices).
``CUBLAS_WORKSPACE_CONFIG`` is set (via ``setdefault``) before any CUDA
context exists so deterministic cuBLAS is possible on CUDA >= 10.2, and
with ``deterministic`` torch is switched to deterministic algorithms in
warn-only mode. Idempotent; call once at every CLI entrypoint before
anything else (GATE ZERO §2.3 contract).
Args:
seed: Base seed applied to every backend (numpy is folded into its
32-bit domain).
deterministic: Enable ``torch.use_deterministic_algorithms(True,
warn_only=True)``; ops without deterministic implementations warn
instead of raising.
"""
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
random.seed(seed)
np.random.seed(seed % (2**32))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
if deterministic:
torch.use_deterministic_algorithms(True, warn_only=True)
def make_generator(seed: int) -> np.random.Generator:
"""Create a fresh seeded numpy generator.
Args:
seed: Seed for the new generator.
Returns:
A ``numpy.random.default_rng`` generator seeded with ``seed``.
"""
return np.random.default_rng(seed)
def worker_init_fn(worker_id: int) -> None:
"""Seed numpy/``random`` inside a DataLoader worker process.
Derives a per-worker seed from ``torch.initial_seed()`` (which the
DataLoader already derived from the loader's own seeded generator), so
worker-side randomness is reproducible from the run config. Pass as
``DataLoader(worker_init_fn=...)``.
Args:
worker_id: Worker index supplied by the DataLoader (unused beyond
the signature contract; the seed comes from torch state).
"""
seed = torch.initial_seed() % (2**32)
np.random.seed(seed)
random.seed(seed)
+613
View File
@@ -0,0 +1,613 @@
"""Signal store: pod5 extraction, the on-disk store format and its reader.
The store contract is the GATE ZERO schema (§4, §5.3 with the §12.1 binding
deviations): ``manifest.parquet`` (one row per window, ``window_id`` equal to
row order), ``shard_%05d.npy`` files holding stacked fixed-length
median-IQR-normalised windows, and ``store_meta.json`` describing provenance
and drop counts. A synthetic duty-cycle corpus builder provides the
"labels survive normalisation" fixture (GATE ZERO §12.9) and smoke-test data.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import polars as pl
from .config import ExtractConfig
LABEL_COLUMNS: tuple[str, ...] = (
"read_id",
"genus",
"species",
"ref_name",
"identity",
"aligned_len",
"query_len",
"source_run",
)
MANIFEST_COLUMNS: tuple[str, ...] = (
"window_id",
"read_id",
"genus",
"species",
"source_run",
"window_index",
"n_samples",
)
STORE_META = "store_meta.json"
MANIFEST_NAME = "manifest.parquet"
def median_iqr_normalise(x: np.ndarray) -> np.ndarray:
"""Normalise a window by its robust centre and spread: ``(x - med) / IQR``.
Amplitude and offset are erased (a two-level square wave always maps to
the same two normalised levels), which is why genus identity must be
encoded as *shape* (duty cycle) in the synthetic corpus.
Args:
x: 1-D signal window, any numeric dtype.
Returns:
float32 normalised window.
Raises:
ValueError: If the interquartile range is zero (dead/constant
channel).
"""
x = np.asarray(x, dtype=np.float64)
median = np.median(x)
p75, p25 = np.percentile(x, [75, 25])
iqr = p75 - p25
if iqr == 0:
raise ValueError("median_iqr normalisation undefined for a constant window")
return ((x - median) / iqr).astype(np.float32)
def median_mad_normalise(x: np.ndarray) -> np.ndarray:
"""Normalise a window as ``(x - med) / (1.4826 * MAD)``.
Alternative to :func:`median_iqr_normalise` with a Gaussian-consistent
scale estimate.
Args:
x: 1-D signal window, any numeric dtype.
Returns:
float32 normalised window.
Raises:
ValueError: If the median absolute deviation is zero.
"""
x = np.asarray(x, dtype=np.float64)
median = np.median(x)
mad = np.median(np.abs(x - median))
if mad == 0:
raise ValueError("median_mad normalisation undefined for a constant window")
return ((x - median) / (1.4826 * mad)).astype(np.float32)
_NORMALISERS = {
"median_iqr": median_iqr_normalise,
"median_mad": median_mad_normalise,
}
@dataclass(frozen=True)
class RawSignalRecord:
"""One eagerly materialised pod5 read (pod5 lazy access dies with the reader).
Attributes:
read_id: ONT read id string.
signal: Calibrated picoampere trace, float32.
num_samples: Length of ``signal`` in samples.
sample_rate: Acquisition sample rate (Hz, default 5000 fallback).
"""
read_id: str
signal: np.ndarray
num_samples: int
sample_rate: int
def signal_pa(record: Any) -> np.ndarray:
"""Return a pod5 record's signal in calibrated picoamps.
Prefers the reader's own ``signal_pa`` property when it exists and
yields a non-empty 1-D array; otherwise applies the record's calibration
manually as ``(raw + shift) * scale``.
Args:
record: A ``pod5.ReadRecord`` (duck-typed; only attributes are used).
Returns:
float32 picoampere trace.
"""
if hasattr(record, "signal_pa"):
try:
values = np.asarray(record.signal_pa, dtype=np.float32)
if values.ndim == 1 and values.size > 0:
return values
except (RuntimeError, ValueError, TypeError, AttributeError):
pass
shift = float(getattr(record, "shift", 0.0))
scale = float(getattr(record, "scale", 1.0))
return (np.asarray(record.signal, dtype=np.float32) + shift) * scale
def read_pod5_records(path: Path) -> Iterator[RawSignalRecord]:
"""Yield every read of a pod5 file as eager :class:`RawSignalRecord` objects.
The reader context is closed when the generator is exhausted or dropped,
so all calibration/signal access happens inside it.
Args:
path: Pod5 file path.
Yields:
One record per read, in file order.
Raises:
FileNotFoundError: If ``path`` does not exist.
ImportError: If the optional ``pod5`` package is not installed.
"""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"pod5 file not found: {path}")
try:
import pod5
except ImportError as exc:
raise ImportError(
"the pod5 package is required to read pod5 files (pip install pod5)"
) from exc
with pod5.Reader(str(path)) as reader:
for record in reader.reads():
signal = signal_pa(record)
run_info = getattr(record, "run_info", None)
sample_rate = int(getattr(run_info, "sample_rate", 5000))
yield RawSignalRecord(
read_id=str(record.read_id),
signal=signal,
num_samples=int(signal.shape[0]),
sample_rate=sample_rate,
)
def load_labels(path: Path) -> pl.DataFrame:
"""Load and validate a labels parquet (schema: ``LABEL_COLUMNS``).
Args:
path: Labels parquet path.
Returns:
The validated polars DataFrame.
Raises:
FileNotFoundError: If ``path`` does not exist.
ValueError: On missing columns or duplicate read ids.
"""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"labels file not found: {path}")
df = pl.read_parquet(path)
validate_labels(df)
return df
def validate_labels(df: pl.DataFrame) -> None:
"""Check a labels DataFrame against the label-table contract.
Args:
df: Candidate labels table (columns checked order-free).
Raises:
ValueError: If any of ``LABEL_COLUMNS`` is missing or ``read_id``
contains duplicates.
"""
missing = sorted(set(LABEL_COLUMNS) - set(df.columns))
if missing:
raise ValueError(f"labels parquet missing columns: {missing}")
if df["read_id"].n_unique() != df.height:
raise ValueError("labels read_id column contains duplicates")
class _StoreWriter:
"""Streaming builder for a signal store directory.
Buffers normalised windows, flushes them into fixed-size
``shard_%05d.npy`` files, accumulates manifest rows and finally writes
``manifest.parquet`` plus ``store_meta.json``.
"""
def __init__(self, out_dir: Path, window_samples: int, dtype: Any, shard_windows: int) -> None:
"""Prepare the writer and create ``out_dir``.
Args:
out_dir: Store directory to create/write into.
window_samples: Fixed window length for every emitted window.
dtype: numpy dtype for the on-disk shards (fp16 or fp32).
shard_windows: Windows per shard file.
Raises:
ValueError: If ``window_samples`` or ``shard_windows`` < 1.
"""
if window_samples < 1:
raise ValueError("window_samples must be >= 1")
if shard_windows < 1:
raise ValueError("shard_windows must be >= 1")
self.out_dir = Path(out_dir)
self.out_dir.mkdir(parents=True, exist_ok=True)
self.window_samples = int(window_samples)
self.dtype = dtype
self.shard_windows = int(shard_windows)
self._buffer: list[np.ndarray] = []
self._rows: list[dict[str, Any]] = []
self._shard_idx = 0
self._window_id = 0
self.n_windows = 0
self.n_reads = 0
def emit(
self,
window: np.ndarray,
read_id: str,
genus: str,
species: str | None,
source_run: str,
window_index: int,
) -> None:
"""Append one normalised window and its manifest row.
Args:
window: Normalised signal window of length ``window_samples``.
read_id: Owning read id.
genus: Genus label (never null here; labelling pre-filtered).
species: Optional species label (may be ``None``).
source_run: Provenance run id for the read.
window_index: Zero-based window position within the read.
"""
self._buffer.append(np.asarray(window, dtype=np.dtype(self.dtype)))
self._rows.append(
{
"window_id": self._window_id,
"read_id": read_id,
"genus": genus,
"species": species,
"source_run": source_run,
"window_index": window_index,
"n_samples": self.window_samples,
}
)
self._window_id += 1
self.n_windows += 1
if len(self._buffer) >= self.shard_windows:
self._flush()
def count_read(self) -> None:
"""Record that one read contributed at least one window."""
self.n_reads += 1
def _flush(self) -> None:
"""Write the buffered windows as the next shard file, if any."""
if not self._buffer:
return
arr = np.stack(self._buffer).astype(self.dtype)
np.save(self.out_dir / f"shard_{self._shard_idx:05d}.npy", arr)
self._shard_idx += 1
self._buffer = []
def finish(self, meta: dict[str, Any]) -> dict[str, Any]:
"""Flush the tail shard and write manifest and store meta.
Args:
meta: Extra provenance fields merged into ``store_meta.json``
(window/count fields are added automatically).
Returns:
The full store meta dict that was written.
Raises:
ValueError: If no window was ever emitted.
"""
self._flush()
if self.n_windows == 0:
raise ValueError("zero windows written to the signal store")
pl.DataFrame(self._rows, schema=list(MANIFEST_COLUMNS)).write_parquet(
self.out_dir / MANIFEST_NAME
)
meta = {
"n_windows": self.n_windows,
"n_reads": self.n_reads,
"shard_windows": self.shard_windows,
**meta,
}
(self.out_dir / STORE_META).write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n")
return meta
def extract_store(cfg: ExtractConfig, labels: pl.DataFrame, out_dir: Path) -> Path:
"""Build a signal store from pod5 files and a labels table.
For each sorted pod5 path, reads with a non-null genus label and at
least ``min_read_samples`` samples are kept, sorted by read id, and
their first ``max_windows_per_read`` contiguous windows (after skipping
``skip_head_samples``) are normalised and written as shards. Fully
deterministic: no RNG anywhere in the window choice. Drop counters
(short/unlabelled) are reported in ``store_meta.json``.
Args:
cfg: Extraction parameters (paths, windowing, normaliser, dtype,
shard size).
labels: Validated labels table (``LABEL_COLUMNS``); reads with null
or empty genus are ignored.
out_dir: Directory the store is written into (created as needed).
Returns:
``out_dir`` (the store directory).
Raises:
ValueError: On empty ``pod5_paths``, missing label columns,
duplicate read ids, zero labelled reads, or zero surviving
windows.
FileNotFoundError: If a pod5 path is missing.
ImportError: If the ``pod5`` package is unavailable.
"""
validate_labels(labels)
if not cfg.pod5_paths:
raise ValueError("ExtractConfig.pod5_paths is empty")
labelled = labels.filter(pl.col("genus").is_not_null() & (pl.col("genus") != ""))
if labelled.height == 0:
raise ValueError("no reads with a non-null genus in the labels table")
info = {
row[0]: (row[1], row[2], row[3])
for row in zip(
labelled["read_id"].to_list(),
labelled["genus"].to_list(),
labelled["species"].to_list(),
labelled["source_run"].to_list(),
)
}
normalise = _NORMALISERS[cfg.norm]
dtype = np.dtype(np.float16 if cfg.dtype == "float16" else np.float32)
writer = _StoreWriter(out_dir, cfg.window_samples, dtype, cfg.shard_windows)
n_dropped_short = 0
n_dropped_unlabelled = 0
for pod5_path in sorted(Path(p) for p in cfg.pod5_paths):
kept: list[RawSignalRecord] = []
for record in read_pod5_records(pod5_path):
if record.read_id not in info:
n_dropped_unlabelled += 1
continue
if record.num_samples < cfg.min_read_samples:
n_dropped_short += 1
continue
kept.append(record)
kept.sort(key=lambda r: r.read_id)
for record in kept:
genus, species, source_run = info[record.read_id]
for w in range(cfg.max_windows_per_read):
start = cfg.skip_head_samples + w * cfg.window_samples
if start + cfg.window_samples > record.num_samples:
break
window = normalise(record.signal[start : start + cfg.window_samples])
writer.emit(window, record.read_id, genus, species, source_run, w)
writer.count_read()
writer.finish(
{
"synthetic": False,
"window_samples": cfg.window_samples,
"skip_head_samples": cfg.skip_head_samples,
"max_windows_per_read": cfg.max_windows_per_read,
"min_read_samples": cfg.min_read_samples,
"dtype": str(cfg.dtype),
"norm": cfg.norm,
"pod5_paths": [str(p) for p in sorted(Path(p) for p in cfg.pod5_paths)],
"n_labelled_reads": labelled.height,
"n_dropped_short": n_dropped_short,
"n_dropped_unlabelled": n_dropped_unlabelled,
}
)
return Path(out_dir)
def synthetic_store(
out_dir: Path,
n_genera: int = 11,
reads_per_genus: int = 200,
window_samples: int = 12_000,
period: int | None = None,
noise: float = 0.15,
low: float = 1.0,
high: float = 3.0,
seed: int = 0,
shard_windows: int = 8_192,
genus_prefix: str = "genus_",
) -> Path:
"""Build a synthetic store whose classes are square-wave duty cycles.
Genus ``g`` of ``n_genera`` is encoded with duty cycle
``(g + 1) / (n_genera + 1)`` over a square wave of ``period`` samples
with a per-read random phase and Gaussian noise. The encoding survives
median-IQR normalisation by construction (GATE ZERO §12.9), so a healthy
probe trains to near-perfect recall while the shuffled-label control
stays at chance. Windows go through the same writer/normalisation path
as real extractions.
Args:
out_dir: Store directory to create.
n_genera: Number of classes (distinct duties).
reads_per_genus: One window per synthetic read.
window_samples: Window length (samples).
period: Square-wave period; defaults to ``window_samples // 24``.
noise: Gaussian noise sigma added to the raw levels.
low: Raw low level (arbitrary units; erased by normalisation).
high: Raw high level.
seed: Seed for phase/noise draws.
shard_windows: Windows per shard file.
genus_prefix: Prefix for genus names (e.g. ``"trap_"`` for eval-only
impostor corpora).
Returns:
``out_dir`` (the store directory).
Raises:
ValueError: On ``n_genera < 2``, ``reads_per_genus < 1`` or a
degenerate ``period``.
"""
if n_genera < 2:
raise ValueError("synthetic corpus needs n_genera >= 2")
if reads_per_genus < 1:
raise ValueError("reads_per_genus must be >= 1")
if period is None:
period = max(4, window_samples // 24)
if period < 2:
raise ValueError("period must be >= 2")
rng = np.random.default_rng(seed)
dtype = np.dtype(np.float16)
writer = _StoreWriter(out_dir, window_samples, dtype, shard_windows)
for g in range(n_genera):
genus = f"{genus_prefix}{g:02d}"
duty = (g + 1) / (n_genera + 1)
for r in range(reads_per_genus):
read_id = f"{genus}_read_{r:06d}"
phase = float(rng.uniform(0.0, period))
offsets = (np.arange(window_samples, dtype=np.float64) + phase) % period
signal = np.where(offsets < duty * period, high, low)
signal = signal + rng.normal(0.0, noise, window_samples)
window = median_iqr_normalise(signal)
writer.emit(window, read_id, genus, None, "synthetic", 0)
writer.count_read()
writer.finish(
{
"synthetic": True,
"window_samples": window_samples,
"dtype": "float16",
"norm": "median_iqr",
"period": period,
"noise": noise,
"n_genera": n_genera,
"reads_per_genus": reads_per_genus,
"seed": seed,
}
)
return Path(out_dir)
class SignalStore:
"""Random-access reader for a signal store directory.
Validates the manifest schema and meta on construction, then serves
windows lazily from memory-mapped shards. Safe to share across
forked DataLoader workers (read-only memmaps).
"""
def __init__(self, store_dir: Path) -> None:
"""Open and validate the store.
Args:
store_dir: Directory holding ``manifest.parquet``,
``store_meta.json`` and ``shard_%05d.npy`` files.
Raises:
FileNotFoundError: If the manifest or store meta is missing.
ValueError: If manifest columns are missing, ``window_id`` is
not the row order, or required meta keys are absent.
"""
self.dir = Path(store_dir)
manifest_path = self.dir / MANIFEST_NAME
if not manifest_path.is_file():
raise FileNotFoundError(f"signal store manifest not found: {manifest_path}")
self._manifest = pl.read_parquet(manifest_path)
missing = sorted(set(MANIFEST_COLUMNS) - set(self._manifest.columns))
if missing:
raise ValueError(f"store manifest missing columns: {missing}")
window_ids = self._manifest["window_id"].to_numpy()
if not np.array_equal(window_ids, np.arange(self._manifest.height)):
raise ValueError("store manifest window_id must equal the row order")
meta_path = self.dir / STORE_META
if not meta_path.is_file():
raise FileNotFoundError(f"store meta not found: {meta_path}")
self._meta = json.loads(meta_path.read_text())
for key in ("window_samples", "shard_windows"):
if key not in self._meta:
raise ValueError(f"store_meta.json missing key: {key}")
self._shard_windows = int(self._meta["shard_windows"])
self._shards: dict[int, np.ndarray] = {}
def __len__(self) -> int:
"""Return the number of windows in the store."""
return self._manifest.height
def window_len(self) -> int:
"""Return the fixed window length in samples."""
return int(self._meta["window_samples"])
def manifest(self) -> pl.DataFrame:
"""Return the (cached) manifest DataFrame, one row per window."""
return self._manifest
def meta(self) -> dict[str, Any]:
"""Return a copy of ``store_meta.json`` contents."""
return dict(self._meta)
def _shard(self, index: int) -> np.ndarray:
"""Return the (memory-mapped) shard array covering ``index``.
Args:
index: Global window id.
Returns:
The shard's 2-D array (opened and cached on first use).
Raises:
FileNotFoundError: If the shard file is absent.
"""
shard = index // self._shard_windows
if shard not in self._shards:
path = self.dir / f"shard_{shard:05d}.npy"
if not path.is_file():
raise FileNotFoundError(f"missing signal shard: {path}")
self._shards[shard] = np.load(path, mmap_mode="r")
return self._shards[shard]
def get(self, index: int) -> np.ndarray:
"""Fetch one window as a detached float32 array.
Args:
index: Global window id (0-based, the manifest row order).
Returns:
float32 array of shape ``(window_len(),)``.
Raises:
IndexError: If ``index`` is out of range for the store.
"""
if index < 0 or index >= len(self):
raise IndexError(f"window index out of range: {index}")
arr = self._shard(index)
row = index % self._shard_windows
if row >= arr.shape[0]:
raise IndexError(f"window {index} beyond shard rows")
return np.asarray(arr[row], dtype=np.float32)
+466
View File
@@ -0,0 +1,466 @@
"""Training semantics: device/AMP policy, optimizer, loop and inference.
Encodes the training contract of the torch model spec §5: cross-entropy
with optional label smoothing, AdamW with no decay on norm/bias
parameters, linear-warmup cosine schedule to zero, L2 gradient clipping,
per-epoch validation with early stopping on ``val_recall_macro`` (the
headline gate metric) and best-epoch checkpointing to CPU tensors. All
reported metrics are computed from CPU numpy logits so MPS/CUDA numeric
differences cannot enter the reported numbers.
"""
from __future__ import annotations
import math
import time
from contextlib import ContextDecorator, nullcontext
from dataclasses import dataclass
from typing import Any
import numpy as np
import polars as pl
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from .config import TrainConfig
from .data import Splits, make_loaders
from .metrics import macro_recall
from .models import GenusProbe
from .store import SignalStore
def select_device(requested: str | None) -> torch.device:
"""Resolve the compute device (cuda -> mps -> cpu, never hard-coded CUDA).
Args:
requested: ``None``/``""``/``"auto"`` picks the first available of
cuda, mps, cpu; otherwise a torch device string such as
``"cpu"``, ``"cuda"`` or ``"cuda:1"``.
Returns:
The resolved ``torch.device``.
Raises:
ValueError: If ``requested`` is not parseable as a device, names
an unsupported type, or names cuda/mps on a machine where it
is unavailable.
"""
if requested is None or requested in ("", "auto"):
if torch.cuda.is_available():
return torch.device("cuda")
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
try:
device = torch.device(requested)
except RuntimeError as exc:
raise ValueError(f"invalid device: {requested!r}") from exc
if device.type == "cuda" and not torch.cuda.is_available():
raise ValueError("cuda requested but torch.cuda.is_available() is False")
if device.type == "mps" and not (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
):
raise ValueError("mps requested but not available")
if device.type not in ("cuda", "mps", "cpu"):
raise ValueError(f"unsupported device type: {device.type}")
return device
def resolve_amp(amp: str, device: torch.device) -> str:
"""Map a requested AMP policy onto what this device actually supports.
Policy (spec §5): ``bf16`` where supported (CUDA with bf16 tensors,
recent MPS) else silently fp32; ``fp16`` on CUDA only (GradScaler
handles it); ``off`` is fp32 everywhere; no autocast on CPU for
reproducibility. Idempotent — passing an already-resolved policy
returns it unchanged.
Args:
amp: Requested policy: ``"off"``, ``"bf16"`` or ``"fp16"`` (an
already-resolved ``"fp32"`` also round-trips).
device: Device the policy is resolved against.
Returns:
Effective policy: ``"fp32"``, ``"bf16"`` or ``"fp16"``.
"""
if amp == "off":
return "fp32"
if device.type == "cuda":
if amp == "bf16":
return "bf16" if torch.cuda.is_bf16_supported() else "fp32"
if amp == "fp16":
return "fp16"
if device.type == "mps" and amp == "bf16":
try:
with torch.autocast(device_type="mps", dtype=torch.bfloat16):
torch.tensor([1.0]).mul(2.0)
return "bf16"
except (RuntimeError, TypeError, ValueError):
return "fp32"
if amp == "fp16" and device.type != "cuda":
print(
f"warning: amp={amp!r} is CUDA-only, running fp32 on {device.type}"
)
return "fp32"
return "fp32"
def autocast_context(policy: str, device: torch.device) -> ContextDecorator:
"""Build the autocast context for an effective AMP policy.
Args:
policy: Effective policy from :func:`resolve_amp`
(``"fp32"``/``"bf16"``/``"fp16"``).
device: Device the ops run on.
Returns:
A usable context manager — ``torch.autocast`` for bf16/fp16 on
accelerators, a no-op context for fp32 and for any policy on CPU.
"""
if policy in ("fp32", "off") or device.type == "cpu":
return nullcontext()
dtype = torch.bfloat16 if policy == "bf16" else torch.float16
return torch.autocast(device_type=device.type, dtype=dtype)
def build_optimizer(
probe: GenusProbe, cfg: TrainConfig, steps_per_epoch: int
) -> tuple[torch.optim.Optimizer, torch.optim.lr_scheduler.LambdaLR]:
"""Create AdamW with the no-decay split and warmup-cosine schedule.
Parameters with ``ndim <= 1`` or a ``bias`` name (all LayerNorm
weights, every bias) go into a zero-decay group; everything else gets
``cfg.weight_decay`` decoupled decay. The LambdaLR factor ramps
linearly from 0 to 1 over ``warmup_frac`` of the total steps, then
cosines from 1 to 0 across the remainder (the schedule therefore hits
zero exactly at ``max_epochs``).
Args:
probe: The probe whose parameters are optimised.
cfg: Training config (lr, weight decay, epochs, warmup fraction).
steps_per_epoch: Optimiser steps per epoch (``len(train_loader)``
with ``drop_last=True``).
Returns:
``(optimizer, scheduler)``; the caller steps the scheduler once
per optimiser step.
"""
decay: list[torch.nn.Parameter] = []
no_decay: list[torch.nn.Parameter] = []
for name, param in probe.named_parameters():
if not param.requires_grad:
continue
if param.ndim <= 1 or name.endswith("bias"):
no_decay.append(param)
else:
decay.append(param)
groups = [
{"params": no_decay, "weight_decay": 0.0},
{"params": decay, "weight_decay": cfg.weight_decay},
]
optimizer = torch.optim.AdamW(groups, lr=cfg.lr)
total_steps = max(1, steps_per_epoch * cfg.max_epochs)
warmup_steps = max(1, int(cfg.warmup_frac * total_steps))
def lr_lambda(step: int) -> float:
"""Linear-warmup cosine LR factor for LambdaLR.
Ramps from 0 → 1 over ``warmup_steps``, then cosines from 1 → 0
across the remaining steps so the schedule hits zero at
``max_epochs``.
Args:
step: Current global optimiser step (0-based).
Returns:
LR multiplier in [0, 1].
"""
if step < warmup_steps:
return (step + 1) / warmup_steps
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
return 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress)))
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
return optimizer, scheduler
def _mean_cross_entropy(logits: np.ndarray, y: np.ndarray) -> float:
"""Mean cross-entropy of CPU logits (plain, no label smoothing).
Args:
logits: ``(N, C)`` score matrix.
y: ``(N,)`` int class ids.
Returns:
The mean negative log-likelihood under a numerically stable
log-softmax.
"""
z = logits - logits.max(axis=1, keepdims=True)
log_probs = z - np.log(np.exp(z).sum(axis=1, keepdims=True))
return float(-log_probs[np.arange(len(y)), y].mean())
@dataclass
class TrainResult:
"""Outcome of one training run.
Attributes:
best_state: Weights of the best epoch (CPU tensor copies) — what
``ckpt.pt`` stores; not final-epoch weights.
best_epoch: 1-based epoch whose metric won (or -1 if training
never improved).
history: Per-epoch table: epoch, lr, train_loss, val_loss,
val_acc, val_recall_macro, seconds (also written to
``history.parquet``).
seconds: Total wall time over all epochs run.
"""
best_state: dict[str, torch.Tensor]
best_epoch: int
history: pl.DataFrame
seconds: float
def train_probe(
probe: GenusProbe,
store: SignalStore,
splits: Splits,
cfg: TrainConfig,
out_dir: Any,
) -> TrainResult:
"""Train a probe with early stopping and write ``history.parquet``.
Per epoch: full train pass (AMP per the resolved policy, GradScaler
for fp16, unscaled L2 grad clipping), then validation via
:func:`predict`; the early-stop metric improves or the patience
counter advances. The best epoch's weights are copied to CPU and
restored into the probe before returning. If the projected wall time
over ``max_epochs`` exceeds 3 h, a warning is printed (the GPU-hour
budget is a constraint, not a suggestion).
Args:
probe: The probe to train (moved to the resolved device).
store: Store the splits read from.
splits: Split window ids and labels (val must be non-empty).
cfg: Training semantics.
out_dir: Directory for ``history.parquet`` (the run directory;
created as needed).
Returns:
The :class:`TrainResult` (best weights, history, timing).
Raises:
ValueError: On invalid epochs/metric, a train split smaller than
the batch size (``drop_last`` would empty it) or an empty
train loader.
RuntimeError: If the validation split is empty (nothing to select
on).
"""
import pathlib
out_dir = pathlib.Path(out_dir)
if cfg.max_epochs < 1:
raise ValueError("max_epochs must be >= 1")
if cfg.early_stop_metric not in ("val_recall_macro", "val_loss"):
raise ValueError(f"unknown early_stop_metric: {cfg.early_stop_metric}")
if len(splits.val) == 0:
raise RuntimeError("validation split is empty")
if len(splits.train) < cfg.batch_size:
raise ValueError(
f"train split ({len(splits.train)}) smaller than batch size ({cfg.batch_size})"
)
device = select_device(cfg.device)
amp = resolve_amp(cfg.amp, device)
train_loader, val_loader, _ = make_loaders(store, splits, cfg)
probe = probe.to(device)
steps_per_epoch = len(train_loader)
if steps_per_epoch == 0:
raise ValueError("train loader is empty")
optimizer, scheduler = build_optimizer(probe, cfg, steps_per_epoch)
scaler = torch.amp.GradScaler(device.type) if amp == "fp16" else None
maximize = cfg.early_stop_metric == "val_recall_macro"
best_metric = -math.inf if maximize else math.inf
best_epoch = -1
best_state: dict[str, torch.Tensor] | None = None
bad_epochs = 0
rows: list[dict[str, float]] = []
n_classes = int(probe.head.out_features)
start = time.perf_counter()
epochs_run = 0
for epoch in range(1, cfg.max_epochs + 1):
epoch_start = time.perf_counter()
probe.train()
running_loss = 0.0
n_seen = 0
for windows, labels in train_loader:
windows = windows.to(device, non_blocking=device.type == "cuda")
labels = labels.to(device, non_blocking=device.type == "cuda")
optimizer.zero_grad(set_to_none=True)
with autocast_context(amp, device):
logits = probe(windows)
loss = F.cross_entropy(
logits, labels, label_smoothing=cfg.label_smoothing
)
if scaler is not None:
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
if cfg.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(
(p for g in optimizer.param_groups for p in g["params"]),
cfg.grad_clip,
)
scaler.step(optimizer)
scaler.update()
else:
loss.backward()
if cfg.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(
(p for g in optimizer.param_groups for p in g["params"]),
cfg.grad_clip,
)
optimizer.step()
scheduler.step()
running_loss += float(loss.item()) * windows.shape[0]
n_seen += windows.shape[0]
train_loss = running_loss / max(1, n_seen)
val_logits, val_y = predict(probe, val_loader, device, amp)
val_pred = val_logits.argmax(axis=1)
val_loss = _mean_cross_entropy(val_logits, val_y)
val_acc = float((val_pred == val_y).mean()) if len(val_y) else float("nan")
val_recall = macro_recall(val_y, val_pred, n_classes)
seconds = time.perf_counter() - epoch_start
metric = val_recall if maximize else val_loss
improved = metric > best_metric if maximize else metric < best_metric
if improved:
best_metric = metric
best_epoch = epoch
best_state = {
key: value.detach().to("cpu").clone()
for key, value in probe.state_dict().items()
}
bad_epochs = 0
else:
bad_epochs += 1
rows.append(
{
"epoch": epoch,
"lr": optimizer.param_groups[0]["lr"],
"train_loss": train_loss,
"val_loss": val_loss,
"val_acc": val_acc,
"val_recall_macro": val_recall,
"seconds": seconds,
}
)
epochs_run = epoch
if bad_epochs >= cfg.patience:
break
total_seconds = time.perf_counter() - start
if best_state is None:
raise RuntimeError("training completed without a best epoch")
probe.load_state_dict(best_state)
history = pl.DataFrame(rows)
out_dir.mkdir(parents=True, exist_ok=True)
history.write_parquet(out_dir / "history.parquet")
projected = (total_seconds / max(1, epochs_run)) * cfg.max_epochs
if projected > 3 * 3600:
print(
f"warning: run projects to {projected / 3600:.1f} h over {cfg.max_epochs} epochs "
"(budget guideline is 1-3 h per run)"
)
return TrainResult(
best_state=best_state,
best_epoch=best_epoch,
history=history,
seconds=total_seconds,
)
def predict(
probe: GenusProbe,
loader: DataLoader,
device: torch.device,
amp: str,
) -> tuple[np.ndarray, np.ndarray]:
"""Run the probe over a loader and collect logits on CPU.
The module is switched to eval mode for the pass and its prior mode is
restored on exit; gradients are disabled throughout.
Args:
probe: The probe to evaluate (already on ``device``).
loader: Any window loader (its labels are returned alongside).
device: Compute device.
amp: Requested or already-resolved AMP policy (re-resolved
idempotently).
Returns:
``(logits (N, C) float32 CPU, labels (N,) int64)`` in loader
order; empty loaders yield correctly-shaped empty arrays.
"""
policy = resolve_amp(amp, device)
was_training = probe.training
probe.eval()
logits_chunks: list[np.ndarray] = []
label_chunks: list[np.ndarray] = []
with torch.no_grad():
for windows, labels in loader:
windows = windows.to(device, non_blocking=device.type == "cuda")
with autocast_context(policy, device):
out = probe(windows)
logits_chunks.append(out.detach().to("cpu").float().numpy())
label_chunks.append(labels.numpy())
probe.train(was_training)
if not logits_chunks:
return (
np.zeros((0, int(probe.head.out_features)), dtype=np.float32),
np.zeros(0, dtype=np.int64),
)
return np.concatenate(logits_chunks), np.concatenate(label_chunks)
def embed_probe(
probe: GenusProbe,
loader: DataLoader,
device: torch.device,
amp: str,
) -> np.ndarray:
"""Run the probe's ``embed()`` over a loader and collect embeddings.
Companion to :func:`predict` for the E3 reuse path (e.g. trap centroid
vectors); same eval-mode/grad-free semantics.
Args:
probe: The probe whose encoder embedding is extracted.
loader: Any window loader (labels ignored).
device: Compute device.
amp: Requested or already-resolved AMP policy.
Returns:
Embeddings of shape ``(N, d_embed)`` float32 on CPU.
"""
policy = resolve_amp(amp, device)
was_training = probe.training
probe.eval()
chunks: list[np.ndarray] = []
with torch.no_grad():
for windows, _ in loader:
windows = windows.to(device, non_blocking=device.type == "cuda")
with autocast_context(policy, device):
out = probe.embed(windows)
chunks.append(out.detach().to("cpu").float().numpy())
probe.train(was_training)
if not chunks:
return np.zeros((0, int(probe.head.in_features)), dtype=np.float32)
return np.concatenate(chunks)
+50
View File
@@ -0,0 +1,50 @@
"""Shared fixtures: fast synthetic stores (no pod5 needed) and determinism."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from custom_models.seed import seed_everything
from custom_models.store import synthetic_store
@pytest.fixture(autouse=True)
def _seed_every_test() -> None:
"""Seed all RNGs before each test (the GATE ZERO §2.3 contract)."""
seed_everything(0)
@pytest.fixture(scope="session")
def fast_store(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A small 6-genus duty-cycle store for split/config tests."""
out = tmp_path_factory.mktemp("store") / "main"
synthetic_store(
out_dir=out,
n_genera=6,
reads_per_genus=12,
window_samples=1200,
shard_windows=8,
seed=0,
)
return out
@pytest.fixture(scope="session")
def trap_store(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A small eval-only impostor store (and test-split source)."""
out = tmp_path_factory.mktemp("store") / "trap"
synthetic_store(
out_dir=out,
n_genera=2,
reads_per_genus=6,
window_samples=1200,
shard_windows=8,
seed=1,
genus_prefix="trap_",
)
return out
+136
View File
@@ -0,0 +1,136 @@
"""Torch module acceptance tests: budget resolution, geometry and shapes."""
from __future__ import annotations
import pytest
import torch
from custom_models.config import ModelConfig
from custom_models.models import (
ConvEncoder,
GenusProbe,
LinearAttention,
LinearAttentionEncoder,
SignalPatchEmbed,
build_probe,
count_params,
)
from custom_models.seed import seed_everything
N_CLASSES = 11
BUDGETS = (1_000_000, 5_000_000, 20_000_000, 50_000_000, 100_000_000)
@pytest.mark.parametrize("n_classes", [4, 8])
@pytest.mark.parametrize("budget", BUDGETS)
@pytest.mark.parametrize("arch", ["cnn", "linatt"])
def test_build_probe_within_budget(arch: str, budget: int, n_classes: int) -> None:
cfg = ModelConfig(arch=arch, param_budget=budget, stride=4)
probe, realised = build_probe(cfg, n_classes)
ceiling = int(1.1 * budget)
assert realised.params_realised <= ceiling
assert realised.params_realised == count_params(probe)
assert realised.d_model is not None and realised.n_layers is not None
assert realised.param_budget == budget
assert probe.head.out_features == n_classes
def test_build_probe_cnn_five_million_geometry_is_stable() -> None:
cfg = ModelConfig(arch="cnn", param_budget=5_000_000, stride=4)
probe_a, realised_a = build_probe(cfg, 11)
_probe_b, realised_b = build_probe(cfg, 11)
assert realised_a.d_model == realised_b.d_model
assert realised_a.n_layers == realised_b.n_layers
assert realised_a.params_realised == realised_b.params_realised
assert realised_a.params_realised == count_params(probe_a)
def test_build_probe_deterministic_state() -> None:
seed_everything(0)
cfg = ModelConfig(arch="cnn", param_budget=1_000_000, stride=4)
probe_a, _ = build_probe(cfg, 4)
seed_everything(0)
probe_b, _ = build_probe(cfg, 4)
for (a_name, a_val), (b_name, b_val) in zip(
probe_a.state_dict().items(), probe_b.state_dict().items()
):
assert a_name == b_name
assert torch.equal(a_val, b_val)
def test_build_probe_no_candidate_raises() -> None:
cfg = ModelConfig(arch="cnn", param_budget=100, stride=4)
with pytest.raises(ValueError, match=r"no .+ candidate"):
build_probe(cfg, 4)
def test_genus_probe_rejects_single_class() -> None:
encoder = ConvEncoder(16, 1, 4, 4, 8)
with pytest.raises(ValueError, match="n_classes must be >= 2"):
GenusProbe(encoder, 8, 1)
@pytest.mark.parametrize(("stride", "expected_tokens"), [(4, 3000), (8, 1500)])
def test_patch_embed_token_count(stride: int, expected_tokens: int) -> None:
embed = SignalPatchEmbed(d_model=16, patch_len=min(4, stride), stride=stride)
x = torch.zeros(2, 12_000)
tokens = embed(x)
assert tokens.shape == (2, expected_tokens, 16)
@pytest.mark.parametrize("stride", [4, 8])
def test_forward_shapes_and_finiteness(stride: int) -> None:
cfg = ModelConfig(arch="cnn", param_budget=100_000, stride=stride)
probe, realised = build_probe(cfg, N_CLASSES)
x = torch.zeros(2, 1200)
logits = probe(x)
assert logits.shape == (2, N_CLASSES)
assert torch.isfinite(logits).all()
emb = probe.embed(x)
assert emb.shape == (2, realised.d_embed)
def test_encoder_rejects_indivisible_window() -> None:
embed = SignalPatchEmbed(d_model=16, patch_len=4, stride=8)
with pytest.raises(ValueError, match="not divisible"):
embed(torch.zeros(1, 12_002))
def test_no_batchnorm_and_uniform_pool_at_init() -> None:
cfg = ModelConfig(arch="cnn", param_budget=100_000, stride=4)
probe, _ = build_probe(cfg, N_CLASSES)
assert not any("BatchNorm" in type(m).__name__ for m in probe.modules())
encoder = probe.encoder
assert isinstance(encoder, ConvEncoder)
assert torch.count_nonzero(encoder.score.weight) == 0
assert torch.count_nonzero(encoder.score.bias) == 0
def test_conv_pool_starts_as_plain_mean() -> None:
encoder = ConvEncoder(d_model=16, n_layers=1, patch_len=4, stride=4, d_embed=8)
x = torch.randn(2, 1200)
h = encoder.embed(x)
for block in encoder.blocks:
h = block(h)
expected = encoder.proj(h.mean(dim=1))
assert torch.allclose(encoder(x), expected, atol=1e-5)
scores = torch.softmax(encoder.score(h), dim=1)
assert torch.allclose(scores, torch.full_like(scores, 1.0 / scores.shape[1]))
def test_linear_attention_heads_guard() -> None:
with pytest.raises(ValueError, match="divisible"):
LinearAttention(d_model=32, n_heads=5)
with pytest.raises(ValueError, match="n_heads must be >= 1"):
LinearAttention(d_model=32, n_heads=0)
def test_linear_attention_encoder_mean_pool() -> None:
encoder = LinearAttentionEncoder(16, 1, 4, 4, 8, n_heads=4)
x = torch.randn(2, 1200)
out = encoder(x)
assert out.shape == (2, 8)
h = encoder.embed(x)
for block in encoder.blocks:
h = block(h)
assert torch.allclose(encoder.proj(h.mean(dim=1)), out, atol=1e-5)
+416
View File
@@ -0,0 +1,416 @@
"""Pipeline acceptance tests: store, splits, metrics, training and the CLI."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import polars as pl
import pytest
import torch
from custom_models.cli import main as cli_main
from custom_models.config import (
DataConfig,
ModelConfig,
RunConfig,
TrainConfig,
load_config,
run_id_for,
save_config,
)
from custom_models.data import make_splits, permute_read_labels
from custom_models.metrics import choose_tau_msp, closed_set_report, msp
from custom_models.runner import evaluate_test, evaluate_val, train_run
from custom_models.seed import seed_everything
from custom_models.store import SignalStore, synthetic_store
from custom_models.train import select_device
# ---------------------------------------------------------------- store
def test_synthetic_store_roundtrip(tmp_path: Path) -> None:
out = tmp_path / "s"
synthetic_store(
out_dir=out,
n_genera=3,
reads_per_genus=4,
window_samples=1200,
shard_windows=4,
seed=0,
)
store = SignalStore(out)
assert len(store) == 3 * 4
assert store.window_len() == 1200
meta = store.meta()
assert meta["synthetic"] is True
assert meta["n_genera"] == 3
assert meta["n_windows"] == len(store)
windows = [store.get(i) for i in range(len(store))]
assert all(w.shape == (1200,) and w.dtype == np.float32 for w in windows)
manifest = store.manifest()
assert set(manifest.columns) >= {
"window_id",
"read_id",
"genus",
"species",
"source_run",
"window_index",
"n_samples",
}
assert manifest["window_id"].to_list() == list(range(len(store)))
genera = manifest["genus"].unique().sort().to_list()
assert genera == [f"genus_{i:02d}" for i in range(3)]
def test_store_labels_and_split_determinism(fast_store: Path) -> None:
store = SignalStore(fast_store)
manifest = store.manifest()
selected = ("genus_00", "genus_01", "genus_02")
cfg = DataConfig(genera=selected, min_test_windows_per_genus=0, seed=0)
a = make_splits(manifest, cfg)
b = make_splits(manifest, cfg)
assert all(
np.array_equal(x, y) for x, y in zip((a.train, a.val, a.test), (b.train, b.val, b.test))
)
assert a.genera == list(selected)
# read-level purity: every window of a read shares one split
read_arr = manifest["read_id"].to_numpy()
wid_arr = manifest["window_id"].to_numpy()
split_of = {int(w): 0 for w in a.train}
split_of.update({int(w): 1 for w in a.val})
split_of.update({int(w): 2 for w in a.test})
by_read: dict[str, set[int]] = {}
for wid, read_id in zip(wid_arr, read_arr):
if int(wid) in split_of: # windows of unselected genera have no split
by_read.setdefault(str(read_id), set()).add(split_of[int(wid)])
assert all(len(v) == 1 for v in by_read.values())
# a different data seed re-shuffles the read assignment
c = make_splits(manifest, DataConfig(genera=selected, min_test_windows_per_genus=0, seed=1))
assert not np.array_equal(a.train, c.train)
# each read keeps its genus label; class ids map onto splits.genera
assert len(a.labels) == len(manifest)
assert (a.labels[a.train] >= 0).all()
# ---------------------------------------------------------------- splits
def test_make_splits_min_test_windows_guard(trap_store: Path) -> None:
store = SignalStore(trap_store)
with pytest.raises(ValueError, match="min_test_windows_per_genus"):
make_splits(
store.manifest(),
DataConfig(genera=("trap_00", "trap_01"), min_test_windows_per_genus=10**6),
)
def test_make_splits_holdout_runs_guard(fast_store: Path) -> None:
store = SignalStore(fast_store)
manifest = store.manifest()
# two source_runs, alternating per read: holdout picks news_run_*1 for test
runs = [
"news_run_0" if i % 2 == 0 else "news_run_1"
for i, read_id in enumerate(manifest["read_id"].unique().sort().to_list())
]
split_map = dict(zip(manifest["read_id"].unique().sort().to_list(), runs))
two_runs = manifest.with_columns(
pl.col("read_id").replace_strict(split_map).alias("source_run")
)
assert (
len(make_splits(two_runs, DataConfig(holdout_runs=True, min_test_windows_per_genus=1)).test)
> 0
)
single_run = manifest.with_columns(pl.lit("only_run").alias("source_run"))
with pytest.raises(ValueError, match="holdout_runs"):
make_splits(single_run, DataConfig(holdout_runs=True, min_test_windows_per_genus=0))
def test_permute_read_labels_preserves_counts(fast_store: Path) -> None:
store = SignalStore(fast_store)
manifest = store.manifest()
pd = permute_read_labels(manifest, (), 3)
counts_before = manifest["genus"].value_counts().sort("genus")
counts_after = pd["genus"].value_counts().sort("genus")
assert counts_before.equals(counts_after)
# windows keep their read's label: read -> single genus before and after
for frame in (manifest, pd):
per_read = frame.group_by("read_id").agg(pl.col("genus").n_unique().alias("k"))
assert per_read["k"].max() == 1
pd2 = permute_read_labels(manifest, (), 3)
assert pd.equals(pd2)
with pytest.raises(ValueError, match="at least two reads"):
permute_read_labels(manifest.filter(pl.col("read_id") == manifest["read_id"][0]), (), 0)
# ---------------------------------------------------------------- configs
def test_config_roundtrip(tmp_path: Path) -> None:
cfg = RunConfig(
run_id="cnn_1.0M_s4_g11",
stage="arch_ladder",
store_dir=Path("/data/store"),
data=DataConfig(genera=("A", "B"), val_fraction=0.2, seed=7),
model=ModelConfig(
arch="cnn",
param_budget=1_000_000,
stride=4,
d_model=64,
n_layers=8,
params_realised=912_345,
),
train=TrainConfig(lr=1e-3, seed=3),
out_dir=Path("/runs"),
genera=("A", "B"),
notes="hello",
)
path = tmp_path / "config.json"
save_config(cfg, path)
assert load_config(path) == cfg
def test_run_id_format() -> None:
assert run_id_for("cnn", 1_000_000, 4, 11, "arch_ladder") == "cnn_1.0M_s4_g11"
assert run_id_for("linatt", 5_000_000, 8, 6, "windows_ablation") == "linatt_5.0M_s8_g6"
assert run_id_for("cnn", 1_000_000, 4, 11, "control") == "cnn_1.0M_s4_g11-shuf"
# ---------------------------------------------------------------- metrics
def test_msp_and_choose_tau() -> None:
logits = np.array([[10.0, 0.0], [0.0, 10.0], [1.0, 1.0]])
values = msp(logits)
assert np.all(values >= 0.5) and values[2] == pytest.approx(0.5)
with pytest.raises(ValueError):
msp(np.array([1.0, 0.0]))
with pytest.raises(ValueError):
choose_tau_msp(np.array([]))
arr = np.linspace(0.1, 0.9, 9)
tau = choose_tau_msp(arr, target_known_recall=0.95)
assert tau == pytest.approx(np.quantile(arr, 0.05))
def test_closed_set_report_perfect_predictions() -> None:
y = np.array([0, 0, 1, 1, 2])
report = closed_set_report(y, y.copy(), ["a", "b", "c"], n_boot=50, seed=0)
assert report.recall_macro == pytest.approx(1.0)
assert report.recall_micro == pytest.approx(1.0)
assert report.f1_macro == pytest.approx(1.0)
assert (report.confusion == np.diag(np.diag(report.confusion))).all()
assert report.ci["recall_macro"] == (1.0, 1.0)
# ---------------------------------------------------------------- training
def test_trainability_duty_cycle(tmp_path: Path) -> None:
seed_everything(0)
store_dir = tmp_path / "s"
synthetic_store(
store_dir, n_genera=2, reads_per_genus=12, window_samples=1200, shard_windows=12
)
cfg = RunConfig(
run_id="",
stage="arch_ladder",
store_dir=store_dir,
data=DataConfig(
genera=(), val_fraction=0.25, test_fraction=0.25, min_test_windows_per_genus=1, seed=0
),
model=ModelConfig(arch="cnn", param_budget=100_000, stride=4),
train=TrainConfig(
max_epochs=25,
patience=25,
num_workers=0,
amp="off",
device="cpu",
batch_size=8,
),
out_dir=tmp_path / "runs",
)
realised, _result, _splits = train_run(cfg)
run_dir = realised.out_dir / realised.run_id
assert (run_dir / "history.parquet").is_file()
val_payload = evaluate_val(run_dir, n_boot=0)
assert val_payload["closed"]["recall_macro"] >= 0.9
def test_control_run_lands_at_chance(tmp_path: Path) -> None:
seed_everything(1)
store_dir = tmp_path / "s"
synthetic_store(
store_dir, n_genera=3, reads_per_genus=12, window_samples=1200, shard_windows=12
)
cfg = RunConfig(
run_id="",
stage="control",
store_dir=store_dir,
data=DataConfig(genera=(), val_fraction=0.25, min_test_windows_per_genus=1, seed=0),
model=ModelConfig(arch="cnn", param_budget=100_000, stride=4),
train=TrainConfig(
max_epochs=4, patience=4, num_workers=0, amp="off", device="cpu", batch_size=8
),
out_dir=tmp_path / "runs",
)
realised, _result, _splits = train_run(cfg)
assert realised.run_id.endswith("-shuf")
payload = evaluate_test(realised.out_dir / realised.run_id, n_boot=0)
chance = 1.0 / payload["n_classes"]
assert 0.0 <= payload["closed"]["recall_macro"] <= 1.5 * chance
# ---------------------------------------------------------------- CLI
def _cli_store_cmd(store_dir: Path) -> int:
return cli_main(
[
"store",
"--synthetic",
"--out",
str(store_dir),
"--n-genera",
"4",
"--reads-per-genus",
"8",
"--window-samples",
"1200",
"--shard-windows",
"64",
"--seed",
"0",
]
)
def test_cli_end_to_end(tmp_path: Path) -> None:
store_dir = tmp_path / "cli_store"
assert _cli_store_cmd(store_dir) == 0
out_dir = tmp_path / "runs"
assert (
cli_main(
[
"train",
"--store",
str(store_dir),
"--out-dir",
str(out_dir),
"--arch",
"cnn",
"--budget",
"100000",
"--batch",
"8",
"--epochs",
"3",
"--patience",
"3",
"--workers",
"0",
"--min-test-windows",
"1",
"--amp",
"off",
"--device",
"cpu",
"--stage",
"arch_ladder",
]
)
== 0
)
run_dir = out_dir / "cnn_0.1M_s4_g4"
assert (run_dir / "config.json").is_file()
assert (run_dir / "ckpt.pt").is_file()
assert cli_main(["validate", "--run-dir", str(run_dir), "--n-boot", "50"]) == 0
assert (run_dir / "val_report.json").is_file()
assert cli_main(["test", "--run-dir", str(run_dir), "--n-boot", "50"]) == 0
assert (run_dir / "report.json").is_file()
# a control run for the gate, then the verdict
assert (
cli_main(
[
"train",
"--store",
str(store_dir),
"--out-dir",
str(out_dir),
"--arch",
"cnn",
"--budget",
"100000",
"--batch",
"8",
"--epochs",
"3",
"--patience",
"3",
"--workers",
"0",
"--min-test-windows",
"1",
"--amp",
"off",
"--device",
"cpu",
"--control",
]
)
== 0
)
control_dir = out_dir / "cnn_0.1M_s4_g4-shuf"
assert cli_main(["test", "--run-dir", str(control_dir), "--n-boot", "50"]) == 0
assert cli_main(["gate", "--runs", str(run_dir), "--control", str(control_dir)]) == 0
assert (out_dir / "gate_verdict.json").is_file()
def test_cli_test_with_trap_store(tmp_path: Path, trap_store: Path) -> None:
store_dir = tmp_path / "s"
synthetic_store(
store_dir, n_genera=4, reads_per_genus=10, window_samples=1200, shard_windows=64
)
out_dir = tmp_path / "runs"
assert cli_main(["train", "--store", str(store_dir), "--out-dir", str(out_dir),
"--arch", "cnn", "--budget", "100000", "--batch", "8", "--epochs", "2",
"--min-test-windows", "1", "--workers", "0", "--amp", "off",
"--device", "cpu"]) == 0
run_dir = out_dir / "cnn_0.1M_s4_g4"
assert (
cli_main(
["test", "--run-dir", str(run_dir), "--trap-store", str(trap_store), "--n-boot", "50"]
)
== 0
)
report = json.loads((run_dir / "report.json").read_text())
assert report["trap"] is not None
assert "trap_fpr_at_tau" in report["trap"]
assert (run_dir / "trap_report.json").is_file()
def test_build_subcommand_prints_realised(capsys: pytest.CaptureFixture) -> None:
assert cli_main(["build", "--arch", "cnn", "--budget", "1000000", "--n-classes", "11"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["params_realised"] <= int(1.1 * 1_000_000)
assert payload["params_realised"] == payload["count_params"]
assert payload["n_classes"] == 11
# ---------------------------------------------------------------- device/AMP
def test_select_device_and_amp_policies() -> None:
assert select_device("cpu").type == "cpu"
assert select_device("").type == "cpu"
with pytest.raises(ValueError):
select_device("cuda")
with pytest.raises(ValueError):
select_device("not-a-device")
device = torch.device("cpu")
from custom_models.train import resolve_amp
assert resolve_amp("off", device) == "fp32"
assert resolve_amp("bf16", device) == "fp32"
assert resolve_amp("fp32", device) == "fp32"
assert resolve_amp("fp16", device) == "fp32"
Generated
+866
View File
@@ -0,0 +1,866 @@
version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform != 'darwin'",
"python_full_version >= '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and sys_platform != 'darwin'",
"python_full_version < '3.12' and sys_platform == 'darwin'",
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "custom-models"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "polars" },
{ name = "torch", version = "2.14.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
{ name = "torch", version = "2.14.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" },
]
[package.optional-dependencies]
dev = [
{ name = "pod5" },
{ name = "pytest" },
{ name = "ruff" },
]
pod5 = [
{ name = "pod5" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=2.0,<3" },
{ name = "pod5", marker = "extra == 'dev'" },
{ name = "pod5", marker = "extra == 'pod5'" },
{ name = "polars", specifier = ">=1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "torch", specifier = ">=2.6", index = "https://download.pytorch.org/whl/cpu" },
]
provides-extras = ["pod5", "dev"]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "filelock"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" },
]
[[package]]
name = "fsspec"
version = "2026.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" },
]
[[package]]
name = "h5py"
version = "3.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" },
{ url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" },
{ url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" },
{ url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" },
{ url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" },
{ url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" },
{ url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" },
{ url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" },
{ url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" },
{ url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" },
{ url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" },
{ url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" },
{ url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" },
{ url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" },
{ url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" },
{ url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" },
{ url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" },
{ url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" },
{ url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" },
{ url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" },
{ url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" },
{ url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" },
{ url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" },
{ url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" },
{ url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" },
{ url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" },
{ url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "iso8601"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "lib-pod5"
version = "0.3.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/4e/788343abb38686452b5faf058975229b9be13ab9a4af5309ad430bff2d05/lib_pod5-0.3.48-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3417cdc923aa6fe0e5969355c658535148beacfa1f88e8268004079d18aef776", size = 2867133, upload-time = "2026-09-21T12:10:26.422Z" },
{ url = "https://files.pythonhosted.org/packages/c1/d5/3e785c2ba5f2fcbc56c083b7fb79d75ded722ce662c698da56bf8c48bd81/lib_pod5-0.3.48-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8ba21fb068120646db42ffecfe2e7f97bfd5343e3e9d139609afb337ea9b4076", size = 3821233, upload-time = "2026-09-21T12:10:28.04Z" },
{ url = "https://files.pythonhosted.org/packages/da/df/343eb43119ef7cefcbbd7b7c63ecf086cd722fc1256a9a9aa22d336da074/lib_pod5-0.3.48-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:deb0085c3cc15f5de23133312b34ac062fb8ef92ccaceed42e82d909d0be5729", size = 4230017, upload-time = "2026-09-21T12:10:29.792Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/2f0761c2f3f76b8c01c4d2de78c1b7231fdbe4a1e2524879f97a0f008d28/lib_pod5-0.3.48-cp311-cp311-win_amd64.whl", hash = "sha256:7f89ef6eae54df747814af2b9971d6c13acacae05f5f988373d8753d11d8892b", size = 3640682, upload-time = "2026-09-21T12:10:31.78Z" },
{ url = "https://files.pythonhosted.org/packages/79/47/72f7acfd5587f408a9113bfbf2cc8a2a642d3fa3f5d0434241e894d3f1aa/lib_pod5-0.3.48-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:101155f03f4c4f71a0ee1f5aae915bdd4ca95f4453a6944cf0965a69f4bc60d4", size = 2868198, upload-time = "2026-09-21T12:10:33.519Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/af0c4249ec765bbddc550279d129fcca4f8033ba8d8a575e087982c92143/lib_pod5-0.3.48-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:494537ecccefed6fb00ea17904b4bb705e336b403b5daa054aa0e94d208508b1", size = 3821483, upload-time = "2026-09-21T12:10:35.07Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/675997f79cb12bafd0a14fe36b1b87fef446f5952961556ff5647f16e4a3/lib_pod5-0.3.48-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:df9180c5fc20cf37c079cd9a48e52a79abb32f297f4ba42aa1012a68e980f38f", size = 4229812, upload-time = "2026-09-21T12:10:36.469Z" },
{ url = "https://files.pythonhosted.org/packages/45/c2/6481740a042d6b93afba6f065c2539c680c7a23ef58d2f9856312f681edc/lib_pod5-0.3.48-cp312-cp312-win_amd64.whl", hash = "sha256:d294556d2d9174c9da5f747220db21daac40a417c860a19cdd3f01eedb12c60b", size = 3633151, upload-time = "2026-09-21T12:10:37.938Z" },
{ url = "https://files.pythonhosted.org/packages/82/0d/4f2898ca1814697396d8d1adf19a346e634973e73ffd58741839b6cdfb87/lib_pod5-0.3.48-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:94b4e3cb2a603e250ab96f00bb70f6f1c12c05d73cb107374a139a9a34985b0a", size = 2868300, upload-time = "2026-09-21T12:10:39.84Z" },
{ url = "https://files.pythonhosted.org/packages/10/ca/de3276f4a43b0ccd9e598c61186935a20b5dca599b314de3371abfb3202f/lib_pod5-0.3.48-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57cfb3c3093fcc97d45e80e157351b0f68083bb511606afc40226e48fece5ccf", size = 3821555, upload-time = "2026-09-21T12:10:42.787Z" },
{ url = "https://files.pythonhosted.org/packages/c8/55/9cdc96f28e6cef35c65f63ad2427b9b559d632193d269596e96ce1ac9cb2/lib_pod5-0.3.48-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2211156f13c20c83dbb768ce2992dbf3f4f72cda6e0858cadf270b5c48faa40e", size = 4229879, upload-time = "2026-09-21T12:10:44.651Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/edaa5410c159b5025394b0de136066db07fa956ddd4e389c3a5a4f99142f/lib_pod5-0.3.48-cp313-cp313-win_amd64.whl", hash = "sha256:d37865d81d0f002f8920d8690413e5babed6543830bf0dc86b9031e9069c6c1a", size = 3633351, upload-time = "2026-09-21T12:10:46.348Z" },
{ url = "https://files.pythonhosted.org/packages/a6/a2/4b0522e4abea7aabda02ce21b29c5afde656bf3cd6eee762ed0027568a60/lib_pod5-0.3.48-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9f0c65dbe08687940d4d46d983790c199de053c55a26e9d7079612fb75b644fc", size = 2868888, upload-time = "2026-09-21T12:10:48.009Z" },
{ url = "https://files.pythonhosted.org/packages/c3/4e/89962dc4a837624b3026253af3a664fa6b3428b458230b6464554e9b6ab5/lib_pod5-0.3.48-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:774c535d0a766b790855585da389fd32266fd4d5888837c378089caaf7cac0b7", size = 3823110, upload-time = "2026-09-21T12:10:49.629Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5a/794d115508b7bf95b1aa98f598c322eb8be8b262314cc5b9c78389e1aafd/lib_pod5-0.3.48-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:56c166983e69dc59e1a50663a297f78d17609b69977d10a6fc96690872e5e196", size = 4229991, upload-time = "2026-09-21T12:10:52.463Z" },
{ url = "https://files.pythonhosted.org/packages/05/07/97d6603e4d27e95bb51aa353a34418f494bffba946793660adc2b026ebab/lib_pod5-0.3.48-cp314-cp314-win_amd64.whl", hash = "sha256:10ba82ea4faa5971523779799cd0d3c0447174be80d872e0af811b8eb54daf1f", size = 3836102, upload-time = "2026-09-21T12:10:54.167Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "more-itertools"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12' and sys_platform != 'darwin'",
"python_full_version < '3.12' and sys_platform == 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "networkx"
version = "3.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12' and sys_platform != 'darwin'",
"python_full_version >= '3.12' and sys_platform == 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12' and sys_platform != 'darwin'",
"python_full_version < '3.12' and sys_platform == 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12' and sys_platform != 'darwin'",
"python_full_version >= '3.12' and sys_platform == 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pod5"
version = "0.3.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "h5py" },
{ name = "iso8601" },
{ name = "lib-pod5" },
{ name = "more-itertools" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "packaging" },
{ name = "polars" },
{ name = "pyarrow" },
{ name = "pytz" },
{ name = "tqdm" },
{ name = "vbz-h5py-plugin" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2d/41/1f985d0394536f89b3262304976751384ea9d172ae299c97c96127986e2a/pod5-0.3.48.tar.gz", hash = "sha256:841e96228dc434b9c15a1a18b23600d4d32348af9780674b23cf5c27a09c05bc", size = 66912, upload-time = "2026-09-21T12:10:57.26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/39/2fada1b6f37d71818b97aefd7e483aeaa373c9375695373809de14cbcba3/pod5-0.3.48-py3-none-any.whl", hash = "sha256:0cd93900e5ea445bfe3820fc85d38b76ae83d3ec4a0ef1e6f7d2b1aadfef163b", size = 69921, upload-time = "2026-09-21T12:10:56.026Z" },
]
[[package]]
name = "polars"
version = "1.44.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/15/e8541eefc22fbc7ca89bcb5112298a153729f73cfbc0cf6a668e509f975c/polars-1.44.2.tar.gz", hash = "sha256:86c8e26b6c2de8c8d344bb910b74dfc47b118ac3fe0f19b44909467990a0b281", size = 766272, upload-time = "2026-09-09T07:42:08.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6d/3014112c7f717d1253223faa13b6db3ac3a64ed00ab2a3bc1b942bc9cdd4/polars-1.44.2-py3-none-any.whl", hash = "sha256:1bb331f17a40d9d931101533dcd33637b66edc61eb377b07020dac16a0f0377b", size = 865768, upload-time = "2026-09-09T07:40:12.053Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.44.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/a1/a7eace6587b56f22cf2a21ab4d5e695db372dc23fd96accb68b1ec12660b/polars_runtime_32-1.44.2.tar.gz", hash = "sha256:b84842f7d621aaca7a52e165e19a24f89db45f8aa13744941430218419a14a67", size = 3172205, upload-time = "2026-09-09T07:42:10.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/5b/a5215f82c3dd443dc5d6911b0d3e937f97056e0ef7753f7e123422481a18/polars_runtime_32-1.44.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1fd536720668ba203a16a20b08cd6b23057e407a0279cf36b2f35f879d6e3208", size = 48334014, upload-time = "2026-09-09T07:40:16.43Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e0/f3dc93fce4b4e99370db6a89001a1b8d3c606e3560d0d91dda809d6c6324/polars_runtime_32-1.44.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0fd43720c8222ae39919c8ff891636d53b352706087120e62f83544dd3ff782", size = 43331099, upload-time = "2026-09-09T07:40:21.029Z" },
{ url = "https://files.pythonhosted.org/packages/4e/4f/076626ce93ddd622203c4b27be2a96d034cf5b24110c52e96e6029f0ea33/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf9b45040291dc1c6c588c837019c33557bde25ec536562a9cca9e1f6dfcc45", size = 46590853, upload-time = "2026-09-09T07:40:24.934Z" },
{ url = "https://files.pythonhosted.org/packages/e9/24/ed9982657c446dd5491b089370eea196725673570cfc61f7225a9fdd7ef0/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1bafb441e99199a62c63bf1bbdc0ea09ee9776dbac2bf31452b5000fb1df2f7", size = 49912258, upload-time = "2026-09-09T07:40:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/71/42/5490ab360aa2406119825ad82203a5e2ff27a3a5893ca8e0b93c053a59a3/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10c0c695a418407617b5159db7d9a21074a733e4c6d61275b6762f25cb31ca99", size = 46737310, upload-time = "2026-09-09T07:40:33.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/8f/d741afb1dcd1848161189e017d27972e7e78556d8dce66b94d4235093706/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c4a09fb14aad711526346efc0cb2015c2fd0555ce4118b6524e5debbaea65ff5", size = 49887654, upload-time = "2026-09-09T07:40:37.455Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e7/c61c1c7eea37705920fe7c1302d1dd80d1165db2b928f0da3eae6d1ebb75/polars_runtime_32-1.44.2-cp310-abi3-win_amd64.whl", hash = "sha256:8598e7a20efba70bb74978c7df7af7c606ff4d79b9b48fdd808250b189bc9a13", size = 51297967, upload-time = "2026-09-09T07:40:41.993Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/d0dd0d2ec95fa328dd47055905fae53ba3cd79f11c8973326ebe75a49e4c/polars_runtime_32-1.44.2-cp310-abi3-win_arm64.whl", hash = "sha256:d51040d3ab40157f6db3c62be59cab5b80fb3c8d158924769c4982a1c8eef730", size = 44303345, upload-time = "2026-09-09T07:40:47.081Z" },
]
[[package]]
name = "pyarrow"
version = "22.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/53/04a7fdc63e6056116c9ddc8b43bc28c12cdd181b85cbeadb79278475f3ae/pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9", size = 1151151, upload-time = "2025-10-24T12:30:00.762Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/b7/18f611a8cdc43417f9394a3ccd3eace2f32183c08b9eddc3d17681819f37/pyarrow-22.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3e294c5eadfb93d78b0763e859a0c16d4051fc1c5231ae8956d61cb0b5666f5a", size = 34272022, upload-time = "2025-10-24T10:04:28.973Z" },
{ url = "https://files.pythonhosted.org/packages/26/5c/f259e2526c67eb4b9e511741b19870a02363a47a35edbebc55c3178db22d/pyarrow-22.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:69763ab2445f632d90b504a815a2a033f74332997052b721002298ed6de40f2e", size = 35995834, upload-time = "2025-10-24T10:04:35.467Z" },
{ url = "https://files.pythonhosted.org/packages/50/8d/281f0f9b9376d4b7f146913b26fac0aa2829cd1ee7e997f53a27411bbb92/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b41f37cabfe2463232684de44bad753d6be08a7a072f6a83447eeaf0e4d2a215", size = 45030348, upload-time = "2025-10-24T10:04:43.366Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e5/53c0a1c428f0976bf22f513d79c73000926cb00b9c138d8e02daf2102e18/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:35ad0f0378c9359b3f297299c3309778bb03b8612f987399a0333a560b43862d", size = 47699480, upload-time = "2025-10-24T10:04:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/95/e1/9dbe4c465c3365959d183e6345d0a8d1dc5b02ca3f8db4760b3bc834cf25/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8382ad21458075c2e66a82a29d650f963ce51c7708c7c0ff313a8c206c4fd5e8", size = 48011148, upload-time = "2025-10-24T10:04:59.585Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b4/7caf5d21930061444c3cf4fa7535c82faf5263e22ce43af7c2759ceb5b8b/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a812a5b727bc09c3d7ea072c4eebf657c2f7066155506ba31ebf4792f88f016", size = 50276964, upload-time = "2025-10-24T10:05:08.175Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f3/cec89bd99fa3abf826f14d4e53d3d11340ce6f6af4d14bdcd54cd83b6576/pyarrow-22.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ec5d40dd494882704fb876c16fa7261a69791e784ae34e6b5992e977bd2e238c", size = 28106517, upload-time = "2025-10-24T10:05:14.314Z" },
{ url = "https://files.pythonhosted.org/packages/af/63/ba23862d69652f85b615ca14ad14f3bcfc5bf1b99ef3f0cd04ff93fdad5a/pyarrow-22.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:bea79263d55c24a32b0d79c00a1c58bb2ee5f0757ed95656b01c0fb310c5af3d", size = 34211578, upload-time = "2025-10-24T10:05:21.583Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d0/f9ad86fe809efd2bcc8be32032fa72e8b0d112b01ae56a053006376c5930/pyarrow-22.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:12fe549c9b10ac98c91cf791d2945e878875d95508e1a5d14091a7aaa66d9cf8", size = 35989906, upload-time = "2025-10-24T10:05:29.485Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a8/f910afcb14630e64d673f15904ec27dd31f1e009b77033c365c84e8c1e1d/pyarrow-22.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:334f900ff08ce0423407af97e6c26ad5d4e3b0763645559ece6fbf3747d6a8f5", size = 45021677, upload-time = "2025-10-24T10:05:38.274Z" },
{ url = "https://files.pythonhosted.org/packages/13/95/aec81f781c75cd10554dc17a25849c720d54feafb6f7847690478dcf5ef8/pyarrow-22.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c6c791b09c57ed76a18b03f2631753a4960eefbbca80f846da8baefc6491fcfe", size = 47726315, upload-time = "2025-10-24T10:05:47.314Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d4/74ac9f7a54cfde12ee42734ea25d5a3c9a45db78f9def949307a92720d37/pyarrow-22.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c3200cb41cdbc65156e5f8c908d739b0dfed57e890329413da2748d1a2cd1a4e", size = 47990906, upload-time = "2025-10-24T10:05:58.254Z" },
{ url = "https://files.pythonhosted.org/packages/2e/71/fedf2499bf7a95062eafc989ace56572f3343432570e1c54e6599d5b88da/pyarrow-22.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ac93252226cf288753d8b46280f4edf3433bf9508b6977f8dd8526b521a1bbb9", size = 50306783, upload-time = "2025-10-24T10:06:08.08Z" },
{ url = "https://files.pythonhosted.org/packages/68/ed/b202abd5a5b78f519722f3d29063dda03c114711093c1995a33b8e2e0f4b/pyarrow-22.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:44729980b6c50a5f2bfcc2668d36c569ce17f8b17bccaf470c4313dcbbf13c9d", size = 27972883, upload-time = "2025-10-24T10:06:14.204Z" },
{ url = "https://files.pythonhosted.org/packages/a6/d6/d0fac16a2963002fc22c8fa75180a838737203d558f0ed3b564c4a54eef5/pyarrow-22.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6e95176209257803a8b3d0394f21604e796dadb643d2f7ca21b66c9c0b30c9a", size = 34204629, upload-time = "2025-10-24T10:06:20.274Z" },
{ url = "https://files.pythonhosted.org/packages/c6/9c/1d6357347fbae062ad3f17082f9ebc29cc733321e892c0d2085f42a2212b/pyarrow-22.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:001ea83a58024818826a9e3f89bf9310a114f7e26dfe404a4c32686f97bd7901", size = 35985783, upload-time = "2025-10-24T10:06:27.301Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c0/782344c2ce58afbea010150df07e3a2f5fdad299cd631697ae7bd3bac6e3/pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ce20fe000754f477c8a9125543f1936ea5b8867c5406757c224d745ed033e691", size = 45020999, upload-time = "2025-10-24T10:06:35.387Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8b/5362443737a5307a7b67c1017c42cd104213189b4970bf607e05faf9c525/pyarrow-22.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e0a15757fccb38c410947df156f9749ae4a3c89b2393741a50521f39a8cf202a", size = 47724601, upload-time = "2025-10-24T10:06:43.551Z" },
{ url = "https://files.pythonhosted.org/packages/69/4d/76e567a4fc2e190ee6072967cb4672b7d9249ac59ae65af2d7e3047afa3b/pyarrow-22.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cedb9dd9358e4ea1d9bce3665ce0797f6adf97ff142c8e25b46ba9cdd508e9b6", size = 48001050, upload-time = "2025-10-24T10:06:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/01/5e/5653f0535d2a1aef8223cee9d92944cb6bccfee5cf1cd3f462d7cb022790/pyarrow-22.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:252be4a05f9d9185bb8c18e83764ebcfea7185076c07a7a662253af3a8c07941", size = 50307877, upload-time = "2025-10-24T10:07:02.405Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f8/1d0bd75bf9328a3b826e24a16e5517cd7f9fbf8d34a3184a4566ef5a7f29/pyarrow-22.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:a4893d31e5ef780b6edcaf63122df0f8d321088bb0dee4c8c06eccb1ca28d145", size = 27977099, upload-time = "2025-10-24T10:08:07.259Z" },
{ url = "https://files.pythonhosted.org/packages/90/81/db56870c997805bf2b0f6eeeb2d68458bf4654652dccdcf1bf7a42d80903/pyarrow-22.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:f7fe3dbe871294ba70d789be16b6e7e52b418311e166e0e3cba9522f0f437fb1", size = 34336685, upload-time = "2025-10-24T10:07:11.47Z" },
{ url = "https://files.pythonhosted.org/packages/1c/98/0727947f199aba8a120f47dfc229eeb05df15bcd7a6f1b669e9f882afc58/pyarrow-22.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ba95112d15fd4f1105fb2402c4eab9068f0554435e9b7085924bcfaac2cc306f", size = 36032158, upload-time = "2025-10-24T10:07:18.626Z" },
{ url = "https://files.pythonhosted.org/packages/96/b4/9babdef9c01720a0785945c7cf550e4acd0ebcd7bdd2e6f0aa7981fa85e2/pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c064e28361c05d72eed8e744c9605cbd6d2bb7481a511c74071fd9b24bc65d7d", size = 44892060, upload-time = "2025-10-24T10:07:26.002Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ca/2f8804edd6279f78a37062d813de3f16f29183874447ef6d1aadbb4efa0f/pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6f9762274496c244d951c819348afbcf212714902742225f649cf02823a6a10f", size = 47504395, upload-time = "2025-10-24T10:07:34.09Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f0/77aa5198fd3943682b2e4faaf179a674f0edea0d55d326d83cb2277d9363/pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a9d9ffdc2ab696f6b15b4d1f7cec6658e1d788124418cb30030afbae31c64746", size = 48066216, upload-time = "2025-10-24T10:07:43.528Z" },
{ url = "https://files.pythonhosted.org/packages/79/87/a1937b6e78b2aff18b706d738c9e46ade5bfcf11b294e39c87706a0089ac/pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ec1a15968a9d80da01e1d30349b2b0d7cc91e96588ee324ce1b5228175043e95", size = 50288552, upload-time = "2025-10-24T10:07:53.519Z" },
{ url = "https://files.pythonhosted.org/packages/60/ae/b5a5811e11f25788ccfdaa8f26b6791c9807119dffcf80514505527c384c/pyarrow-22.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bba208d9c7decf9961998edf5c65e3ea4355d5818dd6cd0f6809bec1afb951cc", size = 28262504, upload-time = "2025-10-24T10:08:00.932Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b0/0fa4d28a8edb42b0a7144edd20befd04173ac79819547216f8a9f36f9e50/pyarrow-22.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9bddc2cade6561f6820d4cd73f99a0243532ad506bc510a75a5a65a522b2d74d", size = 34224062, upload-time = "2025-10-24T10:08:14.101Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a8/7a719076b3c1be0acef56a07220c586f25cd24de0e3f3102b438d18ae5df/pyarrow-22.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e70ff90c64419709d38c8932ea9fe1cc98415c4f87ea8da81719e43f02534bc9", size = 35990057, upload-time = "2025-10-24T10:08:21.842Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/359ed54c93b47fb6fe30ed16cdf50e3f0e8b9ccfb11b86218c3619ae50a8/pyarrow-22.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:92843c305330aa94a36e706c16209cd4df274693e777ca47112617db7d0ef3d7", size = 45068002, upload-time = "2025-10-24T10:08:29.034Z" },
{ url = "https://files.pythonhosted.org/packages/55/fc/4945896cc8638536ee787a3bd6ce7cec8ec9acf452d78ec39ab328efa0a1/pyarrow-22.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6dda1ddac033d27421c20d7a7943eec60be44e0db4e079f33cc5af3b8280ccde", size = 47737765, upload-time = "2025-10-24T10:08:38.559Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5e/7cb7edeb2abfaa1f79b5d5eb89432356155c8426f75d3753cbcb9592c0fd/pyarrow-22.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:84378110dd9a6c06323b41b56e129c504d157d1a983ce8f5443761eb5256bafc", size = 48048139, upload-time = "2025-10-24T10:08:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/88/c6/546baa7c48185f5e9d6e59277c4b19f30f48c94d9dd938c2a80d4d6b067c/pyarrow-22.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:854794239111d2b88b40b6ef92aa478024d1e5074f364033e73e21e3f76b25e0", size = 50314244, upload-time = "2025-10-24T10:08:55.771Z" },
{ url = "https://files.pythonhosted.org/packages/3c/79/755ff2d145aafec8d347bf18f95e4e81c00127f06d080135dfc86aea417c/pyarrow-22.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:b883fe6fd85adad7932b3271c38ac289c65b7337c2c132e9569f9d3940620730", size = 28757501, upload-time = "2025-10-24T10:09:59.891Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d2/237d75ac28ced3147912954e3c1a174df43a95f4f88e467809118a8165e0/pyarrow-22.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a820d8ae11facf32585507c11f04e3f38343c1e784c9b5a8b1da5c930547fe2", size = 34355506, upload-time = "2025-10-24T10:09:02.953Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/733dfffe6d3069740f98e57ff81007809067d68626c5faef293434d11bd6/pyarrow-22.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:c6ec3675d98915bf1ec8b3c7986422682f7232ea76cad276f4c8abd5b7319b70", size = 36047312, upload-time = "2025-10-24T10:09:10.334Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2b/29d6e3782dc1f299727462c1543af357a0f2c1d3c160ce199950d9ca51eb/pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3e739edd001b04f654b166204fc7a9de896cf6007eaff33409ee9e50ceaff754", size = 45081609, upload-time = "2025-10-24T10:09:18.61Z" },
{ url = "https://files.pythonhosted.org/packages/8d/42/aa9355ecc05997915af1b7b947a7f66c02dcaa927f3203b87871c114ba10/pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7388ac685cab5b279a41dfe0a6ccd99e4dbf322edfb63e02fc0443bf24134e91", size = 47703663, upload-time = "2025-10-24T10:09:27.369Z" },
{ url = "https://files.pythonhosted.org/packages/ee/62/45abedde480168e83a1de005b7b7043fd553321c1e8c5a9a114425f64842/pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f633074f36dbc33d5c05b5dc75371e5660f1dbf9c8b1d95669def05e5425989c", size = 48066543, upload-time = "2025-10-24T10:09:34.908Z" },
{ url = "https://files.pythonhosted.org/packages/84/e9/7878940a5b072e4f3bf998770acafeae13b267f9893af5f6d4ab3904b67e/pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4c19236ae2402a8663a2c8f21f1870a03cc57f0bef7e4b6eb3238cc82944de80", size = 50288838, upload-time = "2025-10-24T10:09:44.394Z" },
{ url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytz"
version = "2026.3.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" },
]
[[package]]
name = "ruff"
version = "0.16.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
]
[[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 = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "torch"
version = "2.14.0"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1040f689e560ba6e02a0a147dfb9ec8dfdd3e4486ebec4b24dc8fdaa258fa9ba", upload-time = "2026-09-02T00:26:07Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ab7e226c18d3cbc134dfa952d18ab1df202055dd33446837c8fd050ad449e6f5", upload-time = "2026-09-02T00:26:12Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:caf6359d64c0074bcb9f8641a169239118c8165a0a0fef79110c72bfd6474bec", upload-time = "2026-09-02T00:26:15Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d0f78744000a7863cc3f1eafaf511d4b9944f079f08277de6fefe4b01f4d41b9", upload-time = "2026-09-02T00:26:19Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:dd4c297f1af83ad44368ffdaa9ed0318984567ff952bbda3d90cc300afe1e8a0", upload-time = "2026-09-02T00:26:23Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:c65f7d7783e76272bbd53e4220aa029ac14e2dc346c6641691e041202a03b0e0", upload-time = "2026-09-02T00:26:27Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:879140333148a3a5b1a14ab29a35b58c3c8db4d85c7130018a79178eb239008b", upload-time = "2026-09-02T00:26:30Z" },
]
[[package]]
name = "torch"
version = "2.14.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
"python_full_version >= '3.12' and sys_platform != 'darwin'",
"python_full_version < '3.12' and sys_platform != 'darwin'",
]
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:3fa39713d13f23563ad11636bfff76b677ab4addbc240c54e6b7e47622e8973d", upload-time = "2026-09-02T18:31:08Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:08894195f84541edcbd09072e6c53b791d4cdd09f650b05473f35718a848fd7b", upload-time = "2026-09-02T18:31:13Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:673dbf5c9bbadfffab7a386b6dd7a0c219f1408a328b7b4e86d0ae551cdafa42", upload-time = "2026-09-02T18:31:19Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:8e2c47c6556c7d5a85848634372bb2252907d411e9cad669c99406856d536eb5", upload-time = "2026-09-02T18:31:24Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:38f0a0330267b36d76b988754878d94001c1519fe3743bd92f038ce5622bea5a", upload-time = "2026-09-02T18:31:28Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:07aefe1a437b6f1b41b0cba15e16583e2f43549ec9d062fcea8e7981a78e014a", upload-time = "2026-09-02T18:31:32Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da8140c3d4a41500d29710e35bf8e66a4295ec31026487c69f54a3f583310f8d", upload-time = "2026-09-02T18:31:37Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a09987c95ec4cffdb6df798d3d641558110a334cfccce22f2f046d83142bc260", upload-time = "2026-09-02T18:31:42Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:738cd9237bf63443a5e5f9652e5115f7b0510b089636a43d4011e9fccede714f", upload-time = "2026-09-02T18:31:48Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:420e7dbed969bbd045b34105fa90d1ccf3e5c09c4e9b0d0a5f9df25df9d02d2e", upload-time = "2026-09-02T18:31:52Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:d87cfff3af33c937b88c9bc6c0dfa17f2156e2599f63447c696f2345928b518a", upload-time = "2026-09-02T18:31:55Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:092d5c12938850dfbd90a654b3c8dac34c33e300f88eb19ee6f4ef93992c6347", upload-time = "2026-09-02T18:31:59Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:160e1bc46aeded3111d2801f8ae10dc9a1b946843a7e126b4dbf5e19c5706e95", upload-time = "2026-09-02T18:32:05Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:f2ffdacd95d7090a8bdfa8426f5d1557625874b19a96ba23a41f640d4a0c9c28", upload-time = "2026-09-02T18:32:10Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:4b1b78f8b9b1393576ef03cab0e6d941a6aa562b7539c45da12d80e86c09f9ed", upload-time = "2026-09-02T18:32:13Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:266a253980047f3c037191f45d7e7ecfa838d13102141ca57b86fcc892663004", upload-time = "2026-09-02T18:32:17Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:27b34640fea7268862961b734c723873e15b1c2657b1eb84bcfac053e5841527", upload-time = "2026-09-02T18:32:23Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f152f41dc5dc462afe0de780e451ebb47ea8b4451f8f919f9537aa8e2cbe1d7e", upload-time = "2026-09-02T18:32:29Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:ca9b3c2c652587f7b26c86d30be3648f553bd07fb3cf9b3edb645e7eafe295a3", upload-time = "2026-09-02T18:32:35Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:c1d81be03f715eca5b065fc767f73b2a26668a16d614e163a4378a3f38a5e471", upload-time = "2026-09-02T18:32:39Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a6ff46c0c0c4f5b9b399dff5e4856a98ce18e61ca14ad74b21790174c7cbde08", upload-time = "2026-09-02T18:32:43Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:2baf9cbaef964d821465df0c53a91cc01b2d0ea29ebd199e2f9c7d27a7fc70a4", upload-time = "2026-09-02T18:32:48Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:8fe5ff05cf3511daecdea986005f312f3b9cdbaf07ccf0bc42c68d01a4ae5d4d", upload-time = "2026-09-02T18:32:53Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:07d50e40232d2757be323693e38f531e04ee654d40537bc0b04e787c1e73a27a", upload-time = "2026-09-02T18:32:57Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:a8b2fa9a03d81bc083f279737fce1cd8d1ed2e267da92215260cd644dcfdd3fe", upload-time = "2026-09-02T18:33:02Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315-win_amd64.whl", hash = "sha256:70a98493729cdd06f2735a7e4c4867e64e5d5b4eebbd6ffb841b7b034b16aff3", upload-time = "2026-09-02T18:33:07Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:e22ddf8a9fff9934fe2dd2440d45c437bb0539f2d6d24b57b26981d6dca4157c", upload-time = "2026-09-02T18:33:11Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:18b417c953e9b4d8b79294ca5184586790f0a609b3929e39258cea854b945870", upload-time = "2026-09-02T18:33:16Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp315-cp315t-win_amd64.whl", hash = "sha256:70b02d91b68ba0652b53294f51afbe774eea0f1f52b91a20207982298af294a2", upload-time = "2026-09-02T18:33:20Z" },
]
[[package]]
name = "tqdm"
version = "4.70.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "vbz-h5py-plugin"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h5py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/43/feb5016ab64d83f5a9cd0baa3da1f62c17a42764f44375fa59dbd576f6a0/vbz_h5py_plugin-1.0.1.tar.gz", hash = "sha256:c784458bb0aad6303474cb2f10956179116b35555803fd1154eb4ef362519341", size = 1441103, upload-time = "2022-11-10T11:53:22.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/dd/eab7a9cac698afd8d4d469d703303390a88570ace01da358088459a6a48d/vbz_h5py_plugin-1.0.1-py3-none-any.whl", hash = "sha256:7b8f9bf250a3c072e36bb8a84beb035468bba0536e11a9086649188f075589b1", size = 1446187, upload-time = "2022-11-10T11:53:19.893Z" },
]
[[package]]
name = "wrapt"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/c6/5e08ef1e8ad5ff518354db77b7a303666c7e0652e2a569116b5ccb72b690/wrapt-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b6ea396edcfb305698f44ef0a47b9adaca15d30386ce093ca606844f776d7236", size = 97984, upload-time = "2026-09-10T23:09:52.569Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ec/e77ba77fdce732f39522382a6a9b968e5abf3471f352d51670120534e271/wrapt-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b86bfe741840cec6d8d5858b1eb53a325f564f08d7e6fb5694981961edcec230", size = 98241, upload-time = "2026-09-10T23:09:53.72Z" },
{ url = "https://files.pythonhosted.org/packages/7b/af/c54bd0bf32ef3c5f6d2d50af6bd9d88c040ef53d9f8e113e065337d76788/wrapt-2.4.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ec7aeb92e956810eedc268fe75f8c11f05d6c3d864d0995a53eb6623b3b6a51", size = 226167, upload-time = "2026-09-10T23:09:54.946Z" },
{ url = "https://files.pythonhosted.org/packages/00/3c/934e13c7e27680760fa74e25e4b291b213fd607aa42333d883f732de6d73/wrapt-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba66eccadd4b857a845e309c08457bb71fa8300000e508e3a86cd695bf9c7515", size = 228439, upload-time = "2026-09-10T23:09:56.272Z" },
{ url = "https://files.pythonhosted.org/packages/cf/5e/7593f3fe527260ff3e8eb09ed26ff8f0bf51cda5702a75fb5969b689b4a2/wrapt-2.4.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d5c8f3b4eae814213d4097f134be03aeb23816e6358213454ec5e62e8449fb6", size = 213495, upload-time = "2026-09-10T23:09:57.622Z" },
{ url = "https://files.pythonhosted.org/packages/42/a8/d4b4d6ac44cb2c7d4a882499786fd96758c97feb6b1539ba0ed5c823849a/wrapt-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3bc7ab495e564449f39db2f54c1033d7190c944736d525f3bd06cefaf0134df0", size = 226108, upload-time = "2026-09-10T23:09:59.132Z" },
{ url = "https://files.pythonhosted.org/packages/05/b3/aedc046bbd8ff2333c2ce9d5267db42117221d4192ac13c66e871b528d9f/wrapt-2.4.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2e2e694057bcfd46f43e28f7b50fad8f5fec7113cc6445fdf251219539a08f28", size = 211144, upload-time = "2026-09-10T23:10:01.141Z" },
{ url = "https://files.pythonhosted.org/packages/96/60/c01efb0e8049a9df379b04ebc589eeef995d6cf184b1d8be9f1b84b4d5f7/wrapt-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f5577fdc702427698c81c8c9c3b8479cbecb3dfd0f697be8c1d09c89f5838cc", size = 215496, upload-time = "2026-09-10T23:10:02.778Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b5/26214472915eb447725982e2735c593131c5bae0c3fd582ad61e7b965907/wrapt-2.4.1-cp311-cp311-win32.whl", hash = "sha256:e78c0f6d66a564bab245faf170d55595df6fc237117b99d97fd838678f0906f0", size = 93493, upload-time = "2026-09-10T23:10:04.196Z" },
{ url = "https://files.pythonhosted.org/packages/d9/10/5f7d2dd40c97c5e572dce771b8d4c1c78d46a90cfc24c7b25f158253215a/wrapt-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:1f1851be0e593d65e68d7c1bc9d3b971e19fefa219f617e490d75e80e467ee14", size = 98679, upload-time = "2026-09-10T23:10:05.413Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6a/f7565da9ab1323b2aa5fb4a83b919b6519cc0f4ea4b91ec8f2d25f5bcbab/wrapt-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:7ff549316d94c404999f96bb0a6993222566231ff4bede1b6f68908d1958a08f", size = 95071, upload-time = "2026-09-10T23:10:06.679Z" },
{ url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" },
{ url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" },
{ url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" },
{ url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" },
{ url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" },
{ url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" },
{ url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1a/9b5aa3c2391aa6d00fffa085c2219e8fc91cd4d2b9b080d2b4e4b6b93f42/wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3", size = 98597, upload-time = "2026-09-10T23:10:23.723Z" },
{ url = "https://files.pythonhosted.org/packages/fe/07/dc98150c2f9ee5b5fcbd841765e178ea6cc6c43733ab8d1f5181a4fb9f3d/wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145", size = 98842, upload-time = "2026-09-10T23:10:25.109Z" },
{ url = "https://files.pythonhosted.org/packages/08/c2/0e772a570e8d75c1b3ec45930a3023492fa68223f8f5e3485af4844c10c3/wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc", size = 234642, upload-time = "2026-09-10T23:10:26.644Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/4ce4d02dd95ff9ed972a2fc396d04fec636daa5a4ac18cc73a3dc20aa6c3/wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1", size = 235484, upload-time = "2026-09-10T23:10:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/436ef1df620ef8edd9ef057b4d55a0cd704435ff66852a0ef26cbaa02a58/wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d", size = 214371, upload-time = "2026-09-10T23:10:29.841Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2c/cc5b7503843399087db3106a7cf60d60d0900f69539e96148b9fff116291/wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697", size = 232347, upload-time = "2026-09-10T23:10:31.511Z" },
{ url = "https://files.pythonhosted.org/packages/86/fb/17668b1ca572c44b458c09d76064d35f5f57d5ac7629248871604bef816c/wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73", size = 212792, upload-time = "2026-09-10T23:10:32.968Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2b/0c5de10d7259e07c478c44bf2fbe179c6878727d09edf0632b97884801db/wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4", size = 223585, upload-time = "2026-09-10T23:10:34.43Z" },
{ url = "https://files.pythonhosted.org/packages/aa/36/013dce1c687f8f1c87b1e78f403d04c80ae9b2ae77de4ddba16069a3e7d1/wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4", size = 93425, upload-time = "2026-09-10T23:10:35.901Z" },
{ url = "https://files.pythonhosted.org/packages/b7/1d/d53bcf5910209a45191c8bc173ff0e00830416547d8038772dc444932ee0/wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca", size = 98569, upload-time = "2026-09-10T23:10:37.316Z" },
{ url = "https://files.pythonhosted.org/packages/e8/1f/759d0c522918f9dfb620136622d8e1ce619570adda0de8fa2cfbb55cbb86/wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f", size = 95272, upload-time = "2026-09-10T23:10:38.657Z" },
{ url = "https://files.pythonhosted.org/packages/08/05/ed5aa8991c5e9969e2ca17f0e44eb324a9757f44fa982bfc13844cfe9e1d/wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b", size = 98876, upload-time = "2026-09-10T23:10:40.078Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5f/f8bf07c3b9a2ad01d28d5ca419e28819bb16937d129167d97c446c05875d/wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224", size = 99054, upload-time = "2026-09-10T23:10:41.484Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/518e5f3ebd18472652e5332bf37728cd6634ff81a0aa568969d05cc66099/wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2", size = 237430, upload-time = "2026-09-10T23:10:43.007Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/503835d183c1ca99268e0b5a98af8f487d528ebabb124f4003cd96e73b1f/wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e", size = 237988, upload-time = "2026-09-10T23:10:44.466Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/a11a3e1eb18ae1d9adef1c39f8e8e36fd5b86db33938bebf8e6bc5fd9b06/wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1", size = 218554, upload-time = "2026-09-10T23:10:45.969Z" },
{ url = "https://files.pythonhosted.org/packages/e1/61/61a6f983737ba81008561ab634373bba25b986761d3a0f0aded8352ae3e8/wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8", size = 235476, upload-time = "2026-09-10T23:10:47.528Z" },
{ url = "https://files.pythonhosted.org/packages/42/68/acb7cba46e0f662eaae421487807d2fed28ba52df9a00a3f04db16675e38/wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660", size = 216454, upload-time = "2026-09-10T23:10:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/8b/52/3a4dab8d4803df595de82f19775535d28c036746340c1b498fc8ecba39aa/wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47", size = 225346, upload-time = "2026-09-10T23:10:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/d72d051757fb7955021d8174014679f951fd5067187d2faed95520e1f8d6/wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c", size = 93870, upload-time = "2026-09-10T23:10:52.495Z" },
{ url = "https://files.pythonhosted.org/packages/a1/00/1a1bf4d8b60b9e7ac009969595438549ae6e33b2e3e174b78d26a833aad3/wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91", size = 98910, upload-time = "2026-09-10T23:10:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/1d/59/b3169c3bcdcff70a6d02ed2b6366097b083d31391d616c407efe9a943a82/wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6", size = 96013, upload-time = "2026-09-10T23:10:55.607Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/d29a063c2ca6e779305cd444dd5acaac60833d73b2d94c7b1d0820c27e3e/wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466", size = 102069, upload-time = "2026-09-10T23:10:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/7435de516099b528e2232770b459d6706002736f41d8f092bbb11ea01575/wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5", size = 102647, upload-time = "2026-09-10T23:10:58.852Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a7/cca7fe0f26aa11cbbf09b2e6f4f774db167feeca43ad22fa1772afdccbad/wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26", size = 275614, upload-time = "2026-09-10T23:11:00.464Z" },
{ url = "https://files.pythonhosted.org/packages/77/22/f3b5f4428ae679b1e2c0d4833519045d8983a9df0ad24df96b195d44dd1b/wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa", size = 287064, upload-time = "2026-09-10T23:11:02.111Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b8/24274ec01b6ab6d0672f4312bfa29b5f1ffd9d56f16c8da67d5683883956/wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c", size = 255440, upload-time = "2026-09-10T23:11:03.746Z" },
{ url = "https://files.pythonhosted.org/packages/8b/df/c63a36f0f03d70c7b92f60ec1ad3757088167914e1a262f5dcc7df233d51/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f", size = 281681, upload-time = "2026-09-10T23:11:05.41Z" },
{ url = "https://files.pythonhosted.org/packages/bc/c7/4930b6a369d9b3da59f5ada4ae9e659e2d4a68fe72c5d1ce15e5bead2d24/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d", size = 252409, upload-time = "2026-09-10T23:11:06.867Z" },
{ url = "https://files.pythonhosted.org/packages/68/d7/f6bc5703061598c4a2ad4f7a2efce696782be6e9d5afed714a59f5902ead/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67", size = 270559, upload-time = "2026-09-10T23:11:08.385Z" },
{ url = "https://files.pythonhosted.org/packages/68/a3/952337a153e634f530079334c47dcc935b20f8ea22f364729fc2a8cf1821/wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227", size = 96241, upload-time = "2026-09-10T23:11:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/09/28/48f0651547a125dd84e312b86268d4150c565a29f81ff9ca6fd81eba4be4/wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697", size = 102655, upload-time = "2026-09-10T23:11:11.388Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/977441bed5e5afbffe7a789067ac2f5fd2e9f848884122a23db9ccae6a70/wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c", size = 98495, upload-time = "2026-09-10T23:11:12.952Z" },
{ url = "https://files.pythonhosted.org/packages/7c/1c/03bcbc3dbf6ac11231f0434f2494aa7b80657b130be1a1a639192a3e44c9/wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b", size = 98878, upload-time = "2026-09-10T23:11:14.506Z" },
{ url = "https://files.pythonhosted.org/packages/87/26/5ad9bd421b8cdce0b2227a61f09586423765da3c284f8bbf0f69fc149fdf/wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008", size = 99090, upload-time = "2026-09-10T23:11:16.101Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c7/fa768261966d587650004ac37bef541540014b1fe23fbf4919c497ebc98c/wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63", size = 237803, upload-time = "2026-09-10T23:11:17.738Z" },
{ url = "https://files.pythonhosted.org/packages/0f/37/9c1c876bd88fef8d065b3e8bf125cad9291d70ed3695cedb41b36b2453ec/wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572", size = 238338, upload-time = "2026-09-10T23:11:19.742Z" },
{ url = "https://files.pythonhosted.org/packages/91/5b/47b2f2f08b90d9d3b4e1790ccae0d4e4de40e73f6614c9a52465a04f02a5/wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af", size = 220668, upload-time = "2026-09-10T23:11:21.422Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/da7844c7a3f7c01f3496e690e15ce9d01c0949278c64d4287ec472c90f72/wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16", size = 235812, upload-time = "2026-09-10T23:11:23.211Z" },
{ url = "https://files.pythonhosted.org/packages/69/af/ac4aa9f795721a54a21bac329201f7df1b199b71ef064187a266d4d29836/wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535", size = 218195, upload-time = "2026-09-10T23:11:24.983Z" },
{ url = "https://files.pythonhosted.org/packages/78/aa/cff7670ec1cfa75b951171cfb41d45c7bdb94e928620a1599ec2b00cd2dd/wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0", size = 225713, upload-time = "2026-09-10T23:11:26.576Z" },
{ url = "https://files.pythonhosted.org/packages/77/c6/64f138aef50b5ea1dfacca2a023b32ba3e41ef087bbd92e8997a95f4a0b8/wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2", size = 93880, upload-time = "2026-09-10T23:11:28.253Z" },
{ url = "https://files.pythonhosted.org/packages/8d/aa/afe7484a0f7232e87f2ebb65286c1025d43cbe1de4e4051b127b024a92f7/wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96", size = 98919, upload-time = "2026-09-10T23:11:29.832Z" },
{ url = "https://files.pythonhosted.org/packages/83/21/6865f976c6a1082ca61e2792bc6a2d6a0ed03cd750a46f837753127191f8/wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a", size = 96021, upload-time = "2026-09-10T23:11:31.448Z" },
{ url = "https://files.pythonhosted.org/packages/8d/48/baf784cc9f1fe554f96daf15af06c7c466d76469d5540e357cf564755606/wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0", size = 102028, upload-time = "2026-09-10T23:11:33.102Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/fb809c95bf5512196aeda4c640aabf4e92f607340b8b2556555690a52b7a/wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110", size = 102640, upload-time = "2026-09-10T23:11:35.145Z" },
{ url = "https://files.pythonhosted.org/packages/a1/3f/bd462010ccbbe298479f320bf2bb02996706003e9c20a15790b65a186f8b/wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d", size = 275999, upload-time = "2026-09-10T23:11:36.863Z" },
{ url = "https://files.pythonhosted.org/packages/56/1e/bd043b2bad2943336e090583ff6cea2f3e8776bde02f073ccf760b16eee1/wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914", size = 287576, upload-time = "2026-09-10T23:11:38.79Z" },
{ url = "https://files.pythonhosted.org/packages/1c/d9/9937bbd61ca4e7f58a7e8a38f5ff3562334f741de51b87f0e031df441254/wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51", size = 256763, upload-time = "2026-09-10T23:11:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0f78cc7e7fc6313cdba32df06f4b29a45a4a2aed3499040d6c1628d3f339/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e", size = 282244, upload-time = "2026-09-10T23:11:42.483Z" },
{ url = "https://files.pythonhosted.org/packages/be/0d/30317d738b9898a7fbedc193657f151a468b0fa7235f4abd58a646a99cd8/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3", size = 254153, upload-time = "2026-09-10T23:11:44.357Z" },
{ url = "https://files.pythonhosted.org/packages/12/53/fe1f251a89f471ca7c5e17f2edbd8efd48fea6f1c08430dcf60986ea4183/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2", size = 271234, upload-time = "2026-09-10T23:11:46.132Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7c/d63758cd7fa0d18c415a87312a07cdd9f0102eb824aaab5c7450b3ec08e5/wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9", size = 96236, upload-time = "2026-09-10T23:11:47.942Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2e/1785e6d2696db08b07c053b1f4ccb2b5e8f9d12e9bf2c7497693be6b4504/wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00", size = 102662, upload-time = "2026-09-10T23:11:49.831Z" },
{ url = "https://files.pythonhosted.org/packages/2f/fd/3f4ac5c4754948355e050f952ae7a64cdca94891e49fe9927c3f90a73334/wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c", size = 98500, upload-time = "2026-09-10T23:11:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" },
]