commit 88e98effad71b6b5de0e5dc2a1e86fe1842855b7 Author: Tom Kasper Date: Tue Sep 22 13:22:50 2026 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c9ef2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..673afbe --- /dev/null +++ b/README.md @@ -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 ... +# 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 ... +``` + +--- + +## 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 ... +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..62ad8f1 --- /dev/null +++ b/pyproject.toml @@ -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" } diff --git a/src/custom_models/__init__.py b/src/custom_models/__init__.py new file mode 100644 index 0000000..3c7f903 --- /dev/null +++ b/src/custom_models/__init__.py @@ -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", +] diff --git a/src/custom_models/__main__.py b/src/custom_models/__main__.py new file mode 100644 index 0000000..f7fe6c2 --- /dev/null +++ b/src/custom_models/__main__.py @@ -0,0 +1,8 @@ +"""Module entry point: ``python -m custom_models ``.""" + +from __future__ import annotations + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/custom_models/cli.py b/src/custom_models/cli.py new file mode 100644 index 0000000..bc55e02 --- /dev/null +++ b/src/custom_models/cli.py @@ -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()) diff --git a/src/custom_models/config.py b/src/custom_models/config.py new file mode 100644 index 0000000..a0bb118 --- /dev/null +++ b/src/custom_models/config.py @@ -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 diff --git a/src/custom_models/data.py b/src/custom_models/data.py new file mode 100644 index 0000000..0647b8c --- /dev/null +++ b/src/custom_models/data.py @@ -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, + ) diff --git a/src/custom_models/metrics.py b/src/custom_models/metrics.py new file mode 100644 index 0000000..c64703f --- /dev/null +++ b/src/custom_models/metrics.py @@ -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, + } diff --git a/src/custom_models/models.py b/src/custom_models/models.py new file mode 100644 index 0000000..e1b758b --- /dev/null +++ b/src/custom_models/models.py @@ -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 diff --git a/src/custom_models/runner.py b/src/custom_models/runner.py new file mode 100644 index 0000000..596fff4 --- /dev/null +++ b/src/custom_models/runner.py @@ -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 diff --git a/src/custom_models/seed.py b/src/custom_models/seed.py new file mode 100644 index 0000000..6423790 --- /dev/null +++ b/src/custom_models/seed.py @@ -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) diff --git a/src/custom_models/store.py b/src/custom_models/store.py new file mode 100644 index 0000000..3e874b2 --- /dev/null +++ b/src/custom_models/store.py @@ -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) diff --git a/src/custom_models/train.py b/src/custom_models/train.py new file mode 100644 index 0000000..84048d4 --- /dev/null +++ b/src/custom_models/train.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d36b94 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..6a22544 --- /dev/null +++ b/tests/test_models.py @@ -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) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..aab96a8 --- /dev/null +++ b/tests/test_pipeline.py @@ -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" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d08e351 --- /dev/null +++ b/uv.lock @@ -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" }, +]