From 413b4a4b735926572961a6a0bfe0a1107d21c6b5 Mon Sep 17 00:00:00 2001 From: jwtoney Date: Sun, 24 May 2026 20:24:35 -0400 Subject: [PATCH 01/12] added ReBind QM9 baseline; organometallic models running --- .gitignore | 4 + .gitmodules | 3 + README.md | 108 +- configs/bostmc.yaml | 28 + configs/bostmc_smoke.yaml | 25 + configs/qm9.yaml | 26 + configs/qm9_smoke.yaml | 26 + configs/tmqmg.yaml | 24 + configs/tmqmg_smoke.yaml | 25 + external/ReBIND | 1 + pyproject.toml | 37 +- scripts/train.sh | 27 + src/step_up/__init__.py | 5 +- src/step_up/cli.py | 45 + src/step_up/data/__init__.py | 1 + src/step_up/data/csv_dataset.py | 122 ++ src/step_up/data/featurize.py | 123 +++ src/step_up/data/mol2.py | 361 ++++++ src/step_up/data/splits.py | 25 + src/step_up/eval/__init__.py | 1 + src/step_up/eval/metrics.py | 74 ++ src/step_up/models/__init__.py | 1 + src/step_up/models/rebind.py | 341 ++++++ src/step_up/train.py | 337 ++++++ tests/conftest.py | 32 + tests/test_csv_dataset.py | 52 + tests/test_forward.py | 41 + tests/test_metrics.py | 46 + tests/test_smoke.py | 7 +- uv.lock | 1841 +++++++++++++++++++++++++++++++ 30 files changed, 3775 insertions(+), 14 deletions(-) create mode 100644 .gitmodules create mode 100644 configs/bostmc.yaml create mode 100644 configs/bostmc_smoke.yaml create mode 100644 configs/qm9.yaml create mode 100644 configs/qm9_smoke.yaml create mode 100644 configs/tmqmg.yaml create mode 100644 configs/tmqmg_smoke.yaml create mode 160000 external/ReBIND create mode 100755 scripts/train.sh create mode 100644 src/step_up/cli.py create mode 100644 src/step_up/data/__init__.py create mode 100644 src/step_up/data/csv_dataset.py create mode 100644 src/step_up/data/featurize.py create mode 100644 src/step_up/data/mol2.py create mode 100644 src/step_up/data/splits.py create mode 100644 src/step_up/eval/__init__.py create mode 100644 src/step_up/eval/metrics.py create mode 100644 src/step_up/models/__init__.py create mode 100644 src/step_up/models/rebind.py create mode 100644 src/step_up/train.py create mode 100644 tests/conftest.py create mode 100644 tests/test_csv_dataset.py create mode 100644 tests/test_forward.py create mode 100644 tests/test_metrics.py diff --git a/.gitignore b/.gitignore index 83972fa..340b0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,10 @@ tempCodeRunnerFile.py # Ruff stuff: .ruff_cache/ +# step-up training artifacts and local data +outputs/ +data/processed/ + # PyPI configuration file .pypirc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..dbb8c53 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/ReBIND"] + path = external/ReBIND + url = https://github.com/holymollyhao/ReBIND.git diff --git a/README.md b/README.md index ba92bc8..3d1e7e6 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,23 @@ A benchmark for 2D to 3D conformer generation models. ## Getting Started -This project uses [`uv`](https://docs.astral.sh/uv/) for Python, dependency, and -environment management. +This project uses [`uv`](https://docs.astral.sh/uv/) for Python, dependency, and environment management. + +The first model (ReBind) is vendored as a git submodule under `external/ReBIND`, so clone with `--recursive`: ```bash -git clone https://github.com/LeMaterial/step-up.git +git clone --recursive https://github.com/LeMaterial/step-up.git cd step-up uv sync --dev ``` -Run the test suite: +If you already cloned without `--recursive`: + +```bash +git submodule update --init --recursive +``` + +Run the test suite (CPU-only, ~30s): ```bash uv run pytest @@ -26,15 +33,100 @@ uv run ruff format --check uv run ruff check ``` -## Usage +## CPU Smoke Runs + +Each config under `configs/*_smoke.yaml` trains a tiny model on a 100-molecule +subset for 3 epochs on CPU. The goal is to verify the pipeline end-to-end (data +loader --> model forward --> loss --> optimizer), not to produce meaningful +metrics. Loss should decrease monotonically across epochs. + +```bash +uv run step-up train -c configs/qm9_smoke.yaml # ~25s +uv run step-up train -c configs/tmqmg_smoke.yaml # ~5 min (larger complexes) +uv run step-up train -c configs/bostmc_smoke.yaml # ~6 min (larger complexes) +``` + +## Production Training + +Three full-scale configs are staged. They target GPU (`device: cuda`) and use +ReBind's published QM9 hyperparameters (8 encoder + 8 decoder layers, +d_model=512, lr=9e-5, AdamW, batch 100 for QM9 / 32 for organometallics, +20 epochs by default). + +| Config | Dataset | Rows | Notes | +|---|---|---|---| +| `configs/qm9.yaml` | QM9-full.csv | ~134K | Sanity baseline on organic systems | +| `configs/tmqmg.yaml` | tmQMg-full.csv | ~60K | Singlets, full d-block + La | +| `configs/bostmc.yaml` | BOSTMC-low-spin.csv | ~140K | Singlets + doublets, full d-block | + +To dry-run a config (validates the YAML and dataset path without training): + +```bash +uv run step-up train -c configs/qm9.yaml --dry-run +``` + +```bash +sbatch scripts/train.slurm configs/qm9.yaml +sbatch scripts/train.slurm configs/tmqmg.yaml +sbatch scripts/train.slurm configs/bostmc.yaml +``` + +The Slurm script (1) initializes the submodule, (2) runs `uv sync --dev`, and +(3) launches `uv run step-up train -c `. Each run writes its config, +TensorBoard logs, and best checkpoint (by val D-MAE) to the `output_dir` +specified in the config. default is `outputs/_full/`. + +### Adjusting training duration -The benchmark is in early development. As functionality is added, reusable code -will live under the `step_up` Python package and can be run through `uv`: +To train longer, edit the config's `epochs` field. ReBind's paper used 20 +epochs; more epochs only help if val D-MAE is still trending down at the end. +Inspect TensorBoard during the run: ```bash -uv run python +uv run tensorboard --logdir outputs/qm9_full/tb ``` +### Resuming after compute interruptions + +The current loop saves `best.pt` (model weights only) on the best val D-MAE +epoch but doesn't snapshot the optimizer / scheduler state. Resumption is a +TODO for the next iteration. For long runs, prefer over-provisioning the time +budget in the Slurm header. + +## Repository Layout + +``` +src/step_up/ +|-- data/ +| |-- csv_dataset.py # streaming CSV --> graph-dict dataset +| |-- featurize.py # XYZ path (RDKit DetermineBonds for QM9) +| |-- mol2.py # direct MOL2 parser (no RDKit, used for organometallics) +| |-- splits.py +|-- models/ +| |-- rebind.py # thin wrapper over external/ReBIND + 3 runtime patches +| eval/ +| |-- metrics.py # D-MAE, D-RMSE, coord-RMSD, per-element D-MAE +|-- train.py # config-driven training loop +|-- cli.py # `uv run step-up train -c ` + +configs/ # per-dataset YAML configs (smoke + full) +external/ReBIND/ # git submodule, vendored upstream ReBind +scripts/train.slurm +tests/ # 9 tests covering imports, dataset loading, forward pass, metrics +``` + +## Data Path Notes + +- **QM9 (smiles + xyz)**: built from the XYZ block via + `Chem.MolFromXYZBlock` + `rdDetermineBonds.DetermineBonds`. The SMILES + column is intentionally unused because its atom order doesn't match the + XYZ block in QM9-full.csv. +- **Organometallics (mol2 + xyz)**: built directly from the MOL2 block via + the in-house parser in `step_up/data/mol2.py`. No RDKit* is involved; + connectivity and bond types come straight from the MOL2 file, SYBYL atom + types provide hybridization and aromaticity (e.g. `C.ar`, `N.am`), and rings + are computed from the bond graph via Tarjan bridge-finding. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, pre-commit hooks, diff --git a/configs/bostmc.yaml b/configs/bostmc.yaml new file mode 100644 index 0000000..718acd6 --- /dev/null +++ b/configs/bostmc.yaml @@ -0,0 +1,28 @@ +# Full BOSTMC-low-spin training run (~140K complexes, singlets + doublets, full d-block). GPU-only; staged for Slurm. +# DESIGN DECISION (flagged in plan file): +# - `charge` and `spinmult` columns are currently IGNORED. +# - Recommendation for first publishable run: pre-filter to singlets-only (spinmult == 1) for the cleanest comparison to tmQMg. The CSVMoleculeDataset does not yet expose a filter knob, add one when this config is first run. +# - Follow-up: condition the model on (charge, spinmult) as a global feature. +dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv +dataset_source: mol2 +subset_size: null +split_ratios: [0.9, 0.05, 0.05] +split_seed: 0 + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 4 + +device: cuda +output_dir: outputs/bostmc_full +seed: 0 diff --git a/configs/bostmc_smoke.yaml b/configs/bostmc_smoke.yaml new file mode 100644 index 0000000..286f3e9 --- /dev/null +++ b/configs/bostmc_smoke.yaml @@ -0,0 +1,25 @@ +# BOSTMC smoke run on a 100-molecule subset. Verifies the direct MOL2 parser +# end-to-end (no RDKit) plus the LJ patch on d-block elements. +dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv +dataset_source: mol2 +subset_size: 100 +split_ratios: [0.8, 0.1, 0.1] +split_seed: 0 + +n_layers: 2 +d_model: 64 +d_ffn: 128 +n_head: 4 +dropout: 0.0 + +epochs: 3 +batch_size: 4 +eval_batch_size: 4 +lr: 3.0e-4 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 0 + +device: cpu +output_dir: outputs/bostmc_smoke +seed: 0 diff --git a/configs/qm9.yaml b/configs/qm9.yaml new file mode 100644 index 0000000..e9acd3c --- /dev/null +++ b/configs/qm9.yaml @@ -0,0 +1,26 @@ +# Full QM9-full.csv training run (~134K molecules). +# GPU-only, staged for the Slurm job once compute is available. +# Mirrors ReBind's QM9 hyperparams from external/ReBIND/experiments/conformer_prediction/rebind.sh. +dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv +dataset_source: smiles +subset_size: null +split_ratios: [0.9, 0.05, 0.05] +split_seed: 0 + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 4 + +device: cuda +output_dir: outputs/qm9_full +seed: 0 diff --git a/configs/qm9_smoke.yaml b/configs/qm9_smoke.yaml new file mode 100644 index 0000000..e238b42 --- /dev/null +++ b/configs/qm9_smoke.yaml @@ -0,0 +1,26 @@ +# Smallest possible run that exercises the full pipeline on CPU. +# Goal: prove the loss strictly decreases. Numbers are not meaningful. +dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv +dataset_source: smiles +subset_size: 100 +split_ratios: [0.8, 0.1, 0.1] +split_seed: 0 + +# Tiny model — enough capacity to learn distance regression on 80 toy molecules. +n_layers: 2 +d_model: 64 +d_ffn: 128 +n_head: 4 +dropout: 0.0 + +epochs: 3 +batch_size: 8 +eval_batch_size: 8 +lr: 3.0e-4 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 0 + +device: cpu +output_dir: outputs/qm9_smoke +seed: 0 diff --git a/configs/tmqmg.yaml b/configs/tmqmg.yaml new file mode 100644 index 0000000..3d33274 --- /dev/null +++ b/configs/tmqmg.yaml @@ -0,0 +1,24 @@ +# Full tmQMg-full.csv training run (~60K organometallic complexes, closed-shell singlets, full d-block + La). GPU-only; staged for Slurm. +dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv +dataset_source: mol2 +subset_size: null +split_ratios: [0.9, 0.05, 0.05] +split_seed: 0 + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 4 + +device: cuda +output_dir: outputs/tmqmg_full +seed: 0 diff --git a/configs/tmqmg_smoke.yaml b/configs/tmqmg_smoke.yaml new file mode 100644 index 0000000..a2460b2 --- /dev/null +++ b/configs/tmqmg_smoke.yaml @@ -0,0 +1,25 @@ +# Same shape as qm9_smoke, but reading the MOL2 path on organometallic data. +# Verifies the LJ patch and the MOL2 → graph pipeline end-to-end on CPU. +dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv +dataset_source: mol2 +subset_size: 100 +split_ratios: [0.8, 0.1, 0.1] +split_seed: 0 + +n_layers: 2 +d_model: 64 +d_ffn: 128 +n_head: 4 +dropout: 0.0 + +epochs: 3 +batch_size: 4 # tmQMg complexes are larger (~50 atoms vs ~15 for QM9) +eval_batch_size: 4 +lr: 3.0e-4 +weight_decay: 0.0 +warmup_ratio: 0.1 +num_workers: 0 + +device: cpu +output_dir: outputs/tmqmg_smoke +seed: 0 diff --git a/external/ReBIND b/external/ReBIND new file mode 160000 index 0000000..8d240c6 --- /dev/null +++ b/external/ReBIND @@ -0,0 +1 @@ +Subproject commit 8d240c6e04a6f23867df98755100a9fbd230b24d diff --git a/pyproject.toml b/pyproject.toml index 1d30d04..a85510e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,28 @@ [project] name = "step-up" version = "0.1.0" -description = "Add your description here" +description = "Benchmark for 2D to 3D molecular conformer generation models" readme = "README.md" authors = [ - { name = "Siddharth Betala", email = "betalas5@gmail.com" } + { name = "Siddharth Betala", email = "betalas5@gmail.com" }, + { name = "Jacob Toney", email = "jwt@mit.edu" } ] requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "numpy>=1.26", + "pandas>=2.2", + "pyyaml>=6.0", + "rdkit>=2024.3.3", + "tensorboard>=2.16", + "torch>=2.1", + "torch-geometric>=2.3", + "torchmetrics>=1.0", + "tqdm>=4.66", + "transformers>=4.43", +] + +[project.scripts] +step-up = "step_up.cli:main" [build-system] requires = ["uv_build>=0.10.8,<0.11.0"] @@ -38,3 +53,19 @@ max-complexity = 12 [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::UserWarning", +] + +[tool.uv.sources] +torch = [{ index = "pytorch-cu124" }] + +[[tool.uv.index]] +name = "pytorch-cu124" +url = "https://download.pytorch.org/whl/cu124" +explicit = true + diff --git a/scripts/train.sh b/scripts/train.sh new file mode 100755 index 0000000..d4c4df5 --- /dev/null +++ b/scripts/train.sh @@ -0,0 +1,27 @@ +#!/bin/bash +#SBATCH --job-name=step-up +#SBATCH --gres=gpu:volta:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --output=train.out + +# Usage: sbatch scripts/train.slurm configs/qm9.yaml +# - Edit partition / gres / time to match your cluster. +# - The job runs from the repo root and uses uv for everything. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 2 +fi +CONFIG="$1" + +cd "$(dirname "$0")/.." + +# Make sure the submodule is initialized in case the job runs on a fresh checkout. +git submodule update --init --recursive + +uv sync --dev + +uv run step-up train -c "$CONFIG" diff --git a/src/step_up/__init__.py b/src/step_up/__init__.py index a88258d..7b57afa 100644 --- a/src/step_up/__init__.py +++ b/src/step_up/__init__.py @@ -1,2 +1,3 @@ -def hello() -> str: - return "Hello from step-up!" +"""step-up: a benchmark for 2D to 3D molecular conformer generation models.""" + +__version__ = "0.1.0" diff --git a/src/step_up/cli.py b/src/step_up/cli.py new file mode 100644 index 0000000..f19a353 --- /dev/null +++ b/src/step_up/cli.py @@ -0,0 +1,45 @@ +"""Command-line entry point: ``uv run step-up ``.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .train import TrainConfig, train + + +def _cmd_train(args: argparse.Namespace) -> int: + cfg = TrainConfig.from_yaml(args.config) + if args.dry_run: + print(f"[dry-run] Loaded config from {args.config}") + print(f" dataset: {cfg.dataset_path} ({cfg.dataset_source})") + if not Path(cfg.dataset_path).exists(): + print(f" ERROR: dataset path does not exist: {cfg.dataset_path}") + return 1 + print(f" subset_size={cfg.subset_size} epochs={cfg.epochs} batch={cfg.batch_size}") + print(f" model: layers={cfg.n_layers} d_model={cfg.d_model} d_ffn={cfg.d_ffn}") + print(f" device={cfg.device} output_dir={cfg.output_dir}") + return 0 + result = train(cfg) + print(f"\nBest val D-MAE: {result['best_val_dmae']:.4f}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="step-up") + sub = parser.add_subparsers(dest="command", required=True) + + p_train = sub.add_parser("train", help="Train a model from a YAML config") + p_train.add_argument("-c", "--config", required=True, help="Path to YAML config") + p_train.add_argument( + "--dry-run", action="store_true", help="Validate config and dataset path without training" + ) + p_train.set_defaults(func=_cmd_train) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/src/step_up/data/__init__.py b/src/step_up/data/__init__.py new file mode 100644 index 0000000..084a543 --- /dev/null +++ b/src/step_up/data/__init__.py @@ -0,0 +1 @@ +"""Data loading and featurization for step-up benchmark datasets.""" diff --git a/src/step_up/data/csv_dataset.py b/src/step_up/data/csv_dataset.py new file mode 100644 index 0000000..d089082 --- /dev/null +++ b/src/step_up/data/csv_dataset.py @@ -0,0 +1,122 @@ +"""Unified CSV → graph-dict dataset. + +Backs all three current targets (QM9-full, tmQMg-full, BOSTMC) with one class. +Each row is featurized lazily into a ReBind-compatible dict that the Collator +can pad and batch. + +The dataset performs an upfront *validation pass* that attempts to featurize +every row and keeps only the indices that succeed. This is the explicit answer +to the silent-corruption failure mode where RDKit could not infer bond orders +on certain QM9 rows: rather than silently substituting a wrong feature value, +those rows are filtered out and reported. +""" + +from __future__ import annotations + +import csv +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import pandas as pd +from torch.utils.data import Dataset +from tqdm import tqdm + +from .featurize import featurize_mol2_xyz, featurize_xyz + +# Embedded XYZ/MOL2 fields blow past the default csv.field_size_limit. Raise it +# once at import time so pandas (which uses Python's csv internally for the C +# fallback path) doesn't truncate. +csv.field_size_limit(sys.maxsize) + + +@dataclass(frozen=True) +class DatasetSpec: + """Where to find a CSV and how to read it.""" + + path: Path + source: Literal["smiles", "mol2"] + + +def _read_csv_subset(path: Path, columns: Sequence[str], nrows: int | None) -> pd.DataFrame: + df = pd.read_csv(path, usecols=list(columns), nrows=nrows, low_memory=False) + df = df.reset_index(drop=True) + return df + + +class CSVMoleculeDataset(Dataset): + """Row-streaming dataset over one of QM9-full / tmQMg-full / BOSTMC. + + The CSV is loaded into a pandas DataFrame at construction time (the + ``subset_size`` knob keeps memory bounded for smoke runs). When + ``validate=True`` (the default), the dataset then walks every row, calls + the featurizer, and keeps only indices whose featurization succeeds. The + number of dropped rows is reported once. + """ + + def __init__( + self, + path: str | Path, + source: Literal["smiles", "mol2"], + subset_size: int | None = None, + validate: bool = True, + ) -> None: + self.path = Path(path) + if not self.path.exists(): + raise FileNotFoundError(self.path) + self.source = source + + if source == "smiles": + # QM9 path: XYZ only — RDKit's DetermineBonds recovers chemistry. + # We still load the smiles column (unused) so the column-set check + # asserts the source is actually QM9-shaped. + cols = ("smiles", "xyz") + elif source == "mol2": + cols = ("mol2", "xyz") + else: + raise ValueError(f"Unknown source: {source!r}") + + self._df = _read_csv_subset(self.path, cols, nrows=subset_size) + + if validate: + self._valid_indices = self._validate_rows() + else: + self._valid_indices = list(range(len(self._df))) + + def __len__(self) -> int: + return len(self._valid_indices) + + def __getitem__(self, idx: int) -> dict[str, Any]: + real_idx = self._valid_indices[idx] + return self._featurize(real_idx) + + def _featurize(self, real_idx: int) -> dict[str, Any]: + row = self._df.iloc[real_idx] + if self.source == "smiles": + return featurize_xyz(str(row["xyz"])) + return featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"])) + + def _validate_rows(self) -> list[int]: + n = len(self._df) + kept: list[int] = [] + failures: dict[str, int] = {} + pbar = tqdm(range(n), desc=f"validating {self.path.name}", unit="mol", dynamic_ncols=True) + for i in pbar: + try: + self._featurize(i) + kept.append(i) + except Exception as e: + # Bucket by error class so the summary line is interpretable. + key = type(e).__name__ + failures[key] = failures.get(key, 0) + 1 + dropped = n - len(kept) + if dropped: + summary = ", ".join(f"{k}={v}" for k, v in sorted(failures.items())) + print( + f"[{self.path.name}] validation: kept {len(kept)}/{n} rows, " + f"dropped {dropped} ({summary})", + flush=True, + ) + return kept diff --git a/src/step_up/data/featurize.py b/src/step_up/data/featurize.py new file mode 100644 index 0000000..3e9b927 --- /dev/null +++ b/src/step_up/data/featurize.py @@ -0,0 +1,123 @@ +"""Convert raw XYZ / MOL2 blocks into ReBind-format graph dicts. + +Two paths: + +- **QM9 (XYZ + SMILES column)**: uses RDKit's ``MolFromXYZBlock`` + + ``rdDetermineBonds.DetermineBonds`` to recover the bond graph from + coordinates. SMILES is intentionally ignored because the SMILES atom order + doesn't match the XYZ order in QM9-full.csv. + +- **Organometallic (MOL2 + XYZ)**: parses MOL2 directly via + :mod:`step_up.data.mol2` — no RDKit perception. Connectivity and bond types + come straight from the file, and SYBYL atom types (``C.3``, ``C.ar``, + ``N.am``, ...) provide hybridization / aromaticity that RDKit with + ``sanitize=False`` would discard. Rings are computed from the bond graph. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +import numpy as np +from rdkit import Chem +from rdkit.Chem import rdDetermineBonds + +from .mol2 import mol2_to_graph_dict + +# Load ReBind's `data/utils.py` directly so we share the canonical featurization +# without putting the entire ReBIND tree on sys.path. The submodule lives at +# step-up/external/ReBIND/data/utils.py. +_REBIND_ROOT = Path(__file__).resolve().parents[3] / "external" / "ReBIND" + + +def _load_rebind_data_utils(): + path = _REBIND_ROOT / "data" / "utils.py" + if not path.exists(): + raise FileNotFoundError( + f"ReBind submodule not found at {_REBIND_ROOT}. " + "Run: git submodule update --init --recursive" + ) + spec = importlib.util.spec_from_file_location("_step_up_rebind_data_utils", str(path)) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules["_step_up_rebind_data_utils"] = mod + spec.loader.exec_module(mod) + return mod + + +_rebind_utils = _load_rebind_data_utils() +mol_to_graph_dict = _rebind_utils.mol_to_graph_dict +ALLOWABLE_FEATURES = _rebind_utils.ALLOWABLE_FEATURES + + +def parse_xyz_block(block: str) -> tuple[list[str], np.ndarray]: + """Parse a standard XYZ block string into (symbols, coords). + + Standard XYZ format: + line 1: integer atom count + line 2: comment (ignored) + lines 3..: ``element x y z`` (whitespace-separated) + """ + lines = block.strip().splitlines() + if len(lines) < 2: + raise ValueError(f"XYZ block too short ({len(lines)} lines)") + n = int(lines[0].strip()) + atom_lines = lines[2 : 2 + n] + if len(atom_lines) != n: + raise ValueError(f"XYZ header says {n} atoms but body has {len(atom_lines)}") + symbols: list[str] = [] + coords = np.empty((n, 3), dtype=np.float64) + for i, line in enumerate(atom_lines): + parts = line.split() + symbols.append(parts[0]) + coords[i] = [float(parts[1]), float(parts[2]), float(parts[3])] + return symbols, coords + + +def mol_from_xyz_block(xyz_block: str, charge: int = 0) -> Chem.Mol: + """Build an RDKit molecule from a raw XYZ block, inferring bonds from coords. + + Atom order matches the XYZ block (no remapping). Bond *orders* are inferred + via ``rdDetermineBonds.DetermineBonds`` using the supplied formal charge. + Raises ``ValueError`` if bond inference fails — callers (typically the CSV + dataset's validation pass) are responsible for filtering bad rows out. + + Suitable for closed-shell organic systems (QM9). For organometallics we use + the MOL2 path instead, which carries explicit bonds. + + NOTE: An earlier version of this function fell back to + ``DetermineConnectivity`` (bond presence without bond orders). This was a + silent data-corruption bug: ReBind's ``safe_index`` maps unspecified bond + types to the AROMATIC bucket, so every bond on those rows was being + featurized as aromatic. We now raise instead, so the bad rows get + explicitly filtered upstream. + """ + mol = Chem.MolFromXYZBlock(xyz_block) + if mol is None: + raise ValueError("RDKit failed to parse XYZ block") + rdDetermineBonds.DetermineBonds(mol, charge=charge) + return mol + + +def featurize_xyz(xyz_block: str, charge: int = 0) -> dict[str, Any]: + """One-shot helper: XYZ-only featurization (QM9 path). + + Coordinates come from the XYZ block; bonds are inferred. The original + SMILES is unused — RDKit's ``DetermineBonds`` recovers the same chemistry + and the resulting atom order matches the XYZ. + """ + mol = mol_from_xyz_block(xyz_block, charge=charge) + return mol_to_graph_dict(mol) + + +def featurize_mol2_xyz(mol2_block: str, xyz_block: str) -> dict[str, Any]: + """One-shot helper for MOL2-sourced rows (tmQMg, BOSTMC). + + No RDKit — the MOL2 file already contains all connectivity and bond types + we need, and we keep the XYZ-block coordinates as the canonical geometry. + """ + _, coords = parse_xyz_block(xyz_block) + return mol2_to_graph_dict(mol2_block, coords=coords) diff --git a/src/step_up/data/mol2.py b/src/step_up/data/mol2.py new file mode 100644 index 0000000..552cfb3 --- /dev/null +++ b/src/step_up/data/mol2.py @@ -0,0 +1,361 @@ +"""Direct Tripos MOL2 parser — no RDKit perception. + +For organometallic data we already have the connectivity and bond types in the +MOL2 file (from molSimplify), and we have SYBYL atom types that encode +hybridization and aromaticity. Going through ``Chem.MolFromMol2Block`` with +``sanitize=False`` would give us nothing beyond what's already in the file +while throwing the SYBYL information away — so we parse MOL2 ourselves and +emit a graph dict in the same shape ReBind's collator expects. + +Atom feature semantics match ``ALLOWABLE_FEATURES`` in +``external/ReBIND/data/utils.py``: + + [atomic_num_idx, chirality_idx, degree_idx, formal_charge_idx, + numH_idx, num_radical_idx, hybridization_idx, is_aromatic_idx, + is_in_ring_idx] + +Bond features (4 per bond, undirected expanded to two directed entries): + + [bond_type_idx, bond_dir_idx, bond_stereo_idx, is_conjugated_idx] +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +# --------------------------------------------------------------------------- +# Element table (Z=1..103). Kept inline to avoid a periodic-table dep. +# --------------------------------------------------------------------------- +_ELEMENTS: tuple[str, ...] = ( + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", + "Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar", "K", "Ca", + "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", + "Ga", "Ge", "As", "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", + "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", "Sn", + "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", + "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", + "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", + "Tl", "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", + "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", + "Md", "No", "Lr", +) # fmt: skip +_SYMBOL_TO_Z: dict[str, int] = {sym: i + 1 for i, sym in enumerate(_ELEMENTS)} + +# --------------------------------------------------------------------------- +# ReBind vocab indices (mirroring ALLOWABLE_FEATURES). Keeping these inline so +# the file is independent — if upstream changes, the smoke tests will catch +# the mismatch in feature dimensions. +# --------------------------------------------------------------------------- +_POSSIBLE_ATOMIC_NUM_LEN = 118 # list(range(1, 119)) +_POSSIBLE_DEGREE = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) # plus "misc" -> idx 11 +_POSSIBLE_FORMAL_CHARGE = (-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5) # +"misc"->11 +_POSSIBLE_NUMH = (0, 1, 2, 3, 4, 5, 6, 7, 8) # +"misc"->9 + +# Chirality, hybridization, bond_type, bond_dir, bond_stereo indices line up +# with ReBind's ALLOWABLE_FEATURES tuples. We only use specific entries: +_CHI_UNSPECIFIED = 0 +_HYB = {"SP": 0, "SP2": 1, "SP3": 2, "SP3D": 3, "SP3D2": 4, "misc": 5} +_BOND_TYPE = {"SINGLE": 0, "DOUBLE": 1, "TRIPLE": 2, "AROMATIC": 3} +_BOND_DIR_NONE = 0 +_BOND_STEREO_NONE = 0 + +# SYBYL atom type -> (hybridization name, is_aromatic) +_SYBYL_HYB: dict[str, tuple[str, bool]] = { + "1": ("SP", False), + "2": ("SP2", False), + "3": ("SP3", False), + "4": ("SP3", False), # quaternary N -> SP3 + "ar": ("SP2", True), # aromatic + "am": ("SP2", False), # amide N + "pl3": ("SP2", False), # planar N + "cat": ("SP2", False), # guanidinium C + "co2": ("SP2", False), # carboxylate O + "spc": ("SP3", False), # SPC water O + "t3p": ("SP3", False), # TIP3P water O + "o": ("SP3", False), # generic +} + +# MOL2 bond-type string -> (canonical bond_type_name, is_conjugated) +_MOL2_BOND_TYPE: dict[str, tuple[str, bool]] = { + "1": ("SINGLE", False), + "2": ("DOUBLE", False), + "3": ("TRIPLE", False), + "ar": ("AROMATIC", True), + "am": ("SINGLE", True), # amide C-N -> single but conjugated + "du": ("SINGLE", False), # dummy + "un": ("SINGLE", False), # unknown -> treat as single + "nc": ("SINGLE", False), # not connected (shouldn't appear, but) +} + + +@dataclass +class Mol2Atom: + """One row from the ``@ATOM`` block.""" + + idx: int # 0-indexed + element: str # parsed from SYBYL type prefix + sybyl: str # full SYBYL string (e.g., "C.3", "Ni") + coords: np.ndarray # (3,) + + +@dataclass +class Mol2Bond: + """One row from the ``@BOND`` block.""" + + a: int # 0-indexed + b: int # 0-indexed + mol2_type: str # raw MOL2 type string + + +@dataclass +class Mol2: + atoms: list[Mol2Atom] + bonds: list[Mol2Bond] + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def _element_from_sybyl(sybyl: str) -> str: + """Element symbol = SYBYL prefix before the first dot (or full string).""" + return sybyl.split(".", 1)[0] + + +def _hyb_from_sybyl(sybyl: str) -> tuple[str, bool]: + """SYBYL → (hybridization, is_aromatic). Metals/unknowns default to misc.""" + if "." not in sybyl: + # No SYBYL hybridization given (e.g. metals: "Ni", "Fe"). misc bucket. + return ("misc", False) + suffix = sybyl.split(".", 1)[1] + return _SYBYL_HYB.get(suffix, ("misc", False)) + + +def parse_mol2(text: str) -> Mol2: + """Parse a single-molecule MOL2 block string.""" + atoms: list[Mol2Atom] = [] + bonds: list[Mol2Bond] = [] + section: str | None = None + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("@"): + section = line[len("@") :].strip().upper() + continue + if section == "ATOM": + atoms.append(_parse_atom_line(line, len(atoms))) + elif section == "BOND": + bonds.append(_parse_bond_line(line)) + # Other sections (MOLECULE header, SUBSTRUCTURE, etc.) are ignored. + return Mol2(atoms=atoms, bonds=bonds) + + +def _parse_atom_line(line: str, idx0: int) -> Mol2Atom: + """ATOM line: id name x y z sybyl_type [subst_id [subst_name [charge]]].""" + parts = line.split() + if len(parts) < 6: + raise ValueError(f"MOL2 ATOM line too short: {line!r}") + x, y, z = float(parts[2]), float(parts[3]), float(parts[4]) + sybyl = parts[5] + element = _element_from_sybyl(sybyl) + return Mol2Atom( + idx=idx0, + element=element, + sybyl=sybyl, + coords=np.array([x, y, z], dtype=np.float64), + ) + + +def _parse_bond_line(line: str) -> Mol2Bond: + """BOND line: id atom_a atom_b type.""" + parts = line.split() + if len(parts) < 4: + raise ValueError(f"MOL2 BOND line too short: {line!r}") + a = int(parts[1]) - 1 # MOL2 is 1-indexed + b = int(parts[2]) - 1 + return Mol2Bond(a=a, b=b, mol2_type=parts[3]) + + +# --------------------------------------------------------------------------- +# Graph-dict construction +# --------------------------------------------------------------------------- + + +def _safe_index(seq: tuple, value: Any, miss_idx: int) -> int: + try: + return seq.index(value) + except ValueError: + return miss_idx + + +def _tarjan_bridges(adj: list[list[tuple[int, int]]]) -> set[int]: + """Return the set of bridge edge-ids in an undirected graph. + + A bridge is an edge whose removal disconnects the graph. The implementation + is iterative Tarjan with explicit ``disc``/``low`` arrays. + """ + n = len(adj) + disc = [-1] * n + low = [0] * n + bridges: set[int] = set() + timer = 0 + for start in range(n): + if disc[start] != -1: + continue + # Iterative DFS rooted at ``start``. Stack entries are + # (node, parent_edge_id, neighbor_iter_index). + stack: list[list[int]] = [[start, -1, 0]] + disc[start] = low[start] = timer + timer += 1 + while stack: + node, pedge, i = stack[-1] + if i >= len(adj[node]): + stack.pop() + if not stack: + break + parent = stack[-1][0] + low[parent] = min(low[parent], low[node]) + if low[node] > disc[parent]: + bridges.add(pedge) + continue + stack[-1][2] = i + 1 + nbr, eid = adj[node][i] + if eid == pedge: + continue + if disc[nbr] == -1: + disc[nbr] = low[nbr] = timer + timer += 1 + stack.append([nbr, eid, 0]) + else: + low[node] = min(low[node], disc[nbr]) + return bridges + + +def _find_ring_atoms_and_bonds( + n_atoms: int, bond_pairs: list[tuple[int, int]] +) -> tuple[set[int], set[frozenset[int]]]: + """Return (atoms_in_any_ring, bonds_in_any_ring). + + An edge is a *bridge* iff its removal disconnects the graph, equivalently + iff it is not part of any cycle. The complement of bridges is the set of + ring bonds. + """ + adj: list[list[tuple[int, int]]] = [[] for _ in range(n_atoms)] + for eid, (a, b) in enumerate(bond_pairs): + adj[a].append((b, eid)) + adj[b].append((a, eid)) + bridges = _tarjan_bridges(adj) + ring_edge_set: set[frozenset[int]] = set() + ring_atom_set: set[int] = set() + for eid, (a, b) in enumerate(bond_pairs): + if eid not in bridges: + ring_edge_set.add(frozenset((a, b))) + ring_atom_set.add(a) + ring_atom_set.add(b) + return ring_atom_set, ring_edge_set + + +def mol2_to_graph_dict(mol2_text: str, coords: np.ndarray | None = None) -> dict[str, Any]: + """Build a ReBind-format graph dict directly from a MOL2 block. + + If ``coords`` is supplied (e.g. parsed from a separate XYZ column), + it overrides the MOL2 coordinates — useful when the canonical geometry + lives in a different column. Otherwise we use the MOL2 atom positions. + """ + parsed = parse_mol2(mol2_text) + n = len(parsed.atoms) + if n == 0: + raise ValueError("MOL2 block has zero atoms") + + # ---- Coordinates ------------------------------------------------------ + if coords is not None: + if coords.shape != (n, 3): + raise ValueError(f"coords shape {coords.shape} does not match MOL2 atom count {n}") + conformer = coords.tolist() + else: + conformer = np.stack([a.coords for a in parsed.atoms], axis=0).tolist() + + # ---- Bonds first (need degree, numH, in-ring) ------------------------- + bond_pairs: list[tuple[int, int]] = [] + bond_types: list[str] = [] + bond_conjugated: list[bool] = [] + for b in parsed.bonds: + canonical_type, is_conj = _MOL2_BOND_TYPE.get(b.mol2_type, ("SINGLE", False)) + bond_pairs.append((b.a, b.b)) + bond_types.append(canonical_type) + bond_conjugated.append(is_conj) + + ring_atoms, _ring_bonds = _find_ring_atoms_and_bonds(n, bond_pairs) + degree = [0] * n + num_h_neighbors = [0] * n + for a, b in bond_pairs: + degree[a] += 1 + degree[b] += 1 + if parsed.atoms[b].element == "H": + num_h_neighbors[a] += 1 + if parsed.atoms[a].element == "H": + num_h_neighbors[b] += 1 + + # ---- Atom features ---------------------------------------------------- + node_attr: list[list[int]] = [] + node_type: list[int] = [] + for atom in parsed.atoms: + z = _SYMBOL_TO_Z.get(atom.element) + if z is None: + raise ValueError(f"Unknown element symbol {atom.element!r} in MOL2") + atom_num_idx = z - 1 # index into list(range(1, 119)) + hyb_name, is_aromatic = _hyb_from_sybyl(atom.sybyl) + in_ring = atom.idx in ring_atoms + feat = [ + atom_num_idx, + _CHI_UNSPECIFIED, # MOL2 has no chirality field + _safe_index(_POSSIBLE_DEGREE, degree[atom.idx], len(_POSSIBLE_DEGREE)), + _safe_index(_POSSIBLE_FORMAL_CHARGE, 0, len(_POSSIBLE_FORMAL_CHARGE)), + _safe_index(_POSSIBLE_NUMH, num_h_neighbors[atom.idx], len(_POSSIBLE_NUMH)), + 0, # num radical electrons (MOL2 has no radical info) + _HYB.get(hyb_name, _HYB["misc"]), + int(is_aromatic), + int(in_ring), + ] + node_attr.append(feat) + node_type.append(atom_num_idx) + + # ---- Bond features (bidirectional) ------------------------------------ + edges_a: list[int] = [] + edges_b: list[int] = [] + edge_attr: list[list[int]] = [] + for (a, b), btype, conj in zip(bond_pairs, bond_types, bond_conjugated, strict=True): + feat = [ + _BOND_TYPE.get(btype, _BOND_TYPE["SINGLE"]), + _BOND_DIR_NONE, + _BOND_STEREO_NONE, + int(conj), + ] + edges_a.append(a) + edges_b.append(b) + edge_attr.append(feat) + edges_a.append(b) + edges_b.append(a) + edge_attr.append(feat) + + edge_index = [edges_a, edges_b] + edge_type = [feat[0] for feat in edge_attr] + edge_dir_type = [feat[1] for feat in edge_attr] + + return { + "node_attr": node_attr, + "edge_attr": edge_attr, + "edge_index": edge_index, + "node_type": node_type, + "node_chiral_type": [feat[1] for feat in node_attr], + "edge_type": edge_type, + "edge_dire_type": edge_dir_type, + "num_nodes": n, + "num_edges": len(edge_attr), + "conformer": conformer, + } diff --git a/src/step_up/data/splits.py b/src/step_up/data/splits.py new file mode 100644 index 0000000..bb5c88b --- /dev/null +++ b/src/step_up/data/splits.py @@ -0,0 +1,25 @@ +"""Random and reproducible split helpers.""" + +from __future__ import annotations + +import torch +from torch.utils.data import Dataset, Subset + + +def random_split( + dataset: Dataset, + ratios: tuple[float, float, float] = (0.8, 0.1, 0.1), + seed: int = 0, +) -> tuple[Subset, Subset, Subset]: + """Random train/val/test split. Ratios must sum to 1.""" + if abs(sum(ratios) - 1.0) > 1e-6: + raise ValueError(f"Ratios must sum to 1, got {ratios}") + n = len(dataset) # type: ignore[arg-type] + g = torch.Generator().manual_seed(seed) + perm = torch.randperm(n, generator=g).tolist() + n_train = int(n * ratios[0]) + n_val = int(n * ratios[1]) + train_idx = perm[:n_train] + val_idx = perm[n_train : n_train + n_val] + test_idx = perm[n_train + n_val :] + return Subset(dataset, train_idx), Subset(dataset, val_idx), Subset(dataset, test_idx) diff --git a/src/step_up/eval/__init__.py b/src/step_up/eval/__init__.py new file mode 100644 index 0000000..a9113d1 --- /dev/null +++ b/src/step_up/eval/__init__.py @@ -0,0 +1 @@ +"""Evaluation utilities for step-up.""" diff --git a/src/step_up/eval/metrics.py b/src/step_up/eval/metrics.py new file mode 100644 index 0000000..7b89b6d --- /dev/null +++ b/src/step_up/eval/metrics.py @@ -0,0 +1,74 @@ +"""Conformer-quality metrics matching ReBind's evaluate.py. + +All metrics operate on padded coordinate tensors of shape ``(B, N, 3)`` with a +boolean ``node_mask`` of shape ``(B, N)`` marking valid (non-padding) atoms. +""" + +from __future__ import annotations + +import torch +import torch.nn.functional as F + + +def _pair_mask(node_mask: torch.Tensor) -> torch.Tensor: + m = node_mask.to(torch.bool).unsqueeze(-1) + return (m & m.transpose(-1, -2)).to(torch.float32) + + +def cdist_mae(pred: torch.Tensor, target: torch.Tensor, node_mask: torch.Tensor) -> torch.Tensor: + """Mean absolute error on pairwise distance matrices (ReBind ``D-MAE``).""" + mask = _pair_mask(node_mask) + d_pred = torch.cdist(pred, pred) * mask + d_true = torch.cdist(target, target) * mask + return F.l1_loss(d_pred, d_true, reduction="sum") / mask.sum().clamp_min(1) + + +def cdist_rmse(pred: torch.Tensor, target: torch.Tensor, node_mask: torch.Tensor) -> torch.Tensor: + """Root mean squared error on pairwise distance matrices (ReBind ``D-RMSE``).""" + mask = _pair_mask(node_mask) + d_pred = torch.cdist(pred, pred) * mask + d_true = torch.cdist(target, target) * mask + mse = F.mse_loss(d_pred, d_true, reduction="sum") / mask.sum().clamp_min(1) + return torch.sqrt(mse) + + +def coord_rmsd(pred: torch.Tensor, target: torch.Tensor, node_mask: torch.Tensor) -> torch.Tensor: + """Per-molecule coordinate RMSD averaged over the batch (no Kabsch alignment). + + ReBind aligns predictions to targets *inside* the model head before the loss, + so by the time tensors leave the model, this raw RMSD is already + rotation-aware. For unaligned eval (e.g., a baseline that does no + alignment), use ``kabsch_rmsd_per_mol`` below. + """ + delta = (pred - target).to(torch.float32) + sq = (delta * delta).sum(dim=-1) * node_mask.to(torch.float32) + msd = sq.sum(dim=-1) / node_mask.sum(dim=-1).clamp_min(1) + return torch.sqrt(msd).mean() + + +def per_element_dmae( + pred: torch.Tensor, + target: torch.Tensor, + node_mask: torch.Tensor, + atomic_numbers: torch.Tensor, +) -> dict[int, float]: + """Per-element D-MAE: |d_ij_pred - d_ij_true| averaged over pairs touching Z. + + ``atomic_numbers`` is ``(B, N)`` of integer Z values (use 0 for padding). + Returns a dict mapping Z -> mean |Δd| over pairs (i,j) with i or j of that + element (each pair contributes to both endpoint elements). + """ + mask = _pair_mask(node_mask) + diff = (torch.cdist(pred, pred) - torch.cdist(target, target)).abs() * mask + + zs = atomic_numbers.to(torch.long) + per_z: dict[int, float] = {} + for z in torch.unique(zs): + if int(z.item()) == 0: + continue + # Pairs where either endpoint has atomic number z. + endpoint_mask = (zs == z).unsqueeze(-1) | (zs == z).unsqueeze(-2) + pair_mask = mask * endpoint_mask.to(torch.float32) + denom = pair_mask.sum().clamp_min(1) + per_z[int(z.item())] = float((diff * endpoint_mask.to(torch.float32)).sum() / denom) + return per_z diff --git a/src/step_up/models/__init__.py b/src/step_up/models/__init__.py new file mode 100644 index 0000000..07f7cd7 --- /dev/null +++ b/src/step_up/models/__init__.py @@ -0,0 +1 @@ +"""Model wrappers for step-up benchmark.""" diff --git a/src/step_up/models/rebind.py b/src/step_up/models/rebind.py new file mode 100644 index 0000000..8e4224e --- /dev/null +++ b/src/step_up/models/rebind.py @@ -0,0 +1,341 @@ +"""Thin wrapper around the vendored ReBind implementation. + +Vendored at ``external/ReBIND``. We add that path to ``sys.path`` once on +import so the vendor's intra-package relative imports work. We also patch +``get_sigma_and_epsilon`` so that atomic numbers > 36 (i.e., second-row+ +transition metals, lanthanides, etc.) don't raise a KeyError on the +organometallic datasets. + +This is the explicit "hybrid: vendor for now, refactor later" handoff. The next +iteration will copy the model code into ``step_up.models.rebind`` natively and +delete the sys.path hack. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import torch + +_REBIND_ROOT = Path(__file__).resolve().parents[3] / "external" / "ReBIND" +if not _REBIND_ROOT.exists(): + raise FileNotFoundError( + f"ReBind submodule not found at {_REBIND_ROOT}. " + "Run: git submodule update --init --recursive" + ) + +# Put the vendor root on sys.path so its internal imports (`from .modules ...`, +# `from models import ...`) resolve. Idempotent. +if str(_REBIND_ROOT) not in sys.path: + sys.path.insert(0, str(_REBIND_ROOT)) + +from models import REBIND, Collator, REBINDConfig # noqa: E402 +from models.modules import utils as _rebind_utils # noqa: E402 +from models.rebind import collating_rebind as _rebind_collating # noqa: E402 +from models.rebind import modeling_rebind as _rebind_modeling # noqa: E402 + +__all__ = ["REBIND", "Collator", "REBINDConfig", "build_rebind", "patch_lj_parameters"] + + +# --------------------------------------------------------------------------- +# LJ-parameter extension for organometallics. +# --------------------------------------------------------------------------- +# ReBind's `get_sigma_and_epsilon` hardcodes LJ parameters for atomic-number +# indices 0..35 (i.e., Z=1..36, H through Kr). For organometallics that include +# 4d, 5d, and f-block elements, we extend with a safe fallback. The values are +# order-of-magnitude reasonable (UFF-style sigma ~3.5 A, epsilon ~0.05 kcal/mol); the +# LJ rewiring's contribution is small relative to the bond-graph signal, and +# physically realistic dispersion for metals is dominated by short-range +# Pauli repulsion which the cutoff already handles. + +_DEFAULT_LJ_SIGMA = 3.5 +_DEFAULT_LJ_EPSILON = 0.05 + + +def patch_lj_parameters() -> None: + """Replace ``get_sigma_and_epsilon`` with a Z-tolerant version. + + Idempotent: calling twice has no effect beyond the first. + """ + if getattr(_rebind_utils, "_step_up_patched", False): + return + + original_dict = { + i: {"sigma": _DEFAULT_LJ_SIGMA, "epsilon": _DEFAULT_LJ_EPSILON} for i in range(118) + } + # `lj_parameters` is defined inside `get_sigma_and_epsilon`. Re-read its + # canonical entries from a one-off call by inspecting the function's + # closure-free body: we just copy from a local clone here. + canonical = _canonical_lj_table() + original_dict.update(canonical) + + def patched_get_sigma_and_epsilon( + mol_data: Any, drugs: bool = False + ) -> tuple[torch.Tensor, torch.Tensor]: + eps_list: list[float] = [] + sig_list: list[float] = [] + for i, atom_id in enumerate(mol_data.node_type): + if drugs: + # We don't use the `drugs` branch in step-up. Fall through to + # the index-based lookup, which assumes `node_type` is already + # Z-1. + pass + idx = int(atom_id.item()) + params = original_dict.get( + idx, {"sigma": _DEFAULT_LJ_SIGMA, "epsilon": _DEFAULT_LJ_EPSILON} + ) + eps_list.append(params["epsilon"]) + sig_list.append(params["sigma"]) + del i + return torch.tensor(eps_list), torch.tensor(sig_list) + + _rebind_utils.get_sigma_and_epsilon = patched_get_sigma_and_epsilon + # `from ..modules.utils import get_sigma_and_epsilon` binds the symbol + # directly in the collator module — re-rebind it there too. + _rebind_collating.get_sigma_and_epsilon = patched_get_sigma_and_epsilon + _rebind_utils._step_up_patched = True + + +def _canonical_lj_table() -> dict[int, dict[str, float]]: + """Return ReBind's original Z=1..36 LJ parameter table (key = Z - 1).""" + return { + 0: {"sigma": 2.886, "epsilon": 0.0440}, + 1: {"sigma": 2.362, "epsilon": 0.0560}, + 2: {"sigma": 2.451, "epsilon": 0.0250}, + 3: {"sigma": 2.745, "epsilon": 0.0850}, + 4: {"sigma": 3.637, "epsilon": 0.1800}, + 5: {"sigma": 3.431, "epsilon": 0.1050}, + 6: {"sigma": 3.260, "epsilon": 0.0690}, + 7: {"sigma": 3.118, "epsilon": 0.0600}, + 8: {"sigma": 2.996, "epsilon": 0.0500}, + 9: {"sigma": 2.889, "epsilon": 0.0420}, + 10: {"sigma": 2.983, "epsilon": 0.0300}, + 11: {"sigma": 2.905, "epsilon": 0.1110}, + 12: {"sigma": 4.008, "epsilon": 0.5050}, + 13: {"sigma": 3.826, "epsilon": 0.4020}, + 14: {"sigma": 3.694, "epsilon": 0.3050}, + 15: {"sigma": 3.594, "epsilon": 0.2740}, + 16: {"sigma": 3.516, "epsilon": 0.2270}, + 17: {"sigma": 3.404, "epsilon": 0.1850}, + 18: {"sigma": 3.812, "epsilon": 0.0350}, + 19: {"sigma": 3.487, "epsilon": 0.2380}, + 20: {"sigma": 3.316, "epsilon": 0.0190}, + 21: {"sigma": 3.294, "epsilon": 0.0170}, + 22: {"sigma": 3.273, "epsilon": 0.0160}, + 23: {"sigma": 3.249, "epsilon": 0.0150}, + 24: {"sigma": 3.210, "epsilon": 0.0130}, + 25: {"sigma": 3.174, "epsilon": 0.0130}, + 26: {"sigma": 3.144, "epsilon": 0.0130}, + 27: {"sigma": 3.116, "epsilon": 0.0130}, + 28: {"sigma": 3.083, "epsilon": 0.0050}, + 29: {"sigma": 3.002, "epsilon": 0.1240}, + 30: {"sigma": 4.383, "epsilon": 0.4150}, + 31: {"sigma": 4.310, "epsilon": 0.3790}, + 32: {"sigma": 4.280, "epsilon": 0.3090}, + 33: {"sigma": 4.336, "epsilon": 0.2910}, + 34: {"sigma": 4.403, "epsilon": 0.2510}, + 35: {"sigma": 4.463, "epsilon": 0.2200}, + } + + +def _patched_encoder_forward(self, **inputs): + """Out-of-place equivalent of ``Encoder.forward`` from vendored ReBind. + + The vendored version did an in-place slice-assignment of the Laplacian + positional encoding into ``node_embedding``, which breaks autograd on + modern PyTorch (>=2.6). We replace it with a zero-padded add. + """ + node_attr = inputs.get("node_attr") + node_embedding = self.node_embedding(node_attr) + lap = inputs.get("lap_eigenvectors") + node_embedding = _add_lap_out_of_place(node_embedding, lap) + inputs["node_embedding"] = node_embedding + + attn_weight_dict: dict = {} + for i, encoder_block in enumerate(self.encoder_blocks): + block_out = encoder_block(**inputs) + node_embedding, attn_weight = block_out["out"], block_out["attn_weight"] + inputs["node_embedding"] = node_embedding + attn_weight_dict[f"encoder_block_{i}"] = attn_weight + return {"node_embedding": node_embedding, "attn_weight_dict": attn_weight_dict} + + +def _patched_decoder_forward(self, **inputs): + """Out-of-place equivalent of ``Decoder.forward`` from vendored ReBind.""" + node_embedding = inputs.get("node_embedding") + lap = inputs.get("lap_eigenvectors") + node_embedding = _add_lap_out_of_place(node_embedding, lap) + inputs["node_embedding"] = node_embedding + + attn_weight_dict: dict = {} + for i, decoder_block in enumerate(self.decoder_blocks): + block_out = decoder_block(**inputs) + node_embedding, attn_weight = block_out["out"], block_out["attn_weight"] + inputs["node_embedding"] = node_embedding + attn_weight_dict[f"decoder_block_{i}"] = attn_weight + return {"node_embedding": node_embedding, "attn_weight_dict": attn_weight_dict} + + +def _add_lap_out_of_place(node_embedding: torch.Tensor, lap: torch.Tensor) -> torch.Tensor: + """Add ``lap`` into the leading channels of ``node_embedding`` without in-place ops.""" + d = node_embedding.shape[-1] + lap_dim = lap.shape[-1] + if lap_dim < d: + pad = (0, d - lap_dim) + lap = torch.nn.functional.pad(lap, pad) + elif lap_dim > d: + lap = lap[..., :d] + return node_embedding + lap + + +def patch_inplace_lap_addition() -> None: + """Replace ``Encoder.forward`` and ``Decoder.forward`` with autograd-safe versions.""" + if getattr(_rebind_modeling, "_step_up_inplace_patched", False): + return + _rebind_modeling.Encoder.forward = _patched_encoder_forward + _rebind_modeling.Decoder.forward = _patched_decoder_forward + _rebind_modeling._step_up_inplace_patched = True + + +# Minimum predicted interatomic distance, in angstroms, used to clamp the LJ +# denominator. Real bonded distances are >0.7 A; this value is small enough to +# be a no-op for any realistic prediction and large enough that ``s**6`` (which +# scales as ``sigma**6 / D**6``) cannot overflow FP32 in the LJ block. +_LJ_D_MIN = 0.1 + + +def _patched_rebind_forward(self, **inputs): + """Out-of-place + numerically-defensive copy of ``REBIND.forward``. + + Two changes from vendored ReBind: + + 1. The predicted pairwise-distance tensor ``D_cache`` is clamped to a + small positive minimum before being used in the LJ-force expression + ``s = sigma / D_cache``. Without this, two non-bonded atoms whose + predicted coordinates collide produce ``s = inf`` and downstream + ``inf * 0`` NaNs. The diagonal mask only catches ``i == j`` collisions, + not collisions between distinct atoms — which is exactly what kills + full-scale QM9 training after ~100 steps. Clamp threshold is well + below any real interatomic distance so realistic forward passes are + unaffected. + 2. The final attraction / repulsion adjacency tensors are passed through + ``nan_to_num`` as a belt-and-suspenders guard — if anything upstream + still produces NaN, it gets neutralized to 0 before flowing into the + decoder's attention bias. + """ + conformer, node_mask = inputs.get("conformer"), inputs.get("node_mask") + + encoder_out = self.encoder(**inputs) + node_embedding = encoder_out["node_embedding"] + + cache_out = self.conformer_head( + conformer=conformer, + hidden_X=node_embedding, + padding_mask=node_mask, + compute_loss=True, + ) + loss_cache, conformer_cache = cache_out["loss"], cache_out["conformer_hat"] + + # CHANGE (1): clamp_min on the predicted distance matrix. + D_cache = torch.cdist(conformer_cache, conformer_cache).detach().clamp_min(_LJ_D_MIN) + D_M = _rebind_modeling.make_cdist_mask(node_mask) + inputs["pred_conformation"] = node_embedding + inputs["node_embedding"] = node_embedding + + sigma, epsilon = inputs.get("sigma"), inputs.get("epsilon") + s = sigma / D_cache + s6 = s**6 + LJ_force_orig = 24 * epsilon * (s6 / D_cache) * (2 * s6 - 1) + LJ_force = torch.abs(LJ_force_orig) + + adj_mask = inputs["adjacency"].bool() + self_mask = ( + torch.eye(D_cache.shape[1], device=D_cache.device) + .bool() + .unsqueeze(0) + .expand(D_cache.shape[0], -1, -1) + ) + LJ_force = LJ_force.masked_fill(adj_mask | self_mask, 0) + LJ_force = LJ_force.masked_fill(~D_M.bool(), 0) + + num_retain_edges = inputs.get("num_near_edges") + ret_val = _rebind_modeling.retain_top_k(LJ_force, num_retain_edges, descending=True) + nonzeros = ret_val.nonzero(as_tuple=False) + ret_val[nonzeros[:, 0], nonzeros[:, 1], nonzeros[:, 2]] = 1 + + LJ_nonzero_mask = LJ_force > 0 + LJ_above_zero_mask = LJ_force_orig > 0 + LJ_below_zero_mask = LJ_force_orig < 0 + + repulsive = LJ_nonzero_mask.float() * LJ_above_zero_mask.float() * ret_val * -1 + attractive = LJ_nonzero_mask.float() * LJ_below_zero_mask.float() * ret_val * 1 + # CHANGE (2): scrub any residual NaN/inf out of the attention biases. + inputs["attraction_adjacency"] = torch.nan_to_num(attractive, nan=0.0, posinf=0.0, neginf=0.0) + inputs["repulsion_adjacency"] = torch.nan_to_num(repulsive, nan=0.0, posinf=0.0, neginf=0.0) + + decoder_out = self.decoder(**inputs) + node_embedding = decoder_out["node_embedding"] + + outputs = self.residual_head( + conformer=conformer, + hidden_X=node_embedding, + padding_mask=node_mask, + compute_loss=True, + conformer_base=inputs["pred_conformation"], + ) + + return _rebind_modeling.ConformerPredictionOutput( + loss=(outputs["loss"] + loss_cache) / 2, + cdist_mae=outputs["cdist_mae"], + cdist_mse=outputs["cdist_mse"], + coord_rmsd=outputs["coord_rmsd"], + conformer=outputs["conformer"], + conformer_hat=outputs["conformer_hat"], + ) + + +def patch_rebind_forward() -> None: + """Replace ``REBIND.forward`` with the numerically-defensive version.""" + if getattr(_rebind_modeling, "_step_up_forward_patched", False): + return + _rebind_modeling.REBIND.forward = _patched_rebind_forward + _rebind_modeling._step_up_forward_patched = True + + +# Apply patches eagerly on import. +patch_lj_parameters() +patch_inplace_lap_addition() +patch_rebind_forward() + + +def build_rebind( + n_layers: int = 8, + d_model: int = 512, + d_ffn: int = 1024, + n_head: int = 8, + atom_vocab_size: int = 513, + dropout: float = 0.0, +) -> REBIND: + """Instantiate a REBIND model from a flat keyword-style config.""" + config = REBINDConfig( + n_encode_layers=n_layers, + n_decode_layers=n_layers, + embed_style="atom_type_ids", + atom_vocab_size=atom_vocab_size, + d_embed=d_model, + pre_ln=False, + d_q=d_model, + d_k=d_model, + d_v=d_model, + d_model=d_model, + n_head=n_head, + qkv_bias=True, + attn_drop=dropout, + norm_drop=dropout, + ffn_drop=dropout, + dropout=dropout, + d_ffn=d_ffn, + ) + return REBIND(config) diff --git a/src/step_up/train.py b/src/step_up/train.py new file mode 100644 index 0000000..552ee4e --- /dev/null +++ b/src/step_up/train.py @@ -0,0 +1,337 @@ +"""Config-driven training loop for step-up. + +The loop is deliberately minimal: it owns dataset construction, the ReBind +model, AdamW + linear-warmup, periodic validation, best-checkpoint saving, and +TensorBoard logging. No HuggingFace Trainer, no accelerate — keeping the +control flow legible while we're still iterating on the model. +""" + +from __future__ import annotations + +import json +import math +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import torch +import yaml +from torch.utils.data import DataLoader, Subset +from torch.utils.tensorboard import SummaryWriter +from tqdm import tqdm + +from .data.csv_dataset import CSVMoleculeDataset +from .data.splits import random_split +from .models.rebind import Collator, build_rebind + +# --------------------------------------------------------------------------- +# Config dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class TrainConfig: + dataset_path: str + dataset_source: str # "smiles" | "mol2" + subset_size: int | None = None + split_ratios: tuple[float, float, float] = (0.8, 0.1, 0.1) + split_seed: int = 0 + + # Model + n_layers: int = 8 + d_model: int = 512 + d_ffn: int = 1024 + n_head: int = 8 + dropout: float = 0.0 + + # Optimization + epochs: int = 20 + batch_size: int = 100 + eval_batch_size: int = 100 + lr: float = 9e-5 + weight_decay: float = 0.0 + warmup_ratio: float = 0.1 + # Global gradient-norm clip. ReBind's published training inherited + # HuggingFace Trainer's default of 1.0; without it the 8-layer / d=512 + # model NaNs out in the first epoch on QM9. + grad_clip: float = 1.0 + # Skip batches whose loss comes back non-finite (the LJ block in ReBind's + # head can divide by predicted distances that are ~0 early in training). + # Abort if more than this many batches are skipped over the whole run. + nan_skip_max: int = 50 + num_workers: int = 0 + + # Runtime + device: str = "cpu" + output_dir: str = "outputs/run" + seed: int = 0 + log_interval: int = 10 + + @classmethod + def from_yaml(cls, path: str | Path) -> TrainConfig: + with open(path) as f: + raw = yaml.safe_load(f) or {} + if "split_ratios" in raw: + raw["split_ratios"] = tuple(raw["split_ratios"]) + return cls(**raw) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _set_seed(seed: int) -> None: + import random + + import numpy as np + + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + + +def _move_batch_to_device(batch: dict[str, Any], device: str) -> dict[str, Any]: + return {k: v.to(device) if torch.is_tensor(v) else v for k, v in batch.items()} + + +def _nonfinite_keys(batch: dict[str, Any]) -> list[str]: + """Return floating-point tensors in ``batch`` that contain non-finite values.""" + bad: list[str] = [] + for k, v in batch.items(): + if torch.is_tensor(v) and v.dtype.is_floating_point and not torch.isfinite(v).all(): + bad.append(k) + return bad + + +def _all_params_finite(model: torch.nn.Module) -> bool: + for p in model.parameters(): + if not torch.isfinite(p).all(): + return False + return True + + +def _linear_warmup_lr(step: int, total_steps: int, warmup_steps: int, base_lr: float) -> float: + if step < warmup_steps: + return base_lr * (step + 1) / max(warmup_steps, 1) + progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1) + return base_lr * 0.5 * (1.0 + math.cos(math.pi * progress)) + + +# --------------------------------------------------------------------------- +# Training loop +# --------------------------------------------------------------------------- + + +def _run_epoch( + model: torch.nn.Module, + loader: DataLoader, + optimizer: torch.optim.Optimizer | None, + device: str, + scheduler_state: dict[str, int], + base_lr: float, + total_steps: int, + warmup_steps: int, + grad_clip: float = 0.0, + nan_skip_max: int = 0, +) -> tuple[float, float]: + """Run one pass over ``loader``. Returns (mean_loss, mean_dmae). + + During training, batches whose loss comes back non-finite are skipped + (no backward, no optimizer step). The first occurrence prints whether the + badness is in the input batch or appeared inside the forward pass — useful + to distinguish data corruption from model-side numerical instability. + """ + is_train = optimizer is not None + model.train(is_train) + loss_sum, dmae_sum, n = 0.0, 0.0, 0 + pbar = tqdm(loader, leave=False, desc="train" if is_train else "val", dynamic_ncols=True) + for batch in pbar: + batch = _move_batch_to_device(batch, device) + if is_train: + lr_now = _linear_warmup_lr(scheduler_state["step"], total_steps, warmup_steps, base_lr) + for g in optimizer.param_groups: + g["lr"] = lr_now + optimizer.zero_grad(set_to_none=True) + out = model(**batch) + loss_val = out.loss + if not torch.isfinite(loss_val): + scheduler_state["nan_skipped"] = scheduler_state.get("nan_skipped", 0) + 1 + skipped = scheduler_state["nan_skipped"] + bad_inputs = _nonfinite_keys(batch) + n_atoms = batch["node_mask"].sum(dim=-1).long().tolist() + origin = ( + f"input nonfinite in {bad_inputs}" + if bad_inputs + else "input is clean -> NaN originated in the model forward" + ) + print( + f"WARN: non-finite loss at step {scheduler_state['step']} " + f"(skipped {skipped}/{nan_skip_max}); " + f"batch n_atoms={n_atoms}; {origin}", + flush=True, + ) + if skipped > nan_skip_max: + raise RuntimeError( + f"Exceeded nan_skip_max={nan_skip_max} non-finite batches. " + "Lower lr, raise grad_clip stringency, or filter the dataset." + ) + scheduler_state["step"] += 1 + continue + loss_val.backward() + # clip_grad_norm_ returns the *unclipped* total gradient norm. If + # it's non-finite, the gradients themselves contain NaN/Inf — a + # well-known failure mode of torch.svd on rank-deficient inputs, + # which the Kabsch alignment inside ReBind's conformer head can + # produce. Calling optimizer.step() with NaN gradients would + # poison every parameter with NaN, making every subsequent + # forward pass NaN. Skip the step in that case. + grad_norm = torch.nn.utils.clip_grad_norm_( + model.parameters(), max_norm=grad_clip if grad_clip > 0 else float("inf") + ) + if not torch.isfinite(grad_norm): + scheduler_state["nan_skipped"] = scheduler_state.get("nan_skipped", 0) + 1 + skipped = scheduler_state["nan_skipped"] + print( + f"WARN: non-finite gradient norm ({grad_norm.item()}) at step " + f"{scheduler_state['step']} (skipped {skipped}/{nan_skip_max}). " + f"Likely a degenerate batch through Kabsch SVD.", + flush=True, + ) + optimizer.zero_grad(set_to_none=True) + if skipped > nan_skip_max: + raise RuntimeError( + f"Exceeded nan_skip_max={nan_skip_max} non-finite batches. " + "Lower lr or filter the dataset." + ) + scheduler_state["step"] += 1 + continue + optimizer.step() + # Cheap sanity: any parameter going NaN means we have a bug + # upstream (e.g. another path that bypassed the grad-norm check). + # Fail loudly instead of producing meaningless NaN for thousands + # of steps. + if not _all_params_finite(model): + raise RuntimeError( + f"Model parameters went non-finite after optimizer step at " + f"step {scheduler_state['step']}. This indicates a code " + "bug bypassing the gradient-NaN check." + ) + scheduler_state["step"] += 1 + else: + with torch.no_grad(): + out = model(**batch) + if not torch.isfinite(out.loss): + continue + loss_sum += float(out.loss.detach()) + dmae_sum += float(out.cdist_mae.detach()) + n += 1 + pbar.set_postfix(loss=f"{loss_sum / n:.4f}", dmae=f"{dmae_sum / n:.4f}") + return loss_sum / max(n, 1), dmae_sum / max(n, 1) + + +def train(config: TrainConfig) -> dict[str, Any]: + _set_seed(config.seed) + out_dir = Path(config.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + with open(out_dir / "config.json", "w") as f: + json.dump(config.__dict__, f, indent=2, default=str) + + # Data + dataset = CSVMoleculeDataset( + path=config.dataset_path, + source=config.dataset_source, # type: ignore[arg-type] + subset_size=config.subset_size, + ) + train_set, val_set, test_set = random_split( + dataset, ratios=config.split_ratios, seed=config.split_seed + ) + collator = Collator() + + def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: + return DataLoader( + subset, + batch_size=batch_size, + shuffle=shuffle, + num_workers=config.num_workers, + collate_fn=collator, + persistent_workers=config.num_workers > 0, + ) + + train_loader = _make_loader(train_set, config.batch_size, shuffle=True) + val_loader = _make_loader(val_set, config.eval_batch_size, shuffle=False) + + # Model + optimizer + model = build_rebind( + n_layers=config.n_layers, + d_model=config.d_model, + d_ffn=config.d_ffn, + n_head=config.n_head, + dropout=config.dropout, + ).to(config.device) + optimizer = torch.optim.AdamW( + model.parameters(), lr=config.lr, weight_decay=config.weight_decay + ) + + steps_per_epoch = max(len(train_loader), 1) + total_steps = steps_per_epoch * config.epochs + warmup_steps = int(total_steps * config.warmup_ratio) + + writer = SummaryWriter(out_dir / "tb") + scheduler_state: dict[str, int] = {"step": 0} + history: list[dict[str, float]] = [] + best_val = float("inf") + t0 = time.time() + for epoch in range(1, config.epochs + 1): + train_loss, train_dmae = _run_epoch( + model, + train_loader, + optimizer, + config.device, + scheduler_state, + config.lr, + total_steps, + warmup_steps, + grad_clip=config.grad_clip, + nan_skip_max=config.nan_skip_max, + ) + val_loss, val_dmae = _run_epoch( + model, + val_loader, + None, + config.device, + scheduler_state, + config.lr, + total_steps, + warmup_steps, + ) + writer.add_scalar("train/loss", train_loss, epoch) + writer.add_scalar("train/dmae", train_dmae, epoch) + writer.add_scalar("val/loss", val_loss, epoch) + writer.add_scalar("val/dmae", val_dmae, epoch) + elapsed = time.time() - t0 + print( + f"[epoch {epoch:>3}/{config.epochs}] " + f"train_loss={train_loss:.4f} train_dmae={train_dmae:.4f} " + f"val_loss={val_loss:.4f} val_dmae={val_dmae:.4f} " + f"elapsed={elapsed:.1f}s" + ) + history.append( + { + "epoch": epoch, + "train_loss": train_loss, + "train_dmae": train_dmae, + "val_loss": val_loss, + "val_dmae": val_dmae, + } + ) + if val_dmae < best_val: + best_val = val_dmae + torch.save(model.state_dict(), out_dir / "best.pt") + writer.close() + + with open(out_dir / "history.json", "w") as f: + json.dump(history, f, indent=2) + del test_set # held out; first round does not evaluate on test + return {"history": history, "best_val_dmae": best_val} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c45c1db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +"""Shared pytest fixtures and skip-paths for tests that need the local CSVs.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +QM9_PATH = Path("/home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv") +TMQMG_PATH = Path("/home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv") +BOSTMC_PATH = Path("/home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv") + + +def _skip_if_missing(p: Path) -> Path: + if not p.exists(): + pytest.skip(f"dataset not available at {p}") + return p + + +@pytest.fixture(scope="session") +def qm9_path() -> Path: + return _skip_if_missing(QM9_PATH) + + +@pytest.fixture(scope="session") +def tmqmg_path() -> Path: + return _skip_if_missing(TMQMG_PATH) + + +@pytest.fixture(scope="session") +def bostmc_path() -> Path: + return _skip_if_missing(BOSTMC_PATH) diff --git a/tests/test_csv_dataset.py b/tests/test_csv_dataset.py new file mode 100644 index 0000000..94187da --- /dev/null +++ b/tests/test_csv_dataset.py @@ -0,0 +1,52 @@ +"""Verify each CSV path produces valid ReBind graph dicts on a small subset.""" + +from __future__ import annotations + +from step_up.data.csv_dataset import CSVMoleculeDataset + + +def _check_graph_dict(g: dict) -> None: + required = {"node_attr", "edge_attr", "edge_index", "node_type", "conformer"} + missing = required - g.keys() + assert not missing, f"graph dict missing keys: {missing}" + n = g["num_nodes"] + assert n > 0 + assert len(g["node_type"]) == n + assert len(g["node_attr"]) == n + assert len(g["conformer"]) == n + # edge_index shape: 2 x num_edges (lists) + assert len(g["edge_index"]) == 2 + assert len(g["edge_index"][0]) == len(g["edge_index"][1]) + + +def test_qm9_smoke(qm9_path) -> None: + ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=5) + assert len(ds) == 5 + for i in range(len(ds)): + _check_graph_dict(ds[i]) + + +def test_tmqmg_smoke(tmqmg_path) -> None: + ds = CSVMoleculeDataset(tmqmg_path, source="mol2", subset_size=20) + assert len(ds) == 20 + elements_seen: set[int] = set() + for i in range(len(ds)): + g = ds[i] + _check_graph_dict(g) + elements_seen.update(g["node_type"]) + # tmQMg always includes a transition metal. node_type is Z-1, so 3d/4d/5d + # metals show up at indices >= 20. Confirm at least one heavy element + # made it through the direct MOL2 parser without being silently dropped. + assert max(elements_seen) >= 20, f"no heavy elements seen: {sorted(elements_seen)}" + + +def test_bostmc_smoke(bostmc_path) -> None: + ds = CSVMoleculeDataset(bostmc_path, source="mol2", subset_size=5) + assert len(ds) == 5 + elements_seen: set[int] = set() + for i in range(len(ds)): + g = ds[i] + _check_graph_dict(g) + elements_seen.update(g["node_type"]) + # BOSTMC is d-block-only by construction. + assert max(elements_seen) >= 20, f"no transition metals seen: {sorted(elements_seen)}" diff --git a/tests/test_forward.py b/tests/test_forward.py new file mode 100644 index 0000000..1c35d8a --- /dev/null +++ b/tests/test_forward.py @@ -0,0 +1,41 @@ +"""Forward pass on a 4-molecule QM9 batch through a tiny ReBind.""" + +from __future__ import annotations + +import torch +from torch.utils.data import DataLoader, Subset + +from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.models.rebind import Collator, build_rebind + + +def test_forward_tiny_qm9(qm9_path) -> None: + ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=8) + subset = Subset(ds, [0, 1, 2, 3]) + loader = DataLoader(subset, batch_size=4, shuffle=False, collate_fn=Collator()) + batch = next(iter(loader)) + + model = build_rebind(n_layers=2, d_model=32, d_ffn=64, n_head=4) + model.eval() + with torch.no_grad(): + out = model(**batch) + + assert out.loss.dim() == 0 + assert torch.isfinite(out.loss) + assert out.conformer_hat.shape == out.conformer.shape + b, n, _ = out.conformer_hat.shape + assert b == 4 + assert n > 0 + assert torch.isfinite(out.conformer_hat).all() + + +def test_lj_patch_supports_heavy_z(tmqmg_path) -> None: + """Verify that the LJ patch lets tmQMg (Z up to 80) pass through Collator.""" + ds = CSVMoleculeDataset(tmqmg_path, source="mol2", subset_size=2) + subset = Subset(ds, [0, 1]) + loader = DataLoader(subset, batch_size=2, shuffle=False, collate_fn=Collator()) + # This would KeyError pre-patch on transition-metal indices. + batch = next(iter(loader)) + assert "sigma" in batch and "epsilon" in batch + assert torch.isfinite(batch["sigma"]).all() + assert torch.isfinite(batch["epsilon"]).all() diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..3ae92ba --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,46 @@ +"""Verify D-MAE / D-RMSE / coord-RMSD have expected mathematical behavior.""" + +from __future__ import annotations + +import torch + +from step_up.eval.metrics import cdist_mae, cdist_rmse, coord_rmsd, per_element_dmae + + +def _toy_batch() -> tuple[torch.Tensor, torch.Tensor]: + # Two molecules: first has 3 valid atoms, second has 4. + coords = torch.zeros(2, 4, 3, dtype=torch.float32) + coords[0, 0] = torch.tensor([0.0, 0.0, 0.0]) + coords[0, 1] = torch.tensor([1.0, 0.0, 0.0]) + coords[0, 2] = torch.tensor([0.0, 1.0, 0.0]) + coords[1, 0] = torch.tensor([0.0, 0.0, 0.0]) + coords[1, 1] = torch.tensor([2.0, 0.0, 0.0]) + coords[1, 2] = torch.tensor([0.0, 2.0, 0.0]) + coords[1, 3] = torch.tensor([0.0, 0.0, 2.0]) + mask = torch.tensor([[1, 1, 1, 0], [1, 1, 1, 1]], dtype=torch.float32) + return coords, mask + + +def test_zero_when_identical() -> None: + coords, mask = _toy_batch() + assert cdist_mae(coords, coords, mask).item() == 0.0 + assert cdist_rmse(coords, coords, mask).item() == 0.0 + assert coord_rmsd(coords, coords, mask).item() == 0.0 + + +def test_dmae_positive_on_perturbation() -> None: + coords, mask = _toy_batch() + noisy = coords + 0.1 * torch.randn_like(coords) * mask.unsqueeze(-1) + assert cdist_mae(noisy, coords, mask).item() > 0.0 + assert cdist_rmse(noisy, coords, mask).item() > 0.0 + + +def test_per_element_dmae_groups_by_z() -> None: + coords, mask = _toy_batch() + pred = coords + 0.5 # uniform shift; cdist is translation-invariant -> 0 + # Atomic numbers: first mol = C,N,O (5,6,7); second mol = H,H,H,H (0). + z = torch.tensor([[5, 6, 7, 0], [0, 0, 0, 0]], dtype=torch.long) + per = per_element_dmae(pred, coords, mask, z) + # Uniform translation leaves pairwise distances unchanged -> 0 per element. + for z_val, v in per.items(): + assert v == 0.0, f"element {z_val} should have 0 D-MAE under translation" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7ee65a0..0cc4935 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,7 @@ +"""Package-level import smoke test.""" + + def test_package_imports() -> None: - pass + import step_up + + assert step_up.__version__ diff --git a/uv.lock b/uv.lock index 4c6b6ee..6a823b8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,170 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] [[package]] name = "cfgv" @@ -11,6 +175,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -122,6 +371,234 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602, upload-time = "2026-05-15T11:42:50.487Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -131,6 +608,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -140,6 +626,241 @@ 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 = "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 = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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/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 = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[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 = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +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 = "nodeenv" version = "1.10.0" @@ -149,6 +870,185 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/18/3275231e98620002681c922e792db04d72c356e9d8073c387344fc0e4ff1/numpy-2.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:654fb8674b61b1c4bd568f944d13a908566fdcb0d797303521d4149d16da05ef", size = 16689166, upload-time = "2026-05-15T20:22:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/000aab6a16bdec53307f0f72546b57a3ac9266a62d8c257bee97d85fd078/numpy-2.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4cd9f6fa7ce10dc4627f2bb81dd9075dab67e94632e04c2b638e12575ddaa862", size = 14699514, upload-time = "2026-05-15T20:22:53.678Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/ddaf3af9c46966fef5be879256f213d85a0c56c75d07a3b7defec7cf6b4c/numpy-2.4.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4f5bc96d35d94e4ceab8b38a92241b4611e95dc44e63b9f1fa2a331858ee3507", size = 5204601, upload-time = "2026-05-15T20:22:56.257Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/627fadd11959b3c7759008f34c92a35af8ff942dd8284a66ced648bbe516/numpy-2.4.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4bb33e900ee81730ad77a258965134aa8ceac805124f7e5229347beda4b8d0aa", size = 6551360, upload-time = "2026-05-15T20:22:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/a1/47/0728b986b8682d742ff68c16baa5af9d185484abfc635c5cc700f44e62be/numpy-2.4.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32f8f852273ef32b291201ac2a2c97629c4a1ee8632bb670e3443eaa09fc2e72", size = 15671157, upload-time = "2026-05-15T20:23:01.081Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0b/b905ae82d9419dc38123523862db64978ca2954b69609c3ae8fdaca1084c/numpy-2.4.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685681e956fc8dcb75adc6ff26694e1dfd738b24bd8d4696c51ca0110157f912", size = 16645703, upload-time = "2026-05-15T20:23:04.358Z" }, + { url = "https://files.pythonhosted.org/packages/5f/24/e27fc3f5236b4118ed9eed67111675f5c61a07ea333acec87c869c3b359d/numpy-2.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f64dd84b277a737eb59513f6b9bb6195bf41ab11941ef15b2562dbab43fa8ef", size = 17021018, upload-time = "2026-05-15T20:23:07.021Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a7/9041af38d527ab80a06a93570a77e29425b41507ad41f6acf5da78cfb4a4/numpy-2.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b42d9496f79e3a728192f05a42d86e36163217b7cdecb3813d0028a0aa6b72d7", size = 18368768, upload-time = "2026-05-15T20:23:09.44Z" }, + { url = "https://files.pythonhosted.org/packages/49/82/326a014442f32c2663434fd424d9298791f47f8a0f17585ad60519a5606e/numpy-2.4.5-cp312-cp312-win32.whl", hash = "sha256:86d980970f5110595ca14855768073b08585fc1acc36895de303e039e7dee4a5", size = 5962819, upload-time = "2026-05-15T20:23:11.631Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/cbf5d391b0b3a5e8cad264603e2fae256b0bde8ce43566b13b78faedc659/numpy-2.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:3333dba6a4e611d666f69e177ba8fe4140366ff681a5feb2374d3fd4fff3acb6", size = 12321621, upload-time = "2026-05-15T20:23:14.305Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d0/0f18909d9bc37a5f3f969fc737d2bb5df9f2ff295f71b467e6f52a0d6c4e/numpy-2.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:4593d197270b894efeb538dcbe227e4bcf1c77f88c4c6bf933ead812cfaa4453", size = 10221430, upload-time = "2026-05-15T20:23:16.887Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a4/fb50657c7cab297bf34edcd60a074cb0647f61771430d6363575274160fe/numpy-2.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ef248460b645c102026b82337cc4e88231909c66dd77b59ec6d6cac7e44f277", size = 16684760, upload-time = "2026-05-15T20:23:19.436Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/87e731299b9408eda705b3b9cb31c7bceb9347d2af9cbb16b2b1e4b5bc0f/numpy-2.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4603622bdcdbf8dccb1d9d5b21d16a7aa4e473ae6c8e14048d846fd4ca2907a0", size = 14694117, upload-time = "2026-05-15T20:23:21.832Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/0b2bb8acea222e9dd6e582afc2bc553b89b8833cbdccc68e68f050fb31f8/numpy-2.4.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6c18d49c67689c562854b53fdc433b93e47c12952aa6fa6d59f185e1a5992419", size = 5199141, upload-time = "2026-05-15T20:23:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/39/60/b6972b5d47033d90000f0097c81a98b9486589a2d7003bf725bff275cb0d/numpy-2.4.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b1c663ddc641f4192e90511bec61a09bc231e3bbdb996cdc6edbcaa0e528d685", size = 6546954, upload-time = "2026-05-15T20:23:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e9/ed667cb12c11ca0adde431f685d3a5dd78e6f78b27228c581c8415198e9e/numpy-2.4.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93793222b524f692f12b2f8752ce8b1d9d9125b2bfd5dbf0fb69c92c5e1ce86c", size = 15669430, upload-time = "2026-05-15T20:23:28.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/e5/679f6ffeb01294b0008e5ada4a113cb47617bc0e1819a529fd7973c6d7f4/numpy-2.4.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1616bde34b2bcba2fa9bde06217ce00da4f3d1bdfb264d54525a99e8fe170d83", size = 16633390, upload-time = "2026-05-15T20:23:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/42bfffc9a780ec902ccd7470d3219192ee82b7b442710307dd85b4d121b0/numpy-2.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09d7d97da1c2c62f4818b3e150a57572ff8dcf1cf5ac501aac832ffd4ebd9566", size = 17020709, upload-time = "2026-05-15T20:23:34.08Z" }, + { url = "https://files.pythonhosted.org/packages/44/00/3e840bfee0cc6cec22209f2c97057f26eeb30de031e4933b4dfc0395416c/numpy-2.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d68d0b355ab2e39fe0de59001d7151dfdbbb880ef67baeed806661e03df5097", size = 18357818, upload-time = "2026-05-15T20:23:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/3447b400b9da84134575486f0f656541559b00d4b262477bce9b678bbca8/numpy-2.4.5-cp313-cp313-win32.whl", hash = "sha256:fe28b64777ddfa0eca9b5f51474034ebe3dcb8324f48f27b28f479085673ae33", size = 5961114, upload-time = "2026-05-15T20:23:39.586Z" }, + { url = "https://files.pythonhosted.org/packages/28/f9/a90d2220ffcdc0798f5d55bb5d5463cd6254ec9ef43f384dae80217d7a2f/numpy-2.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:fb4a6c9c537d6ccec9cc4aeae4261bd3cc79b070c67ddc0646f5b1c07fddde42", size = 12318553, upload-time = "2026-05-15T20:23:41.436Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/96f531fb3234545315152d34efdf3de7daee81254448447eb619e8d16967/numpy-2.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d7df2da2e7ea0624a43aa368104b3a3ce14aae98ad4bb2c9a93fecef76f1c97", size = 10222200, upload-time = "2026-05-15T20:23:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/a291caab5a3c520babf93ff77c54fd5fdb1ebbc3296cee2eb2146ce773b1/numpy-2.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:2a235607a18df941760a695927051af4b1cd5d3ee85840d0e2af816785771feb", size = 14821438, upload-time = "2026-05-15T20:23:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/85/26/13dbb1159b864370568e7309063fd72667984df89db74e9caeb175d067c7/numpy-2.4.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:58dcf64969d870f36bc7fbd557d2617e997db7dc06261b6e3327148ea460d0a4", size = 5326663, upload-time = "2026-05-15T20:23:48.18Z" }, + { url = "https://files.pythonhosted.org/packages/7c/99/d233408072a0e019e2288e27edd23f7d572ccd4a73d1539baa3270ede85d/numpy-2.4.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:235f54b0156274d8fa3155db3ed6d2f401c7e8f3367c90db0a12f02a58fde6ed", size = 6646874, upload-time = "2026-05-15T20:23:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/00/eeb6f193dfe767725e952e0464f3e51f44145c5dd261cd7389aa36ac0713/numpy-2.4.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3b5bb65437a3555c648e706475db01c645559ca80dc8b03e4f202ea757e0d6", size = 15728147, upload-time = "2026-05-15T20:23:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c9/b8ed039f1fde1b13a8807c893e7e2f9432a379f4d6401edecf0028da5b2c/numpy-2.4.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f09a7e5f017d7098c66522097c96257411c9620c0926212200d66bc8cee3976", size = 16681770, upload-time = "2026-05-15T20:23:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/11/5b/0198ef6cb7016eca6d895d392106012138127fab23f46637e76d5e25c9f5/numpy-2.4.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:993a88d8fdd8554466a8765cd8bacd97ba56b70ca6b0a04bcdca77f5afed4222", size = 17086218, upload-time = "2026-05-15T20:23:56.646Z" }, + { url = "https://files.pythonhosted.org/packages/f0/fe/8821f3cfc660ae84c92ee158505941874b62c56a42e035a41425228cd8cf/numpy-2.4.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:84f58bed609b5669f5ad3d597901a4f1f86ee5b3c3708aaa55f05b4fe6e0f656", size = 18403542, upload-time = "2026-05-15T20:23:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/0e/00/e64ecaf498865e7b091f57658b2c522503e5d1b70e43b807f5f8247e1d88/numpy-2.4.5-cp313-cp313t-win32.whl", hash = "sha256:7200c58f3f933ca61e66346667dcc8510bb111995e9ce15398a731e6a4afa4bb", size = 6084903, upload-time = "2026-05-15T20:24:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/20/c0/354997dedaf74e8311c2cf9a6027b476fd8d424cb92189cc0ae2b25f501c/numpy-2.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c26c71080d35db5002102f5d9ff614d45de02aa1f7802943e691e063e5ee93bc", size = 12458420, upload-time = "2026-05-15T20:24:03.735Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/917ee5ea4a31ca1a6e4c9a85386477efa318dcc60db257c5ef4adda096c1/numpy-2.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:2caa576d1707b275cba1aeb60a5c50daa6fa2a3f28ecb08123bc05fd439005db", size = 10291826, upload-time = "2026-05-15T20:24:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c1/3be0bf102fc17cff5bd142e3be0bfffabec6fa46da0a462396c76b0765d0/numpy-2.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:889ca2c072315de638a5194a772aa1fa2df92bdd6175f6a222d4784040424b61", size = 16683455, upload-time = "2026-05-15T20:24:08.988Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/0742d724901fa36bc54b338c6e62e463a7601180da896aa44978f0adf004/numpy-2.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89e89304fb1f8c3f0ecfa4a7d48f311dd79771336a940e920159d643d1307e77", size = 14704577, upload-time = "2026-05-15T20:24:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:144fcc5a3a17679b2b82543b4a2d8dd29937230a7af13232b5f753872feb6361", size = 5209756, upload-time = "2026-05-15T20:24:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/52/c0/23fb1bc506f774e03db66219a2830e720f4d3dbcaaddf855a7ff7bb6d96f/numpy-2.4.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:398bb16772b265b9fa5c07b07072646ea97137c10ffb62a9a087b277fc825c29", size = 6543937, upload-time = "2026-05-15T20:24:16.223Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/db4662c26e68520afcc84d672a6f9f5294063dee0e57a46d61afdaa7f9ed/numpy-2.4.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb352e7b8876da1249e72254736d6c58c505fa4e58a3d7e30efca241ca9ca9ce", size = 15685292, upload-time = "2026-05-15T20:24:17.978Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7341b08ff8124d7353939778e2707b8732d03c78c1c30e0815aba2dacbe1245a", size = 16638528, upload-time = "2026-05-15T20:24:20.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/81/364388600932618fe735d97fdd2437cb8dd87a23377ac11d8b9d5db098b7/numpy-2.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:deb01226f012539f3945261ffe1c10aec081a0fa0a5c925419933c70f3ae2d23", size = 17036709, upload-time = "2026-05-15T20:24:22.949Z" }, + { url = "https://files.pythonhosted.org/packages/32/4a/a1185b18a94a6d9587e54b437e7d0ba36ecf6e614f1bea03f5249912c64e/numpy-2.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d888bdf7335f76878c3c7b264ac1ff089863e211ec81249f9fb5795c2183dc25", size = 18363254, upload-time = "2026-05-15T20:24:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8e/95c1d2ed15ae97750ede8c8a0ac487c9c01207afff430f47078b1d9d7dc5/numpy-2.4.5-cp314-cp314-win32.whl", hash = "sha256:15f90d1256e9b2320aff24fde44815b787ab6d7c49a1a11bfd8138b321c5f080", size = 6010184, upload-time = "2026-05-15T20:24:27.852Z" }, + { url = "https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4bd2cd4ef9c0afa87de73723c0a33c0edff62143e1432917458e26d3d195d87f", size = 12450344, upload-time = "2026-05-15T20:24:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/c0ae481f7c3b2f85869bcd8fc5d30aa7c96b394162eef9c9315957f115c5/numpy-2.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:db304568c650e9d7039744d3575d0d287754debb2057d7c7b8cdfdc2c487a957", size = 10495674, upload-time = "2026-05-15T20:24:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/57/89/c5a4c677acf17aa50ba09a15e61812f90baac42bb6ca38d112e005858351/numpy-2.4.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de2883e0d2c63eae1bab1a84b390dca74aabb3d20ea1f5d58f360853c83abf3", size = 14824078, upload-time = "2026-05-15T20:24:34.669Z" }, + { url = "https://files.pythonhosted.org/packages/e7/52/57e7144284f6b51ba93523e495ff239260b1ecd5257e3700a436332e5688/numpy-2.4.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:06760fe73ae5005008748d182de612c733542af3cde063d532cd2127561b27be", size = 5329246, upload-time = "2026-05-15T20:24:36.957Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b3/09dbce80fd4a7db4318f2fc01eec0ae76f29306442b5a32d4b811d082cdf/numpy-2.4.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:4b51a01745cb04cc19278482207444b4d30728ce91c28d27a3bfae5fc6ff24c7", size = 6649877, upload-time = "2026-05-15T20:24:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/dbdb23e82d540b757690ef13f011c386fca6a63848eec6136baf8ce7cbed/numpy-2.4.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a05636d7937d0936f271e5ba957fa8d746b5be3c2025caa1a2508f4fe521d40", size = 15730534, upload-time = "2026-05-15T20:24:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bd/68f6e9b3c20decf40ac06708a7b506757e3a8588efed32988d1b747316be/numpy-2.4.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b86f56048ed09c3bbe48962a7dff077c2fd3274f8cf981800f3b38eac49cc3", size = 16679741, upload-time = "2026-05-15T20:24:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/39/1d/0fcac0b6b4ea1b50ca8fca05a34bed5c8d56e34c1cb5ffb04cf76109ac3c/numpy-2.4.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:130d58151c4db23e9fa860b84784e219a3aa3e030acc88a493ea37006c4dfd4c", size = 17085598, upload-time = "2026-05-15T20:24:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/a472b2564cf6cc498ad7aa9741d9832648221b8ab8cc0dbef41faa248ede/numpy-2.4.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d475afc8cbe935ff5944f753d863bba774d7f4e1feaaa4102901e3e053ca5963", size = 18403855, upload-time = "2026-05-15T20:24:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a4/da82196f8cc4bd28ecf17bd57008c84f3d4696caf06753d9bad45e4ad749/numpy-2.4.5-cp314-cp314t-win32.whl", hash = "sha256:27f4a6dc26353a860b348961b9aa9e009835688b435cfa105e873b8dc2c726f5", size = 6156900, upload-time = "2026-05-15T20:24:53.134Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/860959b91a73d9a085006554fa3850da51a7ffab64599bac5097243438ab/numpy-2.4.5-cp314-cp314t-win_amd64.whl", hash = "sha256:76ac6e90f5e226011c88f9b7040a4bcae612518bc7e9adc127e697a13b28ad1a", size = 12638906, upload-time = "2026-05-15T20:24:55.009Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2a/bbd3097913083ad07c0f28fc9629666221fc18923e17ce97ae22a5dccdd6/numpy-2.4.5-cp314-cp314t-win_arm64.whl", hash = "sha256:7c392e2c1bf596701d3c6832be7567eab5d5b0a13865036c33365ee097d37f8b", size = 10565875, upload-time = "2026-05-15T20:24:57.425Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.4.5.8" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/71/1c91302526c45ab494c23f61c7a84aa568b8c1f9d196efa5993957faf906/nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2fc8da60df463fdefa81e323eef2e36489e1c94335b5358bcb38360adf75ac9b", size = 363438805, upload-time = "2024-04-03T20:57:06.025Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.4.127" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/42/f4f60238e8194a3106d06a058d494b18e006c10bb2b915655bd9f6ea4cb1/nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9dec60f5ac126f7bb551c055072b69d85392b13311fcc1bcda2202d172df30fb", size = 13813957, upload-time = "2024-04-03T20:55:01.564Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.4.127" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/14/91ae57cd4db3f9ef7aa99f4019cfa8d54cb4caa7e00975df6467e9725a9f/nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a178759ebb095827bd30ef56598ec182b85547f1508941a3d560eb7ea1fbf338", size = 24640306, upload-time = "2024-04-03T20:56:01.463Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.4.127" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/27/1795d86fe88ef397885f2e580ac37628ed058a92ed2c39dc8eac3adf0619/nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:64403288fa2136ee8e467cdc9c9427e0434110899d07c779f25b5c068934faa5", size = 883737, upload-time = "2024-04-03T20:54:51.355Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.1.0.70" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fd/713452cd72343f682b1c7b9321e23829f00b842ceaedcda96e742ea0b0b3/nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:165764f44ef8c61fcdfdfdbe769d687e06374059fbb388b6c89ecb0e28793a6f", size = 664752741, upload-time = "2024-04-22T15:24:15.253Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/94/3266821f65b92b3138631e9c8e7fe1fb513804ac934485a8d05776e1dd43/nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f083fc24912aa410be21fa16d157fed2055dab1cc4b6934a0e03cba69eb242b9", size = 211459117, upload-time = "2024-04-03T20:57:40.402Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.5.147" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/6d/44ad094874c6f1b9c654f8ed939590bdc408349f137f9b98a3a23ccec411/nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a88f583d4e0bb643c49743469964103aa59f7f708d862c3ddb0fc07f851e3b8b", size = 56305206, upload-time = "2024-04-03T20:58:08.722Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.6.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/e1/5b9089a4b2a4790dfdea8b3a006052cfecff58139d5a4e34cb1a51df8d6f/nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:19e33fa442bcfd085b3086c4ebf7e8debc07cfe01e11513cc6d332fd918ac260", size = 127936057, upload-time = "2024-04-03T20:58:28.735Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.3.1.170" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f7/97a9ea26ed4bbbfc2d470994b8b4f338ef663be97b8f677519ac195e113d/nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ea4f11a2904e2a8dc4b1833cc1b5181cde564edd0d5cd33e3c168eff2d1863f1", size = 207454763, upload-time = "2024-04-03T20:58:59.995Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/a8/bcbb63b53a4b1234feeafb65544ee55495e1bb37ec31b999b963cbccfd1d/nvidia_cusparselt_cu12-0.6.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:df2c24502fd76ebafe7457dbc4716b2fec071aabaed4fb7691a201cde03704d9", size = 150057751, upload-time = "2024-07-23T02:35:53.074Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.21.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/99/12cd266d6233f47d00daf3a72739872bdc10267d0383508b0b9c84a18bb6/nvidia_nccl_cu12-2.21.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:8579076d30a8c24988834445f8d633c697d42397e92ffc3f63fa26766d25e0a0", size = 188654414, upload-time = "2024-04-03T15:32:57.427Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.4.127" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/ff/847841bacfbefc97a00036e0fce5a0f086b640756dc38caea5e1bb002655/nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:06b3b9b25bf3f8af351d664978ca26a16d2c5127dbd53c0497e28d1fb9611d57", size = 21066810, upload-time = "2024-04-03T20:59:46.957Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.4.127" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/20/199b8713428322a2f22b722c62b8cc278cc53dffa9705d744484b5035ee9/nvidia_nvtx_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:781e950d9b9f60d8241ccea575b32f5105a5baf4c2351cab5256a24869f12a1a", size = 99144, upload-time = "2024-04-03T20:56:12.406Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -158,6 +1058,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -192,6 +1213,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -201,6 +1359,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -231,6 +1398,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.3.0" @@ -290,6 +1469,145 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rdkit" +version = "2026.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ee/e41dc13201769a593143141e214cea54dece91d2ee9553239fed3c5181ce/rdkit-2026.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3371395b23bdd03441ad4fdd575b4bad2d44dfa5a0e3a76c572db60b05d4f4b7", size = 29985222, upload-time = "2026-05-16T11:46:53.707Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/74bfd5b9a71439e9257342941e59d0c750450335a91a6963e536545ced23/rdkit-2026.3.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3083c723f5cd22b084d18661725103738f18b7d38e0876899f61a9a03b6cbb57", size = 35576172, upload-time = "2026-05-16T11:46:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/75b061afd7b84216caee4b5050db42be7c5ac11097b8166cd509db53b6a4/rdkit-2026.3.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5197a104b9ddf18b08388649b91abe8737cc02503e02137c05f7b2bea4961aae", size = 37090229, upload-time = "2026-05-16T11:47:01.797Z" }, + { url = "https://files.pythonhosted.org/packages/55/35/8f361ea5324593638112b605667fa7728d33e738110fb793351d2e7c629a/rdkit-2026.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:119b8ca7394b136ddb414a6e15fbdfbcbb8398925abbb823e853f273977211a9", size = 24613234, upload-time = "2026-05-16T11:47:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/b3e8469291a9725eda29e911987f39be8a625b78f78d64bdc731ddda852f/rdkit-2026.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c30bf6846b2f55a24b0e2beba7f0480dd948a73bfc13ad95cdb29f839586a497", size = 29984328, upload-time = "2026-05-16T11:47:10.123Z" }, + { url = "https://files.pythonhosted.org/packages/f2/46/47f8ab91a05a6b060852a8484b555256a8f32b740c67560b00560ef56eb5/rdkit-2026.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fb14f634f630cac27e6484bf01a789a2ae8e8dbea96c87e365ff956b31c384a2", size = 35575509, upload-time = "2026-05-16T11:47:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ed/568c508712bb4f1b94b9b85f3a514aedcd37da73fb08d6ef22a3b0981698/rdkit-2026.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9908eff69e89da927006b9513adaf7985b46bf0b213cf204a2529d367e932dda", size = 37089613, upload-time = "2026-05-16T11:47:20.002Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/8e095f9406d7416b9c1284817c62049dccc801008ee98a8aaa7a68c609bf/rdkit-2026.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4a3e15fd1e9986284288362ef415478d5a895c9409c72a3fa9320e728b76ca3", size = 24612214, upload-time = "2026-05-16T11:47:23.628Z" }, + { url = "https://files.pythonhosted.org/packages/bc/eb/8325049d449e901fa6ff28f1f5ff9f98aa95faf2e280cf03d9dc582bf310/rdkit-2026.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07ec533914be1a72e00beb24dfda409e3264bf4f0945e062cb616765381998e5", size = 30002251, upload-time = "2026-05-16T11:47:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/62/7b/edc80ca5ce1343ab15686caf645e43a254ea2b8e7af3a4034655c355d7d5/rdkit-2026.3.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:146829850a725db91d6042a4c46fab2169de7ca8db22f0c889ab29a1646175d1", size = 35612930, upload-time = "2026-05-16T11:47:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/85d8c89905663550a5962d754aeb44f2dfc81b2968f83d82dd56692dd763/rdkit-2026.3.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d9deadcf76f9f8d836a3362be1f606ae74bd88fd622cd77a3ddaae71fcad83d9", size = 37102451, upload-time = "2026-05-16T11:47:36.843Z" }, + { url = "https://files.pythonhosted.org/packages/c9/79/dab566de8d9bd89cd1b627c4f493fa2936114c2004e547069625763353fa/rdkit-2026.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:5190d910e086f54ec640234afd3f3a26882a82f69d7f5d6a75748f5cf9177e93", size = 25097379, upload-time = "2026-05-16T11:47:41.396Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -315,10 +1633,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "step-up" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "tensorboard" }, + { name = "torch" }, + { name = "torch-geometric" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "transformers" }, +] [package.dev-dependencies] dev = [ @@ -329,6 +1708,18 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.26" }, + { name = "pandas", specifier = ">=2.2" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rdkit", specifier = ">=2024.3.3" }, + { name = "tensorboard", specifier = ">=2.16" }, + { name = "torch", specifier = ">=2.1", index = "https://download.pytorch.org/whl/cu124" }, + { name = "torch-geometric", specifier = ">=2.3" }, + { name = "torchmetrics", specifier = ">=1.0" }, + { name = "tqdm", specifier = ">=4.66" }, + { name = "transformers", specifier = ">=4.43" }, +] [package.metadata.requires-dev] dev = [ @@ -338,6 +1729,227 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, ] +[[package]] +name = "sympy" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/99/5a5b6f19ff9f083671ddf7b9632028436167cd3d33e11015754e41b249a4/sympy-1.13.1.tar.gz", hash = "sha256:9cebf7e04ff162015ce31c9c6c9144daa34a93bd082f54fd8f12deca4f47515f", size = 7533040, upload-time = "2024-07-19T09:26:51.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/fe/81695a1aa331a842b582453b605175f419fe8540355886031328089d840a/sympy-1.13.1-py3-none-any.whl", hash = "sha256:db36cdc64bf61b9b24578b6f7bab1ecdd2452cf008f34faa33776680c26d66f8", size = 6189177, upload-time = "2024-07-19T09:26:48.863Z" }, +] + +[[package]] +name = "tensorboard" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "torch" +version = "2.6.0+cu124" +source = { registry = "https://download.pytorch.org/whl/cu124" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp312-cp312-linux_x86_64.whl", hash = "sha256:a393b506844035c0dac2f30ea8478c343b8e95a429f06f3b3cadfc7f53adb597", upload-time = "2025-01-30T00:57:08Z" }, + { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp312-cp312-win_amd64.whl", hash = "sha256:3313061c1fec4c7310cf47944e84513dcd27b6173b72a349bb7ca68d0ee6e9c0", upload-time = "2025-01-30T00:57:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313-linux_x86_64.whl", hash = "sha256:0f3bc53c988ce9568cd876a2a5316761e84a8704135ec8068f5f81b4417979cb", upload-time = "2025-01-30T00:59:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313-win_amd64.whl", hash = "sha256:519330eef09534acad8110b6f423d2fe58c1d8e9ada999ed077a637a0021f908", upload-time = "2025-01-30T00:59:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313t-linux_x86_64.whl", hash = "sha256:35cba404c0d742406cdcba1609085874bc60facdfbc50e910c47a92405fef44c", upload-time = "2025-01-30T01:01:25Z" }, +] + +[[package]] +name = "torch-geometric" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/63/b210152635902da7fe79fcdd16517fae108f457a0ed22c737e702a9afbae/torch_geometric-2.7.0.tar.gz", hash = "sha256:f9099e4aece1a9f618c84dbaac33a77f43139736698c7e8bddf3301ef1f2e8d4", size = 876725, upload-time = "2025-10-15T20:48:03.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/4dffd7300500465e0b4a2ae917dcb2ce771de0b9a772670365799a27c024/torch_geometric-2.7.0-py3-none-any.whl", hash = "sha256:6e0cd3ad824d484651ef5d308fc66c687bfcf5ba040d56d1e0fe0f81f365e292", size = 1275346, upload-time = "2025-10-15T20:48:01.949Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "transformers" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/e6/4134ea2fbea322cddc7ffc94a0d8ee47fe32ce8e876b320cd37d88edfc4d/transformers-5.8.1.tar.gz", hash = "sha256:4dd5b6de4105725104d84fd6abd74b305f4debfc251b38c648ee5dd087cf543b", size = 8532019, upload-time = "2026-05-13T03:21:57.234Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b1/8be7e7ef0b5200491312201918b6125ef9c9df9dd0f0240ccef9ac824e6b/transformers-5.8.1-py3-none-any.whl", hash = "sha256:5340fb95962162cdfdae5cc91d7f8fedd92ed75216c1154c5e1f590fcf56dd0e", size = 10632882, upload-time = "2026-05-13T03:21:52.876Z" }, +] + +[[package]] +name = "triton" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/00/59500052cb1cf8cf5316be93598946bc451f14072c6ff256904428eaf03c/triton-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d9b215efc1c26fa7eefb9a157915c92d52e000d2bf83e5f69704047e63f125c", size = 253159365, upload-time = "2025-01-22T19:13:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/37a3384d1e2e9320331baca41e835e90a3767303642c7a80d4510152cbcf/triton-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5dfa23ba84541d7c0a531dfce76d8bcd19159d50a4a8b14ad01e91734a5c1b0", size = 253154278, upload-time = "2025-01-22T19:13:54.221Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "virtualenv" version = "21.3.1" @@ -352,3 +1964,232 @@ sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b855 wheels = [ { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, + { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] From 53bc6f3e96379cd84df904a9645c5cdaf70c9bb3 Mon Sep 17 00:00:00 2001 From: jwtoney Date: Wed, 16 Sep 2026 22:08:50 -0400 Subject: [PATCH 02/12] addressed requested changes --- CONTRIBUTING.md | 7 +- README.md | 51 +- configs/bostmc.yaml | 16 +- configs/bostmc_smoke.yaml | 11 +- configs/qm9.yaml | 7 +- configs/qm9_smoke.yaml | 5 +- configs/tmqmg.yaml | 4 +- configs/tmqmg_smoke.yaml | 5 +- jobscript.sh | 10 + pyproject.toml | 11 +- scripts/train.sh | 13 +- src/step_up/data/csv_dataset.py | 133 ++++- src/step_up/data/featurize.py | 36 +- src/step_up/data/splits.py | 44 +- src/step_up/eval/metrics.py | 38 +- src/step_up/models/rebind.py | 147 +++-- src/step_up/train.py | 110 +++- tests/conftest.py | 34 +- tests/fixtures/bostmc_mini.csv | 925 ++++++++++++++++++++++++++++++++ tests/fixtures/qm9_mini.csv | 411 ++++++++++++++ tests/fixtures/tmqmg_mini.csv | 925 ++++++++++++++++++++++++++++++++ tests/test_csv_dataset.py | 54 +- tests/test_forward.py | 61 ++- tests/test_metrics.py | 39 +- tests/test_mol2.py | 61 +++ tests/test_splits.py | 51 ++ tests/test_train.py | 76 +++ uv.lock | 79 ++- 28 files changed, 3188 insertions(+), 176 deletions(-) create mode 100644 jobscript.sh create mode 100644 tests/fixtures/bostmc_mini.csv create mode 100644 tests/fixtures/qm9_mini.csv create mode 100644 tests/fixtures/tmqmg_mini.csv create mode 100644 tests/test_mol2.py create mode 100644 tests/test_splits.py create mode 100644 tests/test_train.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee936fb..dc16807 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,14 +4,17 @@ Thanks for helping improve `step-up`. ## Development Setup -Clone the repository and install dependencies: +Clone the repository (with `--recursive`, so the vendored ReBind submodule under +`external/ReBIND` is checked out) and install dependencies: ```bash -git clone https://github.com/LeMaterial/step-up.git +git clone --recursive https://github.com/LeMaterial/step-up.git cd step-up uv sync --dev ``` +If you already cloned without `--recursive`, run `git submodule update --init --recursive`. + Install the pre-commit hook: ```bash diff --git a/README.md b/README.md index 3d1e7e6..0aaa9d8 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,29 @@ uv run step-up train -c configs/bostmc_smoke.yaml # ~6 min (larger complexes) ## Production Training -Three full-scale configs are staged. They target GPU (`device: cuda`) and use -ReBind's published QM9 hyperparameters (8 encoder + 8 decoder layers, -d_model=512, lr=9e-5, AdamW, batch 100 for QM9 / 32 for organometallics, -20 epochs by default). +Three full-scale configs are staged. They target GPU (`device: cuda`) and follow +ReBind's published QM9 setup for the architecture and main optimizer settings +(8 encoder + 8 decoder layers, d_model=512, AdamW, lr=9e-5, 10% warmup, gradient +clipping at 1.0, batch 100, 20 epochs by default). The training loop differs from +ReBind's script in three ways: the learning rate decays on a cosine schedule after +warmup (ReBind: linear), AdamW keeps PyTorch's default beta2=0.999 (ReBind: 0.99), +and training runs in fp32 (ReBind: fp16 mixed precision). | Config | Dataset | Rows | Notes | |---|---|---|---| | `configs/qm9.yaml` | QM9-full.csv | ~134K | Sanity baseline on organic systems | -| `configs/tmqmg.yaml` | tmQMg-full.csv | ~60K | Singlets, full d-block + La | -| `configs/bostmc.yaml` | BOSTMC-low-spin.csv | ~140K | Singlets + doublets, full d-block | +| `configs/tmqmg.yaml` | tmQMg-full.csv | ~58K | Singlets, full d-block + La | +| `configs/bostmc.yaml` | BOSTMC-low-spin.csv | ~93K of ~121K | Singlets only (`spinmult == 1`), full d-block | + +The BOSTMC config trains on singlets only: the model isn't conditioned on charge +or spin, so mixing spin states would make comparisons ambiguous. Doublets need +that conditioning first. + +Train/val/test membership is a hash of each molecule's ID (`id_column`, e.g. +`refcode`, or the CSV row number if unset) and `split_seed`. A molecule +therefore stays in the same split regardless of `subset_size`, filtering, or +which rows fail featurization, so different runs and models are scored on the +same test molecules. Split sizes match `split_ratios` approximately. To dry-run a config (validates the YAML and dataset path without training): @@ -66,15 +79,17 @@ uv run step-up train -c configs/qm9.yaml --dry-run ``` ```bash -sbatch scripts/train.slurm configs/qm9.yaml -sbatch scripts/train.slurm configs/tmqmg.yaml -sbatch scripts/train.slurm configs/bostmc.yaml +sbatch scripts/train.sh configs/qm9.yaml +sbatch scripts/train.sh configs/tmqmg.yaml +sbatch scripts/train.sh configs/bostmc.yaml ``` -The Slurm script (1) initializes the submodule, (2) runs `uv sync --dev`, and -(3) launches `uv run step-up train -c `. Each run writes its config, -TensorBoard logs, and best checkpoint (by val D-MAE) to the `output_dir` -specified in the config. default is `outputs/_full/`. +The Slurm script lives at [scripts/train.sh](scripts/train.sh). It (1) +initializes the submodule, (2) runs `uv sync --dev`, and (3) launches +`uv run step-up train -c `. Each run writes its config, TensorBoard +logs, per-epoch history, best checkpoint (by val D-MAE), and the test-set +metrics of that checkpoint (`test_metrics.json`) to the `output_dir` specified +in the config — default is `outputs/_full/`. ### Adjusting training duration @@ -98,21 +113,21 @@ budget in the Slurm header. ``` src/step_up/ |-- data/ -| |-- csv_dataset.py # streaming CSV --> graph-dict dataset +| |-- csv_dataset.py # CSV --> graph-dict dataset | |-- featurize.py # XYZ path (RDKit DetermineBonds for QM9) | |-- mol2.py # direct MOL2 parser (no RDKit, used for organometallics) -| |-- splits.py +| |-- splits.py # hash-based, stable train/val/test splits |-- models/ | |-- rebind.py # thin wrapper over external/ReBIND + 3 runtime patches -| eval/ +|-- eval/ | |-- metrics.py # D-MAE, D-RMSE, coord-RMSD, per-element D-MAE |-- train.py # config-driven training loop |-- cli.py # `uv run step-up train -c ` configs/ # per-dataset YAML configs (smoke + full) external/ReBIND/ # git submodule, vendored upstream ReBind -scripts/train.slurm -tests/ # 9 tests covering imports, dataset loading, forward pass, metrics +scripts/train.sh # Slurm job script (sbatch scripts/train.sh ) +tests/ # imports, dataset loading, MOL2 parsing, forward pass, metrics ``` ## Data Path Notes diff --git a/configs/bostmc.yaml b/configs/bostmc.yaml index 718acd6..650b6d1 100644 --- a/configs/bostmc.yaml +++ b/configs/bostmc.yaml @@ -1,11 +1,15 @@ -# Full BOSTMC-low-spin training run (~140K complexes, singlets + doublets, full d-block). GPU-only; staged for Slurm. -# DESIGN DECISION (flagged in plan file): -# - `charge` and `spinmult` columns are currently IGNORED. -# - Recommendation for first publishable run: pre-filter to singlets-only (spinmult == 1) for the cleanest comparison to tmQMg. The CSVMoleculeDataset does not yet expose a filter knob, add one when this config is first run. -# - Follow-up: condition the model on (charge, spinmult) as a global feature. -dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv +# Full BOSTMC training run, restricted to closed-shell singlets so the metric +# comparison against tmQMg (singlets only) is clean and the model isn't asked +# to predict geometries from two spin manifolds at once. Doublet support is a +# follow-up that requires conditioning the model on (charge, spinmult) as a +# global feature. +dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/filtered/BOSTMC-low-spin.csv dataset_source: mol2 subset_size: null +filter_column: spinmult +filter_value: 1 +# Split key: refcode. +id_column: refcode split_ratios: [0.9, 0.05, 0.05] split_seed: 0 diff --git a/configs/bostmc_smoke.yaml b/configs/bostmc_smoke.yaml index 286f3e9..ddc8e37 100644 --- a/configs/bostmc_smoke.yaml +++ b/configs/bostmc_smoke.yaml @@ -1,8 +1,13 @@ -# BOSTMC smoke run on a 100-molecule subset. Verifies the direct MOL2 parser -# end-to-end (no RDKit) plus the LJ patch on d-block elements. -dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv +# BOSTMC smoke run on the first 100 rows, singlets only (like bostmc.yaml). Verifies +# the direct MOL2 parser end-to-end (no RDKit) plus the LJ patch on d-block elements. +dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/filtered/BOSTMC-low-spin.csv dataset_source: mol2 subset_size: 100 +filter_column: spinmult +filter_value: 1 +# Split key: refcode. +id_column: refcode +cache_dataset: true split_ratios: [0.8, 0.1, 0.1] split_seed: 0 diff --git a/configs/qm9.yaml b/configs/qm9.yaml index e9acd3c..d7d83ff 100644 --- a/configs/qm9.yaml +++ b/configs/qm9.yaml @@ -1,9 +1,12 @@ # Full QM9-full.csv training run (~134K molecules). # GPU-only, staged for the Slurm job once compute is available. -# Mirrors ReBind's QM9 hyperparams from external/ReBIND/experiments/conformer_prediction/rebind.sh. -dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv +# Follows ReBind's QM9 setup from external/ReBIND/experiments/conformer_prediction/rebind.sh +# (see README for the few deliberate differences). +dataset_path: /home/gridsan/jtoney/ElemeNet-benchmarking/benchmarking/datasets/QM9-full.csv dataset_source: smiles subset_size: null +# Split key: mol_id, so QM9's few duplicated rows always share a split. +id_column: mol_id split_ratios: [0.9, 0.05, 0.05] split_seed: 0 diff --git a/configs/qm9_smoke.yaml b/configs/qm9_smoke.yaml index e238b42..665fa2b 100644 --- a/configs/qm9_smoke.yaml +++ b/configs/qm9_smoke.yaml @@ -1,8 +1,11 @@ # Smallest possible run that exercises the full pipeline on CPU. # Goal: prove the loss strictly decreases. Numbers are not meaningful. -dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv +dataset_path: /home/gridsan/jtoney/ElemeNet-benchmarking/benchmarking/datasets/QM9-full.csv dataset_source: smiles subset_size: 100 +# Split key: mol_id, so QM9's few duplicated rows always share a split. +id_column: mol_id +cache_dataset: true split_ratios: [0.8, 0.1, 0.1] split_seed: 0 diff --git a/configs/tmqmg.yaml b/configs/tmqmg.yaml index 3d33274..cdc4b00 100644 --- a/configs/tmqmg.yaml +++ b/configs/tmqmg.yaml @@ -1,7 +1,9 @@ # Full tmQMg-full.csv training run (~60K organometallic complexes, closed-shell singlets, full d-block + La). GPU-only; staged for Slurm. -dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv +dataset_path: /home/gridsan/jtoney/ElemeNet-benchmarking/benchmarking/datasets/tmQMg-full.csv dataset_source: mol2 subset_size: null +# Split key: the tmQMg id (CSD refcode). +id_column: id split_ratios: [0.9, 0.05, 0.05] split_seed: 0 diff --git a/configs/tmqmg_smoke.yaml b/configs/tmqmg_smoke.yaml index a2460b2..ba54ba2 100644 --- a/configs/tmqmg_smoke.yaml +++ b/configs/tmqmg_smoke.yaml @@ -1,8 +1,11 @@ # Same shape as qm9_smoke, but reading the MOL2 path on organometallic data. # Verifies the LJ patch and the MOL2 → graph pipeline end-to-end on CPU. -dataset_path: /home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv +dataset_path: /home/gridsan/jtoney/ElemeNet-benchmarking/benchmarking/datasets/tmQMg-full.csv dataset_source: mol2 subset_size: 100 +# Split key: the tmQMg id (CSD refcode). +id_column: id +cache_dataset: true split_ratios: [0.8, 0.1, 0.1] split_seed: 0 diff --git a/jobscript.sh b/jobscript.sh new file mode 100644 index 0000000..896e279 --- /dev/null +++ b/jobscript.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +#SBATCH --job-name=step-up-bostmc +#SBATCH --output=train-bostmc-%j.out +#SBATCH --gres=gpu:volta:1 +#SBATCH --mem=64G +#SBATCH --cpus-per-task=8 + +bash scripts/train.sh configs/bostmc.yaml + diff --git a/pyproject.toml b/pyproject.toml index a85510e..a90727c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "pyyaml>=6.0", "rdkit>=2024.3.3", "tensorboard>=2.16", - "torch>=2.1", + # Upper bound keeps every platform on the same torch as the cluster: the cu124 index + # (see [tool.uv.sources]) has no builds past 2.6, while PyPI would resolve the latest. + "torch>=2.6,<2.7", "torch-geometric>=2.3", "torchmetrics>=1.0", "tqdm>=4.66", @@ -62,7 +64,12 @@ filterwarnings = [ ] [tool.uv.sources] -torch = [{ index = "pytorch-cu124" }] +# CUDA-12.4 torch wheels are only published for Linux x86_64. On macOS / other +# platforms we fall back to the default PyPI index, which provides CPU wheels. +# This keeps `uv sync --dev` working on contributor laptops and CI runners. +torch = [ + { index = "pytorch-cu124", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, +] [[tool.uv.index]] name = "pytorch-cu124" diff --git a/scripts/train.sh b/scripts/train.sh index d4c4df5..61910d9 100755 --- a/scripts/train.sh +++ b/scripts/train.sh @@ -3,9 +3,9 @@ #SBATCH --gres=gpu:volta:1 #SBATCH --cpus-per-task=8 #SBATCH --mem=64G -#SBATCH --output=train.out +#SBATCH --output=train-%j.out -# Usage: sbatch scripts/train.slurm configs/qm9.yaml +# Usage: sbatch scripts/train.sh configs/qm9.yaml # - Edit partition / gres / time to match your cluster. # - The job runs from the repo root and uses uv for everything. @@ -15,9 +15,14 @@ if [[ $# -lt 1 ]]; then echo "Usage: $0 " exit 2 fi -CONFIG="$1" +# Resolve the config before changing directory so relative paths keep working. +CONFIG="$(realpath "$1")" -cd "$(dirname "$0")/.." +# Under sbatch, $0 is Slurm's spooled copy of this script (in the slurmd spool +# directory), not the file in the repo, so locate the repo from the submission +# directory instead. Outside Slurm, fall back to the script's own location. +REPO_ROOT="$(git -C "${SLURM_SUBMIT_DIR:-$(dirname "$0")}" rev-parse --show-toplevel)" +cd "$REPO_ROOT" # Make sure the submodule is initialized in case the job runs on a fresh checkout. git submodule update --init --recursive diff --git a/src/step_up/data/csv_dataset.py b/src/step_up/data/csv_dataset.py index d089082..b1d16b3 100644 --- a/src/step_up/data/csv_dataset.py +++ b/src/step_up/data/csv_dataset.py @@ -47,13 +47,48 @@ def _read_csv_subset(path: Path, columns: Sequence[str], nrows: int | None) -> p class CSVMoleculeDataset(Dataset): - """Row-streaming dataset over one of QM9-full / tmQMg-full / BOSTMC. - - The CSV is loaded into a pandas DataFrame at construction time (the - ``subset_size`` knob keeps memory bounded for smoke runs). When - ``validate=True`` (the default), the dataset then walks every row, calls - the featurizer, and keeps only indices whose featurization succeeds. The - number of dropped rows is reported once. + """Dataset over one of QM9-full / tmQMg-full / BOSTMC. + + The needed CSV columns are loaded into memory with pandas; rows are + featurized when accessed. + + Parameters + ---------- + path + Path to the CSV. + source + Either ``"smiles"`` (QM9-style: featurize from the ``xyz`` column via + RDKit ``DetermineBonds``) or ``"mol2"`` (organometallic: featurize from + the ``mol2`` column directly via the in-house parser). + subset_size + Optional cap on the number of CSV rows to load (the first rows of the + file). Useful for smoke runs. + validate + If ``True`` (default), every row is featurized once at construction + time and rows whose featurization fails are dropped. Set to ``False`` + only for datasets known to be clean: a bad row then raises during + training instead. The upfront pass takes about a minute on QM9-full. + max_drop_fraction + If validation drops more than this fraction of rows, raise. Defaults + to ``0.5`` — a sane upper bound that flags catastrophic dataset issues + (e.g. wrong column names, encoding problems) while tolerating the + ~6% drop rate seen on QM9-full.csv. + filter_column, filter_value + If both set, restrict the dataset to rows where + ``df[filter_column] == filter_value`` before validation. Used to e.g. + pre-filter BOSTMC to ``spinmult == 1`` so the model trains on a single + spin manifold. + cache + If ``True``, featurized graph dicts are kept in memory and reused by + ``__getitem__`` instead of re-featurizing rows every epoch. The cache is + filled during validation, or on first access when validation is off. + Off by default: worth it for small / smoke datasets, but with + ``num_workers > 0`` each DataLoader worker holds its own copy. + id_column + Optional column holding a stable per-molecule ID (e.g. ``refcode``), + used as the split key by :meth:`split_keys`. Rows sharing an ID always + land in the same split. Without it, a row's key is its row number in + the CSV. """ def __init__( @@ -62,26 +97,51 @@ def __init__( source: Literal["smiles", "mol2"], subset_size: int | None = None, validate: bool = True, + max_drop_fraction: float = 0.5, + filter_column: str | None = None, + filter_value: Any = None, + cache: bool = False, + id_column: str | None = None, ) -> None: self.path = Path(path) if not self.path.exists(): raise FileNotFoundError(self.path) self.source = source + self.id_column = id_column + self._cache_enabled = cache + self._cache: dict[int, dict[str, Any]] = {} if source == "smiles": - # QM9 path: XYZ only — RDKit's DetermineBonds recovers chemistry. - # We still load the smiles column (unused) so the column-set check - # asserts the source is actually QM9-shaped. - cols = ("smiles", "xyz") + cols = ["smiles", "xyz"] elif source == "mol2": - cols = ("mol2", "xyz") + cols = ["mol2", "xyz"] else: raise ValueError(f"Unknown source: {source!r}") + for extra in (filter_column, id_column): + if extra is not None and extra not in cols: + cols.append(extra) + # The DataFrame index is each row's position in the CSV. Filtering keeps it + # (no reset_index), so split keys don't depend on subsetting or filtering. self._df = _read_csv_subset(self.path, cols, nrows=subset_size) + if filter_column is not None: + before = len(self._df) + self._df = self._df[self._df[filter_column] == filter_value] + print( + f"[{self.path.name}] filter {filter_column}=={filter_value}: " + f"kept {len(self._df)}/{before} rows", + flush=True, + ) + + if len(self._df) == 0: + raise ValueError( + f"Dataset {self.path} is empty after loading " + f"(subset_size={subset_size}, filter={filter_column}=={filter_value})" + ) + if validate: - self._valid_indices = self._validate_rows() + self._valid_indices = self._validate_rows(max_drop_fraction) else: self._valid_indices = list(range(len(self._df))) @@ -90,7 +150,27 @@ def __len__(self) -> int: def __getitem__(self, idx: int) -> dict[str, Any]: real_idx = self._valid_indices[idx] - return self._featurize(real_idx) + graph = self._cache.get(real_idx) + if graph is None: + graph = self._featurize(real_idx) + if self._cache_enabled: + self._cache[real_idx] = graph + return graph + + def split_keys(self) -> list[str]: + """Stable per-row keys for :func:`step_up.data.splits.stable_split`. + + Aligned with dataset indices. Each key is the row's ``id_column`` value + if one was given, otherwise its row number in the CSV. Neither depends + on ``subset_size``, filtering, or which rows fail featurization. + """ + rows = self._df.iloc[self._valid_indices] + if self.id_column is None: + return [str(i) for i in rows.index] + ids = rows[self.id_column] + if ids.isna().any(): + raise ValueError(f"id_column {self.id_column!r} has missing values in {self.path}") + return ids.astype(str).tolist() def _featurize(self, real_idx: int) -> dict[str, Any]: row = self._df.iloc[real_idx] @@ -98,15 +178,22 @@ def _featurize(self, real_idx: int) -> dict[str, Any]: return featurize_xyz(str(row["xyz"])) return featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"])) - def _validate_rows(self) -> list[int]: + def _validate_rows(self, max_drop_fraction: float) -> list[int]: n = len(self._df) kept: list[int] = [] failures: dict[str, int] = {} pbar = tqdm(range(n), desc=f"validating {self.path.name}", unit="mol", dynamic_ncols=True) for i in pbar: try: - self._featurize(i) + graph = self._featurize(i) kept.append(i) + if self._cache_enabled: + self._cache[i] = graph + except (FileNotFoundError, ImportError): + # Environment problems (e.g. the ReBind submodule isn't checked out) + # are not bad rows. Re-raise so the actionable message surfaces instead + # of being counted as a per-row failure. + raise except Exception as e: # Bucket by error class so the summary line is interpretable. key = type(e).__name__ @@ -119,4 +206,18 @@ def _validate_rows(self) -> list[int]: f"dropped {dropped} ({summary})", flush=True, ) + # Fail fast on catastrophic drop rates — an empty dataset reaching the + # training loop produces meaningless metrics and silently corrupt + # checkpoints. + if len(kept) == 0: + raise RuntimeError( + f"Validation dropped all {n} rows of {self.path}. " + "Check the column names, encoding, and source format." + ) + if dropped / n > max_drop_fraction: + raise RuntimeError( + f"Validation dropped {dropped}/{n} rows of {self.path} " + f"({dropped / n:.1%}), exceeding max_drop_fraction=" + f"{max_drop_fraction:.1%}. Investigate before training." + ) return kept diff --git a/src/step_up/data/featurize.py b/src/step_up/data/featurize.py index 3e9b927..81f3280 100644 --- a/src/step_up/data/featurize.py +++ b/src/step_up/data/featurize.py @@ -28,29 +28,45 @@ from .mol2 import mol2_to_graph_dict # Load ReBind's `data/utils.py` directly so we share the canonical featurization -# without putting the entire ReBIND tree on sys.path. The submodule lives at -# step-up/external/ReBIND/data/utils.py. +# without putting the entire ReBIND tree on sys.path. The load is deferred until +# the first featurization call so importing this module never fails on a fresh +# checkout that hasn't initialized the submodule yet — `pytest` test collection +# stays clean, and the missing-submodule error gets raised with an actionable +# message only when someone actually tries to featurize a molecule. _REBIND_ROOT = Path(__file__).resolve().parents[3] / "external" / "ReBIND" +_REBIND_UTILS_PATH = _REBIND_ROOT / "data" / "utils.py" +_rebind_utils: Any = None -def _load_rebind_data_utils(): - path = _REBIND_ROOT / "data" / "utils.py" - if not path.exists(): + +def _load_rebind_data_utils() -> Any: + global _rebind_utils + if _rebind_utils is not None: + return _rebind_utils + if not _REBIND_UTILS_PATH.exists(): raise FileNotFoundError( - f"ReBind submodule not found at {_REBIND_ROOT}. " + f"ReBind submodule files missing at {_REBIND_UTILS_PATH}. " "Run: git submodule update --init --recursive" ) - spec = importlib.util.spec_from_file_location("_step_up_rebind_data_utils", str(path)) + spec = importlib.util.spec_from_file_location( + "_step_up_rebind_data_utils", str(_REBIND_UTILS_PATH) + ) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) sys.modules["_step_up_rebind_data_utils"] = mod spec.loader.exec_module(mod) + _rebind_utils = mod return mod -_rebind_utils = _load_rebind_data_utils() -mol_to_graph_dict = _rebind_utils.mol_to_graph_dict -ALLOWABLE_FEATURES = _rebind_utils.ALLOWABLE_FEATURES +def mol_to_graph_dict(mol: Chem.Mol) -> dict[str, Any]: + """Thin wrapper around ReBind's canonical ``data.utils.mol_to_graph_dict``.""" + return _load_rebind_data_utils().mol_to_graph_dict(mol) + + +def get_allowable_features() -> dict[str, Any]: + """Expose ReBind's ``ALLOWABLE_FEATURES`` table on demand.""" + return _load_rebind_data_utils().ALLOWABLE_FEATURES def parse_xyz_block(block: str) -> tuple[list[str], np.ndarray]: diff --git a/src/step_up/data/splits.py b/src/step_up/data/splits.py index bb5c88b..2e60ef9 100644 --- a/src/step_up/data/splits.py +++ b/src/step_up/data/splits.py @@ -1,25 +1,47 @@ -"""Random and reproducible split helpers.""" +"""Deterministic train/val/test split helpers.""" from __future__ import annotations -import torch +import hashlib +from collections.abc import Sequence + from torch.utils.data import Dataset, Subset -def random_split( +def _unit_interval(key: str, seed: int) -> float: + """Map ``(seed, key)`` to a float in [0, 1), identical on every platform and run.""" + digest = hashlib.blake2b(f"{seed}:{key}".encode(), digest_size=8).digest() + return int.from_bytes(digest, "big") / 2**64 + + +def stable_split( dataset: Dataset, + keys: Sequence[str], ratios: tuple[float, float, float] = (0.8, 0.1, 0.1), seed: int = 0, ) -> tuple[Subset, Subset, Subset]: - """Random train/val/test split. Ratios must sum to 1.""" + """Split ``dataset`` into train/val/test by hashing each item's key. + + ``keys[i]`` identifies item ``i``, e.g. a molecule ID or its row number in the + source CSV. An item's split depends only on its own key, ``seed`` and ``ratios``, + so it never moves when other items are added, removed, filtered out, or fail + featurization, and items that share a key always share a split. Split sizes + follow ``ratios`` only approximately, which matters just for tiny datasets. + """ if abs(sum(ratios) - 1.0) > 1e-6: raise ValueError(f"Ratios must sum to 1, got {ratios}") n = len(dataset) # type: ignore[arg-type] - g = torch.Generator().manual_seed(seed) - perm = torch.randperm(n, generator=g).tolist() - n_train = int(n * ratios[0]) - n_val = int(n * ratios[1]) - train_idx = perm[:n_train] - val_idx = perm[n_train : n_train + n_val] - test_idx = perm[n_train + n_val :] + if len(keys) != n: + raise ValueError(f"Got {len(keys)} keys for a dataset of length {n}") + train_idx: list[int] = [] + val_idx: list[int] = [] + test_idx: list[int] = [] + for i, key in enumerate(keys): + u = _unit_interval(key, seed) + if u < ratios[0]: + train_idx.append(i) + elif u < ratios[0] + ratios[1]: + val_idx.append(i) + else: + test_idx.append(i) return Subset(dataset, train_idx), Subset(dataset, val_idx), Subset(dataset, test_idx) diff --git a/src/step_up/eval/metrics.py b/src/step_up/eval/metrics.py index 7b89b6d..21c7f49 100644 --- a/src/step_up/eval/metrics.py +++ b/src/step_up/eval/metrics.py @@ -54,21 +54,39 @@ def per_element_dmae( ) -> dict[int, float]: """Per-element D-MAE: |d_ij_pred - d_ij_true| averaged over pairs touching Z. - ``atomic_numbers`` is ``(B, N)`` of integer Z values (use 0 for padding). - Returns a dict mapping Z -> mean |Δd| over pairs (i,j) with i or j of that - element (each pair contributes to both endpoint elements). + ``atomic_numbers`` is ``(B, N)`` of **true atomic numbers** (1 for H, 6 for + C, etc.). Padding positions are identified via ``node_mask`` (1 = valid, + 0 = padding) — *not* via a sentinel atomic-number value. + + ReBind uses two encodings, so check which one you have: + + - A collated batch's ``node_type`` is already true atomic numbers (the + collator adds 1, with 0 at padding). Pass it directly. + - A single graph dict's ``node_type`` is ``Z - 1`` (H is 0). Add 1 first. + + Raises ``ValueError`` if a non-padding atom has atomic number < 1, which + catches ``Z - 1`` indices passed by mistake whenever hydrogen is present. + + Returns a dict mapping Z -> mean |Δd| over pairs (i, j) with i or j of + that element (each pair contributes to both endpoint elements). """ mask = _pair_mask(node_mask) diff = (torch.cdist(pred, pred) - torch.cdist(target, target)).abs() * mask + valid = node_mask.to(torch.bool) zs = atomic_numbers.to(torch.long) + if (zs[valid] < 1).any(): + raise ValueError( + "atomic_numbers must be true atomic numbers (H = 1), but a non-padding atom " + "has Z < 1. Graph-dict node_type is Z - 1; add 1 before calling." + ) per_z: dict[int, float] = {} - for z in torch.unique(zs): - if int(z.item()) == 0: - continue - # Pairs where either endpoint has atomic number z. - endpoint_mask = (zs == z).unsqueeze(-1) | (zs == z).unsqueeze(-2) - pair_mask = mask * endpoint_mask.to(torch.float32) + for z in torch.unique(zs[valid]): + z_int = int(z.item()) + # Pairs where either endpoint is a valid atom of element z. + endpoint = ((zs == z) & valid).to(torch.float32) + endpoint_mask = (endpoint.unsqueeze(-1) + endpoint.unsqueeze(-2)).clamp_max(1.0) + pair_mask = mask * endpoint_mask denom = pair_mask.sum().clamp_min(1) - per_z[int(z.item())] = float((diff * endpoint_mask.to(torch.float32)).sum() / denom) + per_z[z_int] = float((diff * endpoint_mask).sum() / denom) return per_z diff --git a/src/step_up/models/rebind.py b/src/step_up/models/rebind.py index 8e4224e..c662c28 100644 --- a/src/step_up/models/rebind.py +++ b/src/step_up/models/rebind.py @@ -1,10 +1,17 @@ """Thin wrapper around the vendored ReBind implementation. -Vendored at ``external/ReBIND``. We add that path to ``sys.path`` once on -import so the vendor's intra-package relative imports work. We also patch -``get_sigma_and_epsilon`` so that atomic numbers > 36 (i.e., second-row+ -transition metals, lanthanides, etc.) don't raise a KeyError on the -organometallic datasets. +Vendored at ``external/ReBIND``. Nothing is imported from the submodule until +``build_rebind()`` or ``get_collator()`` is first called. At that point its root +is put on ``sys.path`` (so the vendor's intra-package imports work) and three +runtime patches are applied: + +- ``get_sigma_and_epsilon`` falls back to default LJ parameters for atomic + numbers > 36 (4d/5d transition metals, lanthanides, etc.) instead of raising + a KeyError on the organometallic datasets. +- ``Encoder.forward`` / ``Decoder.forward`` add the Laplacian positional + encoding out of place, so the model can be trained in fp32. +- ``REBIND.forward`` clamps predicted distances in the LJ block and scrubs + non-finite values from the rewired adjacencies. This is the explicit "hybrid: vendor for now, refactor later" handoff. The next iteration will copy the model code into ``step_up.models.rebind`` natively and @@ -20,23 +27,74 @@ import torch _REBIND_ROOT = Path(__file__).resolve().parents[3] / "external" / "ReBIND" -if not _REBIND_ROOT.exists(): - raise FileNotFoundError( - f"ReBind submodule not found at {_REBIND_ROOT}. " - "Run: git submodule update --init --recursive" - ) +# Concrete-file probe: a fresh git checkout without `--recursive` leaves +# `external/ReBIND/` as an empty directory, so `exists()` on the root passes +# but later imports fail with a confusing ModuleNotFoundError. Check for an +# actual vendored file so the error message is actionable. +_REBIND_PROBE = _REBIND_ROOT / "models" / "rebind" / "modeling_rebind.py" -# Put the vendor root on sys.path so its internal imports (`from .modules ...`, -# `from models import ...`) resolve. Idempotent. -if str(_REBIND_ROOT) not in sys.path: - sys.path.insert(0, str(_REBIND_ROOT)) -from models import REBIND, Collator, REBINDConfig # noqa: E402 -from models.modules import utils as _rebind_utils # noqa: E402 -from models.rebind import collating_rebind as _rebind_collating # noqa: E402 -from models.rebind import modeling_rebind as _rebind_modeling # noqa: E402 +def _ensure_rebind_on_path() -> None: + """Verify the submodule is initialized and put its root on ``sys.path``. -__all__ = ["REBIND", "Collator", "REBINDConfig", "build_rebind", "patch_lj_parameters"] + Raises ``FileNotFoundError`` with an actionable message if the submodule + files aren't present. Idempotent. + """ + if not _REBIND_PROBE.exists(): + raise FileNotFoundError( + f"ReBind submodule files missing at {_REBIND_PROBE}. " + "Run: git submodule update --init --recursive" + ) + if str(_REBIND_ROOT) not in sys.path: + sys.path.insert(0, str(_REBIND_ROOT)) + + +# Cached references to the vendored symbols, populated by ``_load_rebind()``. +# Private so that importing them directly fails instead of silently yielding +# ``None`` before the submodule is loaded; use ``build_rebind`` / ``get_collator``. +_REBIND: Any = None +_Collator: Any = None +_REBINDConfig: Any = None +_rebind_utils: Any = None +_rebind_collating: Any = None +_rebind_modeling: Any = None + +# Upstream ``forward`` methods replaced by the patches below, keyed by class, so +# tests can check the patched model against upstream. +_UPSTREAM_FORWARDS: dict[type, Any] = {} + +__all__ = ["build_rebind", "get_collator"] + + +def _load_rebind() -> None: + """Import the vendored ReBind modules and apply the runtime patches. + + Deferred until first use so that importing ``step_up.models.rebind`` from a + fresh checkout (without ``--recursive``) doesn't fail at collection time. + Idempotent. + """ + global _REBIND, _Collator, _REBINDConfig + global _rebind_utils, _rebind_collating, _rebind_modeling + if _REBIND is not None: + return + _ensure_rebind_on_path() + # Imports are deferred so the vendored sys.path entry exists first. + from models import REBIND, Collator, REBINDConfig + from models.modules import utils + from models.rebind import collating_rebind, modeling_rebind + + _REBIND = REBIND + _Collator = Collator + _REBINDConfig = REBINDConfig + _rebind_utils = utils + _rebind_collating = collating_rebind + _rebind_modeling = modeling_rebind + + # Apply the three runtime patches we need (defined further down). They + # depend on the vendored modules above so we can only call them now. + patch_lj_parameters() + patch_inplace_lap_addition() + patch_rebind_forward() # --------------------------------------------------------------------------- @@ -143,9 +201,12 @@ def _canonical_lj_table() -> dict[int, dict[str, float]]: def _patched_encoder_forward(self, **inputs): """Out-of-place equivalent of ``Encoder.forward`` from vendored ReBind. - The vendored version did an in-place slice-assignment of the Laplacian - positional encoding into ``node_embedding``, which breaks autograd on - modern PyTorch (>=2.6). We replace it with a zero-padded add. + Upstream adds the Laplacian positional encoding into ``node_embedding`` with + an in-place slice assignment. In the decoder, that write modifies the encoder + output after ``conformer_head`` has saved it for backward, so fp32 training + fails with autograd's "modified by an inplace operation" error. (Upstream + trains under fp16 autocast, where the error doesn't trigger.) The encoder's + own write is harmless, but both blocks use a zero-padded add for symmetry. """ node_attr = inputs.get("node_attr") node_embedding = self.node_embedding(node_attr) @@ -194,6 +255,8 @@ def patch_inplace_lap_addition() -> None: """Replace ``Encoder.forward`` and ``Decoder.forward`` with autograd-safe versions.""" if getattr(_rebind_modeling, "_step_up_inplace_patched", False): return + _UPSTREAM_FORWARDS[_rebind_modeling.Encoder] = _rebind_modeling.Encoder.forward + _UPSTREAM_FORWARDS[_rebind_modeling.Decoder] = _rebind_modeling.Decoder.forward _rebind_modeling.Encoder.forward = _patched_encoder_forward _rebind_modeling.Decoder.forward = _patched_decoder_forward _rebind_modeling._step_up_inplace_patched = True @@ -241,7 +304,21 @@ def _patched_rebind_forward(self, **inputs): # CHANGE (1): clamp_min on the predicted distance matrix. D_cache = torch.cdist(conformer_cache, conformer_cache).detach().clamp_min(_LJ_D_MIN) D_M = _rebind_modeling.make_cdist_mask(node_mask) - inputs["pred_conformation"] = node_embedding + # NOTE: The variable name ``pred_conformation`` is misleading — this is + # ReBind's intentional design (see vendored ``modeling_rebind.py``). The + # residual head treats ``hidden_X`` (decoder output) and ``conformer_base`` + # (this tensor) as two **hidden states** of identical shape + # ``(B, N, d_model)``, stacks them along a new last dim, computes an + # attention score across the two channels, and projects the weighted sum + # back to coordinates. It is NOT the predicted coordinate tensor + # ``conformer_cache`` (shape ``(B, N, 3)``); using ``conformer_cache`` here + # would crash on the ``torch.stack`` shape mismatch. + # + # Upstream stores the encoder output here, and its decoder then adds the + # Laplacian positional encoding to that same tensor in place, so upstream's + # residual head actually receives ``encoder output + PE``. The out-of-place + # decoder patch no longer mutates it, so the PE is added explicitly here. + inputs["pred_conformation"] = _add_lap_out_of_place(node_embedding, inputs["lap_eigenvectors"]) inputs["node_embedding"] = node_embedding sigma, epsilon = inputs.get("sigma"), inputs.get("epsilon") @@ -300,14 +377,23 @@ def patch_rebind_forward() -> None: """Replace ``REBIND.forward`` with the numerically-defensive version.""" if getattr(_rebind_modeling, "_step_up_forward_patched", False): return + _UPSTREAM_FORWARDS[_rebind_modeling.REBIND] = _rebind_modeling.REBIND.forward _rebind_modeling.REBIND.forward = _patched_rebind_forward _rebind_modeling._step_up_forward_patched = True -# Apply patches eagerly on import. -patch_lj_parameters() -patch_inplace_lap_addition() -patch_rebind_forward() +# NOTE: patches are no longer applied at module import. ``_load_rebind()`` +# applies them on first use (i.e. when ``build_rebind()`` or a ``Collator`` +# instance is requested via the lazy accessors below). This lets test +# collection succeed on a fresh checkout where the submodule isn't initialized +# yet — the missing-submodule error fires only when someone actually tries to +# build the model. + + +def get_collator(): + """Return the (lazily loaded) ReBind ``Collator`` class.""" + _load_rebind() + return _Collator def build_rebind( @@ -317,9 +403,10 @@ def build_rebind( n_head: int = 8, atom_vocab_size: int = 513, dropout: float = 0.0, -) -> REBIND: +): """Instantiate a REBIND model from a flat keyword-style config.""" - config = REBINDConfig( + _load_rebind() + config = _REBINDConfig( n_encode_layers=n_layers, n_decode_layers=n_layers, embed_style="atom_type_ids", @@ -338,4 +425,4 @@ def build_rebind( dropout=dropout, d_ffn=d_ffn, ) - return REBIND(config) + return _REBIND(config) diff --git a/src/step_up/train.py b/src/step_up/train.py index 552ee4e..aa07a01 100644 --- a/src/step_up/train.py +++ b/src/step_up/train.py @@ -1,9 +1,10 @@ """Config-driven training loop for step-up. The loop is deliberately minimal: it owns dataset construction, the ReBind -model, AdamW + linear-warmup, periodic validation, best-checkpoint saving, and -TensorBoard logging. No HuggingFace Trainer, no accelerate — keeping the -control flow legible while we're still iterating on the model. +model, AdamW with linear warmup and cosine decay, periodic validation, +best-checkpoint saving, and TensorBoard logging. No HuggingFace Trainer, no +accelerate — keeping the control flow legible while we're still iterating on +the model. """ from __future__ import annotations @@ -22,8 +23,8 @@ from tqdm import tqdm from .data.csv_dataset import CSVMoleculeDataset -from .data.splits import random_split -from .models.rebind import Collator, build_rebind +from .data.splits import stable_split +from .models.rebind import build_rebind, get_collator # --------------------------------------------------------------------------- # Config dataclasses @@ -37,6 +38,22 @@ class TrainConfig: subset_size: int | None = None split_ratios: tuple[float, float, float] = (0.8, 0.1, 0.1) split_seed: int = 0 + # Column with a stable per-molecule ID (e.g. `refcode`). Each molecule's split + # is a hash of this key and `split_seed` (see `stable_split`); without it the + # key is the CSV row number. + id_column: str | None = None + # Optional CSV column filter (e.g. `filter_column: spinmult, filter_value: 1` + # to restrict BOSTMC to singlets). + filter_column: str | None = None + filter_value: Any = None + # Featurize every row up front and drop the ones that fail. Disable only for + # datasets known to be clean; a bad row then raises mid-training instead. + validate_dataset: bool = True + # Keep featurized graph dicts in memory instead of re-featurizing every epoch. + # Trades memory for speed; recommended for smoke runs. + cache_dataset: bool = False + # Fail validation if more than this fraction of rows are dropped. + max_drop_fraction: float = 0.5 # Model n_layers: int = 8 @@ -112,13 +129,19 @@ def _all_params_finite(model: torch.nn.Module) -> bool: return True -def _linear_warmup_lr(step: int, total_steps: int, warmup_steps: int, base_lr: float) -> float: +def _warmup_cosine_lr(step: int, total_steps: int, warmup_steps: int, base_lr: float) -> float: if step < warmup_steps: return base_lr * (step + 1) / max(warmup_steps, 1) progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1) return base_lr * 0.5 * (1.0 + math.cos(math.pi * progress)) +def _mean_or_nan(total: float, count: int) -> float: + # NaN rather than 0.0 when nothing was averaged: a 0.0 D-MAE would read as a + # perfect score and win best-checkpoint selection. + return total / count if count else math.nan + + # --------------------------------------------------------------------------- # Training loop # --------------------------------------------------------------------------- @@ -142,6 +165,8 @@ def _run_epoch( (no backward, no optimizer step). The first occurrence prints whether the badness is in the input batch or appeared inside the forward pass — useful to distinguish data corruption from model-side numerical instability. + During evaluation, non-finite batches are excluded from the means with a + warning. If no batch contributes, both means are NaN. """ is_train = optimizer is not None model.train(is_train) @@ -150,7 +175,7 @@ def _run_epoch( for batch in pbar: batch = _move_batch_to_device(batch, device) if is_train: - lr_now = _linear_warmup_lr(scheduler_state["step"], total_steps, warmup_steps, base_lr) + lr_now = _warmup_cosine_lr(scheduler_state["step"], total_steps, warmup_steps, base_lr) for g in optimizer.param_groups: g["lr"] = lr_now optimizer.zero_grad(set_to_none=True) @@ -223,12 +248,13 @@ def _run_epoch( with torch.no_grad(): out = model(**batch) if not torch.isfinite(out.loss): + print("WARN: non-finite loss on an evaluation batch; excluded from metrics.") continue loss_sum += float(out.loss.detach()) dmae_sum += float(out.cdist_mae.detach()) n += 1 pbar.set_postfix(loss=f"{loss_sum / n:.4f}", dmae=f"{dmae_sum / n:.4f}") - return loss_sum / max(n, 1), dmae_sum / max(n, 1) + return _mean_or_nan(loss_sum, n), _mean_or_nan(dmae_sum, n) def train(config: TrainConfig) -> dict[str, Any]: @@ -243,11 +269,27 @@ def train(config: TrainConfig) -> dict[str, Any]: path=config.dataset_path, source=config.dataset_source, # type: ignore[arg-type] subset_size=config.subset_size, + validate=config.validate_dataset, + max_drop_fraction=config.max_drop_fraction, + filter_column=config.filter_column, + filter_value=config.filter_value, + cache=config.cache_dataset, + id_column=config.id_column, + ) + train_set, val_set, test_set = stable_split( + dataset, dataset.split_keys(), ratios=config.split_ratios, seed=config.split_seed ) - train_set, val_set, test_set = random_split( - dataset, ratios=config.split_ratios, seed=config.split_seed + print( + f"[split] train={len(train_set)} val={len(val_set)} test={len(test_set)} " + f"(keyed on {config.id_column or 'CSV row number'}, seed={config.split_seed})", + flush=True, ) - collator = Collator() + if len(train_set) == 0 or len(val_set) == 0: + raise ValueError( + f"Empty train or val split from {len(dataset)} molecules with " + f"split_ratios={config.split_ratios}. Use more data or larger ratios." + ) + collator = get_collator()() def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: return DataLoader( @@ -274,14 +316,14 @@ def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: model.parameters(), lr=config.lr, weight_decay=config.weight_decay ) - steps_per_epoch = max(len(train_loader), 1) - total_steps = steps_per_epoch * config.epochs + total_steps = len(train_loader) * config.epochs warmup_steps = int(total_steps * config.warmup_ratio) writer = SummaryWriter(out_dir / "tb") scheduler_state: dict[str, int] = {"step": 0} history: list[dict[str, float]] = [] - best_val = float("inf") + best_val = math.inf + best_epoch: int | None = None t0 = time.time() for epoch in range(1, config.epochs + 1): train_loss, train_dmae = _run_epoch( @@ -326,12 +368,44 @@ def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: "val_dmae": val_dmae, } ) - if val_dmae < best_val: - best_val = val_dmae + if math.isfinite(val_dmae) and val_dmae < best_val: + best_val, best_epoch = val_dmae, epoch torch.save(model.state_dict(), out_dir / "best.pt") writer.close() with open(out_dir / "history.json", "w") as f: json.dump(history, f, indent=2) - del test_set # held out; first round does not evaluate on test - return {"history": history, "best_val_dmae": best_val} + if best_epoch is None: + raise RuntimeError( + "No epoch produced a finite validation D-MAE, so no checkpoint was saved. " + f"Per-epoch metrics are in {out_dir / 'history.json'}." + ) + + # ------------------------------------------------------------------ + # Test-set evaluation on this run's best checkpoint (selected by val D-MAE). + # ------------------------------------------------------------------ + test_metrics: dict[str, float] = {} + if len(test_set) == 0: + print("WARN: test split is empty; skipping test evaluation.", flush=True) + else: + model.load_state_dict(torch.load(out_dir / "best.pt", map_location=config.device)) + test_loader = _make_loader(test_set, config.eval_batch_size, shuffle=False) + test_loss, test_dmae = _run_epoch( + model, + test_loader, + None, + config.device, + scheduler_state, + config.lr, + total_steps, + warmup_steps, + ) + test_metrics = {"test_loss": test_loss, "test_dmae": test_dmae} + with open(out_dir / "test_metrics.json", "w") as f: + json.dump({**test_metrics, "best_epoch": best_epoch}, f, indent=2) + print( + f"[test] test_loss={test_loss:.4f} test_dmae={test_dmae:.4f} " + f"(checkpoint from epoch {best_epoch})" + ) + + return {"history": history, "best_val_dmae": best_val, "best_epoch": best_epoch, **test_metrics} diff --git a/tests/conftest.py b/tests/conftest.py index c45c1db..084ddaf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,11 @@ -"""Shared pytest fixtures and skip-paths for tests that need the local CSVs.""" +"""Shared pytest fixtures. + +Tests run against tiny CSV fixtures committed under ``tests/fixtures/``, so the +suite behaves the same on every machine, including CI runners that don't have +the full QM9-full.csv / tmQMg-full.csv / BOSTMC datasets. A missing fixture is +an error rather than a skip: a skipped test would let CI pass without +exercising the featurization, MOL2 parser, LJ patch, or model forward pass. +""" from __future__ import annotations @@ -6,27 +13,32 @@ import pytest -QM9_PATH = Path("/home/gridsan/jtoney/ElemNet/benchmarking/datasets/QM9-full.csv") -TMQMG_PATH = Path("/home/gridsan/jtoney/ElemNet/benchmarking/datasets/tmQMg-full.csv") -BOSTMC_PATH = Path("/home/gridsan/jtoney/BOSTMC/datasets/BOSTMC-low-spin.csv") +FIXTURE_DIR = Path(__file__).parent / "fixtures" -def _skip_if_missing(p: Path) -> Path: - if not p.exists(): - pytest.skip(f"dataset not available at {p}") - return p +def _fixture(name: str) -> Path: + path = FIXTURE_DIR / name + if not path.exists(): + pytest.fail(f"committed test fixture missing: {path}", pytrace=False) + return path @pytest.fixture(scope="session") def qm9_path() -> Path: - return _skip_if_missing(QM9_PATH) + return _fixture("qm9_mini.csv") @pytest.fixture(scope="session") def tmqmg_path() -> Path: - return _skip_if_missing(TMQMG_PATH) + return _fixture("tmqmg_mini.csv") @pytest.fixture(scope="session") def bostmc_path() -> Path: - return _skip_if_missing(BOSTMC_PATH) + """BOSTMC-shaped CSV with columns ``refcode, xyz, charge, spinmult, mol2``. + + The structures are the public tmQMg complexes from ``tmqmg_mini.csv`` with + made-up ``refcode`` / ``charge`` / ``spinmult`` values (singlets, then + doublets), so no BOSTMC data is committed to the repo. + """ + return _fixture("bostmc_mini.csv") diff --git a/tests/fixtures/bostmc_mini.csv b/tests/fixtures/bostmc_mini.csv new file mode 100644 index 0000000..246d708 --- /dev/null +++ b/tests/fixtures/bostmc_mini.csv @@ -0,0 +1,925 @@ +refcode,xyz,charge,spinmult,mol2 +MINI01,"54 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +C -1.090100 2.615300 -0.569700 +C -2.208300 3.468000 -0.460700 +H -2.101700 4.537300 -0.696800 +C -3.432400 2.935300 -0.055700 +H -4.312700 3.585400 0.049000 +C -3.531800 1.557800 0.177900 +H -4.496900 1.104000 0.433800 +C -2.387000 0.761200 0.035100 +C -2.409200 -0.706900 0.028900 +C -3.577900 -1.468100 0.170100 +H -4.527400 -0.986300 0.432700 +C -3.522300 -2.846700 -0.072300 +H -4.422700 -3.469300 0.029200 +C -2.315200 -3.415500 -0.478500 +H -2.241600 -4.486900 -0.717900 +C -1.171100 -2.597200 -0.582200 +C 0.902400 0.067900 2.039200 +C 1.267700 -1.219100 1.502200 +C 2.222700 -1.325500 0.413300 +C 2.704600 -0.124000 -0.206600 +C 2.315800 1.177900 0.280600 +C 1.408100 1.260700 1.399900 +C -0.010100 0.203900 3.235600 +H -0.976300 0.681000 2.973300 +H 0.462400 0.839500 4.011000 +H -0.235900 -0.767300 3.705600 +C 0.669300 -2.459800 2.117900 +H 1.215200 -2.724900 3.047500 +H 0.721900 -3.324600 1.438400 +H -0.392500 -2.311200 2.384900 +C 2.770200 -2.652200 -0.052100 +H 2.566800 -2.828300 -1.123800 +H 2.362100 -3.502600 0.517100 +H 3.870300 -2.663500 0.089500 +C 3.617700 -0.186400 -1.399400 +H 3.148900 0.332900 -2.258500 +H 3.837700 -1.217000 -1.716900 +H 4.577700 0.319100 -1.171400 +C 2.925300 2.385200 -0.381900 +H 4.029300 2.350400 -0.279500 +H 2.584900 3.335300 0.058700 +H 2.692800 2.403300 -1.463100 +C 0.975800 2.587500 1.969200 +H 1.515600 2.768400 2.922500 +H -0.104300 2.601400 2.204600 +H 1.186900 3.430300 1.294300 +Cl 0.438500 0.028000 -2.519800 +N -1.162100 1.301800 -0.255300 +N -1.202400 -1.282500 -0.270000 +O 0.110500 3.048000 -0.971600 +H 0.041900 3.979600 -1.257000 +O 0.019400 -3.068900 -0.973100 +H -0.076000 -3.999300 -1.254900 +Ru 0.463300 -0.012400 -0.141900 +",0,1,"@MOLECULE + +54 59 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 C1 -1.0901 2.6153 -0.5697 C.3 1 RES1 0.0 +2 C2 -2.2083 3.468 -0.4607 C.2 1 RES1 0.0 +3 H1 -2.1017 4.5373 -0.6968 H 1 RES1 0.0 +4 C3 -3.4324 2.9353 -0.0557 C.2 1 RES1 0.0 +5 H2 -4.3127 3.5854 0.049 H 1 RES1 0.0 +6 C4 -3.5318 1.5578 0.1779 C.2 1 RES1 0.0 +7 H3 -4.4969 1.104 0.4338 H 1 RES1 0.0 +8 C5 -2.387 0.7612 0.0351 C.2 1 RES1 0.0 +9 C6 -2.4092 -0.7069 0.0289 C.3 1 RES1 0.0 +10 C7 -3.5779 -1.4681 0.1701 C.2 1 RES1 0.0 +11 H4 -4.5274 -0.9863 0.4327 H 1 RES1 0.0 +12 C8 -3.5223 -2.8467 -0.0723 C.2 1 RES1 0.0 +13 H5 -4.4227 -3.4693 0.0292 H 1 RES1 0.0 +14 C9 -2.3152 -3.4155 -0.4785 C.2 1 RES1 0.0 +15 H6 -2.2416 -4.4869 -0.7179 H 1 RES1 0.0 +16 C10 -1.1711 -2.5972 -0.5822 C.2 1 RES1 0.0 +17 C11 0.9024 0.0679 2.0392 C.3 1 RES1 0.0 +18 C12 1.2677 -1.2191 1.5022 C.3 1 RES1 0.0 +19 C13 2.2227 -1.3255 0.4133 C.2 1 RES1 0.0 +20 C14 2.7046 -0.124 -0.2066 C.2 1 RES1 0.0 +21 C15 2.3158 1.1779 0.2806 C.3 1 RES1 0.0 +22 C16 1.4081 1.2607 1.3999 C.3 1 RES1 0.0 +23 C17 -0.0101 0.2039 3.2356 C.3 1 RES1 0.0 +24 H7 -0.9763 0.681 2.9733 H 1 RES1 0.0 +25 H8 0.4624 0.8395 4.011 H 1 RES1 0.0 +26 H9 -0.2359 -0.7673 3.7056 H 1 RES1 0.0 +27 C18 0.6693 -2.4598 2.1179 C.3 1 RES1 0.0 +28 H10 1.2152 -2.7249 3.0475 H 1 RES1 0.0 +29 H11 0.7219 -3.3246 1.4384 H 1 RES1 0.0 +30 H12 -0.3925 -2.3112 2.3849 H 1 RES1 0.0 +31 C19 2.7702 -2.6522 -0.0521 C.3 1 RES1 0.0 +32 H13 2.5668 -2.8283 -1.1238 H 1 RES1 0.0 +33 H14 2.3621 -3.5026 0.5171 H 1 RES1 0.0 +34 H15 3.8703 -2.6635 0.0895 H 1 RES1 0.0 +35 C20 3.6177 -0.1864 -1.3994 C.3 1 RES1 0.0 +36 H16 3.1489 0.3329 -2.2585 H 1 RES1 0.0 +37 H17 3.8377 -1.217 -1.7169 H 1 RES1 0.0 +38 H18 4.5777 0.3191 -1.1714 H 1 RES1 0.0 +39 C21 2.9253 2.3852 -0.3819 C.3 1 RES1 0.0 +40 H19 4.0293 2.3504 -0.2795 H 1 RES1 0.0 +41 H20 2.5849 3.3353 0.0587 H 1 RES1 0.0 +42 H21 2.6928 2.4033 -1.4631 H 1 RES1 0.0 +43 C22 0.9758 2.5875 1.9692 C.3 1 RES1 0.0 +44 H22 1.5156 2.7684 2.9225 H 1 RES1 0.0 +45 H23 -0.1043 2.6014 2.2046 H 1 RES1 0.0 +46 H24 1.1869 3.4303 1.2943 H 1 RES1 0.0 +47 Cl1 0.4385 0.028 -2.5198 Cl 1 RES1 0.0 +48 N1 -1.1621 1.3018 -0.2553 N.pl3 1 RES1 0.0 +49 N2 -1.2024 -1.2825 -0.27 N.pl3 1 RES1 0.0 +50 O1 0.1105 3.048 -0.9716 O.3 1 RES1 0.0 +51 H25 0.0419 3.9796 -1.257 H 1 RES1 0.0 +52 O2 0.0194 -3.0689 -0.9731 O.2 1 RES1 0.0 +53 H26 -0.076 -3.9993 -1.2549 H 1 RES1 0.0 +54 Ru1 0.4633 -0.0124 -0.1419 Ru 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 48 1 +1 1 50 1 +1 2 3 1 +1 2 4 2 +1 4 5 1 +1 4 6 1 +1 6 7 1 +1 6 8 2 +1 8 9 1 +1 8 48 1 +1 9 10 1 +1 9 49 1 +1 10 11 1 +1 10 12 2 +1 12 13 1 +1 12 14 1 +1 14 15 1 +1 14 16 2 +1 16 49 1 +1 16 52 1 +1 17 18 1 +1 17 22 1 +1 17 23 1 +1 17 54 1 +1 18 19 1 +1 18 27 1 +1 18 54 1 +1 19 20 2 +1 19 31 1 +1 20 21 1 +1 20 35 1 +1 21 22 1 +1 21 39 1 +1 21 54 1 +1 22 43 1 +1 23 24 1 +1 23 25 1 +1 23 26 1 +1 27 28 1 +1 27 29 1 +1 27 30 1 +1 31 32 1 +1 31 33 1 +1 31 34 1 +1 35 36 1 +1 35 37 1 +1 35 38 1 +1 39 40 1 +1 39 41 1 +1 39 42 1 +1 43 44 1 +1 43 45 1 +1 43 46 1 +1 47 54 1 +1 48 54 1 +1 49 54 1 +1 50 51 1 +1 52 53 1 +@SUBSTRUCTURE +1 RES1 54 + +" +MINI02,"61 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Re 0.045000 -1.706400 -1.296200 +O 0.147600 -2.802800 -2.581900 +O -1.453000 -2.470800 -0.174100 +O -1.494100 -0.522400 -1.910000 +O 1.520400 -0.376200 -1.776100 +O 1.522600 -2.326100 -0.051600 +O -0.156500 -0.173600 0.427500 +C -2.589800 -1.773000 -0.254600 +C -2.611100 -0.704900 -1.202400 +C -3.739800 0.131500 -1.291200 +Cl -3.726000 1.431600 -2.448000 +C -4.850000 -0.075500 -0.433600 +Cl -6.216600 1.000700 -0.511700 +C -4.826800 -1.127900 0.504400 +Cl -6.149700 -1.334300 1.622300 +C -3.700600 -1.986800 0.583600 +Cl -3.625600 -3.263000 1.763300 +C 2.649900 -1.615100 -0.139700 +C 3.781600 -1.824000 0.671000 +Cl 3.717400 -3.068600 1.886000 +C 4.917500 -0.987800 0.541800 +Cl 6.289100 -1.221800 1.591800 +C 4.911200 0.075800 -0.383100 +Cl 6.279700 1.145500 -0.503600 +C 3.767000 0.303700 -1.184300 +Cl 3.691400 1.657500 -2.280600 +C 2.650700 -0.544000 -1.080500 +P -0.098900 1.323400 0.775400 +C -1.213600 1.640900 2.183800 +C -0.942800 2.614300 3.164600 +C -1.870800 2.837000 4.194100 +C -3.062700 2.093200 4.243800 +C -3.327900 1.116600 3.269400 +C -2.402100 0.887300 2.242100 +C 1.581700 1.788300 1.291200 +C 2.282900 0.849900 2.078700 +C 3.597700 1.122700 2.476100 +C 4.218000 2.319700 2.080400 +C 3.524100 3.247600 1.289400 +C 2.202800 2.987000 0.893600 +C -0.612400 2.474600 -0.542900 +C -0.047600 2.333100 -1.828700 +C -0.424800 3.220600 -2.845200 +C -1.361100 4.236300 -2.594400 +C -1.931500 4.366800 -1.319200 +C -1.560200 3.487500 -0.291700 +H -0.005000 3.190200 3.123500 +H -1.660300 3.594000 4.965500 +H -3.787900 2.274200 5.052500 +H -4.255300 0.524000 3.295100 +H -2.593900 0.117400 1.482400 +H 1.807200 -0.107300 2.341600 +H 4.157700 0.376200 3.058000 +H 5.265800 2.507500 2.358100 +H 4.023500 4.168700 0.953000 +H 1.662500 3.697500 0.250200 +H 0.674500 1.525300 -2.029700 +H 0.012700 3.104100 -3.848200 +H -1.659300 4.922800 -3.402300 +H -2.681200 5.148500 -1.123200 +H -2.019200 3.580500 0.703500 +",0,1,"@MOLECULE + +61 67 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Re1 0.045 -1.7064 -1.2962 Re 1 RES1 0.0 +2 O1 0.1476 -2.8028 -2.5819 O.3 1 RES1 0.0 +3 O2 -1.453 -2.4708 -0.1741 O.3 1 RES1 0.0 +4 O3 -1.4941 -0.5224 -1.91 O.3 1 RES1 0.0 +5 O4 1.5204 -0.3762 -1.7761 O.3 1 RES1 0.0 +6 O5 1.5226 -2.3261 -0.0516 O.3 1 RES1 0.0 +7 O6 -0.1565 -0.1736 0.4275 O.3 1 RES1 0.0 +8 C1 -2.5898 -1.773 -0.2546 C.ar 1 RES1 0.0 +9 C2 -2.6111 -0.7049 -1.2024 C.ar 1 RES1 0.0 +10 C3 -3.7398 0.1315 -1.2912 C.ar 1 RES1 0.0 +11 Cl1 -3.726 1.4316 -2.448 Cl 1 RES1 0.0 +12 C4 -4.85 -0.0755 -0.4336 C.ar 1 RES1 0.0 +13 Cl2 -6.2166 1.0007 -0.5117 Cl 1 RES1 0.0 +14 C5 -4.8268 -1.1279 0.5044 C.ar 1 RES1 0.0 +15 Cl3 -6.1497 -1.3343 1.6223 Cl 1 RES1 0.0 +16 C6 -3.7006 -1.9868 0.5836 C.ar 1 RES1 0.0 +17 Cl4 -3.6256 -3.263 1.7633 Cl 1 RES1 0.0 +18 C7 2.6499 -1.6151 -0.1397 C.ar 1 RES1 0.0 +19 C8 3.7816 -1.824 0.671 C.ar 1 RES1 0.0 +20 Cl5 3.7174 -3.0686 1.886 Cl 1 RES1 0.0 +21 C9 4.9175 -0.9878 0.5418 C.ar 1 RES1 0.0 +22 Cl6 6.2891 -1.2218 1.5918 Cl 1 RES1 0.0 +23 C10 4.9112 0.0758 -0.3831 C.ar 1 RES1 0.0 +24 Cl7 6.2797 1.1455 -0.5036 Cl 1 RES1 0.0 +25 C11 3.767 0.3037 -1.1843 C.ar 1 RES1 0.0 +26 Cl8 3.6914 1.6575 -2.2806 Cl 1 RES1 0.0 +27 C12 2.6507 -0.544 -1.0805 C.ar 1 RES1 0.0 +28 P1 -0.0989 1.3234 0.7754 P.3 1 RES1 0.0 +29 C13 -1.2136 1.6409 2.1838 C.ar 1 RES1 0.0 +30 C14 -0.9428 2.6143 3.1646 C.ar 1 RES1 0.0 +31 C15 -1.8708 2.837 4.1941 C.ar 1 RES1 0.0 +32 C16 -3.0627 2.0932 4.2438 C.ar 1 RES1 0.0 +33 C17 -3.3279 1.1166 3.2694 C.ar 1 RES1 0.0 +34 C18 -2.4021 0.8873 2.2421 C.ar 1 RES1 0.0 +35 C19 1.5817 1.7883 1.2912 C.ar 1 RES1 0.0 +36 C20 2.2829 0.8499 2.0787 C.ar 1 RES1 0.0 +37 C21 3.5977 1.1227 2.4761 C.ar 1 RES1 0.0 +38 C22 4.218 2.3197 2.0804 C.ar 1 RES1 0.0 +39 C23 3.5241 3.2476 1.2894 C.ar 1 RES1 0.0 +40 C24 2.2028 2.987 0.8936 C.ar 1 RES1 0.0 +41 C25 -0.6124 2.4746 -0.5429 C.ar 1 RES1 0.0 +42 C26 -0.0476 2.3331 -1.8287 C.ar 1 RES1 0.0 +43 C27 -0.4248 3.2206 -2.8452 C.ar 1 RES1 0.0 +44 C28 -1.3611 4.2363 -2.5944 C.ar 1 RES1 0.0 +45 C29 -1.9315 4.3668 -1.3192 C.ar 1 RES1 0.0 +46 C30 -1.5602 3.4875 -0.2917 C.ar 1 RES1 0.0 +47 H1 -0.005 3.1902 3.1235 H 1 RES1 0.0 +48 H2 -1.6603 3.594 4.9655 H 1 RES1 0.0 +49 H3 -3.7879 2.2742 5.0525 H 1 RES1 0.0 +50 H4 -4.2553 0.524 3.2951 H 1 RES1 0.0 +51 H5 -2.5939 0.1174 1.4824 H 1 RES1 0.0 +52 H6 1.8072 -0.1073 2.3416 H 1 RES1 0.0 +53 H7 4.1577 0.3762 3.058 H 1 RES1 0.0 +54 H8 5.2658 2.5075 2.3581 H 1 RES1 0.0 +55 H9 4.0235 4.1687 0.953 H 1 RES1 0.0 +56 H10 1.6625 3.6975 0.2502 H 1 RES1 0.0 +57 H11 0.6745 1.5253 -2.0297 H 1 RES1 0.0 +58 H12 0.0127 3.1041 -3.8482 H 1 RES1 0.0 +59 H13 -1.6593 4.9228 -3.4023 H 1 RES1 0.0 +60 H14 -2.6812 5.1485 -1.1232 H 1 RES1 0.0 +61 H15 -2.0192 3.5805 0.7035 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 3 1 +1 1 4 1 +1 1 5 1 +1 1 6 1 +1 1 7 1 +1 3 8 1 +1 4 9 1 +1 5 27 1 +1 6 18 1 +1 7 28 1 +1 8 9 2 +1 8 16 1 +1 9 10 1 +1 10 11 1 +1 10 12 2 +1 12 13 1 +1 12 14 1 +1 14 15 1 +1 14 16 2 +1 16 17 1 +1 18 19 2 +1 18 27 1 +1 19 20 1 +1 19 21 1 +1 21 22 1 +1 21 23 2 +1 23 24 1 +1 23 25 1 +1 25 26 1 +1 25 27 2 +1 28 29 1 +1 28 35 1 +1 28 41 1 +1 29 30 2 +1 29 34 1 +1 30 31 1 +1 30 47 1 +1 31 32 2 +1 31 48 1 +1 32 33 1 +1 32 49 1 +1 33 34 2 +1 33 50 1 +1 34 51 1 +1 35 36 2 +1 35 40 1 +1 36 37 1 +1 36 52 1 +1 37 38 2 +1 37 53 1 +1 38 39 1 +1 38 54 1 +1 39 40 2 +1 39 55 1 +1 40 56 1 +1 41 42 2 +1 41 46 1 +1 42 43 1 +1 42 57 1 +1 43 44 2 +1 43 58 1 +1 44 45 1 +1 44 59 1 +1 45 46 2 +1 45 60 1 +1 46 61 1 +@SUBSTRUCTURE +1 RES1 61 + +" +MINI03,"37 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Zn -2.075600 -0.249800 -1.186800 +Cl -0.346900 -0.981000 -2.329000 +Cl -4.255500 -0.075600 -1.460400 +O 6.266400 1.047200 -0.566500 +O 6.085000 -1.109400 -0.885800 +N -1.597600 1.506300 -0.079000 +N -1.737500 -1.217200 0.693600 +N 0.133900 0.210300 0.887500 +N 5.625400 -0.005700 -0.587900 +C -2.404500 2.582600 -0.115900 +H -3.220400 2.530700 -0.856700 +C -2.232500 3.663600 0.756400 +H -2.894900 4.538000 0.687600 +C -1.222800 3.586800 1.727400 +H -1.076600 4.403700 2.450000 +C -0.421200 2.438700 1.794900 +H 0.342300 2.315500 2.575000 +C -0.628700 1.418400 0.850800 +C -0.549700 -0.972900 1.275400 +C -0.035000 -1.818600 2.278400 +H 0.934300 -1.592700 2.743600 +C -0.794100 -2.927600 2.665000 +H -0.415900 -3.603600 3.446600 +C -2.050700 -3.152800 2.074200 +H -2.677700 -4.005800 2.368800 +C -2.488600 -2.261000 1.091700 +H -3.459200 -2.361700 0.577100 +C 1.496700 0.180700 0.512000 +C 2.292000 1.350800 0.508000 +H 1.862100 2.322400 0.781100 +C 3.638400 1.290200 0.141100 +H 4.274600 2.184900 0.135700 +C 4.197800 0.059700 -0.222400 +C 3.419700 -1.106200 -0.263700 +H 3.881500 -2.042700 -0.602800 +C 2.072700 -1.042300 0.087100 +H 1.439400 -1.931400 -0.024300 +",1,1,"@MOLECULE + +37 40 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Zn1 -2.0756 -0.2498 -1.1868 Zn 1 RES1 0.0 +2 Cl1 -0.3469 -0.981 -2.329 Cl 1 RES1 0.0 +3 Cl2 -4.2555 -0.0756 -1.4604 Cl 1 RES1 0.0 +4 O1 6.2664 1.0472 -0.5665 O.2 1 RES1 0.0 +5 O2 6.085 -1.1094 -0.8858 O.2 1 RES1 0.0 +6 N1 -1.5976 1.5063 -0.079 N.pl3 1 RES1 0.0 +7 N2 -1.7375 -1.2172 0.6936 N.pl3 1 RES1 0.0 +8 N3 0.1339 0.2103 0.8875 N.pl3 1 RES1 0.0 +9 N4 5.6254 -0.0057 -0.5879 N.pl3 1 RES1 0.0 +10 C1 -2.4045 2.5826 -0.1159 C.3 1 RES1 0.0 +11 H1 -3.2204 2.5307 -0.8567 H 1 RES1 0.0 +12 C2 -2.2325 3.6636 0.7564 C.2 1 RES1 0.0 +13 H2 -2.8949 4.538 0.6876 H 1 RES1 0.0 +14 C3 -1.2228 3.5868 1.7274 C.2 1 RES1 0.0 +15 H3 -1.0766 4.4037 2.45 H 1 RES1 0.0 +16 C4 -0.4212 2.4387 1.7949 C.2 1 RES1 0.0 +17 H4 0.3423 2.3155 2.575 H 1 RES1 0.0 +18 C5 -0.6287 1.4184 0.8508 C.2 1 RES1 0.0 +19 C6 -0.5497 -0.9729 1.2754 C.3 1 RES1 0.0 +20 C7 -0.035 -1.8186 2.2784 C.2 1 RES1 0.0 +21 H5 0.9343 -1.5927 2.7436 H 1 RES1 0.0 +22 C8 -0.7941 -2.9276 2.665 C.2 1 RES1 0.0 +23 H6 -0.4159 -3.6036 3.4466 H 1 RES1 0.0 +24 C9 -2.0507 -3.1528 2.0742 C.2 1 RES1 0.0 +25 H7 -2.6777 -4.0058 2.3688 H 1 RES1 0.0 +26 C10 -2.4886 -2.261 1.0917 C.2 1 RES1 0.0 +27 H8 -3.4592 -2.3617 0.5771 H 1 RES1 0.0 +28 C11 1.4967 0.1807 0.512 C.ar 1 RES1 0.0 +29 C12 2.292 1.3508 0.508 C.ar 1 RES1 0.0 +30 H9 1.8621 2.3224 0.7811 H 1 RES1 0.0 +31 C13 3.6384 1.2902 0.1411 C.ar 1 RES1 0.0 +32 H10 4.2746 2.1849 0.1357 H 1 RES1 0.0 +33 C14 4.1978 0.0597 -0.2224 C.ar 1 RES1 0.0 +34 C15 3.4197 -1.1062 -0.2637 C.ar 1 RES1 0.0 +35 H11 3.8815 -2.0427 -0.6028 H 1 RES1 0.0 +36 C16 2.0727 -1.0423 0.0871 C.ar 1 RES1 0.0 +37 H12 1.4394 -1.9314 -0.0243 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 3 1 +1 1 6 1 +1 1 7 1 +1 4 9 2 +1 5 9 2 +1 6 10 1 +1 6 18 1 +1 7 19 1 +1 7 26 1 +1 8 18 1 +1 8 19 1 +1 8 28 1 +1 9 33 1 +1 10 11 1 +1 10 12 1 +1 12 13 1 +1 12 14 2 +1 14 15 1 +1 14 16 1 +1 16 17 1 +1 16 18 2 +1 19 20 1 +1 20 21 1 +1 20 22 2 +1 22 23 1 +1 22 24 1 +1 24 25 1 +1 24 26 2 +1 26 27 1 +1 28 29 2 +1 28 36 1 +1 29 30 1 +1 29 31 1 +1 31 32 1 +1 31 33 2 +1 33 34 1 +1 34 35 1 +1 34 36 2 +1 36 37 1 +@SUBSTRUCTURE +1 RES1 37 + +" +MINI04,"48 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Rh 0.033400 1.177600 -0.275300 +O 2.257100 3.238700 -0.525000 +O -2.087900 3.333000 -0.625200 +N 2.370500 -1.043800 0.026600 +N 1.128000 -1.142100 -1.745900 +N -1.241600 -1.039400 -1.759300 +N -2.494200 -0.849800 -0.000400 +C 1.336600 -0.417400 -0.602300 +C 2.809400 -2.129600 -0.731100 +H 3.641700 -2.766900 -0.426900 +C 2.016400 -2.202900 -1.844600 +H 2.022900 -2.904200 -2.684600 +C 2.985800 -0.636300 1.343900 +C 4.038000 0.450400 1.072200 +H 4.814800 0.081200 0.373700 +H 4.536700 0.732400 2.019600 +H 3.588800 1.362700 0.641000 +C 1.875700 -0.133700 2.277600 +H 1.140000 -0.935900 2.486300 +H 1.331800 0.734900 1.849300 +H 2.323500 0.184800 3.238200 +C 3.655300 -1.863300 1.982800 +H 2.947900 -2.709500 2.088600 +H 4.004200 -1.585500 2.995200 +H 4.546900 -2.203000 1.420700 +C -0.043600 -0.889500 -2.576500 +H -0.070900 -1.612500 -3.411100 +H 0.005100 0.145300 -2.967800 +C -1.399300 -0.310100 -0.608300 +C -3.005900 -1.895900 -0.770000 +H -3.889600 -2.466400 -0.476900 +C -2.212000 -2.023200 -1.876300 +H -2.274100 -2.705600 -2.729600 +C -2.979700 -0.603500 1.409300 +C -2.569700 0.785300 1.898700 +H -3.058600 1.586700 1.313500 +H -1.470800 0.935000 1.861700 +H -2.890700 0.893100 2.952200 +C -2.335800 -1.688500 2.291500 +H -2.607000 -2.706000 1.947100 +H -2.681300 -1.577700 3.337500 +H -1.231300 -1.595900 2.279000 +C -4.513200 -0.712100 1.431800 +H -4.974800 -0.019000 0.701400 +H -4.876900 -0.437300 2.439900 +H -4.875500 -1.738600 1.230800 +C 1.425000 2.451900 -0.367300 +C -1.282700 2.530200 -0.420600 +",-1,2,"@MOLECULE + +48 50 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Rh1 0.0334 1.1776 -0.2753 Rh 1 RES1 0.0 +2 O1 2.2571 3.2387 -0.525 O.3 1 RES1 0.0 +3 O2 -2.0879 3.333 -0.6252 O.3 1 RES1 0.0 +4 N1 2.3705 -1.0438 0.0266 N.pl3 1 RES1 0.0 +5 N2 1.128 -1.1421 -1.7459 N.pl3 1 RES1 0.0 +6 N3 -1.2416 -1.0394 -1.7593 N.pl3 1 RES1 0.0 +7 N4 -2.4942 -0.8498 -0.0004 N.pl3 1 RES1 0.0 +8 C1 1.3366 -0.4174 -0.6023 C.3 1 RES1 0.0 +9 C2 2.8094 -2.1296 -0.7311 C.2 1 RES1 0.0 +10 H1 3.6417 -2.7669 -0.4269 H 1 RES1 0.0 +11 C3 2.0164 -2.2029 -1.8446 C.2 1 RES1 0.0 +12 H2 2.0229 -2.9042 -2.6846 H 1 RES1 0.0 +13 C4 2.9858 -0.6363 1.3439 C.3 1 RES1 0.0 +14 C5 4.038 0.4504 1.0722 C.3 1 RES1 0.0 +15 H3 4.8148 0.0812 0.3737 H 1 RES1 0.0 +16 H4 4.5367 0.7324 2.0196 H 1 RES1 0.0 +17 H5 3.5888 1.3627 0.641 H 1 RES1 0.0 +18 C6 1.8757 -0.1337 2.2776 C.3 1 RES1 0.0 +19 H6 1.14 -0.9359 2.4863 H 1 RES1 0.0 +20 H7 1.3318 0.7349 1.8493 H 1 RES1 0.0 +21 H8 2.3235 0.1848 3.2382 H 1 RES1 0.0 +22 C7 3.6553 -1.8633 1.9828 C.3 1 RES1 0.0 +23 H9 2.9479 -2.7095 2.0886 H 1 RES1 0.0 +24 H10 4.0042 -1.5855 2.9952 H 1 RES1 0.0 +25 H11 4.5469 -2.203 1.4207 H 1 RES1 0.0 +26 C8 -0.0436 -0.8895 -2.5765 C.3 1 RES1 0.0 +27 H12 -0.0709 -1.6125 -3.4111 H 1 RES1 0.0 +28 H13 0.0051 0.1453 -2.9678 H 1 RES1 0.0 +29 C9 -1.3993 -0.3101 -0.6083 C.2 1 RES1 0.0 +30 C10 -3.0059 -1.8959 -0.77 C.2 1 RES1 0.0 +31 H14 -3.8896 -2.4664 -0.4769 H 1 RES1 0.0 +32 C11 -2.212 -2.0232 -1.8763 C.2 1 RES1 0.0 +33 H15 -2.2741 -2.7056 -2.7296 H 1 RES1 0.0 +34 C12 -2.9797 -0.6035 1.4093 C.3 1 RES1 0.0 +35 C13 -2.5697 0.7853 1.8987 C.3 1 RES1 0.0 +36 H16 -3.0586 1.5867 1.3135 H 1 RES1 0.0 +37 H17 -1.4708 0.935 1.8617 H 1 RES1 0.0 +38 H18 -2.8907 0.8931 2.9522 H 1 RES1 0.0 +39 C14 -2.3358 -1.6885 2.2915 C.3 1 RES1 0.0 +40 H19 -2.607 -2.706 1.9471 H 1 RES1 0.0 +41 H20 -2.6813 -1.5777 3.3375 H 1 RES1 0.0 +42 H21 -1.2313 -1.5959 2.279 H 1 RES1 0.0 +43 C15 -4.5132 -0.7121 1.4318 C.3 1 RES1 0.0 +44 H22 -4.9748 -0.019 0.7014 H 1 RES1 0.0 +45 H23 -4.8769 -0.4373 2.4399 H 1 RES1 0.0 +46 H24 -4.8755 -1.7386 1.2308 H 1 RES1 0.0 +47 C16 1.425 2.4519 -0.3673 C.3 1 RES1 0.0 +48 C17 -1.2827 2.5302 -0.4206 C.3 1 RES1 0.0 +@BOND +1 1 8 1 +1 1 29 2 +1 1 47 1 +1 1 48 1 +1 2 47 1 +1 3 48 1 +1 4 8 1 +1 4 9 1 +1 4 13 1 +1 5 8 1 +1 5 11 1 +1 5 26 1 +1 6 26 1 +1 6 29 1 +1 6 32 1 +1 7 29 1 +1 7 30 1 +1 7 34 1 +1 9 10 1 +1 9 11 2 +1 11 12 1 +1 13 14 1 +1 13 18 1 +1 13 22 1 +1 14 15 1 +1 14 16 1 +1 14 17 1 +1 18 19 1 +1 18 20 1 +1 18 21 1 +1 22 23 1 +1 22 24 1 +1 22 25 1 +1 26 27 1 +1 26 28 1 +1 30 31 1 +1 30 32 2 +1 32 33 1 +1 34 35 1 +1 34 39 1 +1 34 43 1 +1 35 36 1 +1 35 37 1 +1 35 38 1 +1 39 40 1 +1 39 41 1 +1 39 42 1 +1 43 44 1 +1 43 45 1 +1 43 46 1 +@SUBSTRUCTURE +1 RES1 48 + +" +MINI05,"75 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Ti -0.000000 0.933900 -0.000000 +Cl -0.786400 -0.659000 -1.538300 +N 4.754200 -0.646200 -0.024300 +C 2.133200 0.628900 -1.117600 +C 1.301000 1.421200 -1.968400 +C 0.994700 2.638900 -1.308900 +C 1.654300 2.621000 -0.037100 +C 2.355600 1.392300 0.071600 +C 2.658000 -0.743900 -1.410100 +C 4.194600 -0.853500 -1.350200 +C 4.498700 -1.736100 0.932000 +C 4.451200 -1.222700 2.376100 +C 5.435700 -2.952700 0.798600 +C 6.081700 -0.009300 0.017500 +C 5.957300 1.512800 -0.147200 +C 7.120000 -0.580600 -0.970800 +H 0.918400 1.106500 -2.945400 +H 0.398900 3.460300 -1.719800 +H 1.658200 3.429300 0.701500 +H 2.954100 1.051500 0.919500 +H 2.313600 -1.057900 -2.415200 +H 2.199200 -1.460200 -0.697000 +H 4.482300 -1.843700 -1.791700 +H 4.621500 -0.088500 -2.030400 +H 3.470000 -2.086200 0.703600 +H 5.424300 -0.799300 2.701800 +H 4.206300 -2.051100 3.070800 +H 3.668800 -0.447600 2.490200 +H 5.081600 -3.790000 1.433900 +H 6.469600 -2.708600 1.120300 +H 5.478400 -3.323100 -0.245900 +H 6.472500 -0.194300 1.040000 +H 6.951700 2.003000 -0.108300 +H 5.320200 1.944400 0.648700 +H 5.494700 1.772800 -1.122300 +H 8.118100 -0.134900 -0.782900 +H 6.853300 -0.349300 -2.022500 +H 7.216500 -1.679100 -0.880500 +Cl 0.786300 -0.658900 1.538400 +C -2.133200 0.629000 1.117600 +C -1.301000 1.421300 1.968400 +C -0.994700 2.639000 1.308800 +H -0.918400 1.106700 2.945400 +C -1.654300 2.621000 0.037000 +C -2.355700 1.392400 -0.071700 +C -2.658000 -0.743800 1.410200 +C -4.194700 -0.853400 1.350300 +N -4.754200 -0.646300 0.024300 +C -4.498700 -1.736200 -0.931900 +C -4.451100 -1.223000 -2.376000 +H -5.424100 -0.799700 -2.701900 +H -4.206200 -2.051500 -3.070600 +H -3.668500 -0.448000 -2.490100 +C -5.435800 -2.952800 -0.798400 +H -5.081700 -3.790200 -1.433500 +H -6.469600 -2.708700 -1.120200 +H -5.478600 -3.323100 0.246200 +H -3.470000 -2.086400 -0.703300 +C -6.081700 -0.009300 -0.017600 +C -5.957100 1.512900 0.146900 +H -6.951500 2.003100 0.107800 +H -5.320000 1.944300 -0.649100 +H -5.494500 1.773000 1.121900 +C -7.120000 -0.580300 0.970800 +H -8.118100 -0.134600 0.782700 +H -6.853400 -0.348800 2.022400 +H -7.216600 -1.678800 0.880600 +H -6.472500 -0.194400 -1.040100 +H -4.482400 -1.843500 1.791900 +H -4.621500 -0.088300 2.030400 +H -2.313700 -1.057700 2.415300 +H -2.199200 -1.460200 0.697100 +H -1.658200 3.429300 -0.701600 +H -2.954100 1.051500 -0.919500 +H -0.398900 3.460400 1.719600 +",0,2,"@MOLECULE + +75 78 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Ti1 -0.0 0.9339 -0.0 Ti 1 RES1 0.0 +2 Cl1 -0.7864 -0.659 -1.5383 Cl 1 RES1 0.0 +3 N1 4.7542 -0.6462 -0.0243 N.3 1 RES1 0.0 +4 C1 2.1332 0.6289 -1.1176 C.2 1 RES1 0.0 +5 C2 1.301 1.4212 -1.9684 C.2 1 RES1 0.0 +6 C3 0.9947 2.6389 -1.3089 C.3 1 RES1 0.0 +7 C4 1.6543 2.621 -0.0371 C.3 1 RES1 0.0 +8 C5 2.3556 1.3923 0.0716 C.3 1 RES1 0.0 +9 C6 2.658 -0.7439 -1.4101 C.3 1 RES1 0.0 +10 C7 4.1946 -0.8535 -1.3502 C.3 1 RES1 0.0 +11 C8 4.4987 -1.7361 0.932 C.3 1 RES1 0.0 +12 C9 4.4512 -1.2227 2.3761 C.3 1 RES1 0.0 +13 C10 5.4357 -2.9527 0.7986 C.3 1 RES1 0.0 +14 C11 6.0817 -0.0093 0.0175 C.3 1 RES1 0.0 +15 C12 5.9573 1.5128 -0.1472 C.3 1 RES1 0.0 +16 C13 7.12 -0.5806 -0.9708 C.3 1 RES1 0.0 +17 H1 0.9184 1.1065 -2.9454 H 1 RES1 0.0 +18 H2 0.3989 3.4603 -1.7198 H 1 RES1 0.0 +19 H3 1.6582 3.4293 0.7015 H 1 RES1 0.0 +20 H4 2.9541 1.0515 0.9195 H 1 RES1 0.0 +21 H5 2.3136 -1.0579 -2.4152 H 1 RES1 0.0 +22 H6 2.1992 -1.4602 -0.697 H 1 RES1 0.0 +23 H7 4.4823 -1.8437 -1.7917 H 1 RES1 0.0 +24 H8 4.6215 -0.0885 -2.0304 H 1 RES1 0.0 +25 H9 3.47 -2.0862 0.7036 H 1 RES1 0.0 +26 H10 5.4243 -0.7993 2.7018 H 1 RES1 0.0 +27 H11 4.2063 -2.0511 3.0708 H 1 RES1 0.0 +28 H12 3.6688 -0.4476 2.4902 H 1 RES1 0.0 +29 H13 5.0816 -3.79 1.4339 H 1 RES1 0.0 +30 H14 6.4696 -2.7086 1.1203 H 1 RES1 0.0 +31 H15 5.4784 -3.3231 -0.2459 H 1 RES1 0.0 +32 H16 6.4725 -0.1943 1.04 H 1 RES1 0.0 +33 H17 6.9517 2.003 -0.1083 H 1 RES1 0.0 +34 H18 5.3202 1.9444 0.6487 H 1 RES1 0.0 +35 H19 5.4947 1.7728 -1.1223 H 1 RES1 0.0 +36 H20 8.1181 -0.1349 -0.7829 H 1 RES1 0.0 +37 H21 6.8533 -0.3493 -2.0225 H 1 RES1 0.0 +38 H22 7.2165 -1.6791 -0.8805 H 1 RES1 0.0 +39 Cl2 0.7863 -0.6589 1.5384 Cl 1 RES1 0.0 +40 C14 -2.1332 0.629 1.1176 C.2 1 RES1 0.0 +41 C15 -1.301 1.4213 1.9684 C.2 1 RES1 0.0 +42 C16 -0.9947 2.639 1.3088 C.3 1 RES1 0.0 +43 H23 -0.9184 1.1067 2.9454 H 1 RES1 0.0 +44 C17 -1.6543 2.621 0.037 C.3 1 RES1 0.0 +45 C18 -2.3557 1.3924 -0.0717 C.3 1 RES1 0.0 +46 C19 -2.658 -0.7438 1.4102 C.3 1 RES1 0.0 +47 C20 -4.1947 -0.8534 1.3503 C.3 1 RES1 0.0 +48 N2 -4.7542 -0.6463 0.0243 N.3 1 RES1 0.0 +49 C21 -4.4987 -1.7362 -0.9319 C.3 1 RES1 0.0 +50 C22 -4.4511 -1.223 -2.376 C.3 1 RES1 0.0 +51 H24 -5.4241 -0.7997 -2.7019 H 1 RES1 0.0 +52 H25 -4.2062 -2.0515 -3.0706 H 1 RES1 0.0 +53 H26 -3.6685 -0.448 -2.4901 H 1 RES1 0.0 +54 C23 -5.4358 -2.9528 -0.7984 C.3 1 RES1 0.0 +55 H27 -5.0817 -3.7902 -1.4335 H 1 RES1 0.0 +56 H28 -6.4696 -2.7087 -1.1202 H 1 RES1 0.0 +57 H29 -5.4786 -3.3231 0.2462 H 1 RES1 0.0 +58 H30 -3.47 -2.0864 -0.7033 H 1 RES1 0.0 +59 C24 -6.0817 -0.0093 -0.0176 C.3 1 RES1 0.0 +60 C25 -5.9571 1.5129 0.1469 C.3 1 RES1 0.0 +61 H31 -6.9515 2.0031 0.1078 H 1 RES1 0.0 +62 H32 -5.32 1.9443 -0.6491 H 1 RES1 0.0 +63 H33 -5.4945 1.773 1.1219 H 1 RES1 0.0 +64 C26 -7.12 -0.5803 0.9708 C.3 1 RES1 0.0 +65 H34 -8.1181 -0.1346 0.7827 H 1 RES1 0.0 +66 H35 -6.8534 -0.3488 2.0224 H 1 RES1 0.0 +67 H36 -7.2166 -1.6788 0.8806 H 1 RES1 0.0 +68 H37 -6.4725 -0.1944 -1.0401 H 1 RES1 0.0 +69 H38 -4.4824 -1.8435 1.7919 H 1 RES1 0.0 +70 H39 -4.6215 -0.0883 2.0304 H 1 RES1 0.0 +71 H40 -2.3137 -1.0577 2.4153 H 1 RES1 0.0 +72 H41 -2.1992 -1.4602 0.6971 H 1 RES1 0.0 +73 H42 -1.6582 3.4293 -0.7016 H 1 RES1 0.0 +74 H43 -2.9541 1.0515 -0.9195 H 1 RES1 0.0 +75 H44 -0.3989 3.4604 1.7196 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 6 1 +1 1 7 1 +1 1 39 1 +1 1 42 1 +1 1 44 1 +1 3 10 1 +1 3 11 1 +1 3 14 1 +1 4 5 2 +1 4 8 1 +1 4 9 1 +1 5 6 1 +1 5 17 1 +1 6 7 1 +1 6 18 1 +1 7 8 1 +1 7 19 1 +1 8 20 1 +1 9 10 1 +1 9 21 1 +1 9 22 1 +1 10 23 1 +1 10 24 1 +1 11 12 1 +1 11 13 1 +1 11 25 1 +1 12 26 1 +1 12 27 1 +1 12 28 1 +1 13 29 1 +1 13 30 1 +1 13 31 1 +1 14 15 1 +1 14 16 1 +1 14 32 1 +1 15 33 1 +1 15 34 1 +1 15 35 1 +1 16 36 1 +1 16 37 1 +1 16 38 1 +1 40 41 2 +1 40 45 1 +1 40 46 1 +1 41 42 1 +1 41 43 1 +1 42 44 1 +1 42 75 1 +1 44 45 1 +1 44 73 1 +1 45 74 1 +1 46 47 1 +1 46 71 1 +1 46 72 1 +1 47 48 1 +1 47 69 1 +1 47 70 1 +1 48 49 1 +1 48 59 1 +1 49 50 1 +1 49 54 1 +1 49 58 1 +1 50 51 1 +1 50 52 1 +1 50 53 1 +1 54 55 1 +1 54 56 1 +1 54 57 1 +1 59 60 1 +1 59 64 1 +1 59 68 1 +1 60 61 1 +1 60 62 1 +1 60 63 1 +1 64 65 1 +1 64 66 1 +1 64 67 1 +@SUBSTRUCTURE +1 RES1 75 + +" diff --git a/tests/fixtures/qm9_mini.csv b/tests/fixtures/qm9_mini.csv new file mode 100644 index 0000000..1bf24fe --- /dev/null +++ b/tests/fixtures/qm9_mini.csv @@ -0,0 +1,411 @@ +smiles,xyz +c1cncc(c1CO)F,"15 +gdb_133127 +O 0.019300 1.431200 0.007600 +C 0.019300 0.017400 0.001500 +C -1.403400 -0.477500 0.012100 +C -2.512000 0.364200 0.023200 +C -3.788500 -0.193600 0.032200 +N -4.027700 -1.507200 0.030900 +C -2.969600 -2.321000 0.020300 +C -1.669600 -1.842400 0.010900 +F -0.641300 -2.703900 0.000400 +H 0.932500 1.730400 0.002000 +H 0.531000 -0.385500 -0.886900 +H 0.546800 -0.393300 0.877100 +H -2.368100 1.436900 0.024800 +H -4.662900 0.452000 0.040900 +H -3.147000 -3.393400 0.019100 +" +O=CC12C3OC1COC23,"15 +gdb_48553 +O -0.129900 0.007800 -0.118300 +C -0.020600 1.198700 0.064000 +C 1.242800 1.925200 -0.011800 +C 1.934900 2.990900 0.857900 +O 3.173900 2.303000 0.866500 +C 2.682400 1.403300 -0.186300 +C 3.139100 1.993000 -1.509000 +O 2.517800 3.293500 -1.541700 +C 1.535100 3.323300 -0.547400 +H -0.902900 1.832500 0.308700 +H 1.605800 3.491000 1.762200 +H 2.907800 0.357200 0.011700 +H 4.223500 2.124100 -1.548200 +H 2.805400 1.390000 -2.365300 +H 0.728500 4.022900 -0.734200 +" +CC(C)(C)C(O)C1CN1,"24 +gdb_60526 +C -0.034500 1.518500 -0.093600 +C 0.051000 -0.013200 0.042000 +C 0.387100 -0.368000 1.502800 +C -1.300300 -0.636800 -0.349300 +C 1.159900 -0.518800 -0.927500 +O 2.433300 0.059300 -0.623300 +C 1.275400 -2.028200 -1.031400 +C 0.796700 -2.745500 -2.240900 +N 2.236900 -2.594800 -1.990700 +H 0.935500 1.979400 0.106900 +H -0.342000 1.806800 -1.105300 +H -0.768300 1.930000 0.607600 +H -0.376100 0.037400 2.174900 +H 0.425200 -1.449100 1.671700 +H 1.347100 0.062500 1.805300 +H -1.545500 -0.426300 -1.396500 +H -1.304300 -1.722700 -0.216700 +H -2.104800 -0.222100 0.267400 +H 0.923600 -0.128500 -1.927000 +H 2.820900 -0.439700 0.103500 +H 1.259300 -2.565200 -0.084100 +H 0.419100 -3.761000 -2.159300 +H 0.336100 -2.156300 -3.030100 +H 2.618800 -1.840300 -2.557800 +" +C#CC1COC(=N1)C#C,"14 +gdb_35654 +C 0.015200 -1.192300 0.045100 +C 0.022800 0.008400 0.013000 +C -0.004500 1.466600 0.001100 +C -0.595200 2.083800 1.310400 +O 0.413500 3.018000 1.742200 +C 1.454800 2.868200 0.877300 +N 1.351000 2.055200 -0.098500 +C 2.597500 3.671700 1.154200 +C 3.555800 4.354300 1.394800 +H 0.020900 -2.254200 0.062800 +H -0.576300 1.797400 -0.875400 +H -1.525500 2.631100 1.145600 +H -0.739700 1.337300 2.094700 +H 4.408500 4.954300 1.599800 +" +CC1C2CC3C2CC13,"20 +gdb_18927 +C -0.079000 1.524800 0.021400 +C 0.094500 0.006900 0.030600 +C 1.057100 -0.614500 -1.012400 +C 2.425200 0.027900 -0.661500 +C 2.284000 -0.800200 0.622300 +C 1.520800 -1.899300 -0.185100 +C 0.585600 -2.121100 1.012300 +C 0.829700 -0.607600 1.249700 +H 0.844100 2.059800 0.258900 +H -0.427300 1.877300 -0.956300 +H -0.825700 1.824700 0.764700 +H -0.900200 -0.444700 -0.064200 +H 0.709100 -0.687500 -2.043400 +H 3.230300 -0.377600 -1.282800 +H 2.515400 1.116300 -0.625800 +H 3.102800 -1.021500 1.306400 +H 2.024000 -2.696600 -0.731800 +H -0.445100 -2.458000 0.858800 +H 1.070100 -2.752700 1.763400 +H 0.676900 -0.144200 2.224900 +" +O=C1C=CC2(NC12)C#N,"13 +gdb_43271 +O 0.039700 -0.016500 -0.015200 +C -0.013300 1.186100 0.012500 +C -1.226500 2.051000 0.092200 +C -0.895100 3.343200 -0.006200 +C 0.597500 3.510900 -0.099800 +N 1.283200 3.004300 1.130600 +C 1.178900 2.139200 -0.065200 +C 1.181800 4.584700 -0.849300 +N 1.645300 5.464100 -1.440900 +H -2.218800 1.642700 0.229600 +H -1.566900 4.191600 0.018600 +H 2.229100 3.388100 1.169000 +H 2.068500 1.858300 -0.615400 +" +O=CC1CC(C1)C1CC1,"21 +gdb_49907 +O -0.084200 -0.095100 -0.220800 +C -0.056900 1.083300 0.028100 +C 1.173200 1.945500 0.005900 +C 0.967400 3.271000 -0.801000 +C 0.731200 4.040000 0.531000 +C 1.321400 2.820000 1.295700 +C 1.376800 5.385000 0.711300 +C 0.644900 6.647700 0.324800 +C 0.890500 6.334800 1.779400 +H -0.993800 1.627400 0.303300 +H 2.027700 1.326400 -0.271500 +H 1.895700 3.574800 -1.292300 +H 0.164400 3.285300 -1.543000 +H -0.343200 4.126700 0.731100 +H 0.795000 2.481800 2.192400 +H 2.375300 2.963800 1.548700 +H 2.450000 5.386800 0.530600 +H 1.209900 7.452200 -0.132500 +H -0.368700 6.550000 -0.050300 +H 0.041800 6.026900 2.381700 +H 1.623600 6.925200 2.317700 +" +FC1=NC(=N)NN=C1,"11 +gdb_23823 +F 0.002200 0.074200 0.003600 +C -0.051600 1.395400 0.011400 +N -1.196900 1.957500 0.029900 +C -1.226500 3.354700 0.037400 +N -2.254100 4.101900 0.055700 +N 0.039000 3.980500 0.023400 +N 1.215100 3.382000 0.004300 +C 1.210800 2.081000 -0.002400 +H -3.092900 3.525400 0.064300 +H 0.051400 4.990800 0.028600 +H 2.157900 1.559800 -0.018100 +" +CN=CN1C=CCC1=O,"17 +gdb_97298 +C -0.057300 1.468900 0.054100 +N 0.052300 0.025000 0.087000 +C -1.015600 -0.643000 -0.035300 +N -1.013500 -2.030500 -0.016800 +C 0.120600 -2.857400 0.134200 +C -0.217400 -4.147800 0.110200 +C -1.706900 -4.267300 -0.071300 +C -2.174900 -2.810400 -0.148400 +O -3.291600 -2.375900 -0.291100 +H 0.557800 1.864800 -0.762300 +H -1.087900 1.834000 -0.077100 +H 0.343600 1.888000 0.984200 +H -2.023800 -0.236300 -0.164500 +H 1.079500 -2.376400 0.245300 +H 0.464500 -4.978800 0.205500 +H -2.002400 -4.794700 -0.985900 +H -2.217500 -4.771900 0.757300 +" +c1c(ocn1)CCCO,"18 +gdb_33215 +O -0.084900 1.464900 0.277500 +C 0.005900 0.059700 0.111400 +C -1.411600 -0.495100 0.063400 +C -1.436200 -2.024100 -0.112600 +C -2.808800 -2.594600 -0.144700 +C -3.558400 -3.250600 -1.064800 +N -4.808500 -3.547400 -0.530300 +C -4.755700 -3.064300 0.665400 +O -3.581900 -2.469600 0.984900 +H 0.806500 1.824100 0.307500 +H 0.539200 -0.200100 -0.818500 +H 0.559500 -0.407400 0.943100 +H -1.930500 -0.219700 0.987400 +H -1.953200 -0.016700 -0.759800 +H -0.938500 -2.304800 -1.047500 +H -0.864100 -2.493700 0.699300 +H -3.290900 -3.532400 -2.071000 +H -5.516700 -3.075300 1.429700 +" +C(C#N)N=C(C#N)N,"12 +gdb_9018 +N 0.259800 1.055100 0.866200 +C 0.166800 -0.125800 0.175400 +N 1.073800 -0.811800 -0.411800 +C 2.451400 -0.345100 -0.413400 +C 3.006200 -0.004500 0.908100 +N 3.381500 0.290700 1.961500 +C -1.193500 -0.627900 0.061000 +N -2.285900 -0.997200 -0.007200 +H 1.150300 1.307400 1.271000 +H -0.546900 1.346000 1.396200 +H 3.076200 -1.128700 -0.852000 +H 2.558500 0.542500 -1.055700 +" +CC1C2C(CO)CN12,"19 +gdb_18157 +C -0.088600 1.538400 0.334500 +C 0.055000 0.072800 -0.008300 +C 0.546800 -0.552700 -1.258000 +C 1.736000 0.134400 -1.927600 +C 1.549200 1.399300 -2.746500 +O 2.831200 1.769900 -3.237000 +C 2.408800 0.137700 -0.531600 +N 1.306900 -0.705800 0.067300 +H -1.045400 1.916400 -0.041700 +H -0.085800 1.667500 1.422600 +H 0.700500 2.168600 -0.075200 +H -0.740200 -0.520500 0.444000 +H 0.019900 -1.395400 -1.698500 +H 2.254100 -0.585100 -2.568800 +H 0.847000 1.192200 -3.571700 +H 1.109900 2.206700 -2.144300 +H 2.740000 2.586800 -3.735900 +H 3.350800 -0.410400 -0.491700 +H 2.552400 1.108400 -0.048300 +" +OCC1N=COC2CC12,"18 +gdb_112093 +O 0.117300 1.338100 -0.157500 +C 0.054800 -0.069200 -0.125800 +C -1.385600 -0.551000 0.111300 +N -2.222500 0.073000 -0.914000 +C -3.135600 -0.585700 -1.486600 +O -3.488100 -1.883400 -1.337500 +C -2.660900 -2.692200 -0.525400 +C -1.254600 -2.957800 -0.978700 +C -1.523800 -2.059300 0.216300 +H -0.631600 1.609400 -0.706300 +H 0.432800 -0.515300 -1.060700 +H 0.701500 -0.411700 0.689900 +H -1.707100 -0.117600 1.068500 +H -3.781600 -0.116600 -2.226000 +H -3.252700 -3.480400 -0.077100 +H -0.834300 -3.941700 -0.804500 +H -0.944400 -2.491700 -1.907800 +H -1.293100 -2.471600 1.193800 +" +CC1=C(O)OC(=N)C=N1,"15 +gdb_26884 +C 0.015000 1.545900 0.047000 +C -0.020400 0.045800 0.032200 +C -1.159400 -0.673400 -0.101500 +O -2.385000 -0.149500 -0.236000 +O -1.177200 -2.024500 -0.113300 +C 0.012600 -2.755400 0.015600 +N 0.013600 -4.021600 0.006600 +C 1.198000 -1.920700 0.155400 +N 1.178500 -0.633100 0.162500 +H 0.642900 1.925100 -0.766500 +H -0.985800 1.967400 -0.062700 +H 0.445500 1.912400 0.985200 +H -3.017200 -0.875900 -0.312500 +H -0.927200 -4.399800 -0.102300 +H 2.142000 -2.447300 0.258000 +" +COC(C)CCCC#N,"22 +gdb_101878 +C -0.002200 1.417100 -0.131700 +O -0.034700 0.029600 0.110900 +C -1.322300 -0.569000 0.019100 +C -1.767100 -0.748100 -1.435000 +C -1.230500 -1.907500 0.755700 +C -0.887400 -1.752300 2.239400 +C -0.728600 -3.116300 2.938700 +C -0.416600 -2.988200 4.360800 +N -0.173000 -2.869400 5.484200 +H 1.018100 1.753500 0.068800 +H -0.252900 1.673000 -1.171200 +H -0.691100 1.961700 0.532800 +H -2.052800 0.075000 0.540600 +H -2.757600 -1.211800 -1.479200 +H -1.828300 0.210300 -1.957900 +H -1.059300 -1.386900 -1.972700 +H -2.184200 -2.437900 0.641800 +H -0.465300 -2.518000 0.258500 +H 0.038800 -1.180500 2.337500 +H -1.670900 -1.179200 2.748500 +H -1.646900 -3.707800 2.839800 +H 0.069200 -3.696700 2.460200 +" +CC(C#N)C1(O)CC1,"17 +gdb_9785 +C 0.078300 1.453200 -0.197600 +C 0.020600 -0.067300 0.046100 +C 0.709800 -0.416600 1.296000 +N 1.265300 -0.686500 2.272500 +C -1.415900 -0.598400 0.057100 +O -2.236900 -0.027100 1.034700 +C -2.045000 -1.011300 -1.250800 +C -1.668500 -2.047600 -0.207200 +H 1.111800 1.803700 -0.240800 +H -0.411800 1.694600 -1.145300 +H -0.423700 1.991900 0.610400 +H 0.556200 -0.573800 -0.765800 +H -2.494200 0.853700 0.741200 +H -3.099800 -0.792800 -1.373700 +H -1.458300 -0.952800 -2.161800 +H -0.821200 -2.696100 -0.399900 +H -2.473600 -2.501800 0.357400 +" +Cc1nc(co1)O,"12 +gdb_848 +C -0.008700 1.439000 -0.007300 +C -0.052300 -0.046000 -0.003800 +N -1.063000 -0.820500 -0.274000 +C -0.567000 -2.100200 -0.123900 +O -1.341900 -3.181900 -0.326000 +C 0.738600 -2.051100 0.235400 +O 1.073800 -0.713600 0.313500 +H 0.733600 1.808900 -0.722000 +H -0.990800 1.823500 -0.282900 +H 0.263100 1.826700 0.979900 +H -2.215300 -2.852000 -0.569200 +H 1.505900 -2.769400 0.454000 +" +C1C2COC3COC12C3,"19 +gdb_39267 +C -0.047900 1.452200 0.043600 +C 1.335900 0.804100 -0.058000 +C 2.194100 0.441100 -1.243700 +O 2.303900 -1.001500 -1.311600 +C 1.093100 -1.724900 -1.040200 +C 0.875400 -2.013600 0.483500 +O -0.015200 -0.979200 0.981400 +C 0.069700 -0.021700 -0.043900 +C -0.118800 -0.829000 -1.316000 +H -0.259800 1.913200 1.001700 +H -0.488500 1.965400 -0.806300 +H 1.856000 0.789700 0.895300 +H 1.795900 0.841300 -2.185700 +H 3.223500 0.794500 -1.148200 +H 1.159200 -2.647300 -1.621200 +H 0.395600 -2.982100 0.654400 +H 1.834400 -1.979900 1.012100 +H -0.050600 -0.277900 -2.253500 +H -1.063600 -1.378100 -1.285800 +" +CC(C)(CC1CC1)C#C,"23 +gdb_57623 +C -0.091400 1.555800 -0.084000 +C 0.014100 0.018000 0.028700 +C 0.887800 -0.354200 1.249500 +C -1.395900 -0.642800 0.131800 +C -2.237300 -0.242400 1.326700 +C -3.186600 0.930900 1.262000 +C -3.728300 -0.475400 1.290500 +C 0.656800 -0.503700 -1.183700 +C 1.174700 -0.936600 -2.179200 +H 0.901800 1.999800 -0.196300 +H -0.689300 1.844200 -0.953400 +H -0.559600 1.973200 0.811000 +H 0.468900 0.066100 2.168600 +H 0.958500 -1.439900 1.364400 +H 1.900700 0.039300 1.128700 +H -1.948300 -0.413500 -0.787600 +H -1.252300 -1.730500 0.137600 +H -1.772700 -0.414900 2.293200 +H -3.308300 1.549400 2.144700 +H -3.252400 1.477900 0.327400 +H -4.154200 -0.872200 0.374500 +H -4.218400 -0.822800 2.193300 +H 1.635300 -1.316400 -3.057200 +" +CCCOC1CC1CC,"25 +gdb_122524 +C 0.378600 1.236200 0.486700 +C -0.197600 0.056100 -0.299500 +C 0.838200 -0.605500 -1.197500 +O 0.219400 -1.678100 -1.887800 +C 1.081400 -2.360300 -2.741600 +C 1.989300 -3.430300 -2.178900 +C 0.777400 -3.801400 -3.017700 +C -0.411900 -4.490200 -2.379500 +C -0.210700 -6.002100 -2.230400 +H 1.207900 0.921200 1.129800 +H 0.757800 2.016000 -0.183100 +H -0.382500 1.691400 1.126900 +H -0.595800 -0.699900 0.387200 +H -1.038000 0.385900 -0.920800 +H 1.243600 0.124600 -1.918800 +H 1.688700 -0.976200 -0.602800 +H 1.466500 -1.750200 -3.561000 +H 2.985600 -3.553200 -2.589900 +H 1.912400 -3.612500 -1.111500 +H 0.992400 -4.129200 -4.032600 +H -0.605200 -4.035600 -1.403100 +H -1.306900 -4.296300 -2.984700 +H 0.660500 -6.223200 -1.604800 +H -1.084700 -6.474700 -1.771200 +H -0.047200 -6.479600 -3.203200 +" diff --git a/tests/fixtures/tmqmg_mini.csv b/tests/fixtures/tmqmg_mini.csv new file mode 100644 index 0000000..145fee3 --- /dev/null +++ b/tests/fixtures/tmqmg_mini.csv @@ -0,0 +1,925 @@ +xyz,mol2 +"54 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +C -1.090100 2.615300 -0.569700 +C -2.208300 3.468000 -0.460700 +H -2.101700 4.537300 -0.696800 +C -3.432400 2.935300 -0.055700 +H -4.312700 3.585400 0.049000 +C -3.531800 1.557800 0.177900 +H -4.496900 1.104000 0.433800 +C -2.387000 0.761200 0.035100 +C -2.409200 -0.706900 0.028900 +C -3.577900 -1.468100 0.170100 +H -4.527400 -0.986300 0.432700 +C -3.522300 -2.846700 -0.072300 +H -4.422700 -3.469300 0.029200 +C -2.315200 -3.415500 -0.478500 +H -2.241600 -4.486900 -0.717900 +C -1.171100 -2.597200 -0.582200 +C 0.902400 0.067900 2.039200 +C 1.267700 -1.219100 1.502200 +C 2.222700 -1.325500 0.413300 +C 2.704600 -0.124000 -0.206600 +C 2.315800 1.177900 0.280600 +C 1.408100 1.260700 1.399900 +C -0.010100 0.203900 3.235600 +H -0.976300 0.681000 2.973300 +H 0.462400 0.839500 4.011000 +H -0.235900 -0.767300 3.705600 +C 0.669300 -2.459800 2.117900 +H 1.215200 -2.724900 3.047500 +H 0.721900 -3.324600 1.438400 +H -0.392500 -2.311200 2.384900 +C 2.770200 -2.652200 -0.052100 +H 2.566800 -2.828300 -1.123800 +H 2.362100 -3.502600 0.517100 +H 3.870300 -2.663500 0.089500 +C 3.617700 -0.186400 -1.399400 +H 3.148900 0.332900 -2.258500 +H 3.837700 -1.217000 -1.716900 +H 4.577700 0.319100 -1.171400 +C 2.925300 2.385200 -0.381900 +H 4.029300 2.350400 -0.279500 +H 2.584900 3.335300 0.058700 +H 2.692800 2.403300 -1.463100 +C 0.975800 2.587500 1.969200 +H 1.515600 2.768400 2.922500 +H -0.104300 2.601400 2.204600 +H 1.186900 3.430300 1.294300 +Cl 0.438500 0.028000 -2.519800 +N -1.162100 1.301800 -0.255300 +N -1.202400 -1.282500 -0.270000 +O 0.110500 3.048000 -0.971600 +H 0.041900 3.979600 -1.257000 +O 0.019400 -3.068900 -0.973100 +H -0.076000 -3.999300 -1.254900 +Ru 0.463300 -0.012400 -0.141900 +","@MOLECULE + +54 59 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 C1 -1.0901 2.6153 -0.5697 C.3 1 RES1 0.0 +2 C2 -2.2083 3.468 -0.4607 C.2 1 RES1 0.0 +3 H1 -2.1017 4.5373 -0.6968 H 1 RES1 0.0 +4 C3 -3.4324 2.9353 -0.0557 C.2 1 RES1 0.0 +5 H2 -4.3127 3.5854 0.049 H 1 RES1 0.0 +6 C4 -3.5318 1.5578 0.1779 C.2 1 RES1 0.0 +7 H3 -4.4969 1.104 0.4338 H 1 RES1 0.0 +8 C5 -2.387 0.7612 0.0351 C.2 1 RES1 0.0 +9 C6 -2.4092 -0.7069 0.0289 C.3 1 RES1 0.0 +10 C7 -3.5779 -1.4681 0.1701 C.2 1 RES1 0.0 +11 H4 -4.5274 -0.9863 0.4327 H 1 RES1 0.0 +12 C8 -3.5223 -2.8467 -0.0723 C.2 1 RES1 0.0 +13 H5 -4.4227 -3.4693 0.0292 H 1 RES1 0.0 +14 C9 -2.3152 -3.4155 -0.4785 C.2 1 RES1 0.0 +15 H6 -2.2416 -4.4869 -0.7179 H 1 RES1 0.0 +16 C10 -1.1711 -2.5972 -0.5822 C.2 1 RES1 0.0 +17 C11 0.9024 0.0679 2.0392 C.3 1 RES1 0.0 +18 C12 1.2677 -1.2191 1.5022 C.3 1 RES1 0.0 +19 C13 2.2227 -1.3255 0.4133 C.2 1 RES1 0.0 +20 C14 2.7046 -0.124 -0.2066 C.2 1 RES1 0.0 +21 C15 2.3158 1.1779 0.2806 C.3 1 RES1 0.0 +22 C16 1.4081 1.2607 1.3999 C.3 1 RES1 0.0 +23 C17 -0.0101 0.2039 3.2356 C.3 1 RES1 0.0 +24 H7 -0.9763 0.681 2.9733 H 1 RES1 0.0 +25 H8 0.4624 0.8395 4.011 H 1 RES1 0.0 +26 H9 -0.2359 -0.7673 3.7056 H 1 RES1 0.0 +27 C18 0.6693 -2.4598 2.1179 C.3 1 RES1 0.0 +28 H10 1.2152 -2.7249 3.0475 H 1 RES1 0.0 +29 H11 0.7219 -3.3246 1.4384 H 1 RES1 0.0 +30 H12 -0.3925 -2.3112 2.3849 H 1 RES1 0.0 +31 C19 2.7702 -2.6522 -0.0521 C.3 1 RES1 0.0 +32 H13 2.5668 -2.8283 -1.1238 H 1 RES1 0.0 +33 H14 2.3621 -3.5026 0.5171 H 1 RES1 0.0 +34 H15 3.8703 -2.6635 0.0895 H 1 RES1 0.0 +35 C20 3.6177 -0.1864 -1.3994 C.3 1 RES1 0.0 +36 H16 3.1489 0.3329 -2.2585 H 1 RES1 0.0 +37 H17 3.8377 -1.217 -1.7169 H 1 RES1 0.0 +38 H18 4.5777 0.3191 -1.1714 H 1 RES1 0.0 +39 C21 2.9253 2.3852 -0.3819 C.3 1 RES1 0.0 +40 H19 4.0293 2.3504 -0.2795 H 1 RES1 0.0 +41 H20 2.5849 3.3353 0.0587 H 1 RES1 0.0 +42 H21 2.6928 2.4033 -1.4631 H 1 RES1 0.0 +43 C22 0.9758 2.5875 1.9692 C.3 1 RES1 0.0 +44 H22 1.5156 2.7684 2.9225 H 1 RES1 0.0 +45 H23 -0.1043 2.6014 2.2046 H 1 RES1 0.0 +46 H24 1.1869 3.4303 1.2943 H 1 RES1 0.0 +47 Cl1 0.4385 0.028 -2.5198 Cl 1 RES1 0.0 +48 N1 -1.1621 1.3018 -0.2553 N.pl3 1 RES1 0.0 +49 N2 -1.2024 -1.2825 -0.27 N.pl3 1 RES1 0.0 +50 O1 0.1105 3.048 -0.9716 O.3 1 RES1 0.0 +51 H25 0.0419 3.9796 -1.257 H 1 RES1 0.0 +52 O2 0.0194 -3.0689 -0.9731 O.2 1 RES1 0.0 +53 H26 -0.076 -3.9993 -1.2549 H 1 RES1 0.0 +54 Ru1 0.4633 -0.0124 -0.1419 Ru 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 48 1 +1 1 50 1 +1 2 3 1 +1 2 4 2 +1 4 5 1 +1 4 6 1 +1 6 7 1 +1 6 8 2 +1 8 9 1 +1 8 48 1 +1 9 10 1 +1 9 49 1 +1 10 11 1 +1 10 12 2 +1 12 13 1 +1 12 14 1 +1 14 15 1 +1 14 16 2 +1 16 49 1 +1 16 52 1 +1 17 18 1 +1 17 22 1 +1 17 23 1 +1 17 54 1 +1 18 19 1 +1 18 27 1 +1 18 54 1 +1 19 20 2 +1 19 31 1 +1 20 21 1 +1 20 35 1 +1 21 22 1 +1 21 39 1 +1 21 54 1 +1 22 43 1 +1 23 24 1 +1 23 25 1 +1 23 26 1 +1 27 28 1 +1 27 29 1 +1 27 30 1 +1 31 32 1 +1 31 33 1 +1 31 34 1 +1 35 36 1 +1 35 37 1 +1 35 38 1 +1 39 40 1 +1 39 41 1 +1 39 42 1 +1 43 44 1 +1 43 45 1 +1 43 46 1 +1 47 54 1 +1 48 54 1 +1 49 54 1 +1 50 51 1 +1 52 53 1 +@SUBSTRUCTURE +1 RES1 54 + +" +"61 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Re 0.045000 -1.706400 -1.296200 +O 0.147600 -2.802800 -2.581900 +O -1.453000 -2.470800 -0.174100 +O -1.494100 -0.522400 -1.910000 +O 1.520400 -0.376200 -1.776100 +O 1.522600 -2.326100 -0.051600 +O -0.156500 -0.173600 0.427500 +C -2.589800 -1.773000 -0.254600 +C -2.611100 -0.704900 -1.202400 +C -3.739800 0.131500 -1.291200 +Cl -3.726000 1.431600 -2.448000 +C -4.850000 -0.075500 -0.433600 +Cl -6.216600 1.000700 -0.511700 +C -4.826800 -1.127900 0.504400 +Cl -6.149700 -1.334300 1.622300 +C -3.700600 -1.986800 0.583600 +Cl -3.625600 -3.263000 1.763300 +C 2.649900 -1.615100 -0.139700 +C 3.781600 -1.824000 0.671000 +Cl 3.717400 -3.068600 1.886000 +C 4.917500 -0.987800 0.541800 +Cl 6.289100 -1.221800 1.591800 +C 4.911200 0.075800 -0.383100 +Cl 6.279700 1.145500 -0.503600 +C 3.767000 0.303700 -1.184300 +Cl 3.691400 1.657500 -2.280600 +C 2.650700 -0.544000 -1.080500 +P -0.098900 1.323400 0.775400 +C -1.213600 1.640900 2.183800 +C -0.942800 2.614300 3.164600 +C -1.870800 2.837000 4.194100 +C -3.062700 2.093200 4.243800 +C -3.327900 1.116600 3.269400 +C -2.402100 0.887300 2.242100 +C 1.581700 1.788300 1.291200 +C 2.282900 0.849900 2.078700 +C 3.597700 1.122700 2.476100 +C 4.218000 2.319700 2.080400 +C 3.524100 3.247600 1.289400 +C 2.202800 2.987000 0.893600 +C -0.612400 2.474600 -0.542900 +C -0.047600 2.333100 -1.828700 +C -0.424800 3.220600 -2.845200 +C -1.361100 4.236300 -2.594400 +C -1.931500 4.366800 -1.319200 +C -1.560200 3.487500 -0.291700 +H -0.005000 3.190200 3.123500 +H -1.660300 3.594000 4.965500 +H -3.787900 2.274200 5.052500 +H -4.255300 0.524000 3.295100 +H -2.593900 0.117400 1.482400 +H 1.807200 -0.107300 2.341600 +H 4.157700 0.376200 3.058000 +H 5.265800 2.507500 2.358100 +H 4.023500 4.168700 0.953000 +H 1.662500 3.697500 0.250200 +H 0.674500 1.525300 -2.029700 +H 0.012700 3.104100 -3.848200 +H -1.659300 4.922800 -3.402300 +H -2.681200 5.148500 -1.123200 +H -2.019200 3.580500 0.703500 +","@MOLECULE + +61 67 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Re1 0.045 -1.7064 -1.2962 Re 1 RES1 0.0 +2 O1 0.1476 -2.8028 -2.5819 O.3 1 RES1 0.0 +3 O2 -1.453 -2.4708 -0.1741 O.3 1 RES1 0.0 +4 O3 -1.4941 -0.5224 -1.91 O.3 1 RES1 0.0 +5 O4 1.5204 -0.3762 -1.7761 O.3 1 RES1 0.0 +6 O5 1.5226 -2.3261 -0.0516 O.3 1 RES1 0.0 +7 O6 -0.1565 -0.1736 0.4275 O.3 1 RES1 0.0 +8 C1 -2.5898 -1.773 -0.2546 C.ar 1 RES1 0.0 +9 C2 -2.6111 -0.7049 -1.2024 C.ar 1 RES1 0.0 +10 C3 -3.7398 0.1315 -1.2912 C.ar 1 RES1 0.0 +11 Cl1 -3.726 1.4316 -2.448 Cl 1 RES1 0.0 +12 C4 -4.85 -0.0755 -0.4336 C.ar 1 RES1 0.0 +13 Cl2 -6.2166 1.0007 -0.5117 Cl 1 RES1 0.0 +14 C5 -4.8268 -1.1279 0.5044 C.ar 1 RES1 0.0 +15 Cl3 -6.1497 -1.3343 1.6223 Cl 1 RES1 0.0 +16 C6 -3.7006 -1.9868 0.5836 C.ar 1 RES1 0.0 +17 Cl4 -3.6256 -3.263 1.7633 Cl 1 RES1 0.0 +18 C7 2.6499 -1.6151 -0.1397 C.ar 1 RES1 0.0 +19 C8 3.7816 -1.824 0.671 C.ar 1 RES1 0.0 +20 Cl5 3.7174 -3.0686 1.886 Cl 1 RES1 0.0 +21 C9 4.9175 -0.9878 0.5418 C.ar 1 RES1 0.0 +22 Cl6 6.2891 -1.2218 1.5918 Cl 1 RES1 0.0 +23 C10 4.9112 0.0758 -0.3831 C.ar 1 RES1 0.0 +24 Cl7 6.2797 1.1455 -0.5036 Cl 1 RES1 0.0 +25 C11 3.767 0.3037 -1.1843 C.ar 1 RES1 0.0 +26 Cl8 3.6914 1.6575 -2.2806 Cl 1 RES1 0.0 +27 C12 2.6507 -0.544 -1.0805 C.ar 1 RES1 0.0 +28 P1 -0.0989 1.3234 0.7754 P.3 1 RES1 0.0 +29 C13 -1.2136 1.6409 2.1838 C.ar 1 RES1 0.0 +30 C14 -0.9428 2.6143 3.1646 C.ar 1 RES1 0.0 +31 C15 -1.8708 2.837 4.1941 C.ar 1 RES1 0.0 +32 C16 -3.0627 2.0932 4.2438 C.ar 1 RES1 0.0 +33 C17 -3.3279 1.1166 3.2694 C.ar 1 RES1 0.0 +34 C18 -2.4021 0.8873 2.2421 C.ar 1 RES1 0.0 +35 C19 1.5817 1.7883 1.2912 C.ar 1 RES1 0.0 +36 C20 2.2829 0.8499 2.0787 C.ar 1 RES1 0.0 +37 C21 3.5977 1.1227 2.4761 C.ar 1 RES1 0.0 +38 C22 4.218 2.3197 2.0804 C.ar 1 RES1 0.0 +39 C23 3.5241 3.2476 1.2894 C.ar 1 RES1 0.0 +40 C24 2.2028 2.987 0.8936 C.ar 1 RES1 0.0 +41 C25 -0.6124 2.4746 -0.5429 C.ar 1 RES1 0.0 +42 C26 -0.0476 2.3331 -1.8287 C.ar 1 RES1 0.0 +43 C27 -0.4248 3.2206 -2.8452 C.ar 1 RES1 0.0 +44 C28 -1.3611 4.2363 -2.5944 C.ar 1 RES1 0.0 +45 C29 -1.9315 4.3668 -1.3192 C.ar 1 RES1 0.0 +46 C30 -1.5602 3.4875 -0.2917 C.ar 1 RES1 0.0 +47 H1 -0.005 3.1902 3.1235 H 1 RES1 0.0 +48 H2 -1.6603 3.594 4.9655 H 1 RES1 0.0 +49 H3 -3.7879 2.2742 5.0525 H 1 RES1 0.0 +50 H4 -4.2553 0.524 3.2951 H 1 RES1 0.0 +51 H5 -2.5939 0.1174 1.4824 H 1 RES1 0.0 +52 H6 1.8072 -0.1073 2.3416 H 1 RES1 0.0 +53 H7 4.1577 0.3762 3.058 H 1 RES1 0.0 +54 H8 5.2658 2.5075 2.3581 H 1 RES1 0.0 +55 H9 4.0235 4.1687 0.953 H 1 RES1 0.0 +56 H10 1.6625 3.6975 0.2502 H 1 RES1 0.0 +57 H11 0.6745 1.5253 -2.0297 H 1 RES1 0.0 +58 H12 0.0127 3.1041 -3.8482 H 1 RES1 0.0 +59 H13 -1.6593 4.9228 -3.4023 H 1 RES1 0.0 +60 H14 -2.6812 5.1485 -1.1232 H 1 RES1 0.0 +61 H15 -2.0192 3.5805 0.7035 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 3 1 +1 1 4 1 +1 1 5 1 +1 1 6 1 +1 1 7 1 +1 3 8 1 +1 4 9 1 +1 5 27 1 +1 6 18 1 +1 7 28 1 +1 8 9 2 +1 8 16 1 +1 9 10 1 +1 10 11 1 +1 10 12 2 +1 12 13 1 +1 12 14 1 +1 14 15 1 +1 14 16 2 +1 16 17 1 +1 18 19 2 +1 18 27 1 +1 19 20 1 +1 19 21 1 +1 21 22 1 +1 21 23 2 +1 23 24 1 +1 23 25 1 +1 25 26 1 +1 25 27 2 +1 28 29 1 +1 28 35 1 +1 28 41 1 +1 29 30 2 +1 29 34 1 +1 30 31 1 +1 30 47 1 +1 31 32 2 +1 31 48 1 +1 32 33 1 +1 32 49 1 +1 33 34 2 +1 33 50 1 +1 34 51 1 +1 35 36 2 +1 35 40 1 +1 36 37 1 +1 36 52 1 +1 37 38 2 +1 37 53 1 +1 38 39 1 +1 38 54 1 +1 39 40 2 +1 39 55 1 +1 40 56 1 +1 41 42 2 +1 41 46 1 +1 42 43 1 +1 42 57 1 +1 43 44 2 +1 43 58 1 +1 44 45 1 +1 44 59 1 +1 45 46 2 +1 45 60 1 +1 46 61 1 +@SUBSTRUCTURE +1 RES1 61 + +" +"37 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Zn -2.075600 -0.249800 -1.186800 +Cl -0.346900 -0.981000 -2.329000 +Cl -4.255500 -0.075600 -1.460400 +O 6.266400 1.047200 -0.566500 +O 6.085000 -1.109400 -0.885800 +N -1.597600 1.506300 -0.079000 +N -1.737500 -1.217200 0.693600 +N 0.133900 0.210300 0.887500 +N 5.625400 -0.005700 -0.587900 +C -2.404500 2.582600 -0.115900 +H -3.220400 2.530700 -0.856700 +C -2.232500 3.663600 0.756400 +H -2.894900 4.538000 0.687600 +C -1.222800 3.586800 1.727400 +H -1.076600 4.403700 2.450000 +C -0.421200 2.438700 1.794900 +H 0.342300 2.315500 2.575000 +C -0.628700 1.418400 0.850800 +C -0.549700 -0.972900 1.275400 +C -0.035000 -1.818600 2.278400 +H 0.934300 -1.592700 2.743600 +C -0.794100 -2.927600 2.665000 +H -0.415900 -3.603600 3.446600 +C -2.050700 -3.152800 2.074200 +H -2.677700 -4.005800 2.368800 +C -2.488600 -2.261000 1.091700 +H -3.459200 -2.361700 0.577100 +C 1.496700 0.180700 0.512000 +C 2.292000 1.350800 0.508000 +H 1.862100 2.322400 0.781100 +C 3.638400 1.290200 0.141100 +H 4.274600 2.184900 0.135700 +C 4.197800 0.059700 -0.222400 +C 3.419700 -1.106200 -0.263700 +H 3.881500 -2.042700 -0.602800 +C 2.072700 -1.042300 0.087100 +H 1.439400 -1.931400 -0.024300 +","@MOLECULE + +37 40 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Zn1 -2.0756 -0.2498 -1.1868 Zn 1 RES1 0.0 +2 Cl1 -0.3469 -0.981 -2.329 Cl 1 RES1 0.0 +3 Cl2 -4.2555 -0.0756 -1.4604 Cl 1 RES1 0.0 +4 O1 6.2664 1.0472 -0.5665 O.2 1 RES1 0.0 +5 O2 6.085 -1.1094 -0.8858 O.2 1 RES1 0.0 +6 N1 -1.5976 1.5063 -0.079 N.pl3 1 RES1 0.0 +7 N2 -1.7375 -1.2172 0.6936 N.pl3 1 RES1 0.0 +8 N3 0.1339 0.2103 0.8875 N.pl3 1 RES1 0.0 +9 N4 5.6254 -0.0057 -0.5879 N.pl3 1 RES1 0.0 +10 C1 -2.4045 2.5826 -0.1159 C.3 1 RES1 0.0 +11 H1 -3.2204 2.5307 -0.8567 H 1 RES1 0.0 +12 C2 -2.2325 3.6636 0.7564 C.2 1 RES1 0.0 +13 H2 -2.8949 4.538 0.6876 H 1 RES1 0.0 +14 C3 -1.2228 3.5868 1.7274 C.2 1 RES1 0.0 +15 H3 -1.0766 4.4037 2.45 H 1 RES1 0.0 +16 C4 -0.4212 2.4387 1.7949 C.2 1 RES1 0.0 +17 H4 0.3423 2.3155 2.575 H 1 RES1 0.0 +18 C5 -0.6287 1.4184 0.8508 C.2 1 RES1 0.0 +19 C6 -0.5497 -0.9729 1.2754 C.3 1 RES1 0.0 +20 C7 -0.035 -1.8186 2.2784 C.2 1 RES1 0.0 +21 H5 0.9343 -1.5927 2.7436 H 1 RES1 0.0 +22 C8 -0.7941 -2.9276 2.665 C.2 1 RES1 0.0 +23 H6 -0.4159 -3.6036 3.4466 H 1 RES1 0.0 +24 C9 -2.0507 -3.1528 2.0742 C.2 1 RES1 0.0 +25 H7 -2.6777 -4.0058 2.3688 H 1 RES1 0.0 +26 C10 -2.4886 -2.261 1.0917 C.2 1 RES1 0.0 +27 H8 -3.4592 -2.3617 0.5771 H 1 RES1 0.0 +28 C11 1.4967 0.1807 0.512 C.ar 1 RES1 0.0 +29 C12 2.292 1.3508 0.508 C.ar 1 RES1 0.0 +30 H9 1.8621 2.3224 0.7811 H 1 RES1 0.0 +31 C13 3.6384 1.2902 0.1411 C.ar 1 RES1 0.0 +32 H10 4.2746 2.1849 0.1357 H 1 RES1 0.0 +33 C14 4.1978 0.0597 -0.2224 C.ar 1 RES1 0.0 +34 C15 3.4197 -1.1062 -0.2637 C.ar 1 RES1 0.0 +35 H11 3.8815 -2.0427 -0.6028 H 1 RES1 0.0 +36 C16 2.0727 -1.0423 0.0871 C.ar 1 RES1 0.0 +37 H12 1.4394 -1.9314 -0.0243 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 3 1 +1 1 6 1 +1 1 7 1 +1 4 9 2 +1 5 9 2 +1 6 10 1 +1 6 18 1 +1 7 19 1 +1 7 26 1 +1 8 18 1 +1 8 19 1 +1 8 28 1 +1 9 33 1 +1 10 11 1 +1 10 12 1 +1 12 13 1 +1 12 14 2 +1 14 15 1 +1 14 16 1 +1 16 17 1 +1 16 18 2 +1 19 20 1 +1 20 21 1 +1 20 22 2 +1 22 23 1 +1 22 24 1 +1 24 25 1 +1 24 26 2 +1 26 27 1 +1 28 29 2 +1 28 36 1 +1 29 30 1 +1 29 31 1 +1 31 32 1 +1 31 33 2 +1 33 34 1 +1 34 35 1 +1 34 36 2 +1 36 37 1 +@SUBSTRUCTURE +1 RES1 37 + +" +"48 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Rh 0.033400 1.177600 -0.275300 +O 2.257100 3.238700 -0.525000 +O -2.087900 3.333000 -0.625200 +N 2.370500 -1.043800 0.026600 +N 1.128000 -1.142100 -1.745900 +N -1.241600 -1.039400 -1.759300 +N -2.494200 -0.849800 -0.000400 +C 1.336600 -0.417400 -0.602300 +C 2.809400 -2.129600 -0.731100 +H 3.641700 -2.766900 -0.426900 +C 2.016400 -2.202900 -1.844600 +H 2.022900 -2.904200 -2.684600 +C 2.985800 -0.636300 1.343900 +C 4.038000 0.450400 1.072200 +H 4.814800 0.081200 0.373700 +H 4.536700 0.732400 2.019600 +H 3.588800 1.362700 0.641000 +C 1.875700 -0.133700 2.277600 +H 1.140000 -0.935900 2.486300 +H 1.331800 0.734900 1.849300 +H 2.323500 0.184800 3.238200 +C 3.655300 -1.863300 1.982800 +H 2.947900 -2.709500 2.088600 +H 4.004200 -1.585500 2.995200 +H 4.546900 -2.203000 1.420700 +C -0.043600 -0.889500 -2.576500 +H -0.070900 -1.612500 -3.411100 +H 0.005100 0.145300 -2.967800 +C -1.399300 -0.310100 -0.608300 +C -3.005900 -1.895900 -0.770000 +H -3.889600 -2.466400 -0.476900 +C -2.212000 -2.023200 -1.876300 +H -2.274100 -2.705600 -2.729600 +C -2.979700 -0.603500 1.409300 +C -2.569700 0.785300 1.898700 +H -3.058600 1.586700 1.313500 +H -1.470800 0.935000 1.861700 +H -2.890700 0.893100 2.952200 +C -2.335800 -1.688500 2.291500 +H -2.607000 -2.706000 1.947100 +H -2.681300 -1.577700 3.337500 +H -1.231300 -1.595900 2.279000 +C -4.513200 -0.712100 1.431800 +H -4.974800 -0.019000 0.701400 +H -4.876900 -0.437300 2.439900 +H -4.875500 -1.738600 1.230800 +C 1.425000 2.451900 -0.367300 +C -1.282700 2.530200 -0.420600 +","@MOLECULE + +48 50 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Rh1 0.0334 1.1776 -0.2753 Rh 1 RES1 0.0 +2 O1 2.2571 3.2387 -0.525 O.3 1 RES1 0.0 +3 O2 -2.0879 3.333 -0.6252 O.3 1 RES1 0.0 +4 N1 2.3705 -1.0438 0.0266 N.pl3 1 RES1 0.0 +5 N2 1.128 -1.1421 -1.7459 N.pl3 1 RES1 0.0 +6 N3 -1.2416 -1.0394 -1.7593 N.pl3 1 RES1 0.0 +7 N4 -2.4942 -0.8498 -0.0004 N.pl3 1 RES1 0.0 +8 C1 1.3366 -0.4174 -0.6023 C.3 1 RES1 0.0 +9 C2 2.8094 -2.1296 -0.7311 C.2 1 RES1 0.0 +10 H1 3.6417 -2.7669 -0.4269 H 1 RES1 0.0 +11 C3 2.0164 -2.2029 -1.8446 C.2 1 RES1 0.0 +12 H2 2.0229 -2.9042 -2.6846 H 1 RES1 0.0 +13 C4 2.9858 -0.6363 1.3439 C.3 1 RES1 0.0 +14 C5 4.038 0.4504 1.0722 C.3 1 RES1 0.0 +15 H3 4.8148 0.0812 0.3737 H 1 RES1 0.0 +16 H4 4.5367 0.7324 2.0196 H 1 RES1 0.0 +17 H5 3.5888 1.3627 0.641 H 1 RES1 0.0 +18 C6 1.8757 -0.1337 2.2776 C.3 1 RES1 0.0 +19 H6 1.14 -0.9359 2.4863 H 1 RES1 0.0 +20 H7 1.3318 0.7349 1.8493 H 1 RES1 0.0 +21 H8 2.3235 0.1848 3.2382 H 1 RES1 0.0 +22 C7 3.6553 -1.8633 1.9828 C.3 1 RES1 0.0 +23 H9 2.9479 -2.7095 2.0886 H 1 RES1 0.0 +24 H10 4.0042 -1.5855 2.9952 H 1 RES1 0.0 +25 H11 4.5469 -2.203 1.4207 H 1 RES1 0.0 +26 C8 -0.0436 -0.8895 -2.5765 C.3 1 RES1 0.0 +27 H12 -0.0709 -1.6125 -3.4111 H 1 RES1 0.0 +28 H13 0.0051 0.1453 -2.9678 H 1 RES1 0.0 +29 C9 -1.3993 -0.3101 -0.6083 C.2 1 RES1 0.0 +30 C10 -3.0059 -1.8959 -0.77 C.2 1 RES1 0.0 +31 H14 -3.8896 -2.4664 -0.4769 H 1 RES1 0.0 +32 C11 -2.212 -2.0232 -1.8763 C.2 1 RES1 0.0 +33 H15 -2.2741 -2.7056 -2.7296 H 1 RES1 0.0 +34 C12 -2.9797 -0.6035 1.4093 C.3 1 RES1 0.0 +35 C13 -2.5697 0.7853 1.8987 C.3 1 RES1 0.0 +36 H16 -3.0586 1.5867 1.3135 H 1 RES1 0.0 +37 H17 -1.4708 0.935 1.8617 H 1 RES1 0.0 +38 H18 -2.8907 0.8931 2.9522 H 1 RES1 0.0 +39 C14 -2.3358 -1.6885 2.2915 C.3 1 RES1 0.0 +40 H19 -2.607 -2.706 1.9471 H 1 RES1 0.0 +41 H20 -2.6813 -1.5777 3.3375 H 1 RES1 0.0 +42 H21 -1.2313 -1.5959 2.279 H 1 RES1 0.0 +43 C15 -4.5132 -0.7121 1.4318 C.3 1 RES1 0.0 +44 H22 -4.9748 -0.019 0.7014 H 1 RES1 0.0 +45 H23 -4.8769 -0.4373 2.4399 H 1 RES1 0.0 +46 H24 -4.8755 -1.7386 1.2308 H 1 RES1 0.0 +47 C16 1.425 2.4519 -0.3673 C.3 1 RES1 0.0 +48 C17 -1.2827 2.5302 -0.4206 C.3 1 RES1 0.0 +@BOND +1 1 8 1 +1 1 29 2 +1 1 47 1 +1 1 48 1 +1 2 47 1 +1 3 48 1 +1 4 8 1 +1 4 9 1 +1 4 13 1 +1 5 8 1 +1 5 11 1 +1 5 26 1 +1 6 26 1 +1 6 29 1 +1 6 32 1 +1 7 29 1 +1 7 30 1 +1 7 34 1 +1 9 10 1 +1 9 11 2 +1 11 12 1 +1 13 14 1 +1 13 18 1 +1 13 22 1 +1 14 15 1 +1 14 16 1 +1 14 17 1 +1 18 19 1 +1 18 20 1 +1 18 21 1 +1 22 23 1 +1 22 24 1 +1 22 25 1 +1 26 27 1 +1 26 28 1 +1 30 31 1 +1 30 32 2 +1 32 33 1 +1 34 35 1 +1 34 39 1 +1 34 43 1 +1 35 36 1 +1 35 37 1 +1 35 38 1 +1 39 40 1 +1 39 41 1 +1 39 42 1 +1 43 44 1 +1 43 45 1 +1 43 46 1 +@SUBSTRUCTURE +1 RES1 48 + +" +"75 +10/06/2025 18:27, XYZ structure generated by mol3D Class, molSimplify +Ti -0.000000 0.933900 -0.000000 +Cl -0.786400 -0.659000 -1.538300 +N 4.754200 -0.646200 -0.024300 +C 2.133200 0.628900 -1.117600 +C 1.301000 1.421200 -1.968400 +C 0.994700 2.638900 -1.308900 +C 1.654300 2.621000 -0.037100 +C 2.355600 1.392300 0.071600 +C 2.658000 -0.743900 -1.410100 +C 4.194600 -0.853500 -1.350200 +C 4.498700 -1.736100 0.932000 +C 4.451200 -1.222700 2.376100 +C 5.435700 -2.952700 0.798600 +C 6.081700 -0.009300 0.017500 +C 5.957300 1.512800 -0.147200 +C 7.120000 -0.580600 -0.970800 +H 0.918400 1.106500 -2.945400 +H 0.398900 3.460300 -1.719800 +H 1.658200 3.429300 0.701500 +H 2.954100 1.051500 0.919500 +H 2.313600 -1.057900 -2.415200 +H 2.199200 -1.460200 -0.697000 +H 4.482300 -1.843700 -1.791700 +H 4.621500 -0.088500 -2.030400 +H 3.470000 -2.086200 0.703600 +H 5.424300 -0.799300 2.701800 +H 4.206300 -2.051100 3.070800 +H 3.668800 -0.447600 2.490200 +H 5.081600 -3.790000 1.433900 +H 6.469600 -2.708600 1.120300 +H 5.478400 -3.323100 -0.245900 +H 6.472500 -0.194300 1.040000 +H 6.951700 2.003000 -0.108300 +H 5.320200 1.944400 0.648700 +H 5.494700 1.772800 -1.122300 +H 8.118100 -0.134900 -0.782900 +H 6.853300 -0.349300 -2.022500 +H 7.216500 -1.679100 -0.880500 +Cl 0.786300 -0.658900 1.538400 +C -2.133200 0.629000 1.117600 +C -1.301000 1.421300 1.968400 +C -0.994700 2.639000 1.308800 +H -0.918400 1.106700 2.945400 +C -1.654300 2.621000 0.037000 +C -2.355700 1.392400 -0.071700 +C -2.658000 -0.743800 1.410200 +C -4.194700 -0.853400 1.350300 +N -4.754200 -0.646300 0.024300 +C -4.498700 -1.736200 -0.931900 +C -4.451100 -1.223000 -2.376000 +H -5.424100 -0.799700 -2.701900 +H -4.206200 -2.051500 -3.070600 +H -3.668500 -0.448000 -2.490100 +C -5.435800 -2.952800 -0.798400 +H -5.081700 -3.790200 -1.433500 +H -6.469600 -2.708700 -1.120200 +H -5.478600 -3.323100 0.246200 +H -3.470000 -2.086400 -0.703300 +C -6.081700 -0.009300 -0.017600 +C -5.957100 1.512900 0.146900 +H -6.951500 2.003100 0.107800 +H -5.320000 1.944300 -0.649100 +H -5.494500 1.773000 1.121900 +C -7.120000 -0.580300 0.970800 +H -8.118100 -0.134600 0.782700 +H -6.853400 -0.348800 2.022400 +H -7.216600 -1.678800 0.880600 +H -6.472500 -0.194400 -1.040100 +H -4.482400 -1.843500 1.791900 +H -4.621500 -0.088300 2.030400 +H -2.313700 -1.057700 2.415300 +H -2.199200 -1.460200 0.697100 +H -1.658200 3.429300 -0.701600 +H -2.954100 1.051500 -0.919500 +H -0.398900 3.460400 1.719600 +","@MOLECULE + +75 78 1 +SMALL +PartialCharges +**** +Generated from molSimplify + +@ATOM +1 Ti1 -0.0 0.9339 -0.0 Ti 1 RES1 0.0 +2 Cl1 -0.7864 -0.659 -1.5383 Cl 1 RES1 0.0 +3 N1 4.7542 -0.6462 -0.0243 N.3 1 RES1 0.0 +4 C1 2.1332 0.6289 -1.1176 C.2 1 RES1 0.0 +5 C2 1.301 1.4212 -1.9684 C.2 1 RES1 0.0 +6 C3 0.9947 2.6389 -1.3089 C.3 1 RES1 0.0 +7 C4 1.6543 2.621 -0.0371 C.3 1 RES1 0.0 +8 C5 2.3556 1.3923 0.0716 C.3 1 RES1 0.0 +9 C6 2.658 -0.7439 -1.4101 C.3 1 RES1 0.0 +10 C7 4.1946 -0.8535 -1.3502 C.3 1 RES1 0.0 +11 C8 4.4987 -1.7361 0.932 C.3 1 RES1 0.0 +12 C9 4.4512 -1.2227 2.3761 C.3 1 RES1 0.0 +13 C10 5.4357 -2.9527 0.7986 C.3 1 RES1 0.0 +14 C11 6.0817 -0.0093 0.0175 C.3 1 RES1 0.0 +15 C12 5.9573 1.5128 -0.1472 C.3 1 RES1 0.0 +16 C13 7.12 -0.5806 -0.9708 C.3 1 RES1 0.0 +17 H1 0.9184 1.1065 -2.9454 H 1 RES1 0.0 +18 H2 0.3989 3.4603 -1.7198 H 1 RES1 0.0 +19 H3 1.6582 3.4293 0.7015 H 1 RES1 0.0 +20 H4 2.9541 1.0515 0.9195 H 1 RES1 0.0 +21 H5 2.3136 -1.0579 -2.4152 H 1 RES1 0.0 +22 H6 2.1992 -1.4602 -0.697 H 1 RES1 0.0 +23 H7 4.4823 -1.8437 -1.7917 H 1 RES1 0.0 +24 H8 4.6215 -0.0885 -2.0304 H 1 RES1 0.0 +25 H9 3.47 -2.0862 0.7036 H 1 RES1 0.0 +26 H10 5.4243 -0.7993 2.7018 H 1 RES1 0.0 +27 H11 4.2063 -2.0511 3.0708 H 1 RES1 0.0 +28 H12 3.6688 -0.4476 2.4902 H 1 RES1 0.0 +29 H13 5.0816 -3.79 1.4339 H 1 RES1 0.0 +30 H14 6.4696 -2.7086 1.1203 H 1 RES1 0.0 +31 H15 5.4784 -3.3231 -0.2459 H 1 RES1 0.0 +32 H16 6.4725 -0.1943 1.04 H 1 RES1 0.0 +33 H17 6.9517 2.003 -0.1083 H 1 RES1 0.0 +34 H18 5.3202 1.9444 0.6487 H 1 RES1 0.0 +35 H19 5.4947 1.7728 -1.1223 H 1 RES1 0.0 +36 H20 8.1181 -0.1349 -0.7829 H 1 RES1 0.0 +37 H21 6.8533 -0.3493 -2.0225 H 1 RES1 0.0 +38 H22 7.2165 -1.6791 -0.8805 H 1 RES1 0.0 +39 Cl2 0.7863 -0.6589 1.5384 Cl 1 RES1 0.0 +40 C14 -2.1332 0.629 1.1176 C.2 1 RES1 0.0 +41 C15 -1.301 1.4213 1.9684 C.2 1 RES1 0.0 +42 C16 -0.9947 2.639 1.3088 C.3 1 RES1 0.0 +43 H23 -0.9184 1.1067 2.9454 H 1 RES1 0.0 +44 C17 -1.6543 2.621 0.037 C.3 1 RES1 0.0 +45 C18 -2.3557 1.3924 -0.0717 C.3 1 RES1 0.0 +46 C19 -2.658 -0.7438 1.4102 C.3 1 RES1 0.0 +47 C20 -4.1947 -0.8534 1.3503 C.3 1 RES1 0.0 +48 N2 -4.7542 -0.6463 0.0243 N.3 1 RES1 0.0 +49 C21 -4.4987 -1.7362 -0.9319 C.3 1 RES1 0.0 +50 C22 -4.4511 -1.223 -2.376 C.3 1 RES1 0.0 +51 H24 -5.4241 -0.7997 -2.7019 H 1 RES1 0.0 +52 H25 -4.2062 -2.0515 -3.0706 H 1 RES1 0.0 +53 H26 -3.6685 -0.448 -2.4901 H 1 RES1 0.0 +54 C23 -5.4358 -2.9528 -0.7984 C.3 1 RES1 0.0 +55 H27 -5.0817 -3.7902 -1.4335 H 1 RES1 0.0 +56 H28 -6.4696 -2.7087 -1.1202 H 1 RES1 0.0 +57 H29 -5.4786 -3.3231 0.2462 H 1 RES1 0.0 +58 H30 -3.47 -2.0864 -0.7033 H 1 RES1 0.0 +59 C24 -6.0817 -0.0093 -0.0176 C.3 1 RES1 0.0 +60 C25 -5.9571 1.5129 0.1469 C.3 1 RES1 0.0 +61 H31 -6.9515 2.0031 0.1078 H 1 RES1 0.0 +62 H32 -5.32 1.9443 -0.6491 H 1 RES1 0.0 +63 H33 -5.4945 1.773 1.1219 H 1 RES1 0.0 +64 C26 -7.12 -0.5803 0.9708 C.3 1 RES1 0.0 +65 H34 -8.1181 -0.1346 0.7827 H 1 RES1 0.0 +66 H35 -6.8534 -0.3488 2.0224 H 1 RES1 0.0 +67 H36 -7.2166 -1.6788 0.8806 H 1 RES1 0.0 +68 H37 -6.4725 -0.1944 -1.0401 H 1 RES1 0.0 +69 H38 -4.4824 -1.8435 1.7919 H 1 RES1 0.0 +70 H39 -4.6215 -0.0883 2.0304 H 1 RES1 0.0 +71 H40 -2.3137 -1.0577 2.4153 H 1 RES1 0.0 +72 H41 -2.1992 -1.4602 0.6971 H 1 RES1 0.0 +73 H42 -1.6582 3.4293 -0.7016 H 1 RES1 0.0 +74 H43 -2.9541 1.0515 -0.9195 H 1 RES1 0.0 +75 H44 -0.3989 3.4604 1.7196 H 1 RES1 0.0 +@BOND +1 1 2 1 +1 1 6 1 +1 1 7 1 +1 1 39 1 +1 1 42 1 +1 1 44 1 +1 3 10 1 +1 3 11 1 +1 3 14 1 +1 4 5 2 +1 4 8 1 +1 4 9 1 +1 5 6 1 +1 5 17 1 +1 6 7 1 +1 6 18 1 +1 7 8 1 +1 7 19 1 +1 8 20 1 +1 9 10 1 +1 9 21 1 +1 9 22 1 +1 10 23 1 +1 10 24 1 +1 11 12 1 +1 11 13 1 +1 11 25 1 +1 12 26 1 +1 12 27 1 +1 12 28 1 +1 13 29 1 +1 13 30 1 +1 13 31 1 +1 14 15 1 +1 14 16 1 +1 14 32 1 +1 15 33 1 +1 15 34 1 +1 15 35 1 +1 16 36 1 +1 16 37 1 +1 16 38 1 +1 40 41 2 +1 40 45 1 +1 40 46 1 +1 41 42 1 +1 41 43 1 +1 42 44 1 +1 42 75 1 +1 44 45 1 +1 44 73 1 +1 45 74 1 +1 46 47 1 +1 46 71 1 +1 46 72 1 +1 47 48 1 +1 47 69 1 +1 47 70 1 +1 48 49 1 +1 48 59 1 +1 49 50 1 +1 49 54 1 +1 49 58 1 +1 50 51 1 +1 50 52 1 +1 50 53 1 +1 54 55 1 +1 54 56 1 +1 54 57 1 +1 59 60 1 +1 59 64 1 +1 59 68 1 +1 60 61 1 +1 60 62 1 +1 60 63 1 +1 64 65 1 +1 64 66 1 +1 64 67 1 +@SUBSTRUCTURE +1 RES1 75 + +" diff --git a/tests/test_csv_dataset.py b/tests/test_csv_dataset.py index 94187da..a057c33 100644 --- a/tests/test_csv_dataset.py +++ b/tests/test_csv_dataset.py @@ -2,6 +2,10 @@ from __future__ import annotations +import pandas as pd +import pytest + +from step_up.data import featurize from step_up.data.csv_dataset import CSVMoleculeDataset @@ -27,8 +31,8 @@ def test_qm9_smoke(qm9_path) -> None: def test_tmqmg_smoke(tmqmg_path) -> None: - ds = CSVMoleculeDataset(tmqmg_path, source="mol2", subset_size=20) - assert len(ds) == 20 + ds = CSVMoleculeDataset(tmqmg_path, source="mol2", subset_size=5) + assert len(ds) == 5 elements_seen: set[int] = set() for i in range(len(ds)): g = ds[i] @@ -41,12 +45,52 @@ def test_tmqmg_smoke(tmqmg_path) -> None: def test_bostmc_smoke(bostmc_path) -> None: - ds = CSVMoleculeDataset(bostmc_path, source="mol2", subset_size=5) - assert len(ds) == 5 + ds = CSVMoleculeDataset(bostmc_path, source="mol2") elements_seen: set[int] = set() for i in range(len(ds)): g = ds[i] _check_graph_dict(g) elements_seen.update(g["node_type"]) - # BOSTMC is d-block-only by construction. + # Every complex in the fixture contains a d-block metal. assert max(elements_seen) >= 20, f"no transition metals seen: {sorted(elements_seen)}" + + +def test_bostmc_spin_filter(bostmc_path) -> None: + """The filter_column knob should leave only matching rows in the dataset.""" + unfiltered = CSVMoleculeDataset(bostmc_path, source="mol2", validate=False) + assert len(unfiltered) > 0 + singlets = CSVMoleculeDataset( + bostmc_path, source="mol2", filter_column="spinmult", filter_value=1 + ) + doublets = CSVMoleculeDataset( + bostmc_path, source="mol2", filter_column="spinmult", filter_value=2 + ) + # The mini fixture is built to contain both — filter must split them. + assert len(singlets) > 0 + assert len(doublets) > 0 + assert len(singlets) + len(doublets) <= len(unfiltered) + + +def test_missing_submodule_error_is_not_swallowed(qm9_path, monkeypatch, tmp_path) -> None: + """Without the ReBind checkout, validation must surface the submodule error. + + Otherwise every row is counted as a featurization failure and the user sees + a misleading "Validation dropped all rows" error instead. + """ + monkeypatch.setattr(featurize, "_rebind_utils", None) + monkeypatch.setattr(featurize, "_REBIND_UTILS_PATH", tmp_path / "missing" / "utils.py") + with pytest.raises(FileNotFoundError, match="git submodule update --init --recursive"): + CSVMoleculeDataset(qm9_path, source="smiles", subset_size=2) + + +def test_split_keys_come_from_the_csv_not_dataset_positions(bostmc_path) -> None: + """Keys are CSV row numbers (or id_column values), unchanged by filtering.""" + df = pd.read_csv(bostmc_path) + doublet_rows = df.index[df["spinmult"] == 2] + assert doublet_rows[0] > 0, "fixture should have doublets after the first row" + by_row = CSVMoleculeDataset(bostmc_path, "mol2", filter_column="spinmult", filter_value=2) + assert by_row.split_keys() == [str(i) for i in doublet_rows] + by_id = CSVMoleculeDataset( + bostmc_path, "mol2", filter_column="spinmult", filter_value=2, id_column="refcode" + ) + assert by_id.split_keys() == df.loc[doublet_rows, "refcode"].tolist() diff --git a/tests/test_forward.py b/tests/test_forward.py index 1c35d8a..025de72 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -6,13 +6,14 @@ from torch.utils.data import DataLoader, Subset from step_up.data.csv_dataset import CSVMoleculeDataset -from step_up.models.rebind import Collator, build_rebind +from step_up.models import rebind +from step_up.models.rebind import build_rebind, get_collator def test_forward_tiny_qm9(qm9_path) -> None: ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=8) subset = Subset(ds, [0, 1, 2, 3]) - loader = DataLoader(subset, batch_size=4, shuffle=False, collate_fn=Collator()) + loader = DataLoader(subset, batch_size=4, shuffle=False, collate_fn=get_collator()()) batch = next(iter(loader)) model = build_rebind(n_layers=2, d_model=32, d_ffn=64, n_head=4) @@ -29,11 +30,65 @@ def test_forward_tiny_qm9(qm9_path) -> None: assert torch.isfinite(out.conformer_hat).all() +def test_forward_with_backward(qm9_path) -> None: + """End-to-end train-mode forward+backward through the patched REBIND.forward. + + Specifically exercises the residual_head path that uses + ``inputs["pred_conformation"] = node_embedding`` as ``conformer_base`` — + catches future regressions where someone might be tempted to switch this + to ``conformer_cache`` (which would crash on a shape mismatch). + """ + ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=8) + subset = Subset(ds, [0, 1, 2, 3]) + loader = DataLoader(subset, batch_size=4, shuffle=False, collate_fn=get_collator()()) + batch = next(iter(loader)) + + model = build_rebind(n_layers=2, d_model=32, d_ffn=64, n_head=4) + model.train() + out = model(**batch) + assert torch.isfinite(out.loss) + out.loss.backward() + # At least one parameter must have a finite, nonzero gradient. + finite_grad = any( + p.grad is not None and torch.isfinite(p.grad).all() and (p.grad != 0).any() + for p in model.parameters() + ) + assert finite_grad, "no parameter received a finite, nonzero gradient" + + +def test_patched_forward_matches_upstream(qm9_path, monkeypatch) -> None: + """The patched model must reproduce upstream ReBind's outputs exactly. + + Upstream's in-place writes only break autograd, so under ``no_grad`` the + original methods run and the two forwards can be compared directly. This + guards the residual head's ``conformer_base``, which upstream builds through + an in-place write in its decoder. The LJ distance clamp is the one intended + numerical difference, so it is disabled here. + """ + ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=8) + subset = Subset(ds, [0, 1, 2, 3]) + loader = DataLoader(subset, batch_size=4, shuffle=False, collate_fn=get_collator()()) + batch = next(iter(loader)) + + torch.manual_seed(0) + model = build_rebind(n_layers=2, d_model=32, d_ffn=64, n_head=4) + model.eval() + monkeypatch.setattr(rebind, "_LJ_D_MIN", 0.0) + with torch.no_grad(): + patched = model(**batch) + for cls, upstream_forward in rebind._UPSTREAM_FORWARDS.items(): + monkeypatch.setattr(cls, "forward", upstream_forward) + upstream = model(**batch) + + torch.testing.assert_close(patched.loss, upstream.loss) + torch.testing.assert_close(patched.conformer_hat, upstream.conformer_hat) + + def test_lj_patch_supports_heavy_z(tmqmg_path) -> None: """Verify that the LJ patch lets tmQMg (Z up to 80) pass through Collator.""" ds = CSVMoleculeDataset(tmqmg_path, source="mol2", subset_size=2) subset = Subset(ds, [0, 1]) - loader = DataLoader(subset, batch_size=2, shuffle=False, collate_fn=Collator()) + loader = DataLoader(subset, batch_size=2, shuffle=False, collate_fn=get_collator()()) # This would KeyError pre-patch on transition-metal indices. batch = next(iter(loader)) assert "sigma" in batch and "epsilon" in batch diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 3ae92ba..cfc4afd 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -2,9 +2,12 @@ from __future__ import annotations +import pytest import torch +from step_up.data.csv_dataset import CSVMoleculeDataset from step_up.eval.metrics import cdist_mae, cdist_rmse, coord_rmsd, per_element_dmae +from step_up.models.rebind import get_collator def _toy_batch() -> tuple[torch.Tensor, torch.Tensor]: @@ -38,9 +41,41 @@ def test_dmae_positive_on_perturbation() -> None: def test_per_element_dmae_groups_by_z() -> None: coords, mask = _toy_batch() pred = coords + 0.5 # uniform shift; cdist is translation-invariant -> 0 - # Atomic numbers: first mol = C,N,O (5,6,7); second mol = H,H,H,H (0). - z = torch.tensor([[5, 6, 7, 0], [0, 0, 0, 0]], dtype=torch.long) + # True atomic numbers: first mol = C, N, O (Z = 6, 7, 8); padding atom is + # whatever — the function uses node_mask, not a sentinel Z. Second mol = + # H, H, H, H (Z = 1) — H must NOT be silently dropped just because Z=1 + # is small. + z = torch.tensor([[6, 7, 8, 0], [1, 1, 1, 1]], dtype=torch.long) per = per_element_dmae(pred, coords, mask, z) # Uniform translation leaves pairwise distances unchanged -> 0 per element. + assert set(per.keys()) == {6, 7, 8, 1}, f"hydrogen must appear in keys: {per.keys()}" for z_val, v in per.items(): assert v == 0.0, f"element {z_val} should have 0 D-MAE under translation" + + +def test_per_element_dmae_nonzero_on_perturbation() -> None: + coords, mask = _toy_batch() + z = torch.tensor([[6, 7, 8, 0], [1, 1, 1, 1]], dtype=torch.long) + noisy = coords + 0.1 * torch.randn_like(coords) * mask.unsqueeze(-1) + per = per_element_dmae(noisy, coords, mask, z) + # All real elements should pick up some error. + for z_val in (1, 6, 7, 8): + assert per[z_val] > 0.0, f"element {z_val} should have nonzero D-MAE" + + +def test_per_element_dmae_takes_collated_node_type(qm9_path) -> None: + """A collated batch's node_type is already Z (H = 1) and can be passed directly.""" + ds = CSVMoleculeDataset(qm9_path, source="smiles", subset_size=4) + batch = get_collator()()([ds[i] for i in range(len(ds))]) + coords = batch["conformer"] + per = per_element_dmae(coords, coords, batch["node_mask"], batch["node_type"]) + assert 1 in per, f"hydrogen missing, so node_type wasn't read as true Z: {sorted(per)}" + assert set(per) <= {1, 6, 7, 8, 9} # QM9 elements: H, C, N, O, F + + +def test_per_element_dmae_rejects_z_minus_1_indices() -> None: + coords, mask = _toy_batch() + # Graph-dict style Z - 1 indices: C, N, O (+ padding), then four hydrogens as 0. + z_minus_1 = torch.tensor([[5, 6, 7, 0], [0, 0, 0, 0]], dtype=torch.long) + with pytest.raises(ValueError, match="true atomic numbers"): + per_element_dmae(coords, coords, mask, z_minus_1) diff --git a/tests/test_mol2.py b/tests/test_mol2.py new file mode 100644 index 0000000..1dfe9d7 --- /dev/null +++ b/tests/test_mol2.py @@ -0,0 +1,61 @@ +"""Ring and aromaticity features from the direct MOL2 parser.""" + +from __future__ import annotations + +from step_up.data.mol2 import mol2_to_graph_dict + +# Column positions in ``node_attr`` (see the ``step_up.data.mol2`` docstring). +IS_AROMATIC = 7 +IS_IN_RING = 8 + +# Synthetic complex: Co bound to a pyridine N, a chloride, and both carbons of an +# ethylene (eta-2). It has two independent cycles, the aromatic pyridine ring and +# the Co-C=C metallacycle, while the Co-N and Co-Cl bonds are bridges. Atom +# order: Co, N, C x5 (pyridine), Cl, C x2 (ethylene). +SYNTHETIC_COMPLEX = """\ +@MOLECULE +synthetic +10 11 1 +SMALL +NO_CHARGES + +@ATOM +1 Co1 0.000 0.000 0.000 Co.oh +2 N1 2.000 0.000 0.000 N.ar +3 C1 2.700 1.200 0.000 C.ar +4 C2 4.100 1.200 0.000 C.ar +5 C3 4.800 0.000 0.000 C.ar +6 C4 4.100 -1.200 0.000 C.ar +7 C5 2.700 -1.200 0.000 C.ar +8 Cl1 -2.200 0.000 0.000 Cl +9 C6 0.000 1.400 1.400 C.2 +10 C7 0.000 0.000 2.000 C.2 +@BOND +1 1 2 1 +2 2 3 ar +3 3 4 ar +4 4 5 ar +5 5 6 ar +6 6 7 ar +7 7 2 ar +8 1 8 1 +9 1 9 1 +10 1 10 1 +11 9 10 2 +""" + + +def test_ring_and_aromatic_flags() -> None: + g = mol2_to_graph_dict(SYNTHETIC_COMPLEX) + # "Co.oh" carries a geometry suffix; the element must still parse as Co (Z=27). + assert g["node_type"][0] == 26 + assert [atom[IS_AROMATIC] for atom in g["node_attr"]] == [0, 1, 1, 1, 1, 1, 1, 0, 0, 0] + # Ring membership comes from bridge detection, so the metallacycle counts as a + # ring and the chloride (a bridge) does not. + assert [atom[IS_IN_RING] for atom in g["node_attr"]] == [1, 1, 1, 1, 1, 1, 1, 0, 1, 1] + + # Bond features are [type, dir, stereo, is_conjugated], stored per direction. + bonds = dict(zip(zip(*g["edge_index"], strict=True), g["edge_attr"], strict=True)) + assert bonds[(1, 2)] == bonds[(2, 1)] == [3, 0, 0, 1] # aromatic N-C, conjugated + assert bonds[(8, 9)] == [1, 0, 0, 0] # C=C double + assert bonds[(0, 7)] == [0, 0, 0, 0] # Co-Cl single diff --git a/tests/test_splits.py b/tests/test_splits.py new file mode 100644 index 0000000..acb3598 --- /dev/null +++ b/tests/test_splits.py @@ -0,0 +1,51 @@ +"""Stable, hash-based train/val/test splits.""" + +from __future__ import annotations + +import random + +import pytest + +from step_up.data.splits import stable_split + +RATIOS = (0.8, 0.1, 0.1) + + +def _membership(keys: list[str], seed: int = 0) -> dict[str, str]: + """Map each key to the name of the split it lands in.""" + subsets = stable_split(list(range(len(keys))), keys, ratios=RATIOS, seed=seed) + return { + keys[i]: name + for name, subset in zip(("train", "val", "test"), subsets, strict=True) + for i in subset.indices + } + + +def test_membership_is_independent_of_other_rows() -> None: + keys = [f"mol{i}" for i in range(2000)] + full = _membership(keys) + # Drop a third of the rows (as filtering or failed featurization would) and shuffle. + kept = [key for i, key in enumerate(keys) if i % 3] + random.Random(0).shuffle(kept) + assert _membership(kept) == {key: full[key] for key in kept} + + +def test_proportions_seed_and_shared_keys() -> None: + keys = [f"mol{i}" for i in range(20000)] + membership = _membership(keys) + for name, ratio in zip(("train", "val", "test"), RATIOS, strict=True): + fraction = sum(split == name for split in membership.values()) / len(keys) + assert abs(fraction - ratio) < 0.01, (name, fraction) + assert _membership(keys, seed=1) != membership + # Rows sharing a key (e.g. duplicated molecules) always share a split. + subsets = stable_split(list(range(4)), ["a", "b", "a", "b"], ratios=RATIOS) + split_of = {i: s for s, subset in enumerate(subsets) for i in subset.indices} + assert split_of[0] == split_of[2] + assert split_of[1] == split_of[3] + + +def test_rejects_bad_inputs() -> None: + with pytest.raises(ValueError, match="sum to 1"): + stable_split([0, 1], ["a", "b"], ratios=(0.5, 0.5, 0.5)) + with pytest.raises(ValueError, match="keys"): + stable_split([0, 1], ["a"], ratios=RATIOS) diff --git a/tests/test_train.py b/tests/test_train.py new file mode 100644 index 0000000..609f3b7 --- /dev/null +++ b/tests/test_train.py @@ -0,0 +1,76 @@ +"""End-to-end checks of the training loop on the committed fixtures.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from step_up import train as train_module +from step_up.train import TrainConfig, train + + +def _tiny_config(qm9_path: Path, tmp_path: Path, **overrides) -> TrainConfig: + # (0.6, 0.2, 0.2) with split_seed 0 puts 11 / 4 / 5 of the 20 fixture rows in + # train / val / test. + fields = { + "dataset_path": str(qm9_path), + "dataset_source": "smiles", + "split_ratios": (0.6, 0.2, 0.2), + "n_layers": 1, + "d_model": 32, + "d_ffn": 64, + "n_head": 4, + "epochs": 1, + "batch_size": 4, + "eval_batch_size": 4, + "device": "cpu", + "output_dir": str(tmp_path / "run"), + } + return TrainConfig(**{**fields, **overrides}) + + +def test_train_writes_checkpoint_and_test_metrics(qm9_path, tmp_path) -> None: + result = train(_tiny_config(qm9_path, tmp_path)) + assert result["best_epoch"] == 1 + assert math.isfinite(result["best_val_dmae"]) + assert (tmp_path / "run" / "best.pt").exists() + metrics = json.loads((tmp_path / "run" / "test_metrics.json").read_text()) + assert math.isfinite(metrics["test_dmae"]) + assert metrics["best_epoch"] == 1 + + +def test_train_rejects_empty_val_split(qm9_path, tmp_path) -> None: + with pytest.raises(ValueError, match="Empty train or val split"): + train(_tiny_config(qm9_path, tmp_path, split_ratios=(1.0, 0.0, 0.0))) + + +def test_epoch_with_every_batch_skipped_reports_nan() -> None: + class NaNModel(torch.nn.Module): + def forward(self, **batch): + nan = torch.tensor(math.nan) + return SimpleNamespace(loss=nan, cdist_mae=nan) + + loader = [{"node_mask": torch.ones(1, 3)}] * 2 + loss, dmae = train_module._run_epoch(NaNModel(), loader, None, "cpu", {"step": 0}, 1e-3, 1, 0) + assert math.isnan(loss) + assert math.isnan(dmae) + + +def test_train_fails_without_a_finite_validation_epoch(qm9_path, tmp_path, monkeypatch) -> None: + real_run_epoch = train_module._run_epoch + + def nan_validation(model, loader, optimizer, *args, **kwargs): + if optimizer is None: + return math.nan, math.nan + return real_run_epoch(model, loader, optimizer, *args, **kwargs) + + monkeypatch.setattr(train_module, "_run_epoch", nan_validation) + with pytest.raises(RuntimeError, match="No epoch produced a finite validation D-MAE"): + train(_tiny_config(qm9_path, tmp_path)) + assert not (tmp_path / "run" / "best.pt").exists() + assert not (tmp_path / "run" / "test_metrics.json").exists() diff --git a/uv.lock b/uv.lock index 6a823b8..7d23b8e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,10 +4,12 @@ requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'", + "(python_full_version < '3.14' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] [[package]] @@ -968,7 +970,7 @@ name = "nvidia-cudnn-cu12" version = "9.1.0.70" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/9f/fd/713452cd72343f682b1c7b9321e23829f00b842ceaedcda96e742ea0b0b3/nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:165764f44ef8c61fcdfdfdbe769d687e06374059fbb388b6c89ecb0e28793a6f", size = 664752741, upload-time = "2024-04-22T15:24:15.253Z" }, @@ -979,7 +981,7 @@ name = "nvidia-cufft-cu12" version = "11.2.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/27/94/3266821f65b92b3138631e9c8e7fe1fb513804ac934485a8d05776e1dd43/nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f083fc24912aa410be21fa16d157fed2055dab1cc4b6934a0e03cba69eb242b9", size = 211459117, upload-time = "2024-04-03T20:57:40.402Z" }, @@ -998,9 +1000,9 @@ name = "nvidia-cusolver-cu12" version = "11.6.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/3a/e1/5b9089a4b2a4790dfdea8b3a006052cfecff58139d5a4e34cb1a51df8d6f/nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:19e33fa442bcfd085b3086c4ebf7e8debc07cfe01e11513cc6d332fd918ac260", size = 127936057, upload-time = "2024-04-03T20:58:28.735Z" }, @@ -1011,7 +1013,7 @@ name = "nvidia-cusparse-cu12" version = "12.3.1.170" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/db/f7/97a9ea26ed4bbbfc2d470994b8b4f338ef663be97b8f677519ac195e113d/nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ea4f11a2904e2a8dc4b1833cc1b5181cde564edd0d5cd33e3c168eff2d1863f1", size = 207454763, upload-time = "2024-04-03T20:58:59.995Z" }, @@ -1692,7 +1694,8 @@ dependencies = [ { name = "pyyaml" }, { name = "rdkit" }, { name = "tensorboard" }, - { name = "torch" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.6.0+cu124", source = { registry = "https://download.pytorch.org/whl/cu124" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "torch-geometric" }, { name = "torchmetrics" }, { name = "tqdm" }, @@ -1714,7 +1717,8 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "rdkit", specifier = ">=2024.3.3" }, { name = "tensorboard", specifier = ">=2.16" }, - { name = "torch", specifier = ">=2.1", index = "https://download.pytorch.org/whl/cu124" }, + { name = "torch", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=2.6,<2.7" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.6,<2.7", index = "https://download.pytorch.org/whl/cu124" }, { name = "torch-geometric", specifier = ">=2.3" }, { name = "torchmetrics", specifier = ">=1.0" }, { name = "tqdm", specifier = ">=4.66" }, @@ -1797,15 +1801,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] +[[package]] +name = "torch" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "filelock", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "fsspec", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "jinja2", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "networkx", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "setuptools", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "sympy", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/0c52d708144c2deb595cd22819a609f78fdd699b95ff6f0ebcd456e3c7c1/torch-2.6.0-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:2bb8987f3bb1ef2675897034402373ddfc8f5ef0e156e2d8cfc47cacafdda4a9", size = 766624563, upload-time = "2025-01-29T16:23:19.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/455ab3fbb2c61c71c8842753b566012e1ed111e7a4c82e0e1c20d0c76b62/torch-2.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b789069020c5588c70d5c2158ac0aa23fd24a028f34a8b4fcb8fcb4d7efcf5fb", size = 95607867, upload-time = "2025-01-29T16:25:55.649Z" }, + { url = "https://files.pythonhosted.org/packages/18/cf/ae99bd066571656185be0d88ee70abc58467b76f2f7c8bfeb48735a71fe6/torch-2.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e1448426d0ba3620408218b50aa6ada88aeae34f7a239ba5431f6c8774b1239", size = 204120469, upload-time = "2025-01-29T16:24:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/81/b4/605ae4173aa37fb5aa14605d100ff31f4f5d49f617928c9f486bb3aaec08/torch-2.6.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:9a610afe216a85a8b9bc9f8365ed561535c93e804c2a317ef7fabcc5deda0989", size = 66532538, upload-time = "2025-01-29T16:24:18.976Z" }, + { url = "https://files.pythonhosted.org/packages/24/85/ead1349fc30fe5a32cadd947c91bda4a62fbfd7f8c34ee61f6398d38fb48/torch-2.6.0-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:4874a73507a300a5d089ceaff616a569e7bb7c613c56f37f63ec3ffac65259cf", size = 766626191, upload-time = "2025-01-29T16:17:26.26Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b0/26f06f9428b250d856f6d512413e9e800b78625f63801cbba13957432036/torch-2.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a0d5e1b9874c1a6c25556840ab8920569a7a4137afa8a63a32cee0bc7d89bd4b", size = 95611439, upload-time = "2025-01-29T16:21:21.061Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9c/fc5224e9770c83faed3a087112d73147cd7c7bfb7557dcf9ad87e1dda163/torch-2.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:510c73251bee9ba02ae1cb6c9d4ee0907b3ce6020e62784e2d7598e0cfa4d6cc", size = 204126475, upload-time = "2025-01-29T16:21:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/8b/d60c0491ab63634763be1537ad488694d316ddc4a20eaadd639cedc53971/torch-2.6.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:ff96f4038f8af9f7ec4231710ed4549da1bdebad95923953a25045dcf6fd87e2", size = 66536783, upload-time = "2025-01-29T16:22:08.559Z" }, +] + [[package]] name = "torch" version = "2.6.0+cu124" source = { registry = "https://download.pytorch.org/whl/cu124" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'", +] dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, + { name = "filelock", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "fsspec", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "jinja2", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "networkx", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -1819,16 +1859,14 @@ dependencies = [ { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, + { name = "setuptools", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "sympy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp312-cp312-linux_x86_64.whl", hash = "sha256:a393b506844035c0dac2f30ea8478c343b8e95a429f06f3b3cadfc7f53adb597", upload-time = "2025-01-30T00:57:08Z" }, - { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp312-cp312-win_amd64.whl", hash = "sha256:3313061c1fec4c7310cf47944e84513dcd27b6173b72a349bb7ca68d0ee6e9c0", upload-time = "2025-01-30T00:57:40Z" }, { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313-linux_x86_64.whl", hash = "sha256:0f3bc53c988ce9568cd876a2a5316761e84a8704135ec8068f5f81b4417979cb", upload-time = "2025-01-30T00:59:14Z" }, - { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313-win_amd64.whl", hash = "sha256:519330eef09534acad8110b6f423d2fe58c1d8e9ada999ed077a637a0021f908", upload-time = "2025-01-30T00:59:45Z" }, { url = "https://download-r2.pytorch.org/whl/cu124/torch-2.6.0%2Bcu124-cp313-cp313t-linux_x86_64.whl", hash = "sha256:35cba404c0d742406cdcba1609085874bc60facdfbc50e910c47a92405fef44c", upload-time = "2025-01-30T01:01:25Z" }, ] @@ -1860,7 +1898,8 @@ dependencies = [ { name = "lightning-utilities" }, { name = "numpy" }, { name = "packaging" }, - { name = "torch" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.6.0+cu124", source = { registry = "https://download.pytorch.org/whl/cu124" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } wheels = [ From 78ac4a9b7f0f9361735b713921d589b2283df16e Mon Sep 17 00:00:00 2001 From: jwtoney Date: Wed, 16 Sep 2026 22:22:20 -0400 Subject: [PATCH 03/12] Remove personal Slurm wrapper jobscript.sh --- jobscript.sh | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 jobscript.sh diff --git a/jobscript.sh b/jobscript.sh deleted file mode 100644 index 896e279..0000000 --- a/jobscript.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -#SBATCH --job-name=step-up-bostmc -#SBATCH --output=train-bostmc-%j.out -#SBATCH --gres=gpu:volta:1 -#SBATCH --mem=64G -#SBATCH --cpus-per-task=8 - -bash scripts/train.sh configs/bostmc.yaml - From dd1f34d47ee1159df8039b63d935d2206a735148 Mon Sep 17 00:00:00 2001 From: Luis Pinto Date: Fri, 25 Sep 2026 10:41:36 +0200 Subject: [PATCH 04/12] Add utils with all LJ parameters --- rebind_utils.py | 384 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 rebind_utils.py diff --git a/rebind_utils.py b/rebind_utils.py new file mode 100644 index 0000000..1fab3a4 --- /dev/null +++ b/rebind_utils.py @@ -0,0 +1,384 @@ +""" + This module contains useful functions for the modules. +""" +import torch +import torch.nn.functional as F + +ALLOWABLE_FEATURES_VOCAB = { + # This dictionary contains the allowable features for each node and edge. + "possible_atomic_num": list(range(1, 119)), + "possible_chirality": ["CHI_UNSPECIFIED", "CHI_TETRAHEDRAL_CW", "CHI_TETRAHEDRAL_CCW", "CHI_OTHER"], + "possible_degree": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, "misc"], + "possible_formal_charge": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, "misc"], + "possible_number_radical_e": [0, 1, 2, 3, 4, "misc"], + "possible_hybridization": ["SP", "SP2", "SP3", "SP3D", "SP3D2", "misc"], + "possible_is_aromatic": [False, True], + "possible_is_in_ring": [False, True], + "possible_bond_type": ["SINGLE", "DOUBLE", "TRIPLE", "AROMATIC", "DATIVE", "misc"], +} + + +def get_atom_vocab_dims(): + """get the dimensions of the atom vocabulary. + + Returns: + List[int]: A list of integers, which denotes the dimensions of the atom vocabulary. + """ + dims_ls = list( + map( + len, + [ + ALLOWABLE_FEATURES_VOCAB["possible_atomic_num"], + ALLOWABLE_FEATURES_VOCAB["possible_chirality"], + ALLOWABLE_FEATURES_VOCAB["possible_degree"], + ALLOWABLE_FEATURES_VOCAB["possible_formal_charge"], + ALLOWABLE_FEATURES_VOCAB["possible_number_radical_e"], + ALLOWABLE_FEATURES_VOCAB["possible_hybridization"], + ALLOWABLE_FEATURES_VOCAB["possible_is_aromatic"], + ALLOWABLE_FEATURES_VOCAB["possible_is_in_ring"], + ], + ) + ) + return dims_ls + + +def get_bond_vocab_dims(): + """get the dimensions of the bond vocabulary. + + Returns: + List[int]: A list of integers, which denotes the dimensions of the bond vocabulary. + """ + dims_ls = list( + map( + len, + [ + ALLOWABLE_FEATURES_VOCAB["possible_bond_type"], + ], + ) + ) + return dims_ls + + +def make_mask_for_pyd_batch_graph(batch: torch.Tensor): + """ + Make a mask for the distance matrix of Pyg's big (disconnected) graph that consists of a batch of graphs. + + Args: + batch: A torch tensor of shape (n,) like [0, 0, 0, 1, 1, 1, 1], denoting which graph each node belongs to. + + Returns: + torch.Tensor: A torch tensor of shape (n, n), where n denotes the total number of nodes in this batch. + """ + n = batch.shape[0] + mask = torch.eq(batch.unsqueeze(1), batch.unsqueeze(0)) + mask = (torch.ones((n, n)) - torch.eye(n)).to(batch.device) * mask + return mask + + +def valid_length_to_mask(valid_length: torch.TensorType, max_len: int = None): + """Convert the valid length to mask + + Args: + valid_length (torch.TensorType): The valid length of each sequence with shape (b,). + + Returns: + torch.TensorType: The mask with shape (b, l). + """ + max_len = valid_length.max() if max_len is None else max_len + mask = (torch.arange(max_len)[None, :]) < valid_length[:, None] + return mask.to(torch.float32) + + +def mask_attention_score(attention_score: torch.Tensor, attention_mask: torch.Tensor, mask_pos_value: float = 0.0): + """Mask the attention score with the attention_mask || b: batch_size, l: seq_len, d: hidden_dims, h: num_heads + + Args: + attention_score (torch.Tensor): The attention score with shape (b, h, l, l) or (b, l, l). + attention_mask (torch.Tensor): The attention mask with shape (b, l) or (b, l, l). + mask_pos_value (float, optional): The value of the position to be masked. Defaults to 0.0. + Returns: + torch.Tensor: The masked attention score with shape (b, h, l, l) or (b, l, l). + """ + shape = attention_score.shape + b, h, l, _ = shape if len(shape) == 4 else (shape[0], 1, shape[1], shape[2]) + # (b, l, l) -> (b, 1, l, l) if (b, l, l) else (b, h, l, l) + attention_score = attention_score.view(b, h, l, l) if len(shape) == 3 else attention_score + # (b, l) -> (b*h, l) + attention_mask = attention_mask.repeat_interleave(h, dim=0) # (b*h, l) or (b*h, l, l) + attention_mask = attention_mask.view(b, h, -1, l) # (b, h, 1, l) or (b, h, l, l) + # attention_mask = (1 - attention_mask) * (-100000.0) + # attention_score += attention_mask # (b, h, l, l) + attention_score = attention_score.masked_fill(attention_mask == mask_pos_value, -10000.0) + return attention_score.view(shape) if len(shape) == 3 else attention_score + + +def mask_hidden_state(hidden_X: torch.Tensor, padding_mask: torch.Tensor = None): + """Mask the hidden state with the padding mask || b: batch_size, l: seq_len, d: hidden_dims + + Args: + hidden_X (torch.Tensor): The hidden state with shape (b, l, d) + padding_mask (torch.Tensor, optional): The mask of each sequence with shape (b, l). Defaults to None. + + Returns: + torch.Tensor: The masked hidden state with shape (b, l, d) + """ + if padding_mask is None: + return hidden_X + # (b, l) -> (b, l, 1) + padding_mask = padding_mask.unsqueeze(-1).to(torch.bool) + return padding_mask * hidden_X + + +def make_cdist_mask(padding_mask: torch.Tensor) -> torch.Tensor: + """Make mask for coordinate pairwise distance from padding mask. + + Args: + padding_mask (torch.Tensor): Padding mask for the batched input sequences with shape (b, l). + + Returns: + torch.Tensor: Mask for coordinate pairwise distance with shape (b, l, l). + """ + padding_mask = padding_mask.unsqueeze(-1) + mask = padding_mask * padding_mask.transpose(-1, -2) + return mask + + +def align_conformer_to_origin(conformer: torch.Tensor): + """Align the first atom of each molecule to the origin (x, y, z) -> (0, 0, 0). + + Args: + - conformer (torch.Tensor): The conformer with shape (b, l, 3). + + Returns: + torch.Tensor: The aligned conformer with shape (b, l, 3). + """ + delta = conformer[:, 0, :] + conformer_aligned = conformer - delta.unsqueeze(1) + return conformer_aligned + + +# def align_conformer_hat_to_conformer(conformer_hat, conformer): +# """Align conformer_hat to conformer using Kabsch algorithm. + +# Args: +# - conformer_hat (torch.Tensor): The conformer to be aligned, with shape (b, l, 3). +# - conformer (torch.Tensor): The reference conformer, with shape (b, l, 3). + +# Returns: +# - conformer_hat_aligned (torch.Tensor): The aligned conformer, with shape (b, l, 3). +# """ +# # compute the mean of conformer_hat and conformer, and then center them +# p_mean = conformer_hat.mean(dim=1, keepdim=True) +# q_mean = conformer.mean(dim=1, keepdim=True) +# p_centered = conformer_hat - p_mean +# q_centered = conformer - q_mean +# # compute the rotation matrix using Kabsch algorithm +# H = torch.matmul(q_centered.transpose(1, 2), p_centered).float() # shape: (b, 3, 3) +# U, S, V = torch.svd(H) # shape: (b, 3, 3) +# R = torch.matmul(V, U.transpose(1, 2)) # shape: (b, 3, 3) +# # rotate p_centered using R +# p_rotated = torch.matmul(R, p_centered.transpose(1, 2)) # shape: (b, 3, l) +# p_rotated = p_rotated.transpose(1, 2) # shape: (b, l, 3) +# # align p_rotated to the spatial position of q +# conformer_hat_aligned = p_rotated + q_mean # shape: (b, l, 3) +# return conformer_hat_aligned + +def align_conformer_hat_to_conformer(conformer_hat: torch.Tensor, + conformer: torch.Tensor, + padding_mask: torch.Tensor, + eps: float = 1e-8) -> torch.Tensor: + """ + Kabsch-align conformer_hat to conformer, using padding_mask to ignore padded atoms. + Critically: SVD is done under no_grad to avoid SvdBackward NaNs. + """ + # padding_mask: (B,N) with 1 for real atoms, 0 for padding + w = padding_mask.to(conformer_hat.dtype).unsqueeze(-1) # (B,N,1) + denom = w.sum(dim=1, keepdim=True).clamp_min(1.0) + + # masked means + p_mean = (conformer_hat * w).sum(dim=1, keepdim=True) / denom + q_mean = (conformer * w).sum(dim=1, keepdim=True) / denom + + # masked centered coords + P = (conformer_hat - p_mean) * w + Q = (conformer - q_mean) * w + + # covariance + H = Q.transpose(1, 2) @ P # (B,3,3) + + # compute rotation with detached SVD (prevents LinalgSvdBackward0 NaNs) + with torch.no_grad(): + U, S, Vh = torch.linalg.svd(H.double(), full_matrices=False) # Vh is (B,3,3) + R = Vh.transpose(-2, -1) @ U.transpose(-2, -1) # (B,3,3) + + # reflection fix + det = torch.det(R) + fix = det < 0 + if fix.any(): + Vh_fix = Vh.clone() + Vh_fix[fix, -1, :] *= -1 + R = Vh_fix.transpose(-2, -1) @ U.transpose(-2, -1) + + R = R.to(conformer_hat.dtype) # treat as constant in backward + + # apply rotation (grad flows to conformer_hat, but NOT through SVD) + P_rot = (conformer_hat - p_mean) @ R + return P_rot + q_mean + +def get_mask_with_ratio(node_mask: torch.Tensor, ratio: float) -> torch.Tensor: + """Mask the original mask with ratio on the 1's. + + Args: + - node_mask (torch.Tensor): the original node mask, 1 means the valid position and 0 means the padding position. + - ratio (float): the ratio of the 1's to be masked. + + Returns: + torch.Tensor: the re-masked node mask with ratio, 1 means the masked position and 0 means the valid position. + """ + mask_hat = node_mask == 1 + mask_hat = mask_hat * (1 - ratio) + mask_hat = torch.bernoulli(mask_hat) + return mask_hat.to(torch.long) + + +def get_masked_atom_mask(mask_with_ratio: torch.Tensor, node_mask: torch.Tensor) -> torch.Tensor: + """Get the masked atom mask. 1 means the masked position and 0 means the valid position. + + Args: + - mask_with_ratio (torch.Tensor): the re-masked node mask with ratio, 1 means the masked position and 0 means the valid position. + - node_mask (torch.Tensor): the original node mask, 1 means the valid position and 0 means the padding position. + + Returns: + torch.Tensor: the masked atom mask, 1 means the masked position and 0 means the valid position. + """ + masked_atom_mask = node_mask * (1 - mask_with_ratio) + return masked_atom_mask.to(torch.long) + + +def compute_distance_residual_bias(cdist: torch.Tensor, cdist_mask: torch.Tensor, raw_max: bool = True) -> torch.Tensor: + b, l, _ = cdist.shape + D = cdist * cdist_mask + if not raw_max: + D_max, _ = torch.max(D.view(b, -1), dim=-1) # max value of each sample + D = D_max.view(b, 1, 1) - D # sample-max value subtract every value + else: + D_max, _ = torch.max(D, dim=-1) # max value of every raw in each sample + D = D_max.view(b, l, 1) - D # raw-max value subtract every raw + D.diagonal(dim1=-2, dim2=-1)[:] = 0 # set diagonal to 0 + D = D * cdist_mask + return D + + +def get_sigma_and_epsilon(mol_data): + lj_parameters = { + 0: {'sigma': 2.886, 'epsilon': 0.0440}, # H + 1: {'sigma': 2.362, 'epsilon': 0.0560}, # He + 2: {'sigma': 2.451, 'epsilon': 0.0250}, # Li + 3: {'sigma': 2.745, 'epsilon': 0.0850}, # Be + 4: {'sigma': 3.637, 'epsilon': 0.1800}, # B + 5: {'sigma': 3.431, 'epsilon': 0.1050}, # C + 6: {'sigma': 3.260, 'epsilon': 0.0690}, # N + 7: {'sigma': 3.118, 'epsilon': 0.0600}, # O + 8: {'sigma': 2.996, 'epsilon': 0.0500}, # F + 9: {'sigma': 2.889, 'epsilon': 0.0420}, # Ne + 10: {'sigma': 2.983, 'epsilon': 0.0300}, # Na + 11: {'sigma': 2.905, 'epsilon': 0.1110}, # Mg + 12: {'sigma': 4.008, 'epsilon': 0.5050}, # Al + 13: {'sigma': 3.826, 'epsilon': 0.4020}, # Si + 14: {'sigma': 3.694, 'epsilon': 0.3050}, # P + 15: {'sigma': 3.594, 'epsilon': 0.2740}, # S + 16: {'sigma': 3.516, 'epsilon': 0.2270}, # Cl + 17: {'sigma': 3.404, 'epsilon': 0.1850}, # Ar + 18: {'sigma': 3.812, 'epsilon': 0.0350}, # K + 19: {'sigma': 3.487, 'epsilon': 0.2380}, # Ca + 20: {'sigma': 3.316, 'epsilon': 0.0190}, # Sc + 21: {'sigma': 3.294, 'epsilon': 0.0170}, # Ti + 22: {'sigma': 3.273, 'epsilon': 0.0160}, # V + 23: {'sigma': 3.249, 'epsilon': 0.0150}, # Cr + 24: {'sigma': 3.210, 'epsilon': 0.0130}, # Mn + 25: {'sigma': 3.174, 'epsilon': 0.0130}, # Fe + 26: {'sigma': 3.144, 'epsilon': 0.0130}, # Co + 27: {'sigma': 3.116, 'epsilon': 0.0130}, # Ni + 28: {'sigma': 3.083, 'epsilon': 0.0050}, # Cu + 29: {'sigma': 3.002, 'epsilon': 0.1240}, # Zn + 30: {'sigma': 4.383, 'epsilon': 0.4150}, # Ga + 31: {'sigma': 4.310, 'epsilon': 0.3790}, # Ge + 32: {'sigma': 4.280, 'epsilon': 0.3090}, # As + 33: {'sigma': 4.336, 'epsilon': 0.2910}, # Se + 34: {'sigma': 4.403, 'epsilon': 0.2510}, # Br + 35: {'sigma': 4.463, 'epsilon': 0.2200}, # Kr + 36: {'sigma': 4.114, 'epsilon': 0.0400}, # Rb + 37: {'sigma': 3.641, 'epsilon': 0.2350}, # Sr + 38: {'sigma': 3.345, 'epsilon': 0.0720}, # Y + 39: {'sigma': 3.124, 'epsilon': 0.0690}, # Zr + 40: {'sigma': 3.167, 'epsilon': 0.0590}, # Nb + 41: {'sigma': 3.167, 'epsilon': 0.0560}, # Mo + 42: {'sigma': 3.140, 'epsilon': 0.0480}, # Tc + 43: {'sigma': 3.076, 'epsilon': 0.0560}, # Ru + 44: {'sigma': 3.053, 'epsilon': 0.0530}, # Rh + 45: {'sigma': 3.003, 'epsilon': 0.0410}, # Pd + 46: {'sigma': 3.148, 'epsilon': 0.0360}, # Ag + 47: {'sigma': 2.848, 'epsilon': 0.2280}, # Cd + 48: {'sigma': 4.432, 'epsilon': 0.4080}, # In + 49: {'sigma': 4.295, 'epsilon': 0.3810}, # Sn + 50: {'sigma': 4.314, 'epsilon': 0.3110}, # Sb + 51: {'sigma': 4.382, 'epsilon': 0.2920}, # Te + 52: {'sigma': 4.436, 'epsilon': 0.2520}, # I + 53: {'sigma': 4.465, 'epsilon': 0.2210}, # Xe + 54: {'sigma': 4.302, 'epsilon': 0.0320}, # Cs + 55: {'sigma': 3.666, 'epsilon': 0.2200}, # Ba + 56: {'sigma': 3.522, 'epsilon': 0.0130}, # La + 57: {'sigma': 3.493, 'epsilon': 0.0130}, # Ce + 58: {'sigma': 3.468, 'epsilon': 0.0130}, # Pr + 59: {'sigma': 3.441, 'epsilon': 0.0130}, # Nd + 60: {'sigma': 3.415, 'epsilon': 0.0130}, # Pm + 61: {'sigma': 3.391, 'epsilon': 0.0130}, # Sm + 62: {'sigma': 3.364, 'epsilon': 0.0130}, # Eu + 63: {'sigma': 3.340, 'epsilon': 0.0130}, # Gd + 64: {'sigma': 3.315, 'epsilon': 0.0130}, # Tb + 65: {'sigma': 3.289, 'epsilon': 0.0130}, # Dy + 66: {'sigma': 3.265, 'epsilon': 0.0130}, # Ho + 67: {'sigma': 3.241, 'epsilon': 0.0130}, # Er + 68: {'sigma': 3.216, 'epsilon': 0.0130}, # Tm + 69: {'sigma': 3.191, 'epsilon': 0.0130}, # Yb + 70: {'sigma': 3.167, 'epsilon': 0.0130}, # Lu + 71: {'sigma': 3.141, 'epsilon': 0.0670}, # Hf + 72: {'sigma': 3.170, 'epsilon': 0.0600}, # Ta + 73: {'sigma': 3.168, 'epsilon': 0.0540}, # W + 74: {'sigma': 3.111, 'epsilon': 0.0460}, # Re + 75: {'sigma': 3.120, 'epsilon': 0.0310}, # Os + 76: {'sigma': 3.020, 'epsilon': 0.0320}, # Ir + 77: {'sigma': 2.754, 'epsilon': 0.0800}, # Pt + 78: {'sigma': 3.293, 'epsilon': 0.0390}, # Au + 79: {'sigma': 2.730, 'epsilon': 0.4090}, # Hg + 80: {'sigma': 4.347, 'epsilon': 0.3790}, # Tl + 81: {'sigma': 4.355, 'epsilon': 0.3450}, # Pb + 82: {'sigma': 4.370, 'epsilon': 0.2840}, # Bi + 83: {'sigma': 4.450, 'epsilon': 0.2220}, # Po + 84: {'sigma': 4.750, 'epsilon': 0.2840}, # At + 85: {'sigma': 4.765, 'epsilon': 0.2210}, # Rn + 86: {'sigma': 3.846, 'epsilon': 0.0300}, # Fr + 87: {'sigma': 3.659, 'epsilon': 0.2110}, # Ra + 88: {'sigma': 3.478, 'epsilon': 0.0330}, # Ac + 89: {'sigma': 3.395, 'epsilon': 0.0310}, # Th + 90: {'sigma': 3.424, 'epsilon': 0.0100}, # Pa + 91: {'sigma': 3.397, 'epsilon': 0.0110}, # U + 92: {'sigma': 3.424, 'epsilon': 0.0050}, # Np + 93: {'sigma': 3.424, 'epsilon': 0.0050}, # Pu + 94: {'sigma': 3.381, 'epsilon': 0.0140}, # Am + 95: {'sigma': 3.326, 'epsilon': 0.0140}, # Cm + 96: {'sigma': 3.339, 'epsilon': 0.0140}, # Bk + 97: {'sigma': 3.313, 'epsilon': 0.0140}, # Cf + 98: {'sigma': 3.286, 'epsilon': 0.0140}, # Es + 99: {'sigma': 3.281, 'epsilon': 0.0140}, # Fm + 100: {'sigma': 3.268, 'epsilon': 0.0140}, # Md + 101: {'sigma': 3.254, 'epsilon': 0.0140}, # No + 102: {'sigma': 3.240, 'epsilon': 0.0070}, # Lr + } + eps_list = [] + sig_list = [] + for (i, atom_id) in enumerate(mol_data.node_type): + id_to_type = atom_id.item() + eps_list.append(lj_parameters[id_to_type]["epsilon"]) + sig_list.append(lj_parameters[id_to_type]["sigma"]) + return torch.Tensor(eps_list), torch.Tensor(sig_list) \ No newline at end of file From 614858418d2a033ca55e7fb6e75cf67c379febc0 Mon Sep 17 00:00:00 2001 From: Luis Pinto Date: Fri, 25 Sep 2026 10:44:22 +0200 Subject: [PATCH 05/12] Fix ruff line length and typing in rebind_utils.py so pre-commit passes. --- rebind_utils.py | 305 +++++++++++++++++++++++++----------------------- 1 file changed, 161 insertions(+), 144 deletions(-) diff --git a/rebind_utils.py b/rebind_utils.py index 1fab3a4..6c263e3 100644 --- a/rebind_utils.py +++ b/rebind_utils.py @@ -1,13 +1,18 @@ """ - This module contains useful functions for the modules. +This module contains useful functions for the modules. """ + import torch -import torch.nn.functional as F ALLOWABLE_FEATURES_VOCAB = { # This dictionary contains the allowable features for each node and edge. "possible_atomic_num": list(range(1, 119)), - "possible_chirality": ["CHI_UNSPECIFIED", "CHI_TETRAHEDRAL_CW", "CHI_TETRAHEDRAL_CCW", "CHI_OTHER"], + "possible_chirality": [ + "CHI_UNSPECIFIED", + "CHI_TETRAHEDRAL_CW", + "CHI_TETRAHEDRAL_CCW", + "CHI_OTHER", + ], "possible_degree": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, "misc"], "possible_formal_charge": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, "misc"], "possible_number_radical_e": [0, 1, 2, 3, 4, "misc"], @@ -60,14 +65,14 @@ def get_bond_vocab_dims(): def make_mask_for_pyd_batch_graph(batch: torch.Tensor): - """ - Make a mask for the distance matrix of Pyg's big (disconnected) graph that consists of a batch of graphs. + """Mask the distance matrix of a PyG batch of disconnected graphs. Args: - batch: A torch tensor of shape (n,) like [0, 0, 0, 1, 1, 1, 1], denoting which graph each node belongs to. + batch: Shape (n,), e.g. [0, 0, 0, 1, 1, 1, 1]. Each value is the + graph index of that node. Returns: - torch.Tensor: A torch tensor of shape (n, n), where n denotes the total number of nodes in this batch. + torch.Tensor: Shape (n, n), where n is the number of nodes in the batch. """ n = batch.shape[0] mask = torch.eq(batch.unsqueeze(1), batch.unsqueeze(0)) @@ -75,7 +80,7 @@ def make_mask_for_pyd_batch_graph(batch: torch.Tensor): return mask -def valid_length_to_mask(valid_length: torch.TensorType, max_len: int = None): +def valid_length_to_mask(valid_length: torch.TensorType, max_len: int | None = None): """Convert the valid length to mask Args: @@ -89,35 +94,41 @@ def valid_length_to_mask(valid_length: torch.TensorType, max_len: int = None): return mask.to(torch.float32) -def mask_attention_score(attention_score: torch.Tensor, attention_mask: torch.Tensor, mask_pos_value: float = 0.0): - """Mask the attention score with the attention_mask || b: batch_size, l: seq_len, d: hidden_dims, h: num_heads +def mask_attention_score( + attention_score: torch.Tensor, + attention_mask: torch.Tensor, + mask_pos_value: float = 0.0, +): + """Mask the attention score. b: batch, seq: length, h: heads. Args: - attention_score (torch.Tensor): The attention score with shape (b, h, l, l) or (b, l, l). - attention_mask (torch.Tensor): The attention mask with shape (b, l) or (b, l, l). - mask_pos_value (float, optional): The value of the position to be masked. Defaults to 0.0. + attention_score (torch.Tensor): Shape (b, h, seq, seq) or (b, seq, seq). + attention_mask (torch.Tensor): Shape (b, seq) or (b, seq, seq). + mask_pos_value (float, optional): Value marking positions to mask. + Defaults to 0.0. Returns: - torch.Tensor: The masked attention score with shape (b, h, l, l) or (b, l, l). + torch.Tensor: Masked score, same rank as ``attention_score``. """ shape = attention_score.shape - b, h, l, _ = shape if len(shape) == 4 else (shape[0], 1, shape[1], shape[2]) - # (b, l, l) -> (b, 1, l, l) if (b, l, l) else (b, h, l, l) - attention_score = attention_score.view(b, h, l, l) if len(shape) == 3 else attention_score - # (b, l) -> (b*h, l) - attention_mask = attention_mask.repeat_interleave(h, dim=0) # (b*h, l) or (b*h, l, l) - attention_mask = attention_mask.view(b, h, -1, l) # (b, h, 1, l) or (b, h, l, l) + b, h, seq, _ = shape if len(shape) == 4 else (shape[0], 1, shape[1], shape[2]) + # (b, seq, seq) -> (b, 1, seq, seq); 4D scores stay (b, h, seq, seq) + attention_score = attention_score.view(b, h, seq, seq) if len(shape) == 3 else attention_score + # (b, seq) -> (b*h, seq) + attention_mask = attention_mask.repeat_interleave(h, dim=0) # (b*h, seq) or (b*h, seq, seq) + attention_mask = attention_mask.view(b, h, -1, seq) # (b, h, 1, seq) or (b, h, seq, seq) # attention_mask = (1 - attention_mask) * (-100000.0) # attention_score += attention_mask # (b, h, l, l) attention_score = attention_score.masked_fill(attention_mask == mask_pos_value, -10000.0) return attention_score.view(shape) if len(shape) == 3 else attention_score -def mask_hidden_state(hidden_X: torch.Tensor, padding_mask: torch.Tensor = None): - """Mask the hidden state with the padding mask || b: batch_size, l: seq_len, d: hidden_dims +def mask_hidden_state(hidden_X: torch.Tensor, padding_mask: torch.Tensor | None = None): + """Mask the hidden state. b: batch, seq: length, d: hidden size. Args: - hidden_X (torch.Tensor): The hidden state with shape (b, l, d) - padding_mask (torch.Tensor, optional): The mask of each sequence with shape (b, l). Defaults to None. + hidden_X (torch.Tensor): Hidden state with shape (b, seq, d). + padding_mask (torch.Tensor, optional): Per-sequence mask, shape (b, seq). + Defaults to None. Returns: torch.Tensor: The masked hidden state with shape (b, l, d) @@ -183,10 +194,13 @@ def align_conformer_to_origin(conformer: torch.Tensor): # conformer_hat_aligned = p_rotated + q_mean # shape: (b, l, 3) # return conformer_hat_aligned -def align_conformer_hat_to_conformer(conformer_hat: torch.Tensor, - conformer: torch.Tensor, - padding_mask: torch.Tensor, - eps: float = 1e-8) -> torch.Tensor: + +def align_conformer_hat_to_conformer( + conformer_hat: torch.Tensor, + conformer: torch.Tensor, + padding_mask: torch.Tensor, + eps: float = 1e-8, +) -> torch.Tensor: """ Kabsch-align conformer_hat to conformer, using padding_mask to ignore padded atoms. Critically: SVD is done under no_grad to avoid SvdBackward NaNs. @@ -208,8 +222,8 @@ def align_conformer_hat_to_conformer(conformer_hat: torch.Tensor, # compute rotation with detached SVD (prevents LinalgSvdBackward0 NaNs) with torch.no_grad(): - U, S, Vh = torch.linalg.svd(H.double(), full_matrices=False) # Vh is (B,3,3) - R = Vh.transpose(-2, -1) @ U.transpose(-2, -1) # (B,3,3) + U, _S, Vh = torch.linalg.svd(H.double(), full_matrices=False) # Vh is (B,3,3) + R = Vh.transpose(-2, -1) @ U.transpose(-2, -1) # (B,3,3) # reflection fix det = torch.det(R) @@ -225,15 +239,16 @@ def align_conformer_hat_to_conformer(conformer_hat: torch.Tensor, P_rot = (conformer_hat - p_mean) @ R return P_rot + q_mean + def get_mask_with_ratio(node_mask: torch.Tensor, ratio: float) -> torch.Tensor: """Mask the original mask with ratio on the 1's. Args: - - node_mask (torch.Tensor): the original node mask, 1 means the valid position and 0 means the padding position. - - ratio (float): the ratio of the 1's to be masked. + node_mask (torch.Tensor): 1 is a real atom, 0 is padding. + ratio (float): Fraction of real atoms to mask. Returns: - torch.Tensor: the re-masked node mask with ratio, 1 means the masked position and 0 means the valid position. + torch.Tensor: 1 is a masked atom, 0 is a kept atom. """ mask_hat = node_mask == 1 mask_hat = mask_hat * (1 - ratio) @@ -245,25 +260,27 @@ def get_masked_atom_mask(mask_with_ratio: torch.Tensor, node_mask: torch.Tensor) """Get the masked atom mask. 1 means the masked position and 0 means the valid position. Args: - - mask_with_ratio (torch.Tensor): the re-masked node mask with ratio, 1 means the masked position and 0 means the valid position. - - node_mask (torch.Tensor): the original node mask, 1 means the valid position and 0 means the padding position. + mask_with_ratio (torch.Tensor): 1 is a masked atom, 0 is kept. + node_mask (torch.Tensor): 1 is a real atom, 0 is padding. Returns: - torch.Tensor: the masked atom mask, 1 means the masked position and 0 means the valid position. + torch.Tensor: 1 is a masked atom, 0 is a kept atom. """ masked_atom_mask = node_mask * (1 - mask_with_ratio) return masked_atom_mask.to(torch.long) -def compute_distance_residual_bias(cdist: torch.Tensor, cdist_mask: torch.Tensor, raw_max: bool = True) -> torch.Tensor: - b, l, _ = cdist.shape +def compute_distance_residual_bias( + cdist: torch.Tensor, cdist_mask: torch.Tensor, raw_max: bool = True +) -> torch.Tensor: + b, seq, _ = cdist.shape D = cdist * cdist_mask if not raw_max: D_max, _ = torch.max(D.view(b, -1), dim=-1) # max value of each sample D = D_max.view(b, 1, 1) - D # sample-max value subtract every value else: D_max, _ = torch.max(D, dim=-1) # max value of every raw in each sample - D = D_max.view(b, l, 1) - D # raw-max value subtract every raw + D = D_max.view(b, seq, 1) - D # raw-max value subtract every raw D.diagonal(dim1=-2, dim2=-1)[:] = 0 # set diagonal to 0 D = D * cdist_mask return D @@ -271,114 +288,114 @@ def compute_distance_residual_bias(cdist: torch.Tensor, cdist_mask: torch.Tensor def get_sigma_and_epsilon(mol_data): lj_parameters = { - 0: {'sigma': 2.886, 'epsilon': 0.0440}, # H - 1: {'sigma': 2.362, 'epsilon': 0.0560}, # He - 2: {'sigma': 2.451, 'epsilon': 0.0250}, # Li - 3: {'sigma': 2.745, 'epsilon': 0.0850}, # Be - 4: {'sigma': 3.637, 'epsilon': 0.1800}, # B - 5: {'sigma': 3.431, 'epsilon': 0.1050}, # C - 6: {'sigma': 3.260, 'epsilon': 0.0690}, # N - 7: {'sigma': 3.118, 'epsilon': 0.0600}, # O - 8: {'sigma': 2.996, 'epsilon': 0.0500}, # F - 9: {'sigma': 2.889, 'epsilon': 0.0420}, # Ne - 10: {'sigma': 2.983, 'epsilon': 0.0300}, # Na - 11: {'sigma': 2.905, 'epsilon': 0.1110}, # Mg - 12: {'sigma': 4.008, 'epsilon': 0.5050}, # Al - 13: {'sigma': 3.826, 'epsilon': 0.4020}, # Si - 14: {'sigma': 3.694, 'epsilon': 0.3050}, # P - 15: {'sigma': 3.594, 'epsilon': 0.2740}, # S - 16: {'sigma': 3.516, 'epsilon': 0.2270}, # Cl - 17: {'sigma': 3.404, 'epsilon': 0.1850}, # Ar - 18: {'sigma': 3.812, 'epsilon': 0.0350}, # K - 19: {'sigma': 3.487, 'epsilon': 0.2380}, # Ca - 20: {'sigma': 3.316, 'epsilon': 0.0190}, # Sc - 21: {'sigma': 3.294, 'epsilon': 0.0170}, # Ti - 22: {'sigma': 3.273, 'epsilon': 0.0160}, # V - 23: {'sigma': 3.249, 'epsilon': 0.0150}, # Cr - 24: {'sigma': 3.210, 'epsilon': 0.0130}, # Mn - 25: {'sigma': 3.174, 'epsilon': 0.0130}, # Fe - 26: {'sigma': 3.144, 'epsilon': 0.0130}, # Co - 27: {'sigma': 3.116, 'epsilon': 0.0130}, # Ni - 28: {'sigma': 3.083, 'epsilon': 0.0050}, # Cu - 29: {'sigma': 3.002, 'epsilon': 0.1240}, # Zn - 30: {'sigma': 4.383, 'epsilon': 0.4150}, # Ga - 31: {'sigma': 4.310, 'epsilon': 0.3790}, # Ge - 32: {'sigma': 4.280, 'epsilon': 0.3090}, # As - 33: {'sigma': 4.336, 'epsilon': 0.2910}, # Se - 34: {'sigma': 4.403, 'epsilon': 0.2510}, # Br - 35: {'sigma': 4.463, 'epsilon': 0.2200}, # Kr - 36: {'sigma': 4.114, 'epsilon': 0.0400}, # Rb - 37: {'sigma': 3.641, 'epsilon': 0.2350}, # Sr - 38: {'sigma': 3.345, 'epsilon': 0.0720}, # Y - 39: {'sigma': 3.124, 'epsilon': 0.0690}, # Zr - 40: {'sigma': 3.167, 'epsilon': 0.0590}, # Nb - 41: {'sigma': 3.167, 'epsilon': 0.0560}, # Mo - 42: {'sigma': 3.140, 'epsilon': 0.0480}, # Tc - 43: {'sigma': 3.076, 'epsilon': 0.0560}, # Ru - 44: {'sigma': 3.053, 'epsilon': 0.0530}, # Rh - 45: {'sigma': 3.003, 'epsilon': 0.0410}, # Pd - 46: {'sigma': 3.148, 'epsilon': 0.0360}, # Ag - 47: {'sigma': 2.848, 'epsilon': 0.2280}, # Cd - 48: {'sigma': 4.432, 'epsilon': 0.4080}, # In - 49: {'sigma': 4.295, 'epsilon': 0.3810}, # Sn - 50: {'sigma': 4.314, 'epsilon': 0.3110}, # Sb - 51: {'sigma': 4.382, 'epsilon': 0.2920}, # Te - 52: {'sigma': 4.436, 'epsilon': 0.2520}, # I - 53: {'sigma': 4.465, 'epsilon': 0.2210}, # Xe - 54: {'sigma': 4.302, 'epsilon': 0.0320}, # Cs - 55: {'sigma': 3.666, 'epsilon': 0.2200}, # Ba - 56: {'sigma': 3.522, 'epsilon': 0.0130}, # La - 57: {'sigma': 3.493, 'epsilon': 0.0130}, # Ce - 58: {'sigma': 3.468, 'epsilon': 0.0130}, # Pr - 59: {'sigma': 3.441, 'epsilon': 0.0130}, # Nd - 60: {'sigma': 3.415, 'epsilon': 0.0130}, # Pm - 61: {'sigma': 3.391, 'epsilon': 0.0130}, # Sm - 62: {'sigma': 3.364, 'epsilon': 0.0130}, # Eu - 63: {'sigma': 3.340, 'epsilon': 0.0130}, # Gd - 64: {'sigma': 3.315, 'epsilon': 0.0130}, # Tb - 65: {'sigma': 3.289, 'epsilon': 0.0130}, # Dy - 66: {'sigma': 3.265, 'epsilon': 0.0130}, # Ho - 67: {'sigma': 3.241, 'epsilon': 0.0130}, # Er - 68: {'sigma': 3.216, 'epsilon': 0.0130}, # Tm - 69: {'sigma': 3.191, 'epsilon': 0.0130}, # Yb - 70: {'sigma': 3.167, 'epsilon': 0.0130}, # Lu - 71: {'sigma': 3.141, 'epsilon': 0.0670}, # Hf - 72: {'sigma': 3.170, 'epsilon': 0.0600}, # Ta - 73: {'sigma': 3.168, 'epsilon': 0.0540}, # W - 74: {'sigma': 3.111, 'epsilon': 0.0460}, # Re - 75: {'sigma': 3.120, 'epsilon': 0.0310}, # Os - 76: {'sigma': 3.020, 'epsilon': 0.0320}, # Ir - 77: {'sigma': 2.754, 'epsilon': 0.0800}, # Pt - 78: {'sigma': 3.293, 'epsilon': 0.0390}, # Au - 79: {'sigma': 2.730, 'epsilon': 0.4090}, # Hg - 80: {'sigma': 4.347, 'epsilon': 0.3790}, # Tl - 81: {'sigma': 4.355, 'epsilon': 0.3450}, # Pb - 82: {'sigma': 4.370, 'epsilon': 0.2840}, # Bi - 83: {'sigma': 4.450, 'epsilon': 0.2220}, # Po - 84: {'sigma': 4.750, 'epsilon': 0.2840}, # At - 85: {'sigma': 4.765, 'epsilon': 0.2210}, # Rn - 86: {'sigma': 3.846, 'epsilon': 0.0300}, # Fr - 87: {'sigma': 3.659, 'epsilon': 0.2110}, # Ra - 88: {'sigma': 3.478, 'epsilon': 0.0330}, # Ac - 89: {'sigma': 3.395, 'epsilon': 0.0310}, # Th - 90: {'sigma': 3.424, 'epsilon': 0.0100}, # Pa - 91: {'sigma': 3.397, 'epsilon': 0.0110}, # U - 92: {'sigma': 3.424, 'epsilon': 0.0050}, # Np - 93: {'sigma': 3.424, 'epsilon': 0.0050}, # Pu - 94: {'sigma': 3.381, 'epsilon': 0.0140}, # Am - 95: {'sigma': 3.326, 'epsilon': 0.0140}, # Cm - 96: {'sigma': 3.339, 'epsilon': 0.0140}, # Bk - 97: {'sigma': 3.313, 'epsilon': 0.0140}, # Cf - 98: {'sigma': 3.286, 'epsilon': 0.0140}, # Es - 99: {'sigma': 3.281, 'epsilon': 0.0140}, # Fm - 100: {'sigma': 3.268, 'epsilon': 0.0140}, # Md - 101: {'sigma': 3.254, 'epsilon': 0.0140}, # No - 102: {'sigma': 3.240, 'epsilon': 0.0070}, # Lr + 0: {"sigma": 2.886, "epsilon": 0.0440}, # H + 1: {"sigma": 2.362, "epsilon": 0.0560}, # He + 2: {"sigma": 2.451, "epsilon": 0.0250}, # Li + 3: {"sigma": 2.745, "epsilon": 0.0850}, # Be + 4: {"sigma": 3.637, "epsilon": 0.1800}, # B + 5: {"sigma": 3.431, "epsilon": 0.1050}, # C + 6: {"sigma": 3.260, "epsilon": 0.0690}, # N + 7: {"sigma": 3.118, "epsilon": 0.0600}, # O + 8: {"sigma": 2.996, "epsilon": 0.0500}, # F + 9: {"sigma": 2.889, "epsilon": 0.0420}, # Ne + 10: {"sigma": 2.983, "epsilon": 0.0300}, # Na + 11: {"sigma": 2.905, "epsilon": 0.1110}, # Mg + 12: {"sigma": 4.008, "epsilon": 0.5050}, # Al + 13: {"sigma": 3.826, "epsilon": 0.4020}, # Si + 14: {"sigma": 3.694, "epsilon": 0.3050}, # P + 15: {"sigma": 3.594, "epsilon": 0.2740}, # S + 16: {"sigma": 3.516, "epsilon": 0.2270}, # Cl + 17: {"sigma": 3.404, "epsilon": 0.1850}, # Ar + 18: {"sigma": 3.812, "epsilon": 0.0350}, # K + 19: {"sigma": 3.487, "epsilon": 0.2380}, # Ca + 20: {"sigma": 3.316, "epsilon": 0.0190}, # Sc + 21: {"sigma": 3.294, "epsilon": 0.0170}, # Ti + 22: {"sigma": 3.273, "epsilon": 0.0160}, # V + 23: {"sigma": 3.249, "epsilon": 0.0150}, # Cr + 24: {"sigma": 3.210, "epsilon": 0.0130}, # Mn + 25: {"sigma": 3.174, "epsilon": 0.0130}, # Fe + 26: {"sigma": 3.144, "epsilon": 0.0130}, # Co + 27: {"sigma": 3.116, "epsilon": 0.0130}, # Ni + 28: {"sigma": 3.083, "epsilon": 0.0050}, # Cu + 29: {"sigma": 3.002, "epsilon": 0.1240}, # Zn + 30: {"sigma": 4.383, "epsilon": 0.4150}, # Ga + 31: {"sigma": 4.310, "epsilon": 0.3790}, # Ge + 32: {"sigma": 4.280, "epsilon": 0.3090}, # As + 33: {"sigma": 4.336, "epsilon": 0.2910}, # Se + 34: {"sigma": 4.403, "epsilon": 0.2510}, # Br + 35: {"sigma": 4.463, "epsilon": 0.2200}, # Kr + 36: {"sigma": 4.114, "epsilon": 0.0400}, # Rb + 37: {"sigma": 3.641, "epsilon": 0.2350}, # Sr + 38: {"sigma": 3.345, "epsilon": 0.0720}, # Y + 39: {"sigma": 3.124, "epsilon": 0.0690}, # Zr + 40: {"sigma": 3.167, "epsilon": 0.0590}, # Nb + 41: {"sigma": 3.167, "epsilon": 0.0560}, # Mo + 42: {"sigma": 3.140, "epsilon": 0.0480}, # Tc + 43: {"sigma": 3.076, "epsilon": 0.0560}, # Ru + 44: {"sigma": 3.053, "epsilon": 0.0530}, # Rh + 45: {"sigma": 3.003, "epsilon": 0.0410}, # Pd + 46: {"sigma": 3.148, "epsilon": 0.0360}, # Ag + 47: {"sigma": 2.848, "epsilon": 0.2280}, # Cd + 48: {"sigma": 4.432, "epsilon": 0.4080}, # In + 49: {"sigma": 4.295, "epsilon": 0.3810}, # Sn + 50: {"sigma": 4.314, "epsilon": 0.3110}, # Sb + 51: {"sigma": 4.382, "epsilon": 0.2920}, # Te + 52: {"sigma": 4.436, "epsilon": 0.2520}, # I + 53: {"sigma": 4.465, "epsilon": 0.2210}, # Xe + 54: {"sigma": 4.302, "epsilon": 0.0320}, # Cs + 55: {"sigma": 3.666, "epsilon": 0.2200}, # Ba + 56: {"sigma": 3.522, "epsilon": 0.0130}, # La + 57: {"sigma": 3.493, "epsilon": 0.0130}, # Ce + 58: {"sigma": 3.468, "epsilon": 0.0130}, # Pr + 59: {"sigma": 3.441, "epsilon": 0.0130}, # Nd + 60: {"sigma": 3.415, "epsilon": 0.0130}, # Pm + 61: {"sigma": 3.391, "epsilon": 0.0130}, # Sm + 62: {"sigma": 3.364, "epsilon": 0.0130}, # Eu + 63: {"sigma": 3.340, "epsilon": 0.0130}, # Gd + 64: {"sigma": 3.315, "epsilon": 0.0130}, # Tb + 65: {"sigma": 3.289, "epsilon": 0.0130}, # Dy + 66: {"sigma": 3.265, "epsilon": 0.0130}, # Ho + 67: {"sigma": 3.241, "epsilon": 0.0130}, # Er + 68: {"sigma": 3.216, "epsilon": 0.0130}, # Tm + 69: {"sigma": 3.191, "epsilon": 0.0130}, # Yb + 70: {"sigma": 3.167, "epsilon": 0.0130}, # Lu + 71: {"sigma": 3.141, "epsilon": 0.0670}, # Hf + 72: {"sigma": 3.170, "epsilon": 0.0600}, # Ta + 73: {"sigma": 3.168, "epsilon": 0.0540}, # W + 74: {"sigma": 3.111, "epsilon": 0.0460}, # Re + 75: {"sigma": 3.120, "epsilon": 0.0310}, # Os + 76: {"sigma": 3.020, "epsilon": 0.0320}, # Ir + 77: {"sigma": 2.754, "epsilon": 0.0800}, # Pt + 78: {"sigma": 3.293, "epsilon": 0.0390}, # Au + 79: {"sigma": 2.730, "epsilon": 0.4090}, # Hg + 80: {"sigma": 4.347, "epsilon": 0.3790}, # Tl + 81: {"sigma": 4.355, "epsilon": 0.3450}, # Pb + 82: {"sigma": 4.370, "epsilon": 0.2840}, # Bi + 83: {"sigma": 4.450, "epsilon": 0.2220}, # Po + 84: {"sigma": 4.750, "epsilon": 0.2840}, # At + 85: {"sigma": 4.765, "epsilon": 0.2210}, # Rn + 86: {"sigma": 3.846, "epsilon": 0.0300}, # Fr + 87: {"sigma": 3.659, "epsilon": 0.2110}, # Ra + 88: {"sigma": 3.478, "epsilon": 0.0330}, # Ac + 89: {"sigma": 3.395, "epsilon": 0.0310}, # Th + 90: {"sigma": 3.424, "epsilon": 0.0100}, # Pa + 91: {"sigma": 3.397, "epsilon": 0.0110}, # U + 92: {"sigma": 3.424, "epsilon": 0.0050}, # Np + 93: {"sigma": 3.424, "epsilon": 0.0050}, # Pu + 94: {"sigma": 3.381, "epsilon": 0.0140}, # Am + 95: {"sigma": 3.326, "epsilon": 0.0140}, # Cm + 96: {"sigma": 3.339, "epsilon": 0.0140}, # Bk + 97: {"sigma": 3.313, "epsilon": 0.0140}, # Cf + 98: {"sigma": 3.286, "epsilon": 0.0140}, # Es + 99: {"sigma": 3.281, "epsilon": 0.0140}, # Fm + 100: {"sigma": 3.268, "epsilon": 0.0140}, # Md + 101: {"sigma": 3.254, "epsilon": 0.0140}, # No + 102: {"sigma": 3.240, "epsilon": 0.0070}, # Lr } eps_list = [] sig_list = [] - for (i, atom_id) in enumerate(mol_data.node_type): + for atom_id in mol_data.node_type: id_to_type = atom_id.item() eps_list.append(lj_parameters[id_to_type]["epsilon"]) sig_list.append(lj_parameters[id_to_type]["sigma"]) - return torch.Tensor(eps_list), torch.Tensor(sig_list) \ No newline at end of file + return torch.Tensor(eps_list), torch.Tensor(sig_list) From f8fee238c3284ad8ea7ac8dcc8560a0f1b994edd Mon Sep 17 00:00:00 2001 From: jwtoney Date: Sun, 27 Sep 2026 10:49:26 -0400 Subject: [PATCH 06/12] Add GTMGC, fix the metric protocol, and carry the full LJ table Second benchmark model plus the corrections that came out of running the first one end to end. GTMGC (ICLR 2024) joins ReBind behind `model:` in the config. It shares ReBind's code base, so both wrappers now sit on `models/common.py` (the out-of-place Laplacian addition and the charge/spin conditioning) and the vendored tree loads under a private module name to keep its `models` package from colliding with ReBind's. `from_pretrained` breaks on transformers 5.8.1, so released weights load through an explicit config + state-dict path. `scripts/tokenize_molebert.py` precomputes the Mole-BERT atom ids that its `atom_tokenized_ids` embedding needs. `eval/conformer_eval.py` implements ReBind's published protocol: D-MAE and D-RMSE pooled over every pairwise distance, C-RMSD via RDKit GetBestRMS with hydrogens removed. Both vendored models Kabsch-align inside the prediction head over the *padded* batch tensor, so padding zeros drag the centroid and rotation off; trusting that alignment inflated RMSD roughly threefold (BOSTMC read 8.17 where it is 2.53). `kabsch_rmsd` now aligns per molecule over real atoms only. ReBind hardcodes Lennard-Jones parameters up to Kr and KeyErrors above it, which we had been papering over with a flat sigma/epsilon for every heavier element. `models/lj_params.py` carries the full UFF table through Lr, from the values Luis Pinto compiled in rebind_utils.py; it is bit-identical to ReBind's over Z=1..36, so QM9 is untouched. This is not cosmetic: 65% of tmQMg and 46% of BOSTMC low-spin structures contain an element past Kr, usually the metal centre itself. Also here: stable hash-based splits and published split/ID-list loading, charge and spin conditioning for the organometallic sets with the ablation script that measures whether a model uses it, the QM9 preparation script for ReBind's published split, per-dataset configs, and the first round of results in the README. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + .gitmodules | 3 + README.md | 224 +++++++++++++-- configs/bostmc.yaml | 40 ++- configs/bostmc_smoke.yaml | 9 +- configs/qm9_gtmgc.yaml | 50 ++++ configs/qm9_rebind.yaml | 44 +++ configs/tmqmg.yaml | 37 ++- configs/tmqmg_complete.yaml | 53 ++++ configs/tmqmg_smoke.yaml | 2 + external/GTMGC | 1 + scripts/analyze_conditioning.py | 113 ++++++++ scripts/prepare_qm9_rebind.py | 93 ++++++ scripts/tokenize_molebert.py | 89 ++++++ scripts/train.sh | 10 +- src/step_up/cli.py | 78 ++++- src/step_up/data/csv_dataset.py | 110 ++++++- src/step_up/data/featurize.py | 14 + src/step_up/data/splits.py | 85 +++++- src/step_up/eval/conformer_eval.py | 153 ++++++++++ src/step_up/models/__init__.py | 66 ++++- src/step_up/models/common.py | 85 ++++++ src/step_up/models/gtmgc.py | 271 ++++++++++++++++++ src/step_up/models/lj_params.py | 123 ++++++++ src/step_up/models/rebind.py | 181 ++++++------ src/step_up/train.py | 152 ++++++++-- tests/conftest.py | 20 ++ tests/fixtures/qm9_sdf_mini.csv | 107 +++++++ .../fixtures/qm9_sdf_mini_molebert_tokens.csv | 6 + tests/test_conditioning.py | 67 +++++ tests/test_conformer_eval.py | 97 +++++++ tests/test_csv_dataset.py | 19 ++ tests/test_forward.py | 47 +++ tests/test_gtmgc.py | 76 +++++ tests/test_splits.py | 43 ++- tests/test_train.py | 31 ++ 36 files changed, 2426 insertions(+), 177 deletions(-) create mode 100644 configs/qm9_gtmgc.yaml create mode 100644 configs/qm9_rebind.yaml create mode 100644 configs/tmqmg_complete.yaml create mode 160000 external/GTMGC create mode 100644 scripts/analyze_conditioning.py create mode 100644 scripts/prepare_qm9_rebind.py create mode 100644 scripts/tokenize_molebert.py create mode 100644 src/step_up/eval/conformer_eval.py create mode 100644 src/step_up/models/common.py create mode 100644 src/step_up/models/gtmgc.py create mode 100644 src/step_up/models/lj_params.py create mode 100644 tests/fixtures/qm9_sdf_mini.csv create mode 100644 tests/fixtures/qm9_sdf_mini_molebert_tokens.csv create mode 100644 tests/test_conditioning.py create mode 100644 tests/test_conformer_eval.py create mode 100644 tests/test_gtmgc.py diff --git a/.gitignore b/.gitignore index 340b0fa..837580e 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Slurm job logs (scripts/train.sh writes train-.out into the submit dir) +*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9].out +slurm-*.out diff --git a/.gitmodules b/.gitmodules index dbb8c53..ede1c38 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "external/ReBIND"] path = external/ReBIND url = https://github.com/holymollyhao/ReBIND.git +[submodule "external/GTMGC"] + path = external/GTMGC + url = https://github.com/Rich-XGK/GTMGC.git diff --git a/README.md b/README.md index 0aaa9d8..f7cd0e5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ A benchmark for 2D to 3D conformer generation models. This project uses [`uv`](https://docs.astral.sh/uv/) for Python, dependency, and environment management. -The first model (ReBind) is vendored as a git submodule under `external/ReBIND`, so clone with `--recursive`: +The benchmark models are vendored as git submodules (`external/ReBIND`, +`external/GTMGC`), so clone with `--recursive`: ```bash git clone --recursive https://github.com/LeMaterial/step-up.git @@ -48,29 +49,55 @@ uv run step-up train -c configs/bostmc_smoke.yaml # ~6 min (larger complexes) ## Production Training +Each benchmark model is a thin wrapper over a vendored upstream implementation, +selected by `model:` in the config (`rebind` or `gtmgc`, see +`step_up.models.MODEL_NAMES`). Both descend from the same code base, share the +dataset and metric code, and need the same two fixes: the Laplacian positional +encoding is added out of place so fp32 training works, and charge/spin +conditioning is bolted on for the organometallic sets. + +ReBind additionally hardcodes Lennard-Jones parameters only up to Kr, and +KeyErrors on anything heavier. [lj_params.py](src/step_up/models/lj_params.py) +carries the full UFF table through Lr, identical to theirs over Z=1..36, and the +wrapper patches it in. This is not cosmetic: 65% of tmQMg and 46% of BOSTMC +low-spin structures contain at least one element past Kr, usually the metal +centre itself. + Three full-scale configs are staged. They target GPU (`device: cuda`) and follow -ReBind's published QM9 setup for the architecture and main optimizer settings -(8 encoder + 8 decoder layers, d_model=512, AdamW, lr=9e-5, 10% warmup, gradient -clipping at 1.0, batch 100, 20 epochs by default). The training loop differs from -ReBind's script in three ways: the learning rate decays on a cosine schedule after -warmup (ReBind: linear), AdamW keeps PyTorch's default beta2=0.999 (ReBind: 0.99), -and training runs in fp32 (ReBind: fp16 mixed precision). - -| Config | Dataset | Rows | Notes | -|---|---|---|---| -| `configs/qm9.yaml` | QM9-full.csv | ~134K | Sanity baseline on organic systems | -| `configs/tmqmg.yaml` | tmQMg-full.csv | ~58K | Singlets, full d-block + La | -| `configs/bostmc.yaml` | BOSTMC-low-spin.csv | ~93K of ~121K | Singlets only (`spinmult == 1`), full d-block | - -The BOSTMC config trains on singlets only: the model isn't conditioned on charge -or spin, so mixing spin states would make comparisons ambiguous. Doublets need -that conditioning first. - -Train/val/test membership is a hash of each molecule's ID (`id_column`, e.g. -`refcode`, or the CSV row number if unset) and `split_seed`. A molecule -therefore stays in the same split regardless of `subset_size`, filtering, or -which rows fail featurization, so different runs and models are scored on the -same test molecules. Split sizes match `split_ratios` approximately. +ReBind's published QM9 setup from +`external/ReBIND/experiments/conformer_prediction/rebind.sh`: 8 encoder + 8 +decoder layers, d_model=512, AdamW with betas (0.9, 0.99) and eps 1e-8, lr 9e-5, +linear warmup over 10% of steps followed by linear decay, gradient clipping at +1.0, batch 100 with the last partial batch dropped, and 20 epochs. + +The one setting not mirrored is precision: ReBind trains with fp16 mixed +precision, this loop in fp32. fp32 is the more precise of the two, and upstream's +in-place Laplacian write (see [rebind.py](src/step_up/models/rebind.py)) only +survives autograd under autocast, so their fp16 looks like a constraint of the +code rather than a modelling choice. + +| Config | Dataset | Rows | Split | Notes | +|---|---|---|---|---| +| `configs/qm9_rebind.yaml` | QM9 (gdb9.sdf) | 130,831 | published (ReBind/GTMGC) | Organic reproduction target | +| `configs/qm9_gtmgc.yaml` | QM9 (gdb9.sdf) | 130,831 | published (ReBind/GTMGC) | Same data, second model | +| `configs/tmqmg_complete.yaml` | tmQMg complete | 60,799 | published (TMCgen) | Matches the published baselines | +| `configs/tmqmg.yaml` | tmQMg, outliers removed | 58,409 | published (TMCgen) | Cleaner data, not comparable to them | +| `configs/bostmc.yaml` | BOSTMC low-spin | 121,496 | project random split | Singlets + doublets, full d-block | + +Molecules carry a formal charge and spin multiplicity, which neither upstream +model takes. Both are fed in as scalars (charge in electrons, and unpaired +electrons = multiplicity - 1) through a small MLP added to every atom embedding. +Scalars rather than one-hot categories because BOSTMC's charges run from -8 to ++8 with single-structure tails, and because a scalar lets you ask a trained model +for a charge/spin state that never appeared in training. Set `charge_column` / +`spin_column` to switch it on; QM9 needs neither. + +Splits come from one of three places, in order of precedence: `split_files` +(published ID lists, used for tmQMg and BOSTMC), `split_column` (a published +label per row, used for QM9), or a hash of each molecule's ID (`id_column`, or +the CSV row number) and `split_seed`. The hash keeps a molecule in the same +split regardless of `subset_size`, filtering, or which rows fail featurization, +so different runs and models are scored on the same test molecules. To dry-run a config (validates the YAML and dataset path without training): @@ -79,8 +106,8 @@ uv run step-up train -c configs/qm9.yaml --dry-run ``` ```bash -sbatch scripts/train.sh configs/qm9.yaml -sbatch scripts/train.sh configs/tmqmg.yaml +sbatch scripts/train.sh configs/qm9_rebind.yaml +sbatch scripts/train.sh configs/tmqmg_complete.yaml sbatch scripts/train.sh configs/bostmc.yaml ``` @@ -91,6 +118,141 @@ logs, per-epoch history, best checkpoint (by val D-MAE), and the test-set metrics of that checkpoint (`test_metrics.json`) to the `output_dir` specified in the config — default is `outputs/_full/`. +### Reproducing ReBind's published QM9 numbers + +`configs/qm9_rebind.yaml` trains on the QM9 copy ReBind and GTMGC used +(HuggingFace `RichXuOvO/HFQm9`), with their published split. Fetch the raw data +and build the CSV once: + +```bash +mkdir -p ~/step-up-data/qm9 && cd ~/step-up-data/qm9 +for f in gdb9.sdf train_indices.csv valid_indices.csv test_indices.csv; do + curl -sLO "https://huggingface.co/datasets/RichXuOvO/HFQm9/resolve/main/$f" +done +cd - +uv run python scripts/prepare_qm9_rebind.py \ + --raw-dir ~/step-up-data/qm9 --out ~/step-up-data/qm9/qm9-rebind.csv +``` + +Molblocks are copied out of `gdb9.sdf` verbatim, so bonds are the published ones +rather than re-perceived from coordinates (`dataset_source: sdf`), and each row +carries its published split label (110,000 / 10,000 / 10,831). About 1.4% of +records fail RDKit sanitization and are dropped at load time, leaving +108,478 / 9,873 / 10,661. ReBind's own evaluation skips such records too, though +their RDKit version dropped ~400 fewer. + +```bash +sbatch scripts/train.sh configs/qm9_rebind.yaml +uv run step-up evaluate -c configs/qm9_rebind.yaml --split test +``` + +`step-up evaluate` uses ReBind's metric definitions from their `evaluate.py`: +D-MAE and D-RMSE pooled over every pairwise distance in the split (larger +molecules therefore count more), and C-RMSD from RDKit's `GetBestRMS` with +hydrogens removed. The D-MAE the training loop prints is a per-batch mean, so it +is not directly comparable. + +All numbers below are the same protocol on the same 10,661-molecule test split. +Published values are as tabulated in the ReBind paper. + +| Metric | ReBind paper | ReBind ours | GTMGC paper | GTMGC ours | GTMGC released weights | +|---|---|---|---|---|---| +| D-MAE | 0.254 | 0.233 | 0.281 | 0.264 | 0.265 | +| D-RMSE | 0.446 | 0.439 | 0.471 | 0.462 | 0.459 | +| C-RMSD | 0.321 | 0.265 | 0.414 | 0.372 | 0.344 | + +Two things this table is good for, and one it isn't. + +**Our training is sound.** Training GTMGC ourselves lands within 0.3% of their +released checkpoint on D-MAE (0.264 vs 0.265) and 0.6% on D-RMSE, having never +seen their weights. + +**Model-to-model comparison holds up.** Under one protocol ReBind beats GTMGC by +12% on D-MAE, close to the 10% separating them in the paper, so the benchmark +reproduces the published ordering. + +**Absolute comparisons to published tables do not.** Scoring GTMGC's *own* +released checkpoint with our code gives numbers 6% better than their published +row, and training cannot explain a gap on someone else's weights. Our whole +column therefore carries a systematic offset of roughly that size, which is most +of what makes our ReBind run look better than its paper. The likely causes are +the slightly different test set (we drop 10,661 of their ~10,697 molecules, a +newer RDKit rejecting a few more) and the published row possibly coming from a +different run than the released checkpoint. Quote our numbers against each +other, not against the papers. + +### GTMGC on the same QM9 split + +`configs/qm9_gtmgc.yaml` trains GTMGC (Xu et al., ICLR 2024) on exactly the data +and split above, so the two models are directly comparable. Their conformer +models embed Mole-BERT tokenizer ids rather than atom types, so precompute the +per-atom ids once: + +```bash +uv run python scripts/tokenize_molebert.py -c configs/qm9_gtmgc.yaml \ + --out ~/step-up-data/qm9/qm9-molebert-tokens.csv +sbatch scripts/train.sh configs/qm9_gtmgc.yaml +``` + +`step-up evaluate` also scores released checkpoints, which is a useful check on +our pipeline: their weights under our protocol should reproduce their paper. + +```bash +uv run step-up evaluate -c configs/qm9_gtmgc.yaml --checkpoint RichXuOvO/GTMGC-Qm9 +``` + +Compute nodes on this cluster have no internet access, so anything pulled from +HuggingFace (the tokenizer, released checkpoints) has to be fetched from a login +node first. Either warm the cache by running the command there once, or download +the files and pass the directory: + +```bash +mkdir -p ~/step-up-data/checkpoints/GTMGC-Qm9 && cd $_ +for f in config.json pytorch_model.bin; do + curl -sLO "https://huggingface.co/RichXuOvO/GTMGC-Qm9/resolve/main/$f" +done +``` + +### Organometallic results + +All on each dataset's published/project split, scored with `step-up evaluate`. +RMSD here is Kabsch alignment on each molecule's real atoms, without symmetry +matching, so it is comparable across rows but *not* to a GetBestRMS C-RMSD. +"Relative" is D-MAE over the mean pairwise distance of that test set, which is +what makes organic and organometallic errors comparable at all: D-MAE is an +absolute distance error and grows with molecular size. + +| Run | Test molecules | D-MAE | D-RMSE | RMSD | Relative D-MAE | +|---|---|---|---|---|---| +| QM9, ReBind | 10,661 | 0.233 | 0.439 | 0.858 | 7.2% | +| QM9, GTMGC | 10,661 | 0.264 | 0.462 | 0.931 | 8.2% | +| tmQMg, outliers removed | 1,322 | 0.890 | 1.351 | 2.316 | 15.7% | +| tmQMg, complete | 1,360 | 0.892 | 1.353 | 2.325 | 15.7% | +| BOSTMC low-spin | 12,150 | 0.964 | 1.496 | 2.532 | 16.1% | + +Organometallic error is about twice QM9's in relative terms, not the four times +raw D-MAE suggests. Global structure degrades further than pairwise distances do: +RMSD is 27% of the mean pairwise distance on QM9 against 41-42% on the +organometallic sets. + +Training on tmQMg's 2,379 flagged-unphysical structures costs 0.7% D-MAE +(0.8902 vs 0.8962 on the identical outlier-free test set) and nothing on RMSD, so +the run that matches the published baselines is not meaningfully handicapped. + +### Charge and spin conditioning is used but barely pays + +`scripts/analyze_conditioning.py` re-scores a split with the molecule-level +features zeroed. On BOSTMC the conditioning is clearly active — perturbing charge +and spin moves predicted atoms by 1.3 A against a 3.3 A coordinate spread — but +zeroing it costs only 0.2% D-MAE overall, and at most ~2.5% on any well-populated +charge state. Open-shell doublets are not the hard case: they score 14.4% +relative against singlets' 16.5%. + +So scalars added to the atom embeddings are the wrong lever, or at least a weak +one. Before spending another multi-day run on it, try conditioning that a +LayerNorm cannot wash out: FiLM-style scale and shift per block, or a global +token the attention has to read. + ### Adjusting training duration To train longer, edit the config's `epochs` field. ReBind's paper used 20 @@ -118,20 +280,30 @@ src/step_up/ | |-- mol2.py # direct MOL2 parser (no RDKit, used for organometallics) | |-- splits.py # hash-based, stable train/val/test splits |-- models/ +| |-- common.py # shared conditioning + out-of-place Laplacian helpers +| |-- lj_params.py # full UFF Lennard-Jones table (ReBind's stops at Kr) | |-- rebind.py # thin wrapper over external/ReBIND + 3 runtime patches +| |-- gtmgc.py # thin wrapper over external/GTMGC + Mole-BERT tokenizer |-- eval/ | |-- metrics.py # D-MAE, D-RMSE, coord-RMSD, per-element D-MAE +| |-- conformer_eval.py # ReBind's published metric protocol |-- train.py # config-driven training loop -|-- cli.py # `uv run step-up train -c ` +|-- cli.py # `uv run step-up train|evaluate -c ` configs/ # per-dataset YAML configs (smoke + full) external/ReBIND/ # git submodule, vendored upstream ReBind +external/GTMGC/ # git submodule, vendored upstream GTMGC scripts/train.sh # Slurm job script (sbatch scripts/train.sh ) +scripts/prepare_qm9_rebind.py # QM9 + ReBind's published split --> CSV +scripts/tokenize_molebert.py # per-atom Mole-BERT ids, needed by GTMGC tests/ # imports, dataset loading, MOL2 parsing, forward pass, metrics ``` ## Data Path Notes +- **QM9 (sdf)**: molblocks copied out of `gdb9.sdf`, featurized with ReBind's + own `mol_to_graph_dict`. Bonds and coordinates come from the record, so + nothing is inferred. This is the path used to reproduce their numbers. - **QM9 (smiles + xyz)**: built from the XYZ block via `Chem.MolFromXYZBlock` + `rdDetermineBonds.DetermineBonds`. The SMILES column is intentionally unused because its atom order doesn't match the diff --git a/configs/bostmc.yaml b/configs/bostmc.yaml index 650b6d1..90b79a0 100644 --- a/configs/bostmc.yaml +++ b/configs/bostmc.yaml @@ -1,17 +1,28 @@ -# Full BOSTMC training run, restricted to closed-shell singlets so the metric -# comparison against tmQMg (singlets only) is clean and the model isn't asked -# to predict geometries from two spin manifolds at once. Doublet support is a -# follow-up that requires conditioning the model on (charge, spinmult) as a -# global feature. +# Full BOSTMC low-spin training run: singlets and doublets together (121,496 +# complexes), with the model conditioned on charge and spin so the two manifolds +# are distinguishable. +# +# Data is the filtered release, which drops structures whose molecular graph +# changed during optimization and deduplicates by initial graph hash, keeping the +# best-R-factor representative. +# +# Split: the project's own random split (seed 0, 80/10/10 by refcode) from +# datasets/filtered/splits/random, which covers all 121,496 low-spin rows. The +# similarity ("spectral") split under splits/spectral is the planned follow-up. dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/filtered/BOSTMC-low-spin.csv dataset_source: mol2 subset_size: null -filter_column: spinmult -filter_value: 1 -# Split key: refcode. id_column: refcode -split_ratios: [0.9, 0.05, 0.05] -split_seed: 0 +split_files: + train: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/train-random.csv + val: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/val-random.csv + test: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/test-random.csv + +# Charges span -8..+8 and spin multiplicity is 1 or 2. Both enter as scalars, so +# rare charge states still inform the model and inference isn't restricted to the +# combinations seen in training. +charge_column: charge +spin_column: spinmult n_layers: 8 d_model: 512 @@ -19,14 +30,21 @@ d_ffn: 1024 n_head: 8 dropout: 0.0 +# Optimization as in ReBind's rebind.sh (see README for the fp16 exception). epochs: 20 batch_size: 100 eval_batch_size: 100 lr: 9.0e-5 weight_decay: 0.0 warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true num_workers: 4 device: cuda output_dir: outputs/bostmc_full -seed: 0 +seed: 42 diff --git a/configs/bostmc_smoke.yaml b/configs/bostmc_smoke.yaml index ddc8e37..4aa42f4 100644 --- a/configs/bostmc_smoke.yaml +++ b/configs/bostmc_smoke.yaml @@ -1,12 +1,13 @@ -# BOSTMC smoke run on the first 100 rows, singlets only (like bostmc.yaml). Verifies -# the direct MOL2 parser end-to-end (no RDKit) plus the LJ patch on d-block elements. +# BOSTMC smoke run on the first 100 low-spin rows (like bostmc.yaml). Verifies the +# direct MOL2 parser end-to-end (no RDKit), the LJ patch on d-block elements, and +# the charge/spin conditioning path. dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/filtered/BOSTMC-low-spin.csv dataset_source: mol2 subset_size: 100 -filter_column: spinmult -filter_value: 1 # Split key: refcode. id_column: refcode +charge_column: charge +spin_column: spinmult cache_dataset: true split_ratios: [0.8, 0.1, 0.1] split_seed: 0 diff --git a/configs/qm9_gtmgc.yaml b/configs/qm9_gtmgc.yaml new file mode 100644 index 0000000..de538ab --- /dev/null +++ b/configs/qm9_gtmgc.yaml @@ -0,0 +1,50 @@ +# GTMGC (Xu et al., ICLR 2024) on QM9, same data and split as configs/qm9_rebind.yaml +# so the two models are directly comparable. +# +# Reference numbers for QM9 test, as tabulated in the ReBind paper: +# GTMGC D-MAE 0.281 D-RMSE 0.471 C-RMSD 0.414 +# ReBind D-MAE 0.254 D-RMSE 0.446 C-RMSD 0.321 +# +# Architecture follows their released QM9 checkpoint (RichXuOvO/GTMGC-Qm9): +# 6 + 6 layers, d_model 256, d_ffn 1024, 8 heads, Mole-BERT token embeddings. +# Their conformer models embed tokenizer ids rather than atom types, so the +# per-atom ids must be precomputed first: +# +# uv run python scripts/tokenize_molebert.py -c configs/qm9_gtmgc.yaml \ +# --out /home/gridsan/jtoney/step-up-data/qm9/qm9-molebert-tokens.csv +model: gtmgc +dataset_path: /home/gridsan/jtoney/step-up-data/qm9/qm9-rebind.csv +dataset_source: sdf +subset_size: null +id_column: mol_id +split_column: split +token_file: /home/gridsan/jtoney/step-up-data/qm9/qm9-molebert-tokens.csv +max_drop_fraction: 0.05 + +n_layers: 6 +d_model: 256 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization from their experiments/conformer_prediction/gtmgc_for_conformer_prediction.sh. +# That script is the Molecule3D one (the repo ships no QM9 variant), so the learning +# rate is theirs for Molecule3D; everything else is shared across their runs. As with +# ReBind we train in fp32 rather than their fp16 (see README). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 5.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/qm9_gtmgc +seed: 42 diff --git a/configs/qm9_rebind.yaml b/configs/qm9_rebind.yaml new file mode 100644 index 0000000..1478918 --- /dev/null +++ b/configs/qm9_rebind.yaml @@ -0,0 +1,44 @@ +# Reproduction run for ReBind's published QM9 conformer-prediction numbers +# (paper, test split: D-MAE 0.254, D-RMSE 0.446, C-RMSD 0.321). +# +# Data is the QM9 copy ReBind and GTMGC trained on (HuggingFace RichXuOvO/HFQm9), +# converted by scripts/prepare_qm9_rebind.py. Molblocks are copied out of gdb9.sdf +# verbatim, so bonds are the published ones rather than re-perceived from geometry, +# and the CSV carries their published split (110,000 / 10,000 / 10,831). +# +# Hyperparameters follow external/ReBIND/experiments/conformer_prediction/rebind.sh; +# see README for the few places the loop still differs from their script. +dataset_path: /home/gridsan/jtoney/step-up-data/qm9/qm9-rebind.csv +dataset_source: sdf +subset_size: null +id_column: mol_id +split_column: split +# About 1.4% of records fail RDKit sanitization and are dropped, as in ReBind's +# own evaluation; fail the run if that rate jumps. +max_drop_fraction: 0.05 + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization, verbatim from rebind.sh (fp16 is the one setting we don't mirror; +# we train in fp32, which is the more precise choice — see README). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/qm9_rebind +seed: 42 diff --git a/configs/tmqmg.yaml b/configs/tmqmg.yaml index cdc4b00..48bdde6 100644 --- a/configs/tmqmg.yaml +++ b/configs/tmqmg.yaml @@ -1,11 +1,31 @@ -# Full tmQMg-full.csv training run (~60K organometallic complexes, closed-shell singlets, full d-block + La). GPU-only; staged for Slurm. +# Full tmQMg training run (~58K organometallic complexes, closed-shell singlets, +# full d-block + La). GPU-only; staged for Slurm. +# +# This copy of tmQMg is the original release minus the 2,390 IDs in the dataset's +# outliers.txt (unphysical geometries the community flagged), so it is the cleaner +# training set but NOT the data the published baselines used. See +# configs/tmqmg_complete.yaml for the run that matches TMCgen exactly. +# +# Split: tmQMg's authors publish no canonical ML split, so we adopt TMCgen's +# (Schaufelberger & Jorner, arXiv 2606.00666), whose paper also reports the GeoDiff +# and ConfGF tmQMg baselines we will compare against. Their refcode lists live in +# github.com/digital-chemistry-laboratory/TMCgen under data/raw/tmqmg. They cover +# 58,086 of the rows here; the 2,379 they list that are missing are exactly the +# removed outliers, 38 of which are in their test split. Rows in no list are dropped. dataset_path: /home/gridsan/jtoney/ElemeNet-benchmarking/benchmarking/datasets/tmQMg-full.csv dataset_source: mol2 subset_size: null -# Split key: the tmQMg id (CSD refcode). id_column: id -split_ratios: [0.9, 0.05, 0.05] -split_seed: 0 +split_files: + train: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-train.txt + val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt + test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt + +# Charge runs -1 / 0 / +1 here. Every complex is a closed-shell singlet (no odd +# electron counts), so the spin feature is constant, but the conditioning is kept +# identical to BOSTMC so the two runs share an architecture. +charge_column: charge +spin_column: null n_layers: 8 d_model: 512 @@ -13,14 +33,21 @@ d_ffn: 1024 n_head: 8 dropout: 0.0 +# Optimization as in ReBind's rebind.sh (see README for the fp16 exception). epochs: 20 batch_size: 100 eval_batch_size: 100 lr: 9.0e-5 weight_decay: 0.0 warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true num_workers: 4 device: cuda output_dir: outputs/tmqmg_full -seed: 0 +seed: 42 diff --git a/configs/tmqmg_complete.yaml b/configs/tmqmg_complete.yaml new file mode 100644 index 0000000..a67707c --- /dev/null +++ b/configs/tmqmg_complete.yaml @@ -0,0 +1,53 @@ +# tmQMg trained on exactly the data TMCgen used, so our numbers sit alongside the +# GeoDiff and ConfGF tmQMg baselines from their paper without a caveat. +# +# Three snapshots of tmQMg exist, and they are nested, not recalculated (n_atoms, +# charge and electronic energies are bit-identical wherever they overlap): +# - complete, 60,799 rows: the original release, used here. +# - filtered, 58,409 rows: the same minus the 2,390 IDs in tmQMg's outliers.txt +# (unphysical geometries the community flagged). That's configs/tmqmg.yaml. +# - latest, 74,547 rows: the 2024 extension, which also dropped 87 of the +# originals, 87 of them in TMCgen's split. +# +# Only the complete snapshot covers all 60,465 complexes in TMCgen's split, +# including all 1,360 of their test complexes; 2,379 of the outlier structures are +# in their split and 38 are in their test set, so the baselines were trained and +# scored with them. The 334 rows here that TMCgen never used are dropped. +dataset_path: /home/gridsan/jtoney/step-up-data/tmQMg-complete.csv +dataset_source: mol2 +subset_size: null +id_column: id +split_files: + train: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-train.txt + val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt + test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt + +# Charge runs -1 / 0 / +1; every complex is a closed-shell singlet, so the spin +# feature is constant. Same conditioning as BOSTMC so the runs share an architecture. +charge_column: charge +spin_column: null + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization as in ReBind's rebind.sh (see README for the fp16 exception). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/tmqmg_complete_full +seed: 42 diff --git a/configs/tmqmg_smoke.yaml b/configs/tmqmg_smoke.yaml index ba54ba2..4e05a3f 100644 --- a/configs/tmqmg_smoke.yaml +++ b/configs/tmqmg_smoke.yaml @@ -5,6 +5,8 @@ dataset_source: mol2 subset_size: 100 # Split key: the tmQMg id (CSD refcode). id_column: id +charge_column: charge +spin_column: null cache_dataset: true split_ratios: [0.8, 0.1, 0.1] split_seed: 0 diff --git a/external/GTMGC b/external/GTMGC new file mode 160000 index 0000000..b33855b --- /dev/null +++ b/external/GTMGC @@ -0,0 +1 @@ +Subproject commit b33855bc23b23a749aac750fc651b3c91f8c3b05 diff --git a/scripts/analyze_conditioning.py b/scripts/analyze_conditioning.py new file mode 100644 index 0000000..14a9eeb --- /dev/null +++ b/scripts/analyze_conditioning.py @@ -0,0 +1,113 @@ +"""Check whether a conditioned model actually uses its charge/spin input. + +Scores a split three ways: + +1. as trained, +2. with the molecule-level features zeroed at inference — if the metrics barely + move, the model learned to ignore the conditioning, and +3. optionally broken down by a column of the source CSV (e.g. ``spinmult``), to + see whether one subgroup is carrying the error. + +Usage:: + + uv run python scripts/analyze_conditioning.py -c configs/bostmc.yaml \ + --checkpoint outputs/bostmc_full/best.pt --breakdown-column spinmult +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import replace +from pathlib import Path + +import torch +from torch.utils.data import Subset + +from step_up.eval.conformer_eval import evaluate_split +from step_up.models import N_GLOBAL_FEATURES, build_model, build_model_collator +from step_up.train import TrainConfig, build_splits + + +class ZeroConditioning: + """Collator wrapper that blanks the molecule-level features.""" + + def __init__(self, base): + self.base = base + + def __call__(self, mols): + batch = self.base(mols) + batch["global_features"] = torch.zeros_like(batch["global_features"]) + return batch + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-c", "--config", required=True) + parser.add_argument("--checkpoint", help="Defaults to /best.pt") + parser.add_argument("--split", default="test", choices=["train", "val", "test"]) + parser.add_argument("--breakdown-column", help="CSV column to group the split by") + parser.add_argument("--out", help="Defaults to /conditioning_analysis.json") + args = parser.parse_args(argv) + + cfg = TrainConfig.from_yaml(args.config) + conditioned = cfg.charge_column is not None or cfg.spin_column is not None + if not conditioned: + raise ValueError(f"{args.config} trains without conditioning; nothing to ablate") + device = cfg.device if torch.cuda.is_available() or cfg.device == "cpu" else "cpu" + cfg = replace(cfg, device=device) + + dataset, train_set, val_set, test_set = build_splits(cfg) + subset = {"train": train_set, "val": val_set, "test": test_set}[args.split] + model = build_model( + cfg.model, + n_layers=cfg.n_layers, + d_model=cfg.d_model, + d_ffn=cfg.d_ffn, + n_head=cfg.n_head, + dropout=cfg.dropout, + n_global_features=N_GLOBAL_FEATURES, + ) + checkpoint = args.checkpoint or str(Path(cfg.output_dir) / "best.pt") + model.load_state_dict(torch.load(checkpoint, map_location="cpu")) + model = model.to(device) + collator = build_model_collator(cfg.model, conditioned=True) + + results: dict[str, dict] = {} + + def score(tag: str, rows: Subset, coll) -> None: + metrics = evaluate_split( + model, dataset, rows, coll, device=device, batch_size=cfg.eval_batch_size + ) + results[tag] = metrics + print( + f"{tag:30s} n={int(metrics['n_molecules']):6d} D-MAE={metrics['d_mae']:.4f} " + f"D-RMSE={metrics['d_rmse']:.4f} RMSD={metrics['c_rmsd']:.3f}", + flush=True, + ) + + score(f"{args.split} (as trained)", subset, collator) + score(f"{args.split} (conditioning zeroed)", subset, ZeroConditioning(collator)) + + if args.breakdown_column: + rows = dataset._df.iloc[[dataset._valid_indices[i] for i in subset.indices]] + values = rows[args.breakdown_column].tolist() + for value in sorted(set(values)): + group = Subset( + dataset, [i for i, v in zip(subset.indices, values, strict=True) if v == value] + ) + # Both ways per group: conditioning can be useless on average yet matter + # for the subgroups it describes, e.g. charged or open-shell species. + score(f"{args.breakdown_column}={value}", group, collator) + score(f"{args.breakdown_column}={value} (zeroed)", group, ZeroConditioning(collator)) + + out_path = Path(args.out or Path(cfg.output_dir) / "conditioning_analysis.json") + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as handle: + json.dump(results, handle, indent=2) + print(f"wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_qm9_rebind.py b/scripts/prepare_qm9_rebind.py new file mode 100644 index 0000000..f5c4df0 --- /dev/null +++ b/scripts/prepare_qm9_rebind.py @@ -0,0 +1,93 @@ +"""Build a QM9 CSV carrying ReBind's published train/val/test split. + +Source data is the HuggingFace dataset ``RichXuOvO/HFQm9``, the QM9 copy that +GTMGC and ReBind trained on: + + gdb9.sdf 133,885 molecules, one SDF record each + train_indices.csv 110,000 0-based positions into gdb9.sdf + valid_indices.csv 10,000 + test_indices.csv 10,831 + +The three index files are disjoint and together cover 130,831 molecules; the +3,054 QM9 entries that failed the original consistency check are left out. + +Download them with:: + + mkdir -p && cd + for f in gdb9.sdf train_indices.csv valid_indices.csv test_indices.csv; do + curl -sLO "https://huggingface.co/datasets/RichXuOvO/HFQm9/resolve/main/$f" + done + +Molblocks are copied out of the SDF verbatim (no RDKit round trip), so the +training pipeline featurizes exactly the published structures and bond orders. + +Usage:: + + uv run python scripts/prepare_qm9_rebind.py --raw-dir --out +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +SPLIT_FILES = {"train": "train_indices.csv", "val": "valid_indices.csv", "test": "test_indices.csv"} + + +def read_molblocks(sdf_path: Path) -> list[str]: + """Split an SDF into per-molecule blocks, keeping each record verbatim.""" + records = sdf_path.read_text().split("$$$$\n") + if records and not records[-1].strip(): + records.pop() + return records + + +def read_indices(raw_dir: Path) -> dict[str, list[int]]: + indices: dict[str, list[int]] = {} + for split, name in SPLIT_FILES.items(): + with open(raw_dir / name) as f: + rows = list(csv.DictReader(f)) + indices[split] = [int(row["index"]) for row in rows] + return indices + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--raw-dir", required=True, type=Path, help="Directory holding gdb9.sdf") + parser.add_argument("--out", required=True, type=Path, help="CSV to write") + args = parser.parse_args(argv) + + csv.field_size_limit(sys.maxsize) + blocks = read_molblocks(args.raw_dir / "gdb9.sdf") + print(f"gdb9.sdf: {len(blocks)} records") + indices = read_indices(args.raw_dir) + + assigned: dict[int, str] = {} + for split, idx_list in indices.items(): + for i in idx_list: + if i in assigned: + raise ValueError(f"index {i} appears in both {assigned[i]} and {split}") + if not 0 <= i < len(blocks): + raise ValueError(f"index {i} is out of range for {len(blocks)} records") + assigned[i] = split + print("split sizes: " + ", ".join(f"{s}={len(v)}" for s, v in indices.items())) + + args.out.parent.mkdir(parents=True, exist_ok=True) + with open(args.out, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["mol_id", "split", "sdf"]) + for i in sorted(assigned): + block = blocks[i] + # The first line of an SDF record is the molecule title, e.g. "gdb_73134". + mol_id = block.lstrip("\n").split("\n", 1)[0].strip() + if not mol_id: + raise ValueError(f"record {i} has no title line to use as mol_id") + writer.writerow([mol_id, assigned[i], block]) + print(f"wrote {len(assigned)} rows to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tokenize_molebert.py b/scripts/tokenize_molebert.py new file mode 100644 index 0000000..df72290 --- /dev/null +++ b/scripts/tokenize_molebert.py @@ -0,0 +1,89 @@ +"""Precompute Mole-BERT tokenizer ids for a dataset. + +GTMGC's published conformer models embed atoms as Mole-BERT tokenizer ids +(``embed_style="atom_tokenized_ids"``), so every molecule needs per-atom +``input_ids``. Upstream tokenizes the dataset once and saves it to disk +(``tokenize_mole.py``); we do the same, writing a small ``id,input_ids`` CSV that +``CSVMoleculeDataset`` joins on via ``token_file``. + +The dataset is built from the same config the training run uses, so the rows and +the featurization match; rows that fail featurization are dropped here exactly as +they are in training. + +Usage:: + + uv run python scripts/tokenize_molebert.py -c configs/qm9_gtmgc.yaml \ + --out ~/step-up-data/qm9/qm9-molebert-tokens.csv +""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path + +import torch +from tqdm import tqdm + +from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.models.gtmgc import load_molebert_tokenizer +from step_up.train import TrainConfig + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-c", "--config", required=True, help="Training config to take data from") + parser.add_argument("--out", required=True, type=Path, help="CSV to write") + parser.add_argument("--checkpoint", default="RichXuOvO/MoleBERT-Tokenizer") + parser.add_argument("--batch-size", type=int, default=256) + parser.add_argument("--device", default="cpu") + args = parser.parse_args(argv) + + cfg = TrainConfig.from_yaml(args.config) + if cfg.id_column is None: + raise ValueError("the config needs id_column so tokens can be keyed by molecule") + dataset = CSVMoleculeDataset( + path=cfg.dataset_path, + source=cfg.dataset_source, # type: ignore[arg-type] + subset_size=cfg.subset_size, + validate=cfg.validate_dataset, + max_drop_fraction=cfg.max_drop_fraction, + filter_column=cfg.filter_column, + filter_value=cfg.filter_value, + id_column=cfg.id_column, + # token_file deliberately omitted: this script is what creates it. + ) + keys = dataset.split_keys() + tokenizer, collator = load_molebert_tokenizer(args.checkpoint) + tokenizer = tokenizer.eval().to(args.device) + + args.out.parent.mkdir(parents=True, exist_ok=True) + written = 0 + with open(args.out, "w", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["id", "input_ids"]) + for start in tqdm(range(0, len(dataset), args.batch_size), desc="tokenizing", unit="batch"): + chunk = list(range(start, min(start + args.batch_size, len(dataset)))) + graphs = [dataset[i] for i in chunk] + batch = collator(graphs) + with torch.no_grad(): + out = tokenizer(**{k: v.to(args.device) for k, v in batch.items()}) + token_ids = out["quantized_indices"].cpu().tolist() + offset = 0 + for index, graph in zip(chunk, graphs, strict=True): + n = graph["num_nodes"] + writer.writerow( + [keys[index], " ".join(str(t) for t in token_ids[offset : offset + n])] + ) + offset += n + written += 1 + if offset != len(token_ids): + raise RuntimeError( + f"tokenizer returned {len(token_ids)} ids for {offset} atoms in this batch" + ) + print(f"wrote {written} rows to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/train.sh b/scripts/train.sh index 61910d9..ed93be9 100755 --- a/scripts/train.sh +++ b/scripts/train.sh @@ -25,8 +25,14 @@ REPO_ROOT="$(git -C "${SLURM_SUBMIT_DIR:-$(dirname "$0")}" rev-parse --show-topl cd "$REPO_ROOT" # Make sure the submodule is initialized in case the job runs on a fresh checkout. -git submodule update --init --recursive +# Jobs launched together share this clone, and git's config lock is not safe against +# concurrent writers (a second job dies with "could not lock config file"), so skip +# the update once the submodule is populated and serialize the setup steps. +SETUP_LOCK="$REPO_ROOT/.git/step-up-setup.lock" +if [[ ! -f external/ReBIND/models/rebind/modeling_rebind.py ]]; then + flock "$SETUP_LOCK" git submodule update --init --recursive +fi -uv sync --dev +flock "$SETUP_LOCK" uv sync --dev uv run step-up train -c "$CONFIG" diff --git a/src/step_up/cli.py b/src/step_up/cli.py index f19a353..f3c566a 100644 --- a/src/step_up/cli.py +++ b/src/step_up/cli.py @@ -6,7 +6,11 @@ import sys from pathlib import Path -from .train import TrainConfig, train +import torch + +from .eval.conformer_eval import evaluate_split, write_metrics +from .models import N_GLOBAL_FEATURES, build_model, build_model_collator +from .train import TrainConfig, build_splits, train def _cmd_train(args: argparse.Namespace) -> int: @@ -26,6 +30,64 @@ def _cmd_train(args: argparse.Namespace) -> int: return 0 +def _cmd_evaluate(args: argparse.Namespace) -> int: + """Score a checkpoint with ReBind's published metric definitions.""" + cfg = TrainConfig.from_yaml(args.config) + checkpoint = str(args.checkpoint or Path(cfg.output_dir) / "best.pt") + # A `.pt` path is one of our state dicts; anything else is a released + # checkpoint (HuggingFace repo id or a directory), e.g. RichXuOvO/GTMGC-Qm9. + is_state_dict = Path(checkpoint).suffix in {".pt", ".pth"} + if is_state_dict and not Path(checkpoint).exists(): + print(f"ERROR: checkpoint not found: {checkpoint}") + return 1 + + dataset, train_set, val_set, test_set = build_splits(cfg) + subset = {"train": train_set, "val": val_set, "test": test_set}[args.split] + if len(subset) == 0: + print(f"ERROR: the {args.split} split is empty") + return 1 + + conditioned = cfg.charge_column is not None or cfg.spin_column is not None + if is_state_dict: + model = build_model( + cfg.model, + n_layers=cfg.n_layers, + d_model=cfg.d_model, + d_ffn=cfg.d_ffn, + n_head=cfg.n_head, + dropout=cfg.dropout, + n_global_features=N_GLOBAL_FEATURES if conditioned else 0, + ).to(cfg.device) + model.load_state_dict(torch.load(checkpoint, map_location=cfg.device)) + elif cfg.model == "gtmgc": + from .models.gtmgc import load_pretrained_gtmgc + + print(f"[evaluate] loading released checkpoint {checkpoint}", flush=True) + model = load_pretrained_gtmgc(checkpoint).to(cfg.device) + else: + print(f"ERROR: {cfg.model} cannot load a released checkpoint: {checkpoint}") + return 1 + metrics = evaluate_split( + model, + dataset, + subset, + build_model_collator(cfg.model, conditioned=conditioned), + device=cfg.device, + batch_size=cfg.eval_batch_size, + remove_hs=not args.keep_hs, + ) + out_path = Path(args.out) if args.out else Path(cfg.output_dir) / f"eval_{args.split}.json" + write_metrics(metrics, out_path) + hydrogens = "without hydrogens" if metrics["remove_hs"] else "with hydrogens" + print( + f"[{args.split}] D-MAE={metrics['d_mae']:.4f} D-RMSE={metrics['d_rmse']:.4f} " + f"C-RMSD={metrics['c_rmsd']:.4f} ({hydrogens}, {metrics['c_rmsd_method']}) " + f"over {int(metrics['n_molecules'])} molecules" + ) + print(f"wrote {out_path}") + return 0 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="step-up") sub = parser.add_subparsers(dest="command", required=True) @@ -37,6 +99,20 @@ def main(argv: list[str] | None = None) -> int: ) p_train.set_defaults(func=_cmd_train) + p_eval = sub.add_parser("evaluate", help="Score a checkpoint (ReBind's metric protocol)") + p_eval.add_argument("-c", "--config", required=True, help="Path to YAML config") + p_eval.add_argument( + "--checkpoint", + help="Our .pt state dict, or a released checkpoint such as RichXuOvO/GTMGC-Qm9. " + "Defaults to /best.pt", + ) + p_eval.add_argument("--split", default="test", choices=["train", "val", "test"]) + p_eval.add_argument("--out", help="Defaults to /eval_.json") + p_eval.add_argument( + "--keep-hs", action="store_true", help="Keep hydrogens in C-RMSD (ReBind removes them)" + ) + p_eval.set_defaults(func=_cmd_evaluate) + args = parser.parse_args(argv) return args.func(args) diff --git a/src/step_up/data/csv_dataset.py b/src/step_up/data/csv_dataset.py index b1d16b3..569cd04 100644 --- a/src/step_up/data/csv_dataset.py +++ b/src/step_up/data/csv_dataset.py @@ -21,10 +21,16 @@ from typing import Any, Literal import pandas as pd +from rdkit import Chem from torch.utils.data import Dataset from tqdm import tqdm -from .featurize import featurize_mol2_xyz, featurize_xyz +from .featurize import ( + featurize_mol2_xyz, + featurize_molblock, + featurize_xyz, + mol_from_xyz_block, +) # Embedded XYZ/MOL2 fields blow past the default csv.field_size_limit. Raise it # once at import time so pandas (which uses Python's csv internally for the C @@ -40,6 +46,18 @@ class DatasetSpec: source: Literal["smiles", "mol2"] +def _read_token_file(path: Path) -> dict[str, list[int]]: + """Read ``id,input_ids`` rows into a mapping of id to per-atom token ids.""" + frame = pd.read_csv(path) + missing = {"id", "input_ids"} - set(frame.columns) + if missing: + raise ValueError(f"token file {path} is missing columns: {sorted(missing)}") + return { + str(key): [int(v) for v in str(ids).split()] + for key, ids in zip(frame["id"], frame["input_ids"], strict=True) + } + + def _read_csv_subset(path: Path, columns: Sequence[str], nrows: int | None) -> pd.DataFrame: df = pd.read_csv(path, usecols=list(columns), nrows=nrows, low_memory=False) df = df.reset_index(drop=True) @@ -57,9 +75,11 @@ class CSVMoleculeDataset(Dataset): path Path to the CSV. source - Either ``"smiles"`` (QM9-style: featurize from the ``xyz`` column via - RDKit ``DetermineBonds``) or ``"mol2"`` (organometallic: featurize from - the ``mol2`` column directly via the in-house parser). + One of ``"smiles"`` (QM9-style: featurize from the ``xyz`` column via + RDKit ``DetermineBonds``), ``"mol2"`` (organometallic: featurize from + the ``mol2`` column directly via the in-house parser), or ``"sdf"`` + (featurize an SDF record held in the ``sdf`` column, keeping its own + bonds and coordinates). subset_size Optional cap on the number of CSV rows to load (the first rows of the file). Useful for smoke runs. @@ -89,12 +109,26 @@ class CSVMoleculeDataset(Dataset): used as the split key by :meth:`split_keys`. Rows sharing an ID always land in the same split. Without it, a row's key is its row number in the CSV. + token_file + Optional CSV of ``id,input_ids`` (space-separated per-atom token ids, + see ``scripts/tokenize_molebert.py``), joined on ``id_column``. GTMGC's + published conformer models embed Mole-BERT tokenizer ids rather than + atom types, so their batches need this. + split_column + Optional column holding a published split name per row (``train`` / + ``val`` / ``test``), read by :meth:`split_labels`. Use it to reproduce + someone else's split instead of hashing one. + charge_column, spin_column + Optional columns holding the molecule's formal charge (electrons) and + spin multiplicity. When given, each graph dict carries ``charge`` and + ``spin_multiplicity`` for the model's global conditioning. A missing + column means neutral / closed-shell. """ def __init__( self, path: str | Path, - source: Literal["smiles", "mol2"], + source: Literal["smiles", "mol2", "sdf"], subset_size: int | None = None, validate: bool = True, max_drop_fraction: float = 0.5, @@ -102,12 +136,25 @@ def __init__( filter_value: Any = None, cache: bool = False, id_column: str | None = None, + split_column: str | None = None, + charge_column: str | None = None, + spin_column: str | None = None, + token_file: str | Path | None = None, ) -> None: self.path = Path(path) if not self.path.exists(): raise FileNotFoundError(self.path) self.source = source self.id_column = id_column + self.split_column = split_column + self.charge_column = charge_column + self.spin_column = spin_column + self.token_file = Path(token_file) if token_file is not None else None + self._tokens: dict[str, list[int]] | None = None + if self.token_file is not None: + if id_column is None: + raise ValueError("token_file needs id_column to join the tokens on") + self._tokens = _read_token_file(self.token_file) self._cache_enabled = cache self._cache: dict[int, dict[str, Any]] = {} @@ -115,9 +162,11 @@ def __init__( cols = ["smiles", "xyz"] elif source == "mol2": cols = ["mol2", "xyz"] + elif source == "sdf": + cols = ["sdf"] else: raise ValueError(f"Unknown source: {source!r}") - for extra in (filter_column, id_column): + for extra in (filter_column, id_column, split_column, charge_column, spin_column): if extra is not None and extra not in cols: cols.append(extra) @@ -172,11 +221,56 @@ def split_keys(self) -> list[str]: raise ValueError(f"id_column {self.id_column!r} has missing values in {self.path}") return ids.astype(str).tolist() + def rdkit_mol(self, idx: int): + """RDKit molecule for item ``idx``, atoms in the same order as its graph dict. + + Available for the ``sdf`` and ``smiles`` sources. The MOL2 path never + builds an RDKit molecule, so it raises instead. + """ + row = self._df.iloc[self._valid_indices[idx]] + if self.source == "sdf": + return Chem.MolFromMolBlock(str(row["sdf"]), removeHs=False) + if self.source == "smiles": + return mol_from_xyz_block(str(row["xyz"])) + raise NotImplementedError(f"source {self.source!r} does not go through RDKit") + + def split_labels(self) -> list[str]: + """Published split name per row, aligned with dataset indices.""" + if self.split_column is None: + raise ValueError("split_labels() needs split_column to be set") + labels = self._df.iloc[self._valid_indices][self.split_column] + if labels.isna().any(): + raise ValueError( + f"split_column {self.split_column!r} has missing values in {self.path}" + ) + return labels.astype(str).tolist() + def _featurize(self, real_idx: int) -> dict[str, Any]: row = self._df.iloc[real_idx] if self.source == "smiles": - return featurize_xyz(str(row["xyz"])) - return featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"])) + graph = featurize_xyz(str(row["xyz"])) + elif self.source == "sdf": + graph = featurize_molblock(str(row["sdf"])) + else: + graph = featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"])) + # Molecule-level state for conditioning; the collator turns these into + # model inputs. Absent columns mean a neutral closed-shell molecule. + if self.charge_column is not None: + graph["charge"] = float(row[self.charge_column]) + if self.spin_column is not None: + graph["spin_multiplicity"] = float(row[self.spin_column]) + if self._tokens is not None: + key = str(row[self.id_column]) + tokens = self._tokens.get(key) + if tokens is None: + raise KeyError(f"no Mole-BERT tokens for {key!r} in {self.token_file}") + if len(tokens) != graph["num_nodes"]: + raise ValueError( + f"{key!r} has {len(tokens)} tokens for {graph['num_nodes']} atoms; " + "the token file was built from a different featurization" + ) + graph["input_ids"] = tokens + return graph def _validate_rows(self, max_drop_fraction: float) -> list[int]: n = len(self._df) diff --git a/src/step_up/data/featurize.py b/src/step_up/data/featurize.py index 81f3280..cbac390 100644 --- a/src/step_up/data/featurize.py +++ b/src/step_up/data/featurize.py @@ -129,6 +129,20 @@ def featurize_xyz(xyz_block: str, charge: int = 0) -> dict[str, Any]: return mol_to_graph_dict(mol) +def featurize_molblock(molblock: str) -> dict[str, Any]: + """Featurize one SDF/MOL record (the QM9 path for ReBind's published data). + + Bonds and coordinates are read from the record, so nothing is re-perceived + from geometry, and hydrogens stay explicit. Raises ``ValueError`` when RDKit + cannot parse or sanitize the record; the dataset's validation pass filters + those rows out (ReBind's own evaluation skips them too). + """ + mol = Chem.MolFromMolBlock(molblock, removeHs=False) + if mol is None: + raise ValueError("RDKit failed to parse the molblock") + return mol_to_graph_dict(mol) + + def featurize_mol2_xyz(mol2_block: str, xyz_block: str) -> dict[str, Any]: """One-shot helper for MOL2-sourced rows (tmQMg, BOSTMC). diff --git a/src/step_up/data/splits.py b/src/step_up/data/splits.py index 2e60ef9..91e084d 100644 --- a/src/step_up/data/splits.py +++ b/src/step_up/data/splits.py @@ -3,10 +3,93 @@ from __future__ import annotations import hashlib -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from pathlib import Path from torch.utils.data import Dataset, Subset +SPLIT_NAMES = ("train", "val", "test") +# Names other projects use for the same three splits. +_SPLIT_ALIASES = {"valid": "val", "validation": "val", "dev": "val", "eval": "val"} + + +def split_by_labels(dataset: Dataset, labels: Sequence[str]) -> tuple[Subset, Subset, Subset]: + """Partition ``dataset`` by an explicit per-row split name. + + Use this to adopt a published split (``labels[i]`` is item ``i``'s split) + instead of deriving one. ``valid``/``validation``/``dev``/``eval`` are + accepted as spellings of ``val``. + """ + n = len(dataset) # type: ignore[arg-type] + if len(labels) != n: + raise ValueError(f"Got {len(labels)} labels for a dataset of length {n}") + buckets: dict[str, list[int]] = {name: [] for name in SPLIT_NAMES} + for i, raw in enumerate(labels): + name = str(raw).strip().lower() + name = _SPLIT_ALIASES.get(name, name) + if name not in buckets: + raise ValueError( + f"Unknown split name {raw!r} at row {i}; expected one of {SPLIT_NAMES}" + ) + buckets[name].append(i) + return tuple(Subset(dataset, buckets[name]) for name in SPLIT_NAMES) # type: ignore[return-value] + + +_ID_LIST_HEADERS = {"refcode", "id", "mol_id", "identifier", "name", "complex"} + + +def read_id_list(path: str | Path) -> list[str]: + """Read IDs from a one-per-line text file or a single-column CSV. + + A leading line that looks like a column header (``refcode``, ``id``, ...) is + skipped, and only the first comma-separated field of each line is used. + """ + ids: list[str] = [] + for line in Path(path).read_text().splitlines(): + entry = line.split(",")[0].strip() + if entry: + ids.append(entry) + if ids and ids[0].lower() in _ID_LIST_HEADERS: + ids = ids[1:] + return ids + + +def split_by_id_files( + dataset: Dataset, keys: Sequence[str], files: Mapping[str, str | Path] +) -> tuple[Subset, Subset, Subset]: + """Partition ``dataset`` using published ID lists, one file per split. + + ``files`` maps a split name to a file of IDs, and ``keys[i]`` is item ``i``'s + ID. Items listed in none of the files are dropped, which is how a split + published against a slightly different snapshot of a dataset still applies + to ours. + """ + assignment: dict[str, str] = {} + for raw_name, path in files.items(): + name = str(raw_name).strip().lower() + name = _SPLIT_ALIASES.get(name, name) + if name not in SPLIT_NAMES: + raise ValueError(f"Unknown split name {raw_name!r}; expected one of {SPLIT_NAMES}") + for identifier in read_id_list(path): + previous = assignment.get(identifier) + if previous is not None and previous != name: + raise ValueError(f"ID {identifier!r} is listed in both {previous} and {name}") + assignment[identifier] = name + + buckets: dict[str, list[int]] = {name: [] for name in SPLIT_NAMES} + unlisted = 0 + for i, key in enumerate(keys): + name = assignment.get(str(key)) + if name is None: + unlisted += 1 + continue + buckets[name].append(i) + if unlisted: + print( + f"[split] {unlisted} rows are in none of the split files and were dropped", flush=True + ) + return tuple(Subset(dataset, buckets[name]) for name in SPLIT_NAMES) # type: ignore[return-value] + def _unit_interval(key: str, seed: int) -> float: """Map ``(seed, key)`` to a float in [0, 1), identical on every platform and run.""" diff --git a/src/step_up/eval/conformer_eval.py b/src/step_up/eval/conformer_eval.py new file mode 100644 index 0000000..5916178 --- /dev/null +++ b/src/step_up/eval/conformer_eval.py @@ -0,0 +1,153 @@ +"""Conformer metrics in ReBind's published protocol. + +``external/ReBIND/evaluate.py`` reports three numbers, and the details matter if +you want to compare against the paper: + +- **D-MAE / D-RMSE** pool absolute / squared errors over *every* pairwise + distance in the split and divide by the total count of distances, including + the zero diagonal. This is a distance-weighted average, so larger molecules + count more — unlike the per-batch mean the training loop prints. +- **C-RMSD** is RDKit's ``GetBestRMS`` (optimal alignment, symmetry aware) on + the molecule with hydrogens removed, averaged per molecule. + +For reference, ReBind's paper reports test D-MAE 0.254, D-RMSE 0.446 and +C-RMSD 0.321 on QM9. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import torch +from rdkit import Chem, RDLogger +from rdkit.Chem import rdchem, rdMolAlign +from torch.utils.data import DataLoader, Subset +from tqdm import tqdm + +RDLogger.DisableLog("rdApp.*") + + +def kabsch_rmsd(pred: torch.Tensor, target: torch.Tensor) -> float: + """RMSD after optimally aligning ``pred`` onto ``target`` (Kabsch, no symmetry matching). + + Both are ``(n, 3)`` over an individual molecule's real atoms. Doing the + alignment here rather than trusting the model's own matters: ReBind and GTMGC + align inside the prediction head over the *padded* batch tensor, so padding + zeros pull the centroid and rotation off and inflate the RMSD — badly for a + small molecule batched with a large one. + """ + p = pred - pred.mean(dim=0, keepdim=True) + q = target - target.mean(dim=0, keepdim=True) + u, _, vt = torch.linalg.svd(p.T @ q) + # Flip the last axis if the optimal rotation came out as a reflection. + sign = torch.sign(torch.det(vt.T @ u.T)) + correction = torch.diag(torch.tensor([1.0, 1.0, float(sign)], dtype=p.dtype)) + rotation = vt.T @ correction @ u.T + aligned = (rotation @ p.T).T + return float(((aligned - q) ** 2).sum(dim=-1).mean().sqrt()) + + +def _predicted_mol(mol: Chem.Mol, coords: Sequence[Sequence[float]]) -> Chem.Mol: + """Copy ``mol`` and replace its conformer with ``coords``.""" + mol_hat = Chem.Mol(mol) + conformer = rdchem.Conformer(mol.GetNumAtoms()) + for i, position in enumerate(coords): + conformer.SetAtomPosition(i, [float(v) for v in position]) + mol_hat.RemoveAllConformers() + mol_hat.AddConformer(conformer) + return mol_hat + + +def evaluate_split( + model: torch.nn.Module, + dataset: Any, + subset: Subset, + collator: Any, + device: str = "cpu", + batch_size: int = 100, + remove_hs: bool = True, + rmsd_method: str = "auto", +) -> dict[str, float | str]: + """Score ``subset`` with ReBind's metric definitions. + + ``dataset`` must expose ``rdkit_mol(idx)`` for the C-RMSD part; the indices + in ``subset`` index into it. + + ``rmsd_method`` is ``"auto"`` (RDKit ``GetBestRMS`` where a molecule can be + built, else aligned coordinates) or ``"aligned_coords"`` to force the plain + metric everywhere, which is what makes RMSD comparable between the organic + and organometallic sets. + """ + if rmsd_method not in ("auto", "aligned_coords"): + raise ValueError(f"Unknown rmsd_method: {rmsd_method!r}") + model.eval() + loader = DataLoader(subset, batch_size=batch_size, shuffle=False, collate_fn=collator) + total_abs, total_sq, total_dist = 0.0, 0.0, 0 + total_rmsd, n_rmsd, rmsd_failures, n_mol = 0.0, 0, 0, 0 + # What we ask for vs what we end up reporting: "auto" degrades to aligned + # coordinates for rows that have no RDKit molecule (the MOL2 path). + use_rdkit = rmsd_method == "auto" + reported_method = "rdkit_bestrms" if use_rdkit else "aligned_coords" + + position = 0 + for batch in tqdm(loader, desc="evaluating", leave=False, dynamic_ncols=True): + device_batch = {k: (v.to(device) if torch.is_tensor(v) else v) for k, v in batch.items()} + with torch.no_grad(): + out = model(**device_batch) + node_mask = device_batch["node_mask"].bool() + for row in range(node_mask.shape[0]): + keep = node_mask[row] + pred = out.conformer_hat[row][keep].double().cpu() + target = out.conformer[row][keep].double().cpu() + d_pred, d_true = torch.cdist(pred, pred), torch.cdist(target, target) + delta = (d_pred - d_true).abs() + total_abs += float(delta.sum()) + total_sq += float((delta**2).sum()) + total_dist += delta.numel() # n*n, diagonal included, as in ReBind + n_mol += 1 + + mol = None + if use_rdkit: + try: + mol = dataset.rdkit_mol(subset.indices[position + row]) + except NotImplementedError: + # Organometallic (MOL2) rows never build an RDKit molecule. + reported_method = "aligned_coords" + if mol is None: + # Optimal rigid alignment on this molecule's real atoms. Not + # symmetry matched, so only compare to other aligned_coords numbers. + total_rmsd += kabsch_rmsd(pred, target) + n_rmsd += 1 + else: + ref = _predicted_mol(mol, target.tolist()) + probe = _predicted_mol(mol, pred.tolist()) + try: + if remove_hs: + ref, probe = Chem.RemoveHs(ref), Chem.RemoveHs(probe) + total_rmsd += rdMolAlign.GetBestRMS(probe, ref) + n_rmsd += 1 + except Exception: + # GetBestRMS can fail on molecules RDKit won't match up. + rmsd_failures += 1 + position += node_mask.shape[0] + + metrics: dict[str, float | str] = { + "d_mae": total_abs / max(total_dist, 1), + "d_rmse": (total_sq / max(total_dist, 1)) ** 0.5, + "c_rmsd": total_rmsd / max(n_rmsd, 1), + "c_rmsd_method": reported_method, + "n_molecules": float(n_mol), + "n_rmsd_molecules": float(n_rmsd), + "n_rmsd_failures": float(rmsd_failures), + "remove_hs": float(remove_hs), + } + return metrics + + +def write_metrics(metrics: dict[str, float | str], path: str | Path) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(metrics, f, indent=2) diff --git a/src/step_up/models/__init__.py b/src/step_up/models/__init__.py index 07f7cd7..dcc3550 100644 --- a/src/step_up/models/__init__.py +++ b/src/step_up/models/__init__.py @@ -1 +1,65 @@ -"""Model wrappers for step-up benchmark.""" +"""Model wrappers for step-up benchmark. + +Each benchmark model is a thin wrapper over a vendored upstream implementation +and exposes the same two entry points, so the training loop stays model-agnostic: +``build_model`` for the network and ``build_model_collator`` for its batching. +""" + +from __future__ import annotations + +from typing import Any + +from .common import N_GLOBAL_FEATURES + +MODEL_NAMES = ("rebind", "gtmgc") + + +def build_model( + name: str, + n_layers: int, + d_model: int, + d_ffn: int, + n_head: int, + dropout: float = 0.0, + n_global_features: int = 0, +) -> Any: + """Build one of the benchmark models by name.""" + if name == "rebind": + from .rebind import build_rebind + + return build_rebind( + n_layers=n_layers, + d_model=d_model, + d_ffn=d_ffn, + n_head=n_head, + dropout=dropout, + n_global_features=n_global_features, + ) + if name == "gtmgc": + from .gtmgc import build_gtmgc + + return build_gtmgc( + n_layers=n_layers, + d_model=d_model, + d_ffn=d_ffn, + n_head=n_head, + dropout=dropout, + n_global_features=n_global_features, + ) + raise ValueError(f"Unknown model: {name!r} (expected one of {MODEL_NAMES})") + + +def build_model_collator(name: str, conditioned: bool = False) -> Any: + """Build the collator that matches ``name``'s expected batch layout.""" + if name == "rebind": + from .rebind import build_collator + + return build_collator(conditioned=conditioned) + if name == "gtmgc": + from .gtmgc import build_gtmgc_collator + + return build_gtmgc_collator(conditioned=conditioned) + raise ValueError(f"Unknown model: {name!r} (expected one of {MODEL_NAMES})") + + +__all__ = ["MODEL_NAMES", "N_GLOBAL_FEATURES", "build_model", "build_model_collator"] diff --git a/src/step_up/models/common.py b/src/step_up/models/common.py new file mode 100644 index 0000000..77ae166 --- /dev/null +++ b/src/step_up/models/common.py @@ -0,0 +1,85 @@ +"""Pieces shared by the vendored model wrappers. + +Both ReBind and GTMGC descend from the same code base, so they need the same +two fixes and the same optional conditioning: + +- the Laplacian positional encoding is added in place upstream, which breaks + autograd in fp32 (see the wrappers for the details), and +- neither model takes molecule-level state, so charge and spin are projected + and added to every atom embedding. +""" + +from __future__ import annotations + +from typing import Any + +import torch + +# Charge (electrons) and unpaired electrons (spin multiplicity - 1). +N_GLOBAL_FEATURES = 2 + + +def add_lap_out_of_place(node_embedding: torch.Tensor, lap: torch.Tensor) -> torch.Tensor: + """Add ``lap`` into the leading channels of ``node_embedding`` without in-place ops.""" + d = node_embedding.shape[-1] + lap_dim = lap.shape[-1] + if lap_dim < d: + lap = torch.nn.functional.pad(lap, (0, d - lap_dim)) + elif lap_dim > d: + lap = lap[..., :d] + return node_embedding + lap + + +def global_condition_mlp(n_features: int, d_model: int) -> torch.nn.Module: + """Project molecule-level scalars to ``d_model``, starting as a no-op. + + The last layer is zero-initialised, so a freshly built conditioned model + behaves exactly like the unconditioned one and learns to use the + conditioning from there. + """ + mlp = torch.nn.Sequential( + torch.nn.Linear(n_features, d_model), + torch.nn.SiLU(), + torch.nn.Linear(d_model, d_model), + ) + torch.nn.init.zeros_(mlp[-1].weight) + torch.nn.init.zeros_(mlp[-1].bias) + return mlp + + +def apply_global_conditioning(node_embedding: torch.Tensor, inputs: dict[str, Any]) -> torch.Tensor: + """Broadcast ``inputs["global_embedding"]`` over atoms, if it is there. + + Padding positions embed to zero, so they are re-masked to stay that way. + """ + global_embedding = inputs.get("global_embedding") + if global_embedding is None: + return node_embedding + node_mask = inputs.get("node_mask") + conditioned = node_embedding + global_embedding.unsqueeze(1) + if node_mask is not None: + conditioned = conditioned * node_mask.unsqueeze(-1) + return conditioned + + +class GlobalConditionCollator: + """Wrap a model's collator and add ``global_features`` of shape ``(B, 2)``. + + Column 0 is the formal charge in electrons, column 1 the number of unpaired + electrons (spin multiplicity - 1), which is 0 for a closed-shell singlet. + Graph dicts without those fields are treated as neutral and closed-shell. + """ + + def __init__(self, base: Any) -> None: + self.base = base + + def __call__(self, mol_sq: Any) -> dict[str, Any]: + batch = self.base(mol_sq) + batch["global_features"] = torch.tensor( + [ + [float(mol.get("charge", 0.0)), float(mol.get("spin_multiplicity", 1.0)) - 1.0] + for mol in mol_sq + ], + dtype=torch.float32, + ) + return batch diff --git a/src/step_up/models/gtmgc.py b/src/step_up/models/gtmgc.py new file mode 100644 index 0000000..47671cb --- /dev/null +++ b/src/step_up/models/gtmgc.py @@ -0,0 +1,271 @@ +"""Thin wrapper around the vendored GTMGC implementation. + +Vendored at ``external/GTMGC`` (Xu et al., ICLR 2024). ReBind was built on this +code base, so the two wrappers look alike. GTMGC's package is *also* called +``models``, which would collide with ReBind's entry on ``sys.path``, so this one +is imported under a private module name instead of by path injection. + +Nothing is imported until ``build_gtmgc()`` or ``get_gtmgc_collator()`` runs. One +runtime patch is applied: ``GTMGCEncoder.forward`` and ``GTMGCDecoder.forward`` +add the Laplacian positional encoding with an in-place slice assignment, which +breaks autograd in fp32 exactly as ReBind's does (upstream trains under fp16 +autocast). Unlike ReBind, nothing reads the tensor between the write and its use, +so the out-of-place replacement is value-identical to upstream. + +Their conformer models embed atoms as Mole-BERT tokenizer ids +(``embed_style="atom_tokenized_ids"``), so batches need per-atom ``input_ids``; +see ``scripts/tokenize_molebert.py``. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import Any + +import torch + +from .common import ( + N_GLOBAL_FEATURES, + GlobalConditionCollator, + add_lap_out_of_place, + apply_global_conditioning, + global_condition_mlp, +) + +_GTMGC_ROOT = Path(__file__).resolve().parents[3] / "external" / "GTMGC" +_GTMGC_PROBE = _GTMGC_ROOT / "models" / "gtmgc" / "modeling_gtmgc.py" +_VENDOR_MODULE = "_step_up_vendor_gtmgc" + +_gtmgc: Any = None +_gtmgc_modeling: Any = None +# Upstream ``forward`` methods replaced below, keyed by class, so tests can check +# the patched model against upstream. +_UPSTREAM_FORWARDS: dict[type, Any] = {} +_CONDITIONED_GTMGC: Any = None + +__all__ = ["build_gtmgc", "build_gtmgc_collator", "get_gtmgc_collator"] + + +def _load_gtmgc() -> None: + """Import the vendored GTMGC package and apply the runtime patch. Idempotent.""" + global _gtmgc, _gtmgc_modeling + if _gtmgc is not None: + return + if not _GTMGC_PROBE.exists(): + raise FileNotFoundError( + f"GTMGC submodule files missing at {_GTMGC_PROBE}. " + "Run: git submodule update --init --recursive" + ) + package = _GTMGC_ROOT / "models" + spec = importlib.util.spec_from_file_location( + _VENDOR_MODULE, package / "__init__.py", submodule_search_locations=[str(package)] + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Register before executing so the package's relative imports resolve. + sys.modules[_VENDOR_MODULE] = module + spec.loader.exec_module(module) + _gtmgc = module + _gtmgc_modeling = sys.modules[f"{_VENDOR_MODULE}.gtmgc.modeling_gtmgc"] + patch_inplace_lap_addition() + + +def _patched_encoder_forward(self, **inputs): + """Out-of-place equivalent of ``GTMGCEncoder.forward``.""" + if self.embed_style == "atom_tokenized_ids": + node_embedding = self.node_embedding(inputs.get("node_input_ids")) + elif self.embed_style == "atom_type_ids": + node_embedding = self.node_embedding(inputs.get("node_type")) + elif self.embed_style == "ogb": + node_embedding = self.ogb_node_embedding(inputs["node_attr"]) + else: + raise ValueError(f"Unknown embed_style: {self.embed_style!r}") + + node_embedding = add_lap_out_of_place(node_embedding, inputs.get("lap_eigenvectors")) + node_embedding = apply_global_conditioning(node_embedding, inputs) + inputs["node_embedding"] = node_embedding + + if self.config.encoder_use_D_in_attn: + # Off in their published conformer configs: this would feed the encoder + # distances from the ground-truth conformer. + conformer = inputs.get("conformer") + distance = torch.cdist(conformer, conformer) + mask = _gtmgc_modeling.make_cdist_mask(inputs.get("node_mask")) + inputs["distance"] = _gtmgc_modeling.compute_distance_residual_bias( + cdist=distance, cdist_mask=mask + ) + + attn_weight_dict: dict = {} + for i, encoder_block in enumerate(self.encoder_blocks): + block_out = encoder_block(**inputs) + node_embedding, attn_weight = block_out["out"], block_out["attn_weight"] + inputs["node_embedding"] = node_embedding + attn_weight_dict[f"encoder_block_{i}"] = attn_weight + return {"node_embedding": node_embedding, "attn_weight_dict": attn_weight_dict} + + +def _patched_decoder_forward(self, **inputs): + """Out-of-place equivalent of ``GTMGCDecoder.forward``.""" + node_embedding = add_lap_out_of_place( + inputs.get("node_embedding"), inputs.get("lap_eigenvectors") + ) + inputs["node_embedding"] = node_embedding + + attn_weight_dict: dict = {} + for i, decoder_block in enumerate(self.decoder_blocks): + block_out = decoder_block(**inputs) + node_embedding, attn_weight = block_out["out"], block_out["attn_weight"] + inputs["node_embedding"] = node_embedding + attn_weight_dict[f"decoder_block_{i}"] = attn_weight + return {"node_embedding": node_embedding, "attn_weight_dict": attn_weight_dict} + + +def patch_inplace_lap_addition() -> None: + """Replace the encoder/decoder forwards with autograd-safe versions.""" + if getattr(_gtmgc_modeling, "_step_up_inplace_patched", False): + return + _UPSTREAM_FORWARDS[_gtmgc_modeling.GTMGCEncoder] = _gtmgc_modeling.GTMGCEncoder.forward + _UPSTREAM_FORWARDS[_gtmgc_modeling.GTMGCDecoder] = _gtmgc_modeling.GTMGCDecoder.forward + _gtmgc_modeling.GTMGCEncoder.forward = _patched_encoder_forward + _gtmgc_modeling.GTMGCDecoder.forward = _patched_decoder_forward + _gtmgc_modeling._step_up_inplace_patched = True + + +def _conditioned_gtmgc_class() -> Any: + """Define (once) the GTMGC subclass that adds charge / spin conditioning.""" + global _CONDITIONED_GTMGC + if _CONDITIONED_GTMGC is not None: + return _CONDITIONED_GTMGC + + class ConditionedGTMGC(_gtmgc.GTMGCForConformerPrediction): # type: ignore[misc, valid-type] + """GTMGC conditioned on molecule-level charge and spin.""" + + def __init__(self, config: Any, n_global_features: int = N_GLOBAL_FEATURES) -> None: + super().__init__(config) + self.global_cond = global_condition_mlp(n_global_features, config.d_model) + + def forward(self, **inputs): + global_features = inputs.get("global_features") + if global_features is not None: + inputs["global_embedding"] = self.global_cond(global_features.to(torch.float32)) + return super().forward(**inputs) + + _CONDITIONED_GTMGC = ConditionedGTMGC + return ConditionedGTMGC + + +def get_gtmgc_collator(): + """Return the (lazily loaded) ``GTMGCCollator`` class.""" + _load_gtmgc() + return _gtmgc.GTMGCCollator + + +def build_gtmgc_collator(conditioned: bool = False): + """Collator instance for the DataLoader, optionally with charge/spin features.""" + base = get_gtmgc_collator()() + return GlobalConditionCollator(base) if conditioned else base + + +def build_gtmgc( + n_layers: int = 6, + d_model: int = 256, + d_ffn: int = 1024, + n_head: int = 8, + atom_vocab_size: int = 513, + dropout: float = 0.0, + embed_style: str = "atom_tokenized_ids", + n_global_features: int = 0, +): + """Instantiate GTMGC for conformer prediction. + + Defaults and attention flags follow their released QM9 checkpoint + (``RichXuOvO/GTMGC-Qm9``): 6 + 6 layers, d_model 256, d_ffn 1024, 8 heads, + Mole-BERT token embeddings. The encoder attends over the adjacency only, the + decoder also over the distance matrix predicted by the first conformer head, + so no ground-truth geometry reaches the encoder. + """ + _load_gtmgc() + config = _gtmgc.GTMGCConfig( + n_encode_layers=n_layers, + n_decode_layers=n_layers, + encoder_use_A_in_attn=True, + encoder_use_D_in_attn=False, + decoder_use_A_in_attn=True, + decoder_use_D_in_attn=True, + embed_style=embed_style, + atom_vocab_size=atom_vocab_size, + d_embed=d_model, + pre_ln=False, + d_q=d_model, + d_k=d_model, + d_v=d_model, + d_model=d_model, + n_head=n_head, + qkv_bias=True, + attn_drop=dropout, + norm_drop=dropout, + ffn_drop=dropout, + d_ffn=d_ffn, + ) + if n_global_features: + return _conditioned_gtmgc_class()(config, n_global_features=n_global_features) + return _gtmgc.GTMGCForConformerPrediction(config) + + +def _load_vendor_weights(model_cls: Any, config_cls: Any, checkpoint: str) -> Any: + """Build a vendored model and load HuggingFace-hosted weights into it. + + ``PreTrainedModel.from_pretrained`` is not usable here: the vendored classes + target transformers 4.32 and current transformers expects model APIs they + don't implement. The two steps it would do for these plain checkpoints — + build the config, load the state dict — are done directly instead. + + ``checkpoint`` is a Hub repo id or a local directory holding ``config.json`` + and ``pytorch_model.bin``. + """ + local = Path(checkpoint) + if local.is_dir(): + config_path, weights_path = local / "config.json", local / "pytorch_model.bin" + else: + from huggingface_hub import hf_hub_download + + config_path = Path(hf_hub_download(checkpoint, "config.json")) + weights_path = Path(hf_hub_download(checkpoint, "pytorch_model.bin")) + + with open(config_path) as handle: + config = config_cls(**json.load(handle)) + model = model_cls(config) + state = torch.load(weights_path, map_location="cpu", weights_only=True) + missing, unexpected = model.load_state_dict(state, strict=False) + if missing: + raise RuntimeError(f"{checkpoint} is missing weights for: {sorted(missing)[:8]}") + if unexpected: + print(f"[gtmgc] ignoring {len(unexpected)} unexpected keys in {checkpoint}", flush=True) + return model + + +def load_molebert_tokenizer(checkpoint: str = "RichXuOvO/MoleBERT-Tokenizer"): + """Load the Mole-BERT atom tokenizer and its collator. + + GTMGC's conformer models embed the tokenizer's per-atom codebook indices. + Returns ``(tokenizer, collator)``; the collator turns graph dicts into the + PyG batch the tokenizer expects. + """ + _load_gtmgc() + tokenizer = _load_vendor_weights( + _gtmgc.MoleBERTTokenizer, _gtmgc.MoleBERTTokenizerConfig, checkpoint + ) + return tokenizer, _gtmgc.MoleBERTTokenizerCollator() + + +def load_pretrained_gtmgc(checkpoint: str): + """Load one of their released checkpoints, e.g. ``RichXuOvO/GTMGC-Qm9``. + + Useful as a reference point: scoring their weights with our evaluation code + should reproduce their published numbers. + """ + _load_gtmgc() + return _load_vendor_weights(_gtmgc.GTMGCForConformerPrediction, _gtmgc.GTMGCConfig, checkpoint) diff --git a/src/step_up/models/lj_params.py b/src/step_up/models/lj_params.py new file mode 100644 index 0000000..dfa068a --- /dev/null +++ b/src/step_up/models/lj_params.py @@ -0,0 +1,123 @@ +"""Universal Force Field Lennard-Jones parameters, keyed the way ReBind keys them. + +ReBind's ``get_sigma_and_epsilon`` looks parameters up by ``node_type``, which is +the atomic number minus one, and its own table stops at index 35 (Kr). Everything +heavier — the 4d/5d metals, the lanthanides, iodine — fell through to a flat +default, which is most of what makes an organometallic complex an organometallic +complex. This table carries the full UFF set through index 102 (Lr). + +Values as compiled by Luis Pinto (``rebind_utils.py``); indices 0-35 are +bit-identical to ReBind's own table, so QM9 behaviour is unchanged. +Sigma in angstrom, epsilon in kcal/mol. +""" + +from __future__ import annotations + +# node_type (Z - 1) -> (sigma, epsilon) +UFF_LJ_PARAMETERS: dict[int, tuple[float, float]] = { + 0: (2.886, 0.0440), # H + 1: (2.362, 0.0560), # He + 2: (2.451, 0.0250), # Li + 3: (2.745, 0.0850), # Be + 4: (3.637, 0.1800), # B + 5: (3.431, 0.1050), # C + 6: (3.260, 0.0690), # N + 7: (3.118, 0.0600), # O + 8: (2.996, 0.0500), # F + 9: (2.889, 0.0420), # Ne + 10: (2.983, 0.0300), # Na + 11: (2.905, 0.1110), # Mg + 12: (4.008, 0.5050), # Al + 13: (3.826, 0.4020), # Si + 14: (3.694, 0.3050), # P + 15: (3.594, 0.2740), # S + 16: (3.516, 0.2270), # Cl + 17: (3.404, 0.1850), # Ar + 18: (3.812, 0.0350), # K + 19: (3.487, 0.2380), # Ca + 20: (3.316, 0.0190), # Sc + 21: (3.294, 0.0170), # Ti + 22: (3.273, 0.0160), # V + 23: (3.249, 0.0150), # Cr + 24: (3.210, 0.0130), # Mn + 25: (3.174, 0.0130), # Fe + 26: (3.144, 0.0130), # Co + 27: (3.116, 0.0130), # Ni + 28: (3.083, 0.0050), # Cu + 29: (3.002, 0.1240), # Zn + 30: (4.383, 0.4150), # Ga + 31: (4.310, 0.3790), # Ge + 32: (4.280, 0.3090), # As + 33: (4.336, 0.2910), # Se + 34: (4.403, 0.2510), # Br + 35: (4.463, 0.2200), # Kr + 36: (4.114, 0.0400), # Rb + 37: (3.641, 0.2350), # Sr + 38: (3.345, 0.0720), # Y + 39: (3.124, 0.0690), # Zr + 40: (3.167, 0.0590), # Nb + 41: (3.167, 0.0560), # Mo + 42: (3.140, 0.0480), # Tc + 43: (3.076, 0.0560), # Ru + 44: (3.053, 0.0530), # Rh + 45: (3.003, 0.0410), # Pd + 46: (3.148, 0.0360), # Ag + 47: (2.848, 0.2280), # Cd + 48: (4.432, 0.4080), # In + 49: (4.295, 0.3810), # Sn + 50: (4.314, 0.3110), # Sb + 51: (4.382, 0.2920), # Te + 52: (4.436, 0.2520), # I + 53: (4.465, 0.2210), # Xe + 54: (4.302, 0.0320), # Cs + 55: (3.666, 0.2200), # Ba + 56: (3.522, 0.0130), # La + 57: (3.493, 0.0130), # Ce + 58: (3.468, 0.0130), # Pr + 59: (3.441, 0.0130), # Nd + 60: (3.415, 0.0130), # Pm + 61: (3.391, 0.0130), # Sm + 62: (3.364, 0.0130), # Eu + 63: (3.340, 0.0130), # Gd + 64: (3.315, 0.0130), # Tb + 65: (3.289, 0.0130), # Dy + 66: (3.265, 0.0130), # Ho + 67: (3.241, 0.0130), # Er + 68: (3.216, 0.0130), # Tm + 69: (3.191, 0.0130), # Yb + 70: (3.167, 0.0130), # Lu + 71: (3.141, 0.0670), # Hf + 72: (3.170, 0.0600), # Ta + 73: (3.168, 0.0540), # W + 74: (3.111, 0.0460), # Re + 75: (3.120, 0.0310), # Os + 76: (3.020, 0.0320), # Ir + 77: (2.754, 0.0800), # Pt + 78: (3.293, 0.0390), # Au + 79: (2.730, 0.4090), # Hg + 80: (4.347, 0.3790), # Tl + 81: (4.355, 0.3450), # Pb + 82: (4.370, 0.2840), # Bi + 83: (4.450, 0.2220), # Po + 84: (4.750, 0.2840), # At + 85: (4.765, 0.2210), # Rn + 86: (3.846, 0.0300), # Fr + 87: (3.659, 0.2110), # Ra + 88: (3.478, 0.0330), # Ac + 89: (3.395, 0.0310), # Th + 90: (3.424, 0.0100), # Pa + 91: (3.397, 0.0110), # U + 92: (3.424, 0.0050), # Np + 93: (3.424, 0.0050), # Pu + 94: (3.381, 0.0140), # Am + 95: (3.326, 0.0140), # Cm + 96: (3.339, 0.0140), # Bk + 97: (3.313, 0.0140), # Cf + 98: (3.286, 0.0140), # Es + 99: (3.281, 0.0140), # Fm + 100: (3.268, 0.0140), # Md + 101: (3.254, 0.0140), # No + 102: (3.240, 0.0070), # Lr +} + +MAX_NODE_TYPE = max(UFF_LJ_PARAMETERS) diff --git a/src/step_up/models/rebind.py b/src/step_up/models/rebind.py index c662c28..b8aec49 100644 --- a/src/step_up/models/rebind.py +++ b/src/step_up/models/rebind.py @@ -5,9 +5,9 @@ is put on ``sys.path`` (so the vendor's intra-package imports work) and three runtime patches are applied: -- ``get_sigma_and_epsilon`` falls back to default LJ parameters for atomic - numbers > 36 (4d/5d transition metals, lanthanides, etc.) instead of raising - a KeyError on the organometallic datasets. +- ``get_sigma_and_epsilon`` looks parameters up in the full UFF table + (:mod:`step_up.models.lj_params`) instead of raising a KeyError on any atomic + number above 36 — i.e. on most of the organometallic datasets. - ``Encoder.forward`` / ``Decoder.forward`` add the Laplacian positional encoding out of place, so the model can be trained in fp32. - ``REBIND.forward`` clamps predicted distances in the LJ block and scrubs @@ -26,6 +26,15 @@ import torch +from .common import ( + N_GLOBAL_FEATURES, + GlobalConditionCollator, + add_lap_out_of_place, + apply_global_conditioning, + global_condition_mlp, +) +from .lj_params import MAX_NODE_TYPE, UFF_LJ_PARAMETERS + _REBIND_ROOT = Path(__file__).resolve().parents[3] / "external" / "ReBIND" # Concrete-file probe: a fresh git checkout without `--recursive` leaves # `external/ReBIND/` as an empty directory, so `exists()` on the root passes @@ -63,7 +72,9 @@ def _ensure_rebind_on_path() -> None: # tests can check the patched model against upstream. _UPSTREAM_FORWARDS: dict[type, Any] = {} -__all__ = ["build_rebind", "get_collator"] +_CONDITIONED_REBIND: Any = None + +__all__ = ["build_collator", "build_rebind", "get_collator"] def _load_rebind() -> None: @@ -101,52 +112,40 @@ def _load_rebind() -> None: # LJ-parameter extension for organometallics. # --------------------------------------------------------------------------- # ReBind's `get_sigma_and_epsilon` hardcodes LJ parameters for atomic-number -# indices 0..35 (i.e., Z=1..36, H through Kr). For organometallics that include -# 4d, 5d, and f-block elements, we extend with a safe fallback. The values are -# order-of-magnitude reasonable (UFF-style sigma ~3.5 A, epsilon ~0.05 kcal/mol); the -# LJ rewiring's contribution is small relative to the bond-graph signal, and -# physically realistic dispersion for metals is dominated by short-range -# Pauli repulsion which the cutoff already handles. - -_DEFAULT_LJ_SIGMA = 3.5 -_DEFAULT_LJ_EPSILON = 0.05 +# indices 0..35 (i.e., Z=1..36, H through Kr), and KeyErrors on anything heavier. +# We swap in the full UFF table, which agrees with theirs exactly over Z=1..36 — +# so QM9 is untouched — and covers the 4d/5d metals, the lanthanides and iodine +# that the organometallic sets are made of. def patch_lj_parameters() -> None: - """Replace ``get_sigma_and_epsilon`` with a Z-tolerant version. + """Replace ``get_sigma_and_epsilon`` with one that covers the whole table. Idempotent: calling twice has no effect beyond the first. """ if getattr(_rebind_utils, "_step_up_patched", False): return - original_dict = { - i: {"sigma": _DEFAULT_LJ_SIGMA, "epsilon": _DEFAULT_LJ_EPSILON} for i in range(118) - } - # `lj_parameters` is defined inside `get_sigma_and_epsilon`. Re-read its - # canonical entries from a one-off call by inspecting the function's - # closure-free body: we just copy from a local clone here. - canonical = _canonical_lj_table() - original_dict.update(canonical) - def patched_get_sigma_and_epsilon( mol_data: Any, drugs: bool = False ) -> tuple[torch.Tensor, torch.Tensor]: + # `drugs` is upstream's switch for a differently keyed table; step-up + # never sets it, and `node_type` is always Z - 1 here. + del drugs eps_list: list[float] = [] sig_list: list[float] = [] - for i, atom_id in enumerate(mol_data.node_type): - if drugs: - # We don't use the `drugs` branch in step-up. Fall through to - # the index-based lookup, which assumes `node_type` is already - # Z-1. - pass + for atom_id in mol_data.node_type: idx = int(atom_id.item()) - params = original_dict.get( - idx, {"sigma": _DEFAULT_LJ_SIGMA, "epsilon": _DEFAULT_LJ_EPSILON} - ) - eps_list.append(params["epsilon"]) - sig_list.append(params["sigma"]) - del i + params = UFF_LJ_PARAMETERS.get(idx) + if params is None: + raise ValueError( + f"No LJ parameters for node_type {idx} (Z={idx + 1}); the UFF table " + f"stops at node_type {MAX_NODE_TYPE}. A larger value means the " + "featurizer emitted a bad atomic number." + ) + sigma, epsilon = params + eps_list.append(epsilon) + sig_list.append(sigma) return torch.tensor(eps_list), torch.tensor(sig_list) _rebind_utils.get_sigma_and_epsilon = patched_get_sigma_and_epsilon @@ -156,46 +155,40 @@ def patched_get_sigma_and_epsilon( _rebind_utils._step_up_patched = True -def _canonical_lj_table() -> dict[int, dict[str, float]]: - """Return ReBind's original Z=1..36 LJ parameter table (key = Z - 1).""" - return { - 0: {"sigma": 2.886, "epsilon": 0.0440}, - 1: {"sigma": 2.362, "epsilon": 0.0560}, - 2: {"sigma": 2.451, "epsilon": 0.0250}, - 3: {"sigma": 2.745, "epsilon": 0.0850}, - 4: {"sigma": 3.637, "epsilon": 0.1800}, - 5: {"sigma": 3.431, "epsilon": 0.1050}, - 6: {"sigma": 3.260, "epsilon": 0.0690}, - 7: {"sigma": 3.118, "epsilon": 0.0600}, - 8: {"sigma": 2.996, "epsilon": 0.0500}, - 9: {"sigma": 2.889, "epsilon": 0.0420}, - 10: {"sigma": 2.983, "epsilon": 0.0300}, - 11: {"sigma": 2.905, "epsilon": 0.1110}, - 12: {"sigma": 4.008, "epsilon": 0.5050}, - 13: {"sigma": 3.826, "epsilon": 0.4020}, - 14: {"sigma": 3.694, "epsilon": 0.3050}, - 15: {"sigma": 3.594, "epsilon": 0.2740}, - 16: {"sigma": 3.516, "epsilon": 0.2270}, - 17: {"sigma": 3.404, "epsilon": 0.1850}, - 18: {"sigma": 3.812, "epsilon": 0.0350}, - 19: {"sigma": 3.487, "epsilon": 0.2380}, - 20: {"sigma": 3.316, "epsilon": 0.0190}, - 21: {"sigma": 3.294, "epsilon": 0.0170}, - 22: {"sigma": 3.273, "epsilon": 0.0160}, - 23: {"sigma": 3.249, "epsilon": 0.0150}, - 24: {"sigma": 3.210, "epsilon": 0.0130}, - 25: {"sigma": 3.174, "epsilon": 0.0130}, - 26: {"sigma": 3.144, "epsilon": 0.0130}, - 27: {"sigma": 3.116, "epsilon": 0.0130}, - 28: {"sigma": 3.083, "epsilon": 0.0050}, - 29: {"sigma": 3.002, "epsilon": 0.1240}, - 30: {"sigma": 4.383, "epsilon": 0.4150}, - 31: {"sigma": 4.310, "epsilon": 0.3790}, - 32: {"sigma": 4.280, "epsilon": 0.3090}, - 33: {"sigma": 4.336, "epsilon": 0.2910}, - 34: {"sigma": 4.403, "epsilon": 0.2510}, - 35: {"sigma": 4.463, "epsilon": 0.2200}, - } +# --------------------------------------------------------------------------- +# Molecule-level (charge / spin) conditioning +# --------------------------------------------------------------------------- +# Charge and spin enter as two scalars — the formal charge in electrons and the +# number of unpaired electrons (spin multiplicity - 1) — projected by a small MLP +# and added to every atom's embedding. Scalars rather than one-hot categories +# because BOSTMC's low-spin charges span -8..+8 with single-structure tails that +# a category would never learn, and because a scalar lets you ask the trained +# model for a charge/spin combination that never appeared in training. The MLP's +# last layer starts at zero, so a freshly built conditioned model behaves exactly +# like the unconditioned one and learns to use the conditioning from there. + + +def _conditioned_rebind_class() -> Any: + """Define (once) the REBIND subclass that adds global conditioning.""" + global _CONDITIONED_REBIND + if _CONDITIONED_REBIND is not None: + return _CONDITIONED_REBIND + + class ConditionedREBIND(_REBIND): # type: ignore[misc, valid-type] + """REBIND conditioned on molecule-level charge and spin.""" + + def __init__(self, config: Any, n_global_features: int = N_GLOBAL_FEATURES) -> None: + super().__init__(config) + self.global_cond = global_condition_mlp(n_global_features, config.d_model) + + def forward(self, **inputs): + global_features = inputs.get("global_features") + if global_features is not None: + inputs["global_embedding"] = self.global_cond(global_features.to(torch.float32)) + return super().forward(**inputs) + + _CONDITIONED_REBIND = ConditionedREBIND + return ConditionedREBIND def _patched_encoder_forward(self, **inputs): @@ -211,7 +204,8 @@ def _patched_encoder_forward(self, **inputs): node_attr = inputs.get("node_attr") node_embedding = self.node_embedding(node_attr) lap = inputs.get("lap_eigenvectors") - node_embedding = _add_lap_out_of_place(node_embedding, lap) + node_embedding = add_lap_out_of_place(node_embedding, lap) + node_embedding = apply_global_conditioning(node_embedding, inputs) inputs["node_embedding"] = node_embedding attn_weight_dict: dict = {} @@ -227,7 +221,7 @@ def _patched_decoder_forward(self, **inputs): """Out-of-place equivalent of ``Decoder.forward`` from vendored ReBind.""" node_embedding = inputs.get("node_embedding") lap = inputs.get("lap_eigenvectors") - node_embedding = _add_lap_out_of_place(node_embedding, lap) + node_embedding = add_lap_out_of_place(node_embedding, lap) inputs["node_embedding"] = node_embedding attn_weight_dict: dict = {} @@ -239,18 +233,6 @@ def _patched_decoder_forward(self, **inputs): return {"node_embedding": node_embedding, "attn_weight_dict": attn_weight_dict} -def _add_lap_out_of_place(node_embedding: torch.Tensor, lap: torch.Tensor) -> torch.Tensor: - """Add ``lap`` into the leading channels of ``node_embedding`` without in-place ops.""" - d = node_embedding.shape[-1] - lap_dim = lap.shape[-1] - if lap_dim < d: - pad = (0, d - lap_dim) - lap = torch.nn.functional.pad(lap, pad) - elif lap_dim > d: - lap = lap[..., :d] - return node_embedding + lap - - def patch_inplace_lap_addition() -> None: """Replace ``Encoder.forward`` and ``Decoder.forward`` with autograd-safe versions.""" if getattr(_rebind_modeling, "_step_up_inplace_patched", False): @@ -318,7 +300,7 @@ def _patched_rebind_forward(self, **inputs): # Laplacian positional encoding to that same tensor in place, so upstream's # residual head actually receives ``encoder output + PE``. The out-of-place # decoder patch no longer mutates it, so the PE is added explicitly here. - inputs["pred_conformation"] = _add_lap_out_of_place(node_embedding, inputs["lap_eigenvectors"]) + inputs["pred_conformation"] = add_lap_out_of_place(node_embedding, inputs["lap_eigenvectors"]) inputs["node_embedding"] = node_embedding sigma, epsilon = inputs.get("sigma"), inputs.get("epsilon") @@ -396,6 +378,16 @@ def get_collator(): return _Collator +def build_collator(conditioned: bool = False): + """Collator instance for the DataLoader. + + With ``conditioned=True`` it also emits the charge / spin features that a + model from ``build_rebind(n_global_features=...)`` expects. + """ + base = get_collator()() + return GlobalConditionCollator(base) if conditioned else base + + def build_rebind( n_layers: int = 8, d_model: int = 512, @@ -403,8 +395,13 @@ def build_rebind( n_head: int = 8, atom_vocab_size: int = 513, dropout: float = 0.0, + n_global_features: int = 0, ): - """Instantiate a REBIND model from a flat keyword-style config.""" + """Instantiate a REBIND model from a flat keyword-style config. + + ``n_global_features > 0`` returns the variant conditioned on molecule-level + charge and spin; pair it with ``build_collator(conditioned=True)``. + """ _load_rebind() config = _REBINDConfig( n_encode_layers=n_layers, @@ -425,4 +422,6 @@ def build_rebind( dropout=dropout, d_ffn=d_ffn, ) + if n_global_features: + return _conditioned_rebind_class()(config, n_global_features=n_global_features) return _REBIND(config) diff --git a/src/step_up/train.py b/src/step_up/train.py index aa07a01..e1f540c 100644 --- a/src/step_up/train.py +++ b/src/step_up/train.py @@ -1,7 +1,7 @@ """Config-driven training loop for step-up. -The loop is deliberately minimal: it owns dataset construction, the ReBind -model, AdamW with linear warmup and cosine decay, periodic validation, +The loop is deliberately minimal: it owns dataset construction, the model +(see step_up.models), AdamW with linear warmup and cosine decay, periodic validation, best-checkpoint saving, and TensorBoard logging. No HuggingFace Trainer, no accelerate — keeping the control flow legible while we're still iterating on the model. @@ -23,8 +23,8 @@ from tqdm import tqdm from .data.csv_dataset import CSVMoleculeDataset -from .data.splits import stable_split -from .models.rebind import build_rebind, get_collator +from .data.splits import split_by_id_files, split_by_labels, stable_split +from .models import MODEL_NAMES, N_GLOBAL_FEATURES, build_model, build_model_collator # --------------------------------------------------------------------------- # Config dataclasses @@ -34,7 +34,9 @@ @dataclass class TrainConfig: dataset_path: str - dataset_source: str # "smiles" | "mol2" + dataset_source: str # "smiles" | "mol2" | "sdf" + # Which benchmark model to train (see step_up.models.MODEL_NAMES). + model: str = "rebind" subset_size: int | None = None split_ratios: tuple[float, float, float] = (0.8, 0.1, 0.1) split_seed: int = 0 @@ -42,6 +44,21 @@ class TrainConfig: # is a hash of this key and `split_seed` (see `stable_split`); without it the # key is the CSV row number. id_column: str | None = None + # Column holding a published split name per row (train / val / test). When set, + # it replaces the hashed split, and `split_ratios` / `split_seed` are unused. + split_column: str | None = None + # Published split given as ID lists instead, e.g. + # `split_files: {train: train.txt, val: val.txt, test: test.txt}`. Needs + # `id_column`; rows listed in no file are dropped. Takes precedence over + # `split_column` and the hashed split. + split_files: dict[str, str] | None = None + # Columns holding the molecule's charge (electrons) and spin multiplicity. + # Setting either turns on the model's charge/spin conditioning. + charge_column: str | None = None + spin_column: str | None = None + # CSV of precomputed Mole-BERT token ids (`id,input_ids`), joined on `id_column`. + # GTMGC's published conformer models need it; ReBind does not. + token_file: str | None = None # Optional CSV column filter (e.g. `filter_column: spinmult, filter_value: 1` # to restrict BOSTMC to singlets). filter_column: str | None = None @@ -69,6 +86,15 @@ class TrainConfig: lr: float = 9e-5 weight_decay: float = 0.0 warmup_ratio: float = 0.1 + # AdamW moments. ReBind's script sets beta2=0.99; PyTorch's default is 0.999. + adam_beta1: float = 0.9 + adam_beta2: float = 0.99 + adam_eps: float = 1e-8 + # "linear" reproduces ReBind's `--lr_scheduler_type=linear` (HuggingFace's linear + # warmup then linear decay to zero). "cosine" keeps the decay used before. + lr_schedule: str = "linear" + # ReBind passes `--dataloader_drop_last`, so the last partial training batch is skipped. + drop_last: bool = True # Global gradient-norm clip. ReBind's published training inherited # HuggingFace Trainer's default of 1.0; without it the 8-layer / d=512 # model NaNs out in the first epoch on QM9. @@ -85,6 +111,16 @@ class TrainConfig: seed: int = 0 log_interval: int = 10 + def __post_init__(self) -> None: + # Catch a typo here rather than partway into a run: the schedule name is + # only consulted once warmup ends. + if self.model not in MODEL_NAMES: + raise ValueError(f"Unknown model: {self.model!r} (expected one of {MODEL_NAMES})") + if self.lr_schedule not in ("linear", "cosine"): + raise ValueError( + f"Unknown lr_schedule: {self.lr_schedule!r} (expected 'linear' or 'cosine')" + ) + @classmethod def from_yaml(cls, path: str | Path) -> TrainConfig: with open(path) as f: @@ -129,11 +165,23 @@ def _all_params_finite(model: torch.nn.Module) -> bool: return True -def _warmup_cosine_lr(step: int, total_steps: int, warmup_steps: int, base_lr: float) -> float: +def _lr_at_step( + step: int, total_steps: int, warmup_steps: int, base_lr: float, schedule: str = "linear" +) -> float: + """Learning rate at ``step``. + + ``linear`` matches HuggingFace's ``get_linear_schedule_with_warmup``, which is + what ReBind's script requests: the rate ramps from 0 over the warmup and then + decays linearly to 0 at the last step. + """ if step < warmup_steps: - return base_lr * (step + 1) / max(warmup_steps, 1) + return base_lr * step / max(warmup_steps, 1) progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1) - return base_lr * 0.5 * (1.0 + math.cos(math.pi * progress)) + if schedule == "linear": + return base_lr * max(0.0, 1.0 - progress) + if schedule == "cosine": + return base_lr * 0.5 * (1.0 + math.cos(math.pi * progress)) + raise ValueError(f"Unknown lr_schedule: {schedule!r} (expected 'linear' or 'cosine')") def _mean_or_nan(total: float, count: int) -> float: @@ -158,6 +206,7 @@ def _run_epoch( warmup_steps: int, grad_clip: float = 0.0, nan_skip_max: int = 0, + lr_schedule: str = "linear", ) -> tuple[float, float]: """Run one pass over ``loader``. Returns (mean_loss, mean_dmae). @@ -175,7 +224,9 @@ def _run_epoch( for batch in pbar: batch = _move_batch_to_device(batch, device) if is_train: - lr_now = _warmup_cosine_lr(scheduler_state["step"], total_steps, warmup_steps, base_lr) + lr_now = _lr_at_step( + scheduler_state["step"], total_steps, warmup_steps, base_lr, lr_schedule + ) for g in optimizer.param_groups: g["lr"] = lr_now optimizer.zero_grad(set_to_none=True) @@ -257,14 +308,11 @@ def _run_epoch( return _mean_or_nan(loss_sum, n), _mean_or_nan(dmae_sum, n) -def train(config: TrainConfig) -> dict[str, Any]: - _set_seed(config.seed) - out_dir = Path(config.output_dir) - out_dir.mkdir(parents=True, exist_ok=True) - with open(out_dir / "config.json", "w") as f: - json.dump(config.__dict__, f, indent=2, default=str) +def build_splits(config: TrainConfig) -> tuple[CSVMoleculeDataset, Subset, Subset, Subset]: + """Build the dataset and its train/val/test subsets, as training does. - # Data + Shared with ``step-up evaluate`` so both see identical splits. + """ dataset = CSVMoleculeDataset( path=config.dataset_path, source=config.dataset_source, # type: ignore[arg-type] @@ -275,23 +323,58 @@ def train(config: TrainConfig) -> dict[str, Any]: filter_value=config.filter_value, cache=config.cache_dataset, id_column=config.id_column, + split_column=config.split_column, + charge_column=config.charge_column, + spin_column=config.spin_column, + token_file=config.token_file, ) - train_set, val_set, test_set = stable_split( - dataset, dataset.split_keys(), ratios=config.split_ratios, seed=config.split_seed - ) + if config.split_files: + if config.id_column is None: + raise ValueError("split_files needs id_column so rows can be matched to the lists") + train_set, val_set, test_set = split_by_id_files( + dataset, dataset.split_keys(), config.split_files + ) + split_desc = f"published ID lists keyed on {config.id_column!r}" + elif config.split_column is not None: + train_set, val_set, test_set = split_by_labels(dataset, dataset.split_labels()) + split_desc = f"published split from column {config.split_column!r}" + else: + train_set, val_set, test_set = stable_split( + dataset, dataset.split_keys(), ratios=config.split_ratios, seed=config.split_seed + ) + split_desc = f"hashed on {config.id_column or 'CSV row number'}, seed={config.split_seed}" print( - f"[split] train={len(train_set)} val={len(val_set)} test={len(test_set)} " - f"(keyed on {config.id_column or 'CSV row number'}, seed={config.split_seed})", + f"[split] train={len(train_set)} val={len(val_set)} test={len(test_set)} ({split_desc})", flush=True, ) + return dataset, train_set, val_set, test_set + + +def train(config: TrainConfig) -> dict[str, Any]: + _set_seed(config.seed) + out_dir = Path(config.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + with open(out_dir / "config.json", "w") as f: + json.dump(config.__dict__, f, indent=2, default=str) + + dataset, train_set, val_set, test_set = build_splits(config) if len(train_set) == 0 or len(val_set) == 0: raise ValueError( f"Empty train or val split from {len(dataset)} molecules with " f"split_ratios={config.split_ratios}. Use more data or larger ratios." ) - collator = get_collator()() + conditioned = config.charge_column is not None or config.spin_column is not None + if conditioned: + print( + f"[model] {config.model}: conditioning on molecule charge and unpaired-electron count " + f"(charge_column={config.charge_column!r}, spin_column={config.spin_column!r})", + flush=True, + ) + collator = build_model_collator(config.model, conditioned=conditioned) - def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: + def _make_loader( + subset: Subset, batch_size: int, shuffle: bool, drop_last: bool = False + ) -> DataLoader: return DataLoader( subset, batch_size=batch_size, @@ -299,21 +382,37 @@ def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: num_workers=config.num_workers, collate_fn=collator, persistent_workers=config.num_workers > 0, + drop_last=drop_last, ) - train_loader = _make_loader(train_set, config.batch_size, shuffle=True) + train_loader = _make_loader( + train_set, config.batch_size, shuffle=True, drop_last=config.drop_last + ) + if len(train_loader) == 0: + print( + f"WARN: drop_last would leave no training batches for {len(train_set)} molecules " + f"at batch_size={config.batch_size}; keeping the partial batch.", + flush=True, + ) + train_loader = _make_loader(train_set, config.batch_size, shuffle=True) val_loader = _make_loader(val_set, config.eval_batch_size, shuffle=False) # Model + optimizer - model = build_rebind( + model = build_model( + config.model, n_layers=config.n_layers, d_model=config.d_model, d_ffn=config.d_ffn, n_head=config.n_head, dropout=config.dropout, + n_global_features=N_GLOBAL_FEATURES if conditioned else 0, ).to(config.device) optimizer = torch.optim.AdamW( - model.parameters(), lr=config.lr, weight_decay=config.weight_decay + model.parameters(), + lr=config.lr, + betas=(config.adam_beta1, config.adam_beta2), + eps=config.adam_eps, + weight_decay=config.weight_decay, ) total_steps = len(train_loader) * config.epochs @@ -337,6 +436,7 @@ def _make_loader(subset: Subset, batch_size: int, shuffle: bool) -> DataLoader: warmup_steps, grad_clip=config.grad_clip, nan_skip_max=config.nan_skip_max, + lr_schedule=config.lr_schedule, ) val_loss, val_dmae = _run_epoch( model, diff --git a/tests/conftest.py b/tests/conftest.py index 084ddaf..09a357f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,26 @@ def qm9_path() -> Path: return _fixture("qm9_mini.csv") +@pytest.fixture(scope="session") +def qm9_sdf_path() -> Path: + """QM9 records in ReBind's published form: ``mol_id, split, sdf`` molblocks. + + Five molecules copied from gdb9.sdf (the HuggingFace ``RichXuOvO/HFQm9`` + copy that ReBind and GTMGC used), carrying their published split labels. + """ + return _fixture("qm9_sdf_mini.csv") + + +@pytest.fixture(scope="session") +def qm9_sdf_tokens_path() -> Path: + """Mole-BERT token ids for the molecules in ``qm9_sdf_mini.csv``. + + Committed so the GTMGC tests don't need the tokenizer checkpoint; regenerate + with ``scripts/tokenize_molebert.py``. + """ + return _fixture("qm9_sdf_mini_molebert_tokens.csv") + + @pytest.fixture(scope="session") def tmqmg_path() -> Path: return _fixture("tmqmg_mini.csv") diff --git a/tests/fixtures/qm9_sdf_mini.csv b/tests/fixtures/qm9_sdf_mini.csv new file mode 100644 index 0000000..dfea4c6 --- /dev/null +++ b/tests/fixtures/qm9_sdf_mini.csv @@ -0,0 +1,107 @@ +mol_id,split,sdf +gdb_1,train,"gdb_1 + -OEChem-03231823243D + + 5 4 0 0 0 0 0 0 0999 V2000 + -0.0127 1.0858 0.0080 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0022 -0.0060 0.0020 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.0117 1.4638 0.0003 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5408 1.4475 -0.8766 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5238 1.4379 0.9064 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 3 1 0 0 0 0 + 1 4 1 0 0 0 0 + 1 5 1 0 0 0 0 +M END +" +gdb_2,train,"gdb_2 + -OEChem-03231823233D + + 4 3 0 0 0 0 0 0 0999 V2000 + -0.0404 1.0241 0.0626 N 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0173 0.0125 -0.0274 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9158 1.3587 -0.0288 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5203 1.3435 -0.7755 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 3 1 0 0 0 0 + 1 4 1 0 0 0 0 +M END +" +gdb_11,test,"gdb_11 + -OEChem-03231823243D + + 7 6 0 0 0 0 0 0 0999 V2000 + -0.0029 1.5099 0.0087 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0261 0.0033 -0.0375 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9423 -0.6551 -0.4568 O 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9228 1.9263 -0.3915 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.8620 1.8785 -0.5648 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.1505 1.8439 1.0429 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.8944 -0.4864 0.3577 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 4 1 0 0 0 0 + 1 5 1 0 0 0 0 + 1 6 1 0 0 0 0 + 2 3 2 0 0 0 0 + 2 7 1 0 0 0 0 +M END +" +gdb_22,val,"gdb_22 + -OEChem-03231823253D + + 12 11 0 0 0 0 0 0 0999 V2000 + -0.0332 1.5478 -0.0044 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0111 0.0186 0.0168 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7094 -0.5360 1.2398 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.3322 -0.5171 0.0524 O 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9822 1.9572 -0.0318 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5639 1.9209 -0.8885 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5408 1.9334 0.8858 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.5104 -0.3377 -0.8882 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.2224 -0.1879 2.1565 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.6824 -1.6289 1.2331 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7543 -0.2125 1.2552 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.8182 -0.1558 -0.6955 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 5 1 0 0 0 0 + 1 6 1 0 0 0 0 + 1 7 1 0 0 0 0 + 2 3 1 0 0 0 0 + 2 4 1 0 0 0 0 + 2 8 1 0 0 0 0 + 3 9 1 0 0 0 0 + 3 10 1 0 0 0 0 + 3 11 1 0 0 0 0 + 4 12 1 0 0 0 0 +M END +" +gdb_47,val,"gdb_47 + -OEChem-03231823243D + + 12 12 0 0 0 0 0 0 0999 V2000 + -0.0254 1.5403 -0.0422 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.5256 1.5042 0.0412 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.4215 0.0615 0.6084 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0264 -0.0107 0.0494 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.4659 1.9964 0.8496 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.4790 2.0003 -0.9237 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.9825 1.5138 -0.9530 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0219 2.2599 0.6552 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1429 -0.6796 0.2553 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.4311 0.0575 1.7026 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7905 -0.4853 0.6700 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0523 -0.4724 -0.9424 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 0 0 0 + 1 4 1 0 0 0 0 + 1 5 1 0 0 0 0 + 1 6 1 0 0 0 0 + 2 3 1 0 0 0 0 + 2 7 1 0 0 0 0 + 2 8 1 0 0 0 0 + 3 4 1 0 0 0 0 + 3 9 1 0 0 0 0 + 3 10 1 0 0 0 0 + 4 11 1 0 0 0 0 + 4 12 1 0 0 0 0 +M END +" diff --git a/tests/fixtures/qm9_sdf_mini_molebert_tokens.csv b/tests/fixtures/qm9_sdf_mini_molebert_tokens.csv new file mode 100644 index 0000000..3234466 --- /dev/null +++ b/tests/fixtures/qm9_sdf_mini_molebert_tokens.csv @@ -0,0 +1,6 @@ +id,input_ids +gdb_1,50 468 468 468 468 +gdb_2,163 427 427 427 +gdb_11,50 41 334 467 467 467 504 +gdb_22,50 38 50 366 422 422 422 424 422 422 422 503 +gdb_47,50 50 50 50 422 422 422 422 422 422 422 422 diff --git a/tests/test_conditioning.py b/tests/test_conditioning.py new file mode 100644 index 0000000..5d463d6 --- /dev/null +++ b/tests/test_conditioning.py @@ -0,0 +1,67 @@ +"""Molecule-level charge / spin conditioning.""" + +from __future__ import annotations + +import pandas as pd +import torch +from torch.utils.data import DataLoader, Subset + +from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.models.rebind import N_GLOBAL_FEATURES, build_collator, build_rebind + + +def _dataset(path) -> CSVMoleculeDataset: + return CSVMoleculeDataset(path, "mol2", charge_column="charge", spin_column="spinmult") + + +def _batch(path, n: int) -> dict: + loader = DataLoader( + Subset(_dataset(path), list(range(n))), + batch_size=n, + collate_fn=build_collator(conditioned=True), + ) + return next(iter(loader)) + + +def test_dataset_attaches_charge_and_spin(bostmc_path) -> None: + df = pd.read_csv(bostmc_path) + ds = _dataset(bostmc_path) + assert [ds[i]["charge"] for i in range(len(ds))] == df["charge"].astype(float).tolist() + assert [ds[i]["spin_multiplicity"] for i in range(len(ds))] == ( + df["spinmult"].astype(float).tolist() + ) + + +def test_collator_emits_charge_and_unpaired_electron_count(bostmc_path) -> None: + df = pd.read_csv(bostmc_path) + batch = _batch(bostmc_path, len(df)) + # Column 1 is unpaired electrons, i.e. multiplicity - 1: 0 for a singlet. + expected = torch.tensor( + [[float(c), float(s) - 1.0] for c, s in zip(df["charge"], df["spinmult"], strict=True)] + ) + assert batch["global_features"].shape == (len(df), N_GLOBAL_FEATURES) + torch.testing.assert_close(batch["global_features"], expected) + + +def test_conditioning_is_inert_at_init_then_changes_predictions(bostmc_path) -> None: + """Zero-initialised output layer means a fresh model matches the unconditioned one.""" + batch = _batch(bostmc_path, 2) + torch.manual_seed(0) + model = build_rebind( + n_layers=1, d_model=32, d_ffn=64, n_head=4, n_global_features=N_GLOBAL_FEATURES + ) + model.eval() + without_conditioning = {k: v for k, v in batch.items() if k != "global_features"} + with torch.no_grad(): + conditioned = model(**batch) + plain = model(**without_conditioning) + torch.testing.assert_close(conditioned.conformer_hat, plain.conformer_hat) + + # Once the layer is non-zero, the molecule-level features reach the output. + torch.nn.init.normal_(model.global_cond[-1].weight, std=0.5) + with torch.no_grad(): + neutral = model(**{**batch, "global_features": torch.zeros_like(batch["global_features"])}) + charged = model( + **{**batch, "global_features": torch.full_like(batch["global_features"], 2.0)} + ) + assert not torch.allclose(neutral.conformer_hat, charged.conformer_hat) diff --git a/tests/test_conformer_eval.py b/tests/test_conformer_eval.py new file mode 100644 index 0000000..d20b142 --- /dev/null +++ b/tests/test_conformer_eval.py @@ -0,0 +1,97 @@ +"""Conformer metrics in ReBind's published protocol.""" + +from __future__ import annotations + +import math +from types import SimpleNamespace + +import pytest +import torch +from torch.utils.data import Subset + +from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.eval.conformer_eval import evaluate_split, kabsch_rmsd +from step_up.models.rebind import build_rebind, get_collator + + +class _PerfectModel(torch.nn.Module): + """Predicts the target conformer exactly.""" + + def forward(self, **batch): + conformer = batch["conformer"] + return SimpleNamespace(conformer_hat=conformer, conformer=conformer) + + +def test_perfect_prediction_scores_zero(qm9_sdf_path) -> None: + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf") + subset = Subset(ds, list(range(len(ds)))) + metrics = evaluate_split(_PerfectModel(), ds, subset, get_collator()(), batch_size=2) + assert metrics["n_molecules"] == len(ds) + # Every fixture molecule reaches the RDKit C-RMSD path. + assert metrics["n_rmsd_molecules"] == len(ds) + assert metrics["n_rmsd_failures"] == 0 + assert metrics["d_mae"] == 0.0 + assert metrics["d_rmse"] == 0.0 + assert metrics["c_rmsd"] < 1e-6 + + +def test_untrained_model_gives_finite_metrics(qm9_sdf_path) -> None: + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf") + subset = Subset(ds, list(range(len(ds)))) + torch.manual_seed(0) + model = build_rebind(n_layers=1, d_model=32, d_ffn=64, n_head=4) + metrics = evaluate_split(model, ds, subset, get_collator()(), batch_size=2) + assert metrics["d_mae"] > 0.0 + assert all(math.isfinite(metrics[key]) for key in ("d_mae", "d_rmse", "c_rmsd")) + + +def test_mol2_rows_fall_back_to_aligned_coordinate_rmsd(bostmc_path) -> None: + """Organometallic rows have no RDKit molecule, so C-RMSD uses aligned coordinates.""" + ds = CSVMoleculeDataset(bostmc_path, "mol2") + subset = Subset(ds, list(range(len(ds)))) + metrics = evaluate_split(_PerfectModel(), ds, subset, get_collator()(), batch_size=2) + assert metrics["c_rmsd_method"] == "aligned_coords" + assert metrics["n_rmsd_molecules"] == len(ds) + assert metrics["d_mae"] == 0.0 + assert metrics["c_rmsd"] < 1e-6 + + +def test_forcing_aligned_coords_skips_rdkit(qm9_sdf_path) -> None: + """QM9 rows do build RDKit molecules, so the option has to override that. + + Forcing the plain metric is what makes RMSD comparable with the MOL2 sets. + """ + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf") + subset = Subset(ds, list(range(len(ds)))) + auto = evaluate_split(_PerfectModel(), ds, subset, get_collator()(), batch_size=2) + forced = evaluate_split( + _PerfectModel(), ds, subset, get_collator()(), batch_size=2, rmsd_method="aligned_coords" + ) + assert auto["c_rmsd_method"] == "rdkit_bestrms" + assert forced["c_rmsd_method"] == "aligned_coords" + assert forced["n_rmsd_molecules"] == len(ds) + + with pytest.raises(ValueError, match="Unknown rmsd_method"): + evaluate_split(_PerfectModel(), ds, subset, get_collator()(), rmsd_method="kabsch") + + +def test_kabsch_rmsd_is_invariant_to_rigid_motion() -> None: + """A rotated and translated copy of a structure has zero RMSD.""" + torch.manual_seed(0) + target = torch.randn(12, 3, dtype=torch.float64) + angle = torch.tensor(0.7, dtype=torch.float64) + rotation = torch.tensor( + [ + [torch.cos(angle), -torch.sin(angle), 0.0], + [torch.sin(angle), torch.cos(angle), 0.0], + [0.0, 0.0, 1.0], + ], + dtype=torch.float64, + ) + moved = (rotation @ target.T).T + torch.tensor([3.0, -1.0, 2.0], dtype=torch.float64) + assert kabsch_rmsd(moved, target) < 1e-10 + # A reflection is not a rotation, so it must not be aligned away. + mirrored = target * torch.tensor([1.0, 1.0, -1.0], dtype=torch.float64) + assert kabsch_rmsd(mirrored, target) > 0.1 + # Displacing every atom by a fixed amount along one axis shows up in full. + assert kabsch_rmsd(target + torch.tensor([0.0, 0.0, 0.0]), target) < 1e-10 diff --git a/tests/test_csv_dataset.py b/tests/test_csv_dataset.py index a057c33..0dfbe13 100644 --- a/tests/test_csv_dataset.py +++ b/tests/test_csv_dataset.py @@ -7,6 +7,7 @@ from step_up.data import featurize from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.data.splits import split_by_labels def _check_graph_dict(g: dict) -> None: @@ -94,3 +95,21 @@ def test_split_keys_come_from_the_csv_not_dataset_positions(bostmc_path) -> None bostmc_path, "mol2", filter_column="spinmult", filter_value=2, id_column="refcode" ) assert by_id.split_keys() == df.loc[doublet_rows, "refcode"].tolist() + + +def test_sdf_source_uses_published_bonds_and_split(qm9_sdf_path) -> None: + """The sdf source featurizes molblocks and can read a published split column.""" + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf", id_column="mol_id", split_column="split") + df = pd.read_csv(qm9_sdf_path) + assert len(ds) == len(df) + for i in range(len(ds)): + _check_graph_dict(ds[i]) + assert ds.split_keys() == df["mol_id"].tolist() + assert ds.split_labels() == df["split"].tolist() + + train, val, test = split_by_labels(ds, ds.split_labels()) + assert (len(train), len(val), len(test)) == (2, 2, 1) + # Methane from gdb9.sdf: 4 C-H bonds, stored as two directed edges each. + methane = ds[0] + assert methane["num_nodes"] == 5 + assert methane["num_edges"] == 8 diff --git a/tests/test_forward.py b/tests/test_forward.py index 025de72..82280b2 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -2,11 +2,16 @@ from __future__ import annotations +import ast +from pathlib import Path + +import pytest import torch from torch.utils.data import DataLoader, Subset from step_up.data.csv_dataset import CSVMoleculeDataset from step_up.models import rebind +from step_up.models.lj_params import UFF_LJ_PARAMETERS from step_up.models.rebind import build_rebind, get_collator @@ -94,3 +99,45 @@ def test_lj_patch_supports_heavy_z(tmqmg_path) -> None: assert "sigma" in batch and "epsilon" in batch assert torch.isfinite(batch["sigma"]).all() assert torch.isfinite(batch["epsilon"]).all() + + # Element-specific, not a flat fallback. The collator combines pairs + # (Lorentz-Berthelot), so each atom's own parameters sit on the diagonal. + node_mask = batch["node_mask"].bool() + seen = set() + for row in range(node_mask.shape[0]): + keep = node_mask[row] + node_types = batch["node_type"][row][keep].tolist() + sigmas = batch["sigma"][row][keep][:, keep].diagonal().tolist() + epsilons = batch["epsilon"][row][keep][:, keep].diagonal().tolist() + for node_type, sigma, epsilon in zip(node_types, sigmas, epsilons, strict=True): + # The collator shifts node_type by 1 so 0 can mean padding. + expected = UFF_LJ_PARAMETERS[int(node_type) - 1] + assert sigma == pytest.approx(expected[0], rel=1e-5) + assert epsilon == pytest.approx(expected[1], rel=1e-5) + seen.add(int(node_type)) + assert max(seen) - 1 > 35, "the fixture should contain a metal beyond ReBind's own table" + + +def test_uff_table_agrees_with_rebinds_own_values() -> None: + """Our table must extend upstream's, not restate it differently. + + ReBind defines its parameters inside the function body, so we read them out + of the vendored source: over Z=1..36 the two tables have to match exactly, or + swapping ours in would quietly change the published QM9 setup. + """ + source = ( + Path(__file__).resolve().parents[1] + / "external" + / "ReBIND" + / "models" + / "modules" + / "utils.py" + ).read_text() + start = source.index("lj_parameters = {") + end = source.index("\n }", start) + len("\n }") + upstream = ast.literal_eval(source[start + len("lj_parameters = ") : end]) + + assert upstream, "failed to parse ReBind's LJ table" + for node_type, params in upstream.items(): + assert UFF_LJ_PARAMETERS[node_type] == (params["sigma"], params["epsilon"]) + assert max(UFF_LJ_PARAMETERS) > max(upstream) diff --git a/tests/test_gtmgc.py b/tests/test_gtmgc.py new file mode 100644 index 0000000..9d71656 --- /dev/null +++ b/tests/test_gtmgc.py @@ -0,0 +1,76 @@ +"""The vendored GTMGC wrapper.""" + +from __future__ import annotations + +import torch +from torch.utils.data import DataLoader, Subset + +from step_up.data.csv_dataset import CSVMoleculeDataset +from step_up.models import build_model, build_model_collator +from step_up.models import gtmgc as gtmgc_module + + +def _tokenized_batch(qm9_sdf_path, tokens_path, n: int = 4) -> dict: + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf", id_column="mol_id", token_file=tokens_path) + loader = DataLoader( + Subset(ds, list(range(n))), batch_size=n, collate_fn=build_model_collator("gtmgc") + ) + return next(iter(loader)) + + +def test_tokens_reach_the_batch_as_node_input_ids(qm9_sdf_path, qm9_sdf_tokens_path) -> None: + """GTMGC embeds Mole-BERT ids, so the collator must receive them from the dataset.""" + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf", id_column="mol_id", token_file=qm9_sdf_tokens_path) + methane = ds[0] + assert len(methane["input_ids"]) == methane["num_nodes"] + + batch = _tokenized_batch(qm9_sdf_path, qm9_sdf_tokens_path) + assert "node_input_ids" in batch + # The collator shifts ids by 1 so 0 can mean padding. + assert batch["node_input_ids"][0][0].item() == methane["input_ids"][0] + 1 + assert batch["node_input_ids"].max().item() <= 512 + + +def test_forward_and_backward_in_fp32(qm9_sdf_path, qm9_sdf_tokens_path) -> None: + batch = _tokenized_batch(qm9_sdf_path, qm9_sdf_tokens_path) + torch.manual_seed(0) + model = build_model("gtmgc", n_layers=2, d_model=64, d_ffn=128, n_head=4) + model.train() + out = model(**batch) + assert torch.isfinite(out.loss) + assert out.conformer_hat.shape == out.conformer.shape + # Upstream's in-place Laplacian write breaks this; our patch is what makes fp32 work. + out.loss.backward() + assert any( + p.grad is not None and torch.isfinite(p.grad).all() and (p.grad != 0).any() + for p in model.parameters() + ) + + +def test_patched_forward_matches_upstream(qm9_sdf_path, qm9_sdf_tokens_path, monkeypatch) -> None: + """The out-of-place patch must not change what the model computes.""" + batch = _tokenized_batch(qm9_sdf_path, qm9_sdf_tokens_path) + torch.manual_seed(0) + model = build_model("gtmgc", n_layers=2, d_model=64, d_ffn=128, n_head=4) + model.eval() + with torch.no_grad(): + patched = model(**batch) + for cls, upstream_forward in gtmgc_module._UPSTREAM_FORWARDS.items(): + monkeypatch.setattr(cls, "forward", upstream_forward) + upstream = model(**batch) + torch.testing.assert_close(patched.loss, upstream.loss) + torch.testing.assert_close(patched.conformer_hat, upstream.conformer_hat) + + +def test_atom_type_embedding_needs_no_tokenizer(qm9_sdf_path) -> None: + """Their ablation embed_style runs off atom types, which we always have.""" + ds = CSVMoleculeDataset(qm9_sdf_path, "sdf") + loader = DataLoader(Subset(ds, [0, 1]), batch_size=2, collate_fn=build_model_collator("gtmgc")) + torch.manual_seed(0) + model = gtmgc_module.build_gtmgc( + n_layers=1, d_model=32, d_ffn=64, n_head=4, embed_style="atom_type_ids", atom_vocab_size=119 + ) + model.eval() + with torch.no_grad(): + out = model(**next(iter(loader))) + assert torch.isfinite(out.loss) diff --git a/tests/test_splits.py b/tests/test_splits.py index acb3598..5f88d24 100644 --- a/tests/test_splits.py +++ b/tests/test_splits.py @@ -6,7 +6,7 @@ import pytest -from step_up.data.splits import stable_split +from step_up.data.splits import split_by_id_files, split_by_labels, stable_split RATIOS = (0.8, 0.1, 0.1) @@ -49,3 +49,44 @@ def test_rejects_bad_inputs() -> None: stable_split([0, 1], ["a", "b"], ratios=(0.5, 0.5, 0.5)) with pytest.raises(ValueError, match="keys"): stable_split([0, 1], ["a"], ratios=RATIOS) + + +def test_split_by_labels_accepts_published_names() -> None: + labels = ["train", "valid", "test", "TRAIN", "Validation", " test "] + train, val, test = split_by_labels(list(range(len(labels))), labels) + assert train.indices == [0, 3] + assert val.indices == [1, 4] + assert test.indices == [2, 5] + + +def test_split_by_labels_rejects_bad_input() -> None: + with pytest.raises(ValueError, match="Unknown split name"): + split_by_labels([0, 1], ["train", "holdout"]) + with pytest.raises(ValueError, match="labels"): + split_by_labels([0, 1], ["train"]) + + +def test_split_by_id_files_applies_published_lists(tmp_path) -> None: + """IDs come from txt or csv lists; rows in no list are dropped.""" + (tmp_path / "train.txt").write_text("AAA\nBBB\n") + (tmp_path / "val.csv").write_text("refcode\nCCC\n") + (tmp_path / "test.csv").write_text("refcode\nDDD\n") + keys = ["AAA", "CCC", "ZZZ", "DDD", "BBB"] # ZZZ is in no list + files = { + "train": tmp_path / "train.txt", + "valid": tmp_path / "val.csv", + "test": tmp_path / "test.csv", + } + train, val, test = split_by_id_files(list(range(len(keys))), keys, files) + assert train.indices == [0, 4] + assert val.indices == [1] + assert test.indices == [3] + + +def test_split_by_id_files_rejects_an_id_in_two_lists(tmp_path) -> None: + (tmp_path / "train.txt").write_text("AAA\n") + (tmp_path / "test.txt").write_text("AAA\n") + with pytest.raises(ValueError, match="listed in both"): + split_by_id_files( + [0], ["AAA"], {"train": tmp_path / "train.txt", "test": tmp_path / "test.txt"} + ) diff --git a/tests/test_train.py b/tests/test_train.py index 609f3b7..09effe0 100644 --- a/tests/test_train.py +++ b/tests/test_train.py @@ -74,3 +74,34 @@ def nan_validation(model, loader, optimizer, *args, **kwargs): train(_tiny_config(qm9_path, tmp_path)) assert not (tmp_path / "run" / "best.pt").exists() assert not (tmp_path / "run" / "test_metrics.json").exists() + + +def test_lr_schedule_matches_the_paper_setting() -> None: + """`linear` is HuggingFace's warmup-then-decay, which ReBind's script requests.""" + total, warmup, base = 100, 10, 9e-5 + linear = [train_module._lr_at_step(s, total, warmup, base, "linear") for s in range(total + 1)] + assert linear[0] == 0.0 + assert linear[warmup] == pytest.approx(base) + assert linear[warmup + (total - warmup) // 2] == pytest.approx(base / 2) + assert linear[total] == pytest.approx(0.0) + assert all(a >= b for a, b in zip(linear[warmup:], linear[warmup + 1 :], strict=False)) + + cosine = [train_module._lr_at_step(s, total, warmup, base, "cosine") for s in range(total + 1)] + assert cosine[warmup] == pytest.approx(base) + assert cosine[total] == pytest.approx(0.0, abs=1e-12) + + # An unknown name is rejected at config load, and by the helper itself. + with pytest.raises(ValueError, match="Unknown lr_schedule"): + TrainConfig(dataset_path="x.csv", dataset_source="sdf", lr_schedule="triangular") + with pytest.raises(ValueError, match="Unknown lr_schedule"): + train_module._lr_at_step(warmup + 1, total, warmup, base, "triangular") + + +def test_drop_last_keeps_a_partial_batch_rather_than_training_on_nothing( + qm9_path, tmp_path, capsys +) -> None: + config = _tiny_config(qm9_path, tmp_path, batch_size=64, eval_batch_size=64) + assert config.drop_last, "ReBind's script drops the last partial batch" + result = train(config) + assert result["best_epoch"] == 1 + assert "keeping the partial batch" in capsys.readouterr().out From c214a00bbb61a642ec2aaea9d1fd781d4aafc84e Mon Sep 17 00:00:00 2001 From: jwtoney Date: Sun, 27 Sep 2026 10:53:29 -0400 Subject: [PATCH 07/12] Flag the organometallic results as pre-dating the LJ fix Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f7cd0e5..ee27c46 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,11 @@ absolute distance error and grows with molecular size. | tmQMg, complete | 1,360 | 0.892 | 1.353 | 2.325 | 15.7% | | BOSTMC low-spin | 12,150 | 0.964 | 1.496 | 2.532 | 16.1% | +The three organometallic rows predate the full UFF Lennard-Jones table and were +trained with a flat sigma/epsilon for every element past Kr, so they are a floor +rather than a result. They want rerunning before the numbers travel anywhere. +The QM9 rows are unaffected: that table only covers Z=1..36 either way. + Organometallic error is about twice QM9's in relative terms, not the four times raw D-MAE suggests. Global structure degrades further than pairwise distances do: RMSD is 27% of the mean pairwise distance on QM9 against 41-42% on the From f85040bf99b0aa5e59edd987774d55dc7c16eb88 Mon Sep 17 00:00:00 2001 From: jwtoney Date: Sun, 27 Sep 2026 11:51:20 -0400 Subject: [PATCH 08/12] Drop rebind_utils.py now that its LJ values live in the package Its unique content was the extended Lennard-Jones table, which is now in src/step_up/models/lj_params.py and patched into the vendored ReBind at runtime, so no fork of ReBind is needed. The rest of the file was a copy of external/ReBIND/models/modules/utils.py with the feature vocabulary changed (numH, bond direction, bond stereo and conjugation dropped, DATIVE added), which would not have been safe to use as-is: ReBind's embedding layers zip those vocab dimensions against the 9 atom / 4 bond feature columns our featurizer emits, so the shorter lists would silently drop features. Keeping the paper's vocabulary. Co-Authored-By: Claude Opus 5 (1M context) --- rebind_utils.py | 401 ------------------------------------------------ 1 file changed, 401 deletions(-) delete mode 100644 rebind_utils.py diff --git a/rebind_utils.py b/rebind_utils.py deleted file mode 100644 index 6c263e3..0000000 --- a/rebind_utils.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -This module contains useful functions for the modules. -""" - -import torch - -ALLOWABLE_FEATURES_VOCAB = { - # This dictionary contains the allowable features for each node and edge. - "possible_atomic_num": list(range(1, 119)), - "possible_chirality": [ - "CHI_UNSPECIFIED", - "CHI_TETRAHEDRAL_CW", - "CHI_TETRAHEDRAL_CCW", - "CHI_OTHER", - ], - "possible_degree": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, "misc"], - "possible_formal_charge": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, "misc"], - "possible_number_radical_e": [0, 1, 2, 3, 4, "misc"], - "possible_hybridization": ["SP", "SP2", "SP3", "SP3D", "SP3D2", "misc"], - "possible_is_aromatic": [False, True], - "possible_is_in_ring": [False, True], - "possible_bond_type": ["SINGLE", "DOUBLE", "TRIPLE", "AROMATIC", "DATIVE", "misc"], -} - - -def get_atom_vocab_dims(): - """get the dimensions of the atom vocabulary. - - Returns: - List[int]: A list of integers, which denotes the dimensions of the atom vocabulary. - """ - dims_ls = list( - map( - len, - [ - ALLOWABLE_FEATURES_VOCAB["possible_atomic_num"], - ALLOWABLE_FEATURES_VOCAB["possible_chirality"], - ALLOWABLE_FEATURES_VOCAB["possible_degree"], - ALLOWABLE_FEATURES_VOCAB["possible_formal_charge"], - ALLOWABLE_FEATURES_VOCAB["possible_number_radical_e"], - ALLOWABLE_FEATURES_VOCAB["possible_hybridization"], - ALLOWABLE_FEATURES_VOCAB["possible_is_aromatic"], - ALLOWABLE_FEATURES_VOCAB["possible_is_in_ring"], - ], - ) - ) - return dims_ls - - -def get_bond_vocab_dims(): - """get the dimensions of the bond vocabulary. - - Returns: - List[int]: A list of integers, which denotes the dimensions of the bond vocabulary. - """ - dims_ls = list( - map( - len, - [ - ALLOWABLE_FEATURES_VOCAB["possible_bond_type"], - ], - ) - ) - return dims_ls - - -def make_mask_for_pyd_batch_graph(batch: torch.Tensor): - """Mask the distance matrix of a PyG batch of disconnected graphs. - - Args: - batch: Shape (n,), e.g. [0, 0, 0, 1, 1, 1, 1]. Each value is the - graph index of that node. - - Returns: - torch.Tensor: Shape (n, n), where n is the number of nodes in the batch. - """ - n = batch.shape[0] - mask = torch.eq(batch.unsqueeze(1), batch.unsqueeze(0)) - mask = (torch.ones((n, n)) - torch.eye(n)).to(batch.device) * mask - return mask - - -def valid_length_to_mask(valid_length: torch.TensorType, max_len: int | None = None): - """Convert the valid length to mask - - Args: - valid_length (torch.TensorType): The valid length of each sequence with shape (b,). - - Returns: - torch.TensorType: The mask with shape (b, l). - """ - max_len = valid_length.max() if max_len is None else max_len - mask = (torch.arange(max_len)[None, :]) < valid_length[:, None] - return mask.to(torch.float32) - - -def mask_attention_score( - attention_score: torch.Tensor, - attention_mask: torch.Tensor, - mask_pos_value: float = 0.0, -): - """Mask the attention score. b: batch, seq: length, h: heads. - - Args: - attention_score (torch.Tensor): Shape (b, h, seq, seq) or (b, seq, seq). - attention_mask (torch.Tensor): Shape (b, seq) or (b, seq, seq). - mask_pos_value (float, optional): Value marking positions to mask. - Defaults to 0.0. - Returns: - torch.Tensor: Masked score, same rank as ``attention_score``. - """ - shape = attention_score.shape - b, h, seq, _ = shape if len(shape) == 4 else (shape[0], 1, shape[1], shape[2]) - # (b, seq, seq) -> (b, 1, seq, seq); 4D scores stay (b, h, seq, seq) - attention_score = attention_score.view(b, h, seq, seq) if len(shape) == 3 else attention_score - # (b, seq) -> (b*h, seq) - attention_mask = attention_mask.repeat_interleave(h, dim=0) # (b*h, seq) or (b*h, seq, seq) - attention_mask = attention_mask.view(b, h, -1, seq) # (b, h, 1, seq) or (b, h, seq, seq) - # attention_mask = (1 - attention_mask) * (-100000.0) - # attention_score += attention_mask # (b, h, l, l) - attention_score = attention_score.masked_fill(attention_mask == mask_pos_value, -10000.0) - return attention_score.view(shape) if len(shape) == 3 else attention_score - - -def mask_hidden_state(hidden_X: torch.Tensor, padding_mask: torch.Tensor | None = None): - """Mask the hidden state. b: batch, seq: length, d: hidden size. - - Args: - hidden_X (torch.Tensor): Hidden state with shape (b, seq, d). - padding_mask (torch.Tensor, optional): Per-sequence mask, shape (b, seq). - Defaults to None. - - Returns: - torch.Tensor: The masked hidden state with shape (b, l, d) - """ - if padding_mask is None: - return hidden_X - # (b, l) -> (b, l, 1) - padding_mask = padding_mask.unsqueeze(-1).to(torch.bool) - return padding_mask * hidden_X - - -def make_cdist_mask(padding_mask: torch.Tensor) -> torch.Tensor: - """Make mask for coordinate pairwise distance from padding mask. - - Args: - padding_mask (torch.Tensor): Padding mask for the batched input sequences with shape (b, l). - - Returns: - torch.Tensor: Mask for coordinate pairwise distance with shape (b, l, l). - """ - padding_mask = padding_mask.unsqueeze(-1) - mask = padding_mask * padding_mask.transpose(-1, -2) - return mask - - -def align_conformer_to_origin(conformer: torch.Tensor): - """Align the first atom of each molecule to the origin (x, y, z) -> (0, 0, 0). - - Args: - - conformer (torch.Tensor): The conformer with shape (b, l, 3). - - Returns: - torch.Tensor: The aligned conformer with shape (b, l, 3). - """ - delta = conformer[:, 0, :] - conformer_aligned = conformer - delta.unsqueeze(1) - return conformer_aligned - - -# def align_conformer_hat_to_conformer(conformer_hat, conformer): -# """Align conformer_hat to conformer using Kabsch algorithm. - -# Args: -# - conformer_hat (torch.Tensor): The conformer to be aligned, with shape (b, l, 3). -# - conformer (torch.Tensor): The reference conformer, with shape (b, l, 3). - -# Returns: -# - conformer_hat_aligned (torch.Tensor): The aligned conformer, with shape (b, l, 3). -# """ -# # compute the mean of conformer_hat and conformer, and then center them -# p_mean = conformer_hat.mean(dim=1, keepdim=True) -# q_mean = conformer.mean(dim=1, keepdim=True) -# p_centered = conformer_hat - p_mean -# q_centered = conformer - q_mean -# # compute the rotation matrix using Kabsch algorithm -# H = torch.matmul(q_centered.transpose(1, 2), p_centered).float() # shape: (b, 3, 3) -# U, S, V = torch.svd(H) # shape: (b, 3, 3) -# R = torch.matmul(V, U.transpose(1, 2)) # shape: (b, 3, 3) -# # rotate p_centered using R -# p_rotated = torch.matmul(R, p_centered.transpose(1, 2)) # shape: (b, 3, l) -# p_rotated = p_rotated.transpose(1, 2) # shape: (b, l, 3) -# # align p_rotated to the spatial position of q -# conformer_hat_aligned = p_rotated + q_mean # shape: (b, l, 3) -# return conformer_hat_aligned - - -def align_conformer_hat_to_conformer( - conformer_hat: torch.Tensor, - conformer: torch.Tensor, - padding_mask: torch.Tensor, - eps: float = 1e-8, -) -> torch.Tensor: - """ - Kabsch-align conformer_hat to conformer, using padding_mask to ignore padded atoms. - Critically: SVD is done under no_grad to avoid SvdBackward NaNs. - """ - # padding_mask: (B,N) with 1 for real atoms, 0 for padding - w = padding_mask.to(conformer_hat.dtype).unsqueeze(-1) # (B,N,1) - denom = w.sum(dim=1, keepdim=True).clamp_min(1.0) - - # masked means - p_mean = (conformer_hat * w).sum(dim=1, keepdim=True) / denom - q_mean = (conformer * w).sum(dim=1, keepdim=True) / denom - - # masked centered coords - P = (conformer_hat - p_mean) * w - Q = (conformer - q_mean) * w - - # covariance - H = Q.transpose(1, 2) @ P # (B,3,3) - - # compute rotation with detached SVD (prevents LinalgSvdBackward0 NaNs) - with torch.no_grad(): - U, _S, Vh = torch.linalg.svd(H.double(), full_matrices=False) # Vh is (B,3,3) - R = Vh.transpose(-2, -1) @ U.transpose(-2, -1) # (B,3,3) - - # reflection fix - det = torch.det(R) - fix = det < 0 - if fix.any(): - Vh_fix = Vh.clone() - Vh_fix[fix, -1, :] *= -1 - R = Vh_fix.transpose(-2, -1) @ U.transpose(-2, -1) - - R = R.to(conformer_hat.dtype) # treat as constant in backward - - # apply rotation (grad flows to conformer_hat, but NOT through SVD) - P_rot = (conformer_hat - p_mean) @ R - return P_rot + q_mean - - -def get_mask_with_ratio(node_mask: torch.Tensor, ratio: float) -> torch.Tensor: - """Mask the original mask with ratio on the 1's. - - Args: - node_mask (torch.Tensor): 1 is a real atom, 0 is padding. - ratio (float): Fraction of real atoms to mask. - - Returns: - torch.Tensor: 1 is a masked atom, 0 is a kept atom. - """ - mask_hat = node_mask == 1 - mask_hat = mask_hat * (1 - ratio) - mask_hat = torch.bernoulli(mask_hat) - return mask_hat.to(torch.long) - - -def get_masked_atom_mask(mask_with_ratio: torch.Tensor, node_mask: torch.Tensor) -> torch.Tensor: - """Get the masked atom mask. 1 means the masked position and 0 means the valid position. - - Args: - mask_with_ratio (torch.Tensor): 1 is a masked atom, 0 is kept. - node_mask (torch.Tensor): 1 is a real atom, 0 is padding. - - Returns: - torch.Tensor: 1 is a masked atom, 0 is a kept atom. - """ - masked_atom_mask = node_mask * (1 - mask_with_ratio) - return masked_atom_mask.to(torch.long) - - -def compute_distance_residual_bias( - cdist: torch.Tensor, cdist_mask: torch.Tensor, raw_max: bool = True -) -> torch.Tensor: - b, seq, _ = cdist.shape - D = cdist * cdist_mask - if not raw_max: - D_max, _ = torch.max(D.view(b, -1), dim=-1) # max value of each sample - D = D_max.view(b, 1, 1) - D # sample-max value subtract every value - else: - D_max, _ = torch.max(D, dim=-1) # max value of every raw in each sample - D = D_max.view(b, seq, 1) - D # raw-max value subtract every raw - D.diagonal(dim1=-2, dim2=-1)[:] = 0 # set diagonal to 0 - D = D * cdist_mask - return D - - -def get_sigma_and_epsilon(mol_data): - lj_parameters = { - 0: {"sigma": 2.886, "epsilon": 0.0440}, # H - 1: {"sigma": 2.362, "epsilon": 0.0560}, # He - 2: {"sigma": 2.451, "epsilon": 0.0250}, # Li - 3: {"sigma": 2.745, "epsilon": 0.0850}, # Be - 4: {"sigma": 3.637, "epsilon": 0.1800}, # B - 5: {"sigma": 3.431, "epsilon": 0.1050}, # C - 6: {"sigma": 3.260, "epsilon": 0.0690}, # N - 7: {"sigma": 3.118, "epsilon": 0.0600}, # O - 8: {"sigma": 2.996, "epsilon": 0.0500}, # F - 9: {"sigma": 2.889, "epsilon": 0.0420}, # Ne - 10: {"sigma": 2.983, "epsilon": 0.0300}, # Na - 11: {"sigma": 2.905, "epsilon": 0.1110}, # Mg - 12: {"sigma": 4.008, "epsilon": 0.5050}, # Al - 13: {"sigma": 3.826, "epsilon": 0.4020}, # Si - 14: {"sigma": 3.694, "epsilon": 0.3050}, # P - 15: {"sigma": 3.594, "epsilon": 0.2740}, # S - 16: {"sigma": 3.516, "epsilon": 0.2270}, # Cl - 17: {"sigma": 3.404, "epsilon": 0.1850}, # Ar - 18: {"sigma": 3.812, "epsilon": 0.0350}, # K - 19: {"sigma": 3.487, "epsilon": 0.2380}, # Ca - 20: {"sigma": 3.316, "epsilon": 0.0190}, # Sc - 21: {"sigma": 3.294, "epsilon": 0.0170}, # Ti - 22: {"sigma": 3.273, "epsilon": 0.0160}, # V - 23: {"sigma": 3.249, "epsilon": 0.0150}, # Cr - 24: {"sigma": 3.210, "epsilon": 0.0130}, # Mn - 25: {"sigma": 3.174, "epsilon": 0.0130}, # Fe - 26: {"sigma": 3.144, "epsilon": 0.0130}, # Co - 27: {"sigma": 3.116, "epsilon": 0.0130}, # Ni - 28: {"sigma": 3.083, "epsilon": 0.0050}, # Cu - 29: {"sigma": 3.002, "epsilon": 0.1240}, # Zn - 30: {"sigma": 4.383, "epsilon": 0.4150}, # Ga - 31: {"sigma": 4.310, "epsilon": 0.3790}, # Ge - 32: {"sigma": 4.280, "epsilon": 0.3090}, # As - 33: {"sigma": 4.336, "epsilon": 0.2910}, # Se - 34: {"sigma": 4.403, "epsilon": 0.2510}, # Br - 35: {"sigma": 4.463, "epsilon": 0.2200}, # Kr - 36: {"sigma": 4.114, "epsilon": 0.0400}, # Rb - 37: {"sigma": 3.641, "epsilon": 0.2350}, # Sr - 38: {"sigma": 3.345, "epsilon": 0.0720}, # Y - 39: {"sigma": 3.124, "epsilon": 0.0690}, # Zr - 40: {"sigma": 3.167, "epsilon": 0.0590}, # Nb - 41: {"sigma": 3.167, "epsilon": 0.0560}, # Mo - 42: {"sigma": 3.140, "epsilon": 0.0480}, # Tc - 43: {"sigma": 3.076, "epsilon": 0.0560}, # Ru - 44: {"sigma": 3.053, "epsilon": 0.0530}, # Rh - 45: {"sigma": 3.003, "epsilon": 0.0410}, # Pd - 46: {"sigma": 3.148, "epsilon": 0.0360}, # Ag - 47: {"sigma": 2.848, "epsilon": 0.2280}, # Cd - 48: {"sigma": 4.432, "epsilon": 0.4080}, # In - 49: {"sigma": 4.295, "epsilon": 0.3810}, # Sn - 50: {"sigma": 4.314, "epsilon": 0.3110}, # Sb - 51: {"sigma": 4.382, "epsilon": 0.2920}, # Te - 52: {"sigma": 4.436, "epsilon": 0.2520}, # I - 53: {"sigma": 4.465, "epsilon": 0.2210}, # Xe - 54: {"sigma": 4.302, "epsilon": 0.0320}, # Cs - 55: {"sigma": 3.666, "epsilon": 0.2200}, # Ba - 56: {"sigma": 3.522, "epsilon": 0.0130}, # La - 57: {"sigma": 3.493, "epsilon": 0.0130}, # Ce - 58: {"sigma": 3.468, "epsilon": 0.0130}, # Pr - 59: {"sigma": 3.441, "epsilon": 0.0130}, # Nd - 60: {"sigma": 3.415, "epsilon": 0.0130}, # Pm - 61: {"sigma": 3.391, "epsilon": 0.0130}, # Sm - 62: {"sigma": 3.364, "epsilon": 0.0130}, # Eu - 63: {"sigma": 3.340, "epsilon": 0.0130}, # Gd - 64: {"sigma": 3.315, "epsilon": 0.0130}, # Tb - 65: {"sigma": 3.289, "epsilon": 0.0130}, # Dy - 66: {"sigma": 3.265, "epsilon": 0.0130}, # Ho - 67: {"sigma": 3.241, "epsilon": 0.0130}, # Er - 68: {"sigma": 3.216, "epsilon": 0.0130}, # Tm - 69: {"sigma": 3.191, "epsilon": 0.0130}, # Yb - 70: {"sigma": 3.167, "epsilon": 0.0130}, # Lu - 71: {"sigma": 3.141, "epsilon": 0.0670}, # Hf - 72: {"sigma": 3.170, "epsilon": 0.0600}, # Ta - 73: {"sigma": 3.168, "epsilon": 0.0540}, # W - 74: {"sigma": 3.111, "epsilon": 0.0460}, # Re - 75: {"sigma": 3.120, "epsilon": 0.0310}, # Os - 76: {"sigma": 3.020, "epsilon": 0.0320}, # Ir - 77: {"sigma": 2.754, "epsilon": 0.0800}, # Pt - 78: {"sigma": 3.293, "epsilon": 0.0390}, # Au - 79: {"sigma": 2.730, "epsilon": 0.4090}, # Hg - 80: {"sigma": 4.347, "epsilon": 0.3790}, # Tl - 81: {"sigma": 4.355, "epsilon": 0.3450}, # Pb - 82: {"sigma": 4.370, "epsilon": 0.2840}, # Bi - 83: {"sigma": 4.450, "epsilon": 0.2220}, # Po - 84: {"sigma": 4.750, "epsilon": 0.2840}, # At - 85: {"sigma": 4.765, "epsilon": 0.2210}, # Rn - 86: {"sigma": 3.846, "epsilon": 0.0300}, # Fr - 87: {"sigma": 3.659, "epsilon": 0.2110}, # Ra - 88: {"sigma": 3.478, "epsilon": 0.0330}, # Ac - 89: {"sigma": 3.395, "epsilon": 0.0310}, # Th - 90: {"sigma": 3.424, "epsilon": 0.0100}, # Pa - 91: {"sigma": 3.397, "epsilon": 0.0110}, # U - 92: {"sigma": 3.424, "epsilon": 0.0050}, # Np - 93: {"sigma": 3.424, "epsilon": 0.0050}, # Pu - 94: {"sigma": 3.381, "epsilon": 0.0140}, # Am - 95: {"sigma": 3.326, "epsilon": 0.0140}, # Cm - 96: {"sigma": 3.339, "epsilon": 0.0140}, # Bk - 97: {"sigma": 3.313, "epsilon": 0.0140}, # Cf - 98: {"sigma": 3.286, "epsilon": 0.0140}, # Es - 99: {"sigma": 3.281, "epsilon": 0.0140}, # Fm - 100: {"sigma": 3.268, "epsilon": 0.0140}, # Md - 101: {"sigma": 3.254, "epsilon": 0.0140}, # No - 102: {"sigma": 3.240, "epsilon": 0.0070}, # Lr - } - eps_list = [] - sig_list = [] - for atom_id in mol_data.node_type: - id_to_type = atom_id.item() - eps_list.append(lj_parameters[id_to_type]["epsilon"]) - sig_list.append(lj_parameters[id_to_type]["sigma"]) - return torch.Tensor(eps_list), torch.Tensor(sig_list) From 00ae17d0690f4c6a64b6c918e730fb0697b5c721 Mon Sep 17 00:00:00 2001 From: jwtoney Date: Sun, 27 Sep 2026 12:06:26 -0400 Subject: [PATCH 09/12] Exclude hydrogens from the aligned-coordinate RMSD too The MOL2 rows build no RDKit molecule, so `Chem.RemoveHs` never ran on them and the organometallic RMSDs were being reported over every atom while the QM9 C-RMSD excluded hydrogen. Hydrogen is a quarter to a half of the atoms in these complexes and the hardest to place, so that is not a comparison. The aligned-coordinate path now drops hydrogens by atom type, which needs no RDKit. D-MAE and D-RMSE still pool over every atom, as ReBind's evaluate.py does. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +++++++ src/step_up/eval/conformer_eval.py | 15 ++++++++++++++- tests/test_conformer_eval.py | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee27c46..48224e5 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,13 @@ absolute distance error and grows with molecular size. | tmQMg, complete | 1,360 | 0.892 | 1.353 | 2.325 | 15.7% | | BOSTMC low-spin | 12,150 | 0.964 | 1.496 | 2.532 | 16.1% | +Hydrogens are predicted throughout: every dataset carries explicit hydrogen and +the loss covers every atom. D-MAE and D-RMSE include them, here and in ReBind's +own `evaluate.py`. The RMSD column above, however, does *not* exclude them the +way the QM9 C-RMSD does — the MOL2 rows build no RDKit molecule, so +`Chem.RemoveHs` never ran. The evaluator now drops hydrogens from that path too, +by atom type, so reruns will report lower RMSDs than this table. + The three organometallic rows predate the full UFF Lennard-Jones table and were trained with a flat sigma/epsilon for every element past Kr, so they are a floor rather than a result. They want rerunning before the numbers travel anywhere. diff --git a/src/step_up/eval/conformer_eval.py b/src/step_up/eval/conformer_eval.py index 5916178..c8e0096 100644 --- a/src/step_up/eval/conformer_eval.py +++ b/src/step_up/eval/conformer_eval.py @@ -80,6 +80,11 @@ def evaluate_split( built, else aligned coordinates) or ``"aligned_coords"`` to force the plain metric everywhere, which is what makes RMSD comparable between the organic and organometallic sets. + + ``remove_hs`` applies to both RMSD paths — via ``Chem.RemoveHs`` for the + RDKit one and via the atom types for the aligned-coordinate one. It never + touches D-MAE / D-RMSE, which pool over every atom including hydrogen, as + ReBind's ``evaluate.py`` does. """ if rmsd_method not in ("auto", "aligned_coords"): raise ValueError(f"Unknown rmsd_method: {rmsd_method!r}") @@ -119,7 +124,15 @@ def evaluate_split( if mol is None: # Optimal rigid alignment on this molecule's real atoms. Not # symmetry matched, so only compare to other aligned_coords numbers. - total_rmsd += kabsch_rmsd(pred, target) + # `node_type` is Z here (the collator shifted it by 1 so 0 can + # mean padding), so hydrogens can be dropped without RDKit — + # which matters: they are a third of the atoms in a metal complex + # and the hardest to place. + if remove_hs: + heavy = device_batch["node_type"][row][keep] != 1 + total_rmsd += kabsch_rmsd(pred[heavy.cpu()], target[heavy.cpu()]) + else: + total_rmsd += kabsch_rmsd(pred, target) n_rmsd += 1 else: ref = _predicted_mol(mol, target.tolist()) diff --git a/tests/test_conformer_eval.py b/tests/test_conformer_eval.py index d20b142..57d3f5e 100644 --- a/tests/test_conformer_eval.py +++ b/tests/test_conformer_eval.py @@ -75,6 +75,27 @@ def test_forcing_aligned_coords_skips_rdkit(qm9_sdf_path) -> None: evaluate_split(_PerfectModel(), ds, subset, get_collator()(), rmsd_method="kabsch") +def test_aligned_coords_rmsd_can_drop_hydrogens(bostmc_path) -> None: + """The MOL2 path has no RDKit molecule, so it must drop Hs by atom type. + + Organometallic RMSD was being reported over every atom while the QM9 C-RMSD + excluded hydrogen, which is not a comparison. + """ + ds = CSVMoleculeDataset(bostmc_path, "mol2") + subset = Subset(ds, list(range(len(ds)))) + torch.manual_seed(0) + model = build_rebind(n_layers=1, d_model=32, d_ffn=64, n_head=4) + heavy = evaluate_split(model, ds, subset, get_collator()(), batch_size=2, remove_hs=True) + everything = evaluate_split(model, ds, subset, get_collator()(), batch_size=2, remove_hs=False) + + assert heavy["c_rmsd_method"] == everything["c_rmsd_method"] == "aligned_coords" + assert heavy["c_rmsd"] != everything["c_rmsd"], "the fixture molecules have hydrogens" + # Dropping atoms changes only the RMSD; the distance metrics pool over all + # atoms either way, as ReBind's evaluate.py does. + assert heavy["d_mae"] == everything["d_mae"] + assert heavy["d_rmse"] == everything["d_rmse"] + + def test_kabsch_rmsd_is_invariant_to_rigid_motion() -> None: """A rotated and translated copy of a structure has zero RMSD.""" torch.manual_seed(0) From ef02ac405f32944a0dc7a82de1e88e8b34cab1ce Mon Sep 17 00:00:00 2001 From: jwtoney Date: Mon, 28 Sep 2026 12:54:50 -0400 Subject: [PATCH 10/12] Add heavy-atom training: remove_hs drops hydrogens from the graph Pairs every benchmark run with a variant the model never sees hydrogen in, so "bad at hydrogen" can be told apart from "bad at geometry". This is a different model, not the eval-time --keep-hs flag: hydrogens are gone before featurization, so they are neither embedded, attended over, nor predicted. Both featurization paths support it. The RDKit path (QM9) uses Chem.RemoveHs. The MOL2 path (tmQMg, BOSTMC) has no RDKit molecule to lean on, so the parser filters and reindexes atoms and bonds itself. Either way each heavy atom keeps its hydrogen count in the numH feature, so the graph still knows how many hydrogens there were; only their positions are gone. `rdkit_mol()` returns the stripped molecule too, or C-RMSD would write predicted coordinates onto the wrong atoms. QM9 without hydrogens contains one-atom molecules -- methane, ammonia -- which is where a degree-zero graph could divide by zero in the Laplacian positional encoding. It does not; there is a test pinning it. Three configs staged: qm9_rebind_noh, tmqmg_complete_noh, bostmc_noh. They are labelled as experiments rather than reproductions, since ReBind's published setup keeps hydrogen explicit. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 ++++- configs/bostmc_noh.yaml | 58 ++++++++++++++++++++++++++++ configs/qm9_rebind_noh.yaml | 53 ++++++++++++++++++++++++++ configs/tmqmg_complete_noh.yaml | 61 ++++++++++++++++++++++++++++++ src/step_up/data/csv_dataset.py | 26 +++++++++---- src/step_up/data/featurize.py | 27 ++++++++++--- src/step_up/data/mol2.py | 54 ++++++++++++++++++++++---- src/step_up/train.py | 5 +++ tests/test_csv_dataset.py | 67 +++++++++++++++++++++++++++++++++ tests/test_forward.py | 23 +++++++++++ 10 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 configs/bostmc_noh.yaml create mode 100644 configs/qm9_rebind_noh.yaml create mode 100644 configs/tmqmg_complete_noh.yaml diff --git a/README.md b/README.md index 48224e5..84c7acb 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,13 @@ absolute distance error and grows with molecular size. | BOSTMC low-spin | 12,150 | 0.964 | 1.496 | 2.532 | 16.1% | Hydrogens are predicted throughout: every dataset carries explicit hydrogen and -the loss covers every atom. D-MAE and D-RMSE include them, here and in ReBind's -own `evaluate.py`. The RMSD column above, however, does *not* exclude them the +the loss covers every atom. `remove_hs: true` trains the heavy-atom variant +instead (`configs/*_noh.yaml`), dropping hydrogen from the graph so the model +neither sees nor predicts it — a different model, not a different metric. Each +heavy atom keeps its hydrogen count in the `numH` feature either way. + +In the runs above, D-MAE and D-RMSE include hydrogen, here and in ReBind's own +`evaluate.py`. The RMSD column above, however, does *not* exclude them the way the QM9 C-RMSD does — the MOL2 rows build no RDKit molecule, so `Chem.RemoveHs` never ran. The evaluator now drops hydrogens from that path too, by atom type, so reruns will report lower RMSDs than this table. diff --git a/configs/bostmc_noh.yaml b/configs/bostmc_noh.yaml new file mode 100644 index 0000000..f5b95f8 --- /dev/null +++ b/configs/bostmc_noh.yaml @@ -0,0 +1,58 @@ +# Heavy-atom BOSTMC: configs/bostmc.yaml with hydrogens dropped. +# complexes), with the model conditioned on charge and spin so the two manifolds +# are distinguishable. +# +# Data is the filtered release, which drops structures whose molecular graph +# changed during optimization and deduplicates by initial graph hash, keeping the +# best-R-factor representative. +# +# Split: the project's own random split (seed 0, 80/10/10 by refcode) from +# datasets/filtered/splits/random, which covers all 121,496 low-spin rows. The +# similarity ("spectral") split under splits/spectral is the planned follow-up. +dataset_path: /home/gridsan/jtoney/BOSTMC/datasets/filtered/BOSTMC-low-spin.csv +dataset_source: mol2 +subset_size: null +id_column: refcode +split_files: + train: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/train-random.csv + val: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/val-random.csv + test: /home/gridsan/jtoney/BOSTMC/datasets/filtered/splits/random/test-random.csv + +# Charges span -8..+8 and spin multiplicity is 1 or 2. Both enter as scalars, so +# rare charge states still inform the model and inference isn't restricted to the +# combinations seen in training. +charge_column: charge +spin_column: spinmult + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization as in ReBind's rebind.sh (see README for the fp16 exception). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/bostmc_full_noh +seed: 42 + +# Heavy-atom variant: hydrogens are dropped from the graph, so the model never +# sees or predicts them. Paired with configs/bostmc.yaml to separate "the model is +# bad at hydrogen" from "the model is bad at geometry". Each heavy atom keeps its +# hydrogen count in the numH feature, so the graph is not missing the chemistry, +# only the positions. Note this departs from the published setup, which keeps +# hydrogen explicit, so it is not a reproduction run. +remove_hs: true diff --git a/configs/qm9_rebind_noh.yaml b/configs/qm9_rebind_noh.yaml new file mode 100644 index 0000000..8c5e56f --- /dev/null +++ b/configs/qm9_rebind_noh.yaml @@ -0,0 +1,53 @@ +# Heavy-atom QM9: the ReBind QM9 setup with hydrogens dropped from the graph. +# Not a reproduction of their published numbers — see the remove_hs note below. +# (paper, test split: D-MAE 0.254, D-RMSE 0.446, C-RMSD 0.321). +# +# Data is the QM9 copy ReBind and GTMGC trained on (HuggingFace RichXuOvO/HFQm9), +# converted by scripts/prepare_qm9_rebind.py. Molblocks are copied out of gdb9.sdf +# verbatim, so bonds are the published ones rather than re-perceived from geometry, +# and the CSV carries their published split (110,000 / 10,000 / 10,831). +# +# Hyperparameters follow external/ReBIND/experiments/conformer_prediction/rebind.sh; +# see README for the few places the loop still differs from their script. +dataset_path: /home/gridsan/jtoney/step-up-data/qm9/qm9-rebind.csv +dataset_source: sdf +subset_size: null +id_column: mol_id +split_column: split +# About 1.4% of records fail RDKit sanitization and are dropped, as in ReBind's +# own evaluation; fail the run if that rate jumps. +max_drop_fraction: 0.05 + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization, verbatim from rebind.sh (fp16 is the one setting we don't mirror; +# we train in fp32, which is the more precise choice — see README). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/qm9_rebind_noh +seed: 42 + +# Heavy-atom variant: hydrogens are dropped from the graph, so the model never +# sees or predicts them. Paired with configs/qm9_rebind.yaml to separate "the model is +# bad at hydrogen" from "the model is bad at geometry". Each heavy atom keeps its +# hydrogen count in the numH feature, so the graph is not missing the chemistry, +# only the positions. Note this departs from the published setup, which keeps +# hydrogen explicit, so it is not a reproduction run. +remove_hs: true diff --git a/configs/tmqmg_complete_noh.yaml b/configs/tmqmg_complete_noh.yaml new file mode 100644 index 0000000..cbd8aea --- /dev/null +++ b/configs/tmqmg_complete_noh.yaml @@ -0,0 +1,61 @@ +# Heavy-atom tmQMg: configs/tmqmg_complete.yaml with hydrogens dropped. +# GeoDiff and ConfGF tmQMg baselines from their paper without a caveat. +# +# Three snapshots of tmQMg exist, and they are nested, not recalculated (n_atoms, +# charge and electronic energies are bit-identical wherever they overlap): +# - complete, 60,799 rows: the original release, used here. +# - filtered, 58,409 rows: the same minus the 2,390 IDs in tmQMg's outliers.txt +# (unphysical geometries the community flagged). That's configs/tmqmg.yaml. +# - latest, 74,547 rows: the 2024 extension, which also dropped 87 of the +# originals, 87 of them in TMCgen's split. +# +# Only the complete snapshot covers all 60,465 complexes in TMCgen's split, +# including all 1,360 of their test complexes; 2,379 of the outlier structures are +# in their split and 38 are in their test set, so the baselines were trained and +# scored with them. The 334 rows here that TMCgen never used are dropped. +dataset_path: /home/gridsan/jtoney/step-up-data/tmQMg-complete.csv +dataset_source: mol2 +subset_size: null +id_column: id +split_files: + train: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-train.txt + val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt + test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt + +# Charge runs -1 / 0 / +1; every complex is a closed-shell singlet, so the spin +# feature is constant. Same conditioning as BOSTMC so the runs share an architecture. +charge_column: charge +spin_column: null + +n_layers: 8 +d_model: 512 +d_ffn: 1024 +n_head: 8 +dropout: 0.0 + +# Optimization as in ReBind's rebind.sh (see README for the fp16 exception). +epochs: 20 +batch_size: 100 +eval_batch_size: 100 +lr: 9.0e-5 +weight_decay: 0.0 +warmup_ratio: 0.1 +adam_beta1: 0.9 +adam_beta2: 0.99 +adam_eps: 1.0e-8 +lr_schedule: linear +grad_clip: 1.0 +drop_last: true +num_workers: 4 + +device: cuda +output_dir: outputs/tmqmg_complete_full_noh +seed: 42 + +# Heavy-atom variant: hydrogens are dropped from the graph, so the model never +# sees or predicts them. Paired with configs/tmqmg_complete.yaml to separate "the model is +# bad at hydrogen" from "the model is bad at geometry". Each heavy atom keeps its +# hydrogen count in the numH feature, so the graph is not missing the chemistry, +# only the positions. Note this departs from the published setup, which keeps +# hydrogen explicit, so it is not a reproduction run. +remove_hs: true diff --git a/src/step_up/data/csv_dataset.py b/src/step_up/data/csv_dataset.py index 569cd04..04887ae 100644 --- a/src/step_up/data/csv_dataset.py +++ b/src/step_up/data/csv_dataset.py @@ -30,6 +30,7 @@ featurize_molblock, featurize_xyz, mol_from_xyz_block, + remove_hydrogens, ) # Embedded XYZ/MOL2 fields blow past the default csv.field_size_limit. Raise it @@ -123,6 +124,11 @@ class CSVMoleculeDataset(Dataset): spin multiplicity. When given, each graph dict carries ``charge`` and ``spin_multiplicity`` for the model's global conditioning. A missing column means neutral / closed-shell. + remove_hs + Drop hydrogens from every graph, so the model neither sees nor predicts + them. This is a different model, not a different metric: it changes what + the network is trained on. Each heavy atom keeps its hydrogen count in + the ``numH`` feature either way. """ def __init__( @@ -140,11 +146,13 @@ def __init__( charge_column: str | None = None, spin_column: str | None = None, token_file: str | Path | None = None, + remove_hs: bool = False, ) -> None: self.path = Path(path) if not self.path.exists(): raise FileNotFoundError(self.path) self.source = source + self.remove_hs = remove_hs self.id_column = id_column self.split_column = split_column self.charge_column = charge_column @@ -229,10 +237,14 @@ def rdkit_mol(self, idx: int): """ row = self._df.iloc[self._valid_indices[idx]] if self.source == "sdf": - return Chem.MolFromMolBlock(str(row["sdf"]), removeHs=False) - if self.source == "smiles": - return mol_from_xyz_block(str(row["xyz"])) - raise NotImplementedError(f"source {self.source!r} does not go through RDKit") + mol = Chem.MolFromMolBlock(str(row["sdf"]), removeHs=False) + elif self.source == "smiles": + mol = mol_from_xyz_block(str(row["xyz"])) + else: + raise NotImplementedError(f"source {self.source!r} does not go through RDKit") + # Must have the same atoms in the same order as the graph dict, or the + # evaluator would write predicted coordinates onto the wrong atoms. + return remove_hydrogens(mol, self.remove_hs) if mol is not None else None def split_labels(self) -> list[str]: """Published split name per row, aligned with dataset indices.""" @@ -248,11 +260,11 @@ def split_labels(self) -> list[str]: def _featurize(self, real_idx: int) -> dict[str, Any]: row = self._df.iloc[real_idx] if self.source == "smiles": - graph = featurize_xyz(str(row["xyz"])) + graph = featurize_xyz(str(row["xyz"]), remove_hs=self.remove_hs) elif self.source == "sdf": - graph = featurize_molblock(str(row["sdf"])) + graph = featurize_molblock(str(row["sdf"]), remove_hs=self.remove_hs) else: - graph = featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"])) + graph = featurize_mol2_xyz(str(row["mol2"]), str(row["xyz"]), remove_hs=self.remove_hs) # Molecule-level state for conditioning; the collator turns these into # model inputs. Absent columns mean a neutral closed-shell molecule. if self.charge_column is not None: diff --git a/src/step_up/data/featurize.py b/src/step_up/data/featurize.py index cbac390..0c8cf9b 100644 --- a/src/step_up/data/featurize.py +++ b/src/step_up/data/featurize.py @@ -59,6 +59,21 @@ def _load_rebind_data_utils() -> Any: return mod +def remove_hydrogens(mol: Chem.Mol, remove_hs: bool) -> Chem.Mol: + """Strip explicit hydrogens when asked, so the model never sees them. + + RDKit keeps the hydrogen count in each heavy atom's ``GetTotalNumHs``, which + is the ``numH`` atom feature, so removal costs the graph no information about + how many hydrogens were there — only where they were. + """ + if not remove_hs: + return mol + stripped = Chem.RemoveHs(mol) + if stripped.GetNumAtoms() == 0: + raise ValueError("molecule has no heavy atoms") + return stripped + + def mol_to_graph_dict(mol: Chem.Mol) -> dict[str, Any]: """Thin wrapper around ReBind's canonical ``data.utils.mol_to_graph_dict``.""" return _load_rebind_data_utils().mol_to_graph_dict(mol) @@ -118,7 +133,7 @@ def mol_from_xyz_block(xyz_block: str, charge: int = 0) -> Chem.Mol: return mol -def featurize_xyz(xyz_block: str, charge: int = 0) -> dict[str, Any]: +def featurize_xyz(xyz_block: str, charge: int = 0, remove_hs: bool = False) -> dict[str, Any]: """One-shot helper: XYZ-only featurization (QM9 path). Coordinates come from the XYZ block; bonds are inferred. The original @@ -126,10 +141,10 @@ def featurize_xyz(xyz_block: str, charge: int = 0) -> dict[str, Any]: and the resulting atom order matches the XYZ. """ mol = mol_from_xyz_block(xyz_block, charge=charge) - return mol_to_graph_dict(mol) + return mol_to_graph_dict(remove_hydrogens(mol, remove_hs)) -def featurize_molblock(molblock: str) -> dict[str, Any]: +def featurize_molblock(molblock: str, remove_hs: bool = False) -> dict[str, Any]: """Featurize one SDF/MOL record (the QM9 path for ReBind's published data). Bonds and coordinates are read from the record, so nothing is re-perceived @@ -140,14 +155,14 @@ def featurize_molblock(molblock: str) -> dict[str, Any]: mol = Chem.MolFromMolBlock(molblock, removeHs=False) if mol is None: raise ValueError("RDKit failed to parse the molblock") - return mol_to_graph_dict(mol) + return mol_to_graph_dict(remove_hydrogens(mol, remove_hs)) -def featurize_mol2_xyz(mol2_block: str, xyz_block: str) -> dict[str, Any]: +def featurize_mol2_xyz(mol2_block: str, xyz_block: str, remove_hs: bool = False) -> dict[str, Any]: """One-shot helper for MOL2-sourced rows (tmQMg, BOSTMC). No RDKit — the MOL2 file already contains all connectivity and bond types we need, and we keep the XYZ-block coordinates as the canonical geometry. """ _, coords = parse_xyz_block(xyz_block) - return mol2_to_graph_dict(mol2_block, coords=coords) + return mol2_to_graph_dict(mol2_block, coords=coords, remove_hs=remove_hs) diff --git a/src/step_up/data/mol2.py b/src/step_up/data/mol2.py index 552cfb3..d5932b5 100644 --- a/src/step_up/data/mol2.py +++ b/src/step_up/data/mol2.py @@ -21,7 +21,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any import numpy as np @@ -260,12 +260,49 @@ def _find_ring_atoms_and_bonds( return ring_atom_set, ring_edge_set -def mol2_to_graph_dict(mol2_text: str, coords: np.ndarray | None = None) -> dict[str, Any]: +def _count_h_neighbors(parsed: Mol2) -> list[int]: + """Number of hydrogens bonded to each atom.""" + counts = [0] * len(parsed.atoms) + for b in parsed.bonds: + if parsed.atoms[b.b].element == "H": + counts[b.a] += 1 + if parsed.atoms[b.a].element == "H": + counts[b.b] += 1 + return counts + + +def _drop_hydrogens( + parsed: Mol2, conformer: list[list[float]], num_h_neighbors: list[int] +) -> tuple[Mol2, list[list[float]], list[int]]: + """Return ``parsed`` without its hydrogens, reindexed, with coords and numH to match.""" + keep = [i for i, atom in enumerate(parsed.atoms) if atom.element != "H"] + if not keep: + raise ValueError("MOL2 block has no heavy atoms") + remap = {old: new for new, old in enumerate(keep)} + filtered = Mol2( + atoms=[replace(parsed.atoms[i], idx=remap[i]) for i in keep], + bonds=[ + replace(b, a=remap[b.a], b=remap[b.b]) + for b in parsed.bonds + if b.a in remap and b.b in remap + ], + ) + return filtered, [conformer[i] for i in keep], [num_h_neighbors[i] for i in keep] + + +def mol2_to_graph_dict( + mol2_text: str, coords: np.ndarray | None = None, remove_hs: bool = False +) -> dict[str, Any]: """Build a ReBind-format graph dict directly from a MOL2 block. If ``coords`` is supplied (e.g. parsed from a separate XYZ column), it overrides the MOL2 coordinates — useful when the canonical geometry lives in a different column. Otherwise we use the MOL2 atom positions. + + ``remove_hs`` drops hydrogens from the graph, so the model neither sees nor + predicts them. It mirrors RDKit's ``Chem.RemoveHs``: the hydrogen count + survives in each heavy atom's ``numH`` feature, and ``degree`` becomes the + heavy-atom degree. """ parsed = parse_mol2(mol2_text) n = len(parsed.atoms) @@ -280,6 +317,14 @@ def mol2_to_graph_dict(mol2_text: str, coords: np.ndarray | None = None) -> dict else: conformer = np.stack([a.coords for a in parsed.atoms], axis=0).tolist() + # Counted over the whole molecule, before any hydrogen is dropped, so the + # numH feature says the same thing either way. + num_h_neighbors = _count_h_neighbors(parsed) + + if remove_hs: + parsed, conformer, num_h_neighbors = _drop_hydrogens(parsed, conformer, num_h_neighbors) + n = len(parsed.atoms) + # ---- Bonds first (need degree, numH, in-ring) ------------------------- bond_pairs: list[tuple[int, int]] = [] bond_types: list[str] = [] @@ -292,14 +337,9 @@ def mol2_to_graph_dict(mol2_text: str, coords: np.ndarray | None = None) -> dict ring_atoms, _ring_bonds = _find_ring_atoms_and_bonds(n, bond_pairs) degree = [0] * n - num_h_neighbors = [0] * n for a, b in bond_pairs: degree[a] += 1 degree[b] += 1 - if parsed.atoms[b].element == "H": - num_h_neighbors[a] += 1 - if parsed.atoms[a].element == "H": - num_h_neighbors[b] += 1 # ---- Atom features ---------------------------------------------------- node_attr: list[list[int]] = [] diff --git a/src/step_up/train.py b/src/step_up/train.py index e1f540c..b910f15 100644 --- a/src/step_up/train.py +++ b/src/step_up/train.py @@ -71,6 +71,10 @@ class TrainConfig: cache_dataset: bool = False # Fail validation if more than this fraction of rows are dropped. max_drop_fraction: float = 0.5 + # Train on heavy atoms only: hydrogens are dropped from the graph, so the + # model never sees or predicts them. A different model, not a different + # metric — the published ReBind and GTMGC setups keep hydrogen explicit. + remove_hs: bool = False # Model n_layers: int = 8 @@ -327,6 +331,7 @@ def build_splits(config: TrainConfig) -> tuple[CSVMoleculeDataset, Subset, Subse charge_column=config.charge_column, spin_column=config.spin_column, token_file=config.token_file, + remove_hs=config.remove_hs, ) if config.split_files: if config.id_column is None: diff --git a/tests/test_csv_dataset.py b/tests/test_csv_dataset.py index 0dfbe13..3d96cec 100644 --- a/tests/test_csv_dataset.py +++ b/tests/test_csv_dataset.py @@ -113,3 +113,70 @@ def test_sdf_source_uses_published_bonds_and_split(qm9_sdf_path) -> None: methane = ds[0] assert methane["num_nodes"] == 5 assert methane["num_edges"] == 8 + + +# The numH atom feature is column 4 of ReBind's 9-column atom feature vector, +# and the degree is column 2. +_NUMH_COLUMN = 4 +_DEGREE_COLUMN = 2 + + +@pytest.mark.parametrize( + ("fixture_name", "source"), + [("qm9_sdf_path", "sdf"), ("tmqmg_path", "mol2"), ("bostmc_path", "mol2")], +) +def test_remove_hs_drops_hydrogens_from_every_source(fixture_name, source, request) -> None: + """Training without hydrogens has to work the same on both featurization paths.""" + path = request.getfixturevalue(fixture_name) + full = CSVMoleculeDataset(path, source=source) + heavy = CSVMoleculeDataset(path, source=source, remove_hs=True) + assert len(full) == len(heavy) + + saw_hydrogen = False + for i in range(len(full)): + before, after = full[i], heavy[i] + _check_graph_dict(after) + n_h = sum(1 for z in before["node_type"] if z == 0) # node_type is Z - 1 + saw_hydrogen = saw_hydrogen or n_h > 0 + assert after["num_nodes"] == before["num_nodes"] - n_h + assert 0 not in after["node_type"], "a hydrogen survived the strip" + # Bonds to a dropped atom go too, and the survivors stay in range. + assert all(idx < after["num_nodes"] for side in after["edge_index"] for idx in side) + # Hydrogen count survives as an atom feature, so the graph still knows + # how many there were — only where they were is gone. (RDKit reports 0 + # while the hydrogens are explicit atoms and fills the count in once they + # are removed, so this is checked against the bond graph, not against the + # feature in `before`.) + attached = [0] * before["num_nodes"] + for a, b in zip(*before["edge_index"], strict=True): + if before["node_type"][b] == 0: + attached[a] += 1 + assert [row[_NUMH_COLUMN] for row in after["node_attr"]] == [ + count for count, z in zip(attached, before["node_type"], strict=True) if z != 0 + ] + assert saw_hydrogen, "fixture has no hydrogens, so this proves nothing" + + +def test_remove_hs_reports_the_heavy_atom_degree(qm9_sdf_path) -> None: + """Degree must count surviving bonds, or it would disagree with the edges.""" + heavy = CSVMoleculeDataset(qm9_sdf_path, source="sdf", remove_hs=True) + for i in range(len(heavy)): + graph = heavy[i] + counted = [0] * graph["num_nodes"] + for node in graph["edge_index"][0]: + counted[node] += 1 + assert [row[_DEGREE_COLUMN] for row in graph["node_attr"]] == counted + + +def test_remove_hs_keeps_rdkit_mol_aligned_with_the_graph(qm9_sdf_path) -> None: + """C-RMSD writes predicted coordinates onto this molecule by atom index. + + If it still carried hydrogens while the graph did not, every coordinate + after the first hydrogen would land on the wrong atom. + """ + heavy = CSVMoleculeDataset(qm9_sdf_path, source="sdf", id_column="mol_id", remove_hs=True) + for i in range(len(heavy)): + assert heavy.rdkit_mol(i).GetNumAtoms() == heavy[i]["num_nodes"] + + full = CSVMoleculeDataset(qm9_sdf_path, source="sdf", id_column="mol_id") + assert full.rdkit_mol(0).GetNumAtoms() == full[0]["num_nodes"] diff --git a/tests/test_forward.py b/tests/test_forward.py index 82280b2..7528175 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -141,3 +141,26 @@ def test_uff_table_agrees_with_rebinds_own_values() -> None: for node_type, params in upstream.items(): assert UFF_LJ_PARAMETERS[node_type] == (params["sigma"], params["epsilon"]) assert max(UFF_LJ_PARAMETERS) > max(upstream) + + +def test_single_heavy_atom_molecules_survive_the_forward_pass(qm9_sdf_path) -> None: + """Methane without its hydrogens is one atom and no bonds. + + QM9 has a handful of these, and a one-node graph is where a Laplacian + positional encoding would divide by zero if the degree were not floored. + """ + ds = CSVMoleculeDataset(qm9_sdf_path, source="sdf", remove_hs=True) + assert min(ds[i]["num_nodes"] for i in range(len(ds))) == 1 + loader = DataLoader( + Subset(ds, list(range(len(ds)))), batch_size=len(ds), collate_fn=get_collator()() + ) + batch = next(iter(loader)) + assert torch.isfinite(batch["lap_eigenvectors"]).all() + + torch.manual_seed(0) + model = build_rebind(n_layers=1, d_model=32, d_ffn=64, n_head=4) + model.eval() + with torch.no_grad(): + out = model(**batch) + assert torch.isfinite(out.loss) + assert torch.isfinite(out.conformer_hat).all() From 24d307d7b37f1c4203517fc82dae80b9179a80e6 Mon Sep 17 00:00:00 2001 From: jwtoney Date: Thu, 1 Oct 2026 23:23:28 -0400 Subject: [PATCH 11/12] Dump per-molecule true and predicted structures to CSV One row per test molecule: id, charge, spin_multiplicity, n_atoms, elements, xyz_true, xyz_pred, and that molecule's own d_mae / d_rmse / rmsd. Both geometries are XYZ blocks with atoms in the same order, so they overlay directly, and scripts/eval_and_dump.sh produces the pooled metrics and the CSV from one checkpoint in one job so the two always describe the same state. xyz_pred is Kabsch-aligned onto xyz_true per molecule. The models align inside their prediction head over the padded batch tensor, so raw output sits in a frame corrupted by padding zeros. Only a rotation and translation are applied; a test asserts the prediction's internal distance matrix survives untouched. charge and spin_multiplicity are left blank when the source CSV has no such column, rather than filled with 0 and 1. tmQMg has no spin column at all, and the conditioning collator already defaults that input to zero unpaired electrons -- a default that happens to be consistent with the data (no complex has an odd electron count, so none is forced open-shell) but is not read from it. The tmQMg configs now say that instead of asserting every complex is a closed-shell singlet, and the dump does not repeat the default as if it were data. Co-Authored-By: Claude Opus 5 (1M context) --- configs/tmqmg.yaml | 11 ++- configs/tmqmg_complete.yaml | 11 ++- configs/tmqmg_complete_noh.yaml | 11 ++- scripts/dump_structures.py | 162 ++++++++++++++++++++++++++++++++ scripts/eval_and_dump.sh | 44 +++++++++ src/step_up/data/csv_dataset.py | 22 +++++ tests/test_dump_structures.py | 155 ++++++++++++++++++++++++++++++ 7 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 scripts/dump_structures.py create mode 100755 scripts/eval_and_dump.sh create mode 100644 tests/test_dump_structures.py diff --git a/configs/tmqmg.yaml b/configs/tmqmg.yaml index 48bdde6..7e51a55 100644 --- a/configs/tmqmg.yaml +++ b/configs/tmqmg.yaml @@ -21,9 +21,14 @@ split_files: val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt -# Charge runs -1 / 0 / +1 here. Every complex is a closed-shell singlet (no odd -# electron counts), so the spin feature is constant, but the conditioning is kept -# identical to BOSTMC so the two runs share an architecture. +# Charge runs -1 / 0 / +1 here and is read from the CSV. +# +# There is no spin column in tmQMg to read, so the conditioning collator defaults +# the unpaired-electron input to 0 for every row. That default is consistent with +# the data but not measured from it: every complex has an even electron count, so +# none is *forced* to be open-shell, and tmQM was assembled from closed-shell +# mononuclear complexes. Treat the spin slot as a constant, which a linear layer +# absorbs into its bias; it exists only so tmQMg and BOSTMC share an architecture. charge_column: charge spin_column: null diff --git a/configs/tmqmg_complete.yaml b/configs/tmqmg_complete.yaml index a67707c..b6bd2b8 100644 --- a/configs/tmqmg_complete.yaml +++ b/configs/tmqmg_complete.yaml @@ -22,8 +22,15 @@ split_files: val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt -# Charge runs -1 / 0 / +1; every complex is a closed-shell singlet, so the spin -# feature is constant. Same conditioning as BOSTMC so the runs share an architecture. +# Charge runs -1 / 0 / +1 and is read from the CSV. +# +# There is no spin column in tmQMg to read, so `spin_column: null` and the +# conditioning collator defaults the unpaired-electron input to 0 for every row. +# That default is consistent with the data but is not measured from it: all +# 60,799 complexes have an even electron count, so none is *forced* to be +# open-shell, and tmQM was assembled from closed-shell mononuclear complexes. +# Treat the spin slot here as a constant, which a linear layer absorbs into its +# bias; it exists only so tmQMg and BOSTMC share one architecture. charge_column: charge spin_column: null diff --git a/configs/tmqmg_complete_noh.yaml b/configs/tmqmg_complete_noh.yaml index cbd8aea..c9e3cf0 100644 --- a/configs/tmqmg_complete_noh.yaml +++ b/configs/tmqmg_complete_noh.yaml @@ -22,8 +22,15 @@ split_files: val: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-val-split.txt test: /home/gridsan/jtoney/step-up-data/tmqmg/complexes-test-split.txt -# Charge runs -1 / 0 / +1; every complex is a closed-shell singlet, so the spin -# feature is constant. Same conditioning as BOSTMC so the runs share an architecture. +# Charge runs -1 / 0 / +1 and is read from the CSV. +# +# There is no spin column in tmQMg to read, so `spin_column: null` and the +# conditioning collator defaults the unpaired-electron input to 0 for every row. +# That default is consistent with the data but is not measured from it: all +# 60,799 complexes have an even electron count, so none is *forced* to be +# open-shell, and tmQM was assembled from closed-shell mononuclear complexes. +# Treat the spin slot here as a constant, which a linear layer absorbs into its +# bias; it exists only so tmQMg and BOSTMC share one architecture. charge_column: charge spin_column: null diff --git a/scripts/dump_structures.py b/scripts/dump_structures.py new file mode 100644 index 0000000..17057c7 --- /dev/null +++ b/scripts/dump_structures.py @@ -0,0 +1,162 @@ +"""Write the true and predicted geometry of every test-set molecule to a CSV. + +One row per molecule, so the file can be read back with pandas and the two +structures overlaid directly: + + id, charge, spin_multiplicity, n_atoms, elements, xyz_true, xyz_pred, + d_mae, d_rmse, rmsd + +``xyz_true`` and ``xyz_pred`` are standard XYZ blocks (count line, comment line, +then ``element x y z``), the same format the source CSVs use, with atoms in the +same order in both. + +Two things worth knowing about ``xyz_pred``: + +- It is **Kabsch-aligned onto the true structure** per molecule. The models + align inside their own prediction head over the *padded* batch tensor, so the + raw output sits in a frame corrupted by padding zeros; aligning here is what + makes the two blocks comparable. Only a rotation and translation are applied, + so bond lengths and angles are the model's own. +- Hydrogens are absent for a model trained with ``remove_hs``, because that + model never predicted any. + +``charge`` and ``spin_multiplicity`` come from the source CSV. They are empty +when the dataset has no such column, which is not a claim of neutral or +closed-shell — it means the data does not say. + +Usage:: + + uv run python scripts/dump_structures.py -c configs/bostmc.yaml + uv run python scripts/dump_structures.py -c configs/qm9_rebind.yaml \ + --checkpoint outputs/qm9_rebind/best.pt --split test +""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import replace +from pathlib import Path + +import torch +from rdkit import Chem +from torch.utils.data import DataLoader +from tqdm import tqdm + +from step_up.eval.conformer_eval import kabsch_rmsd +from step_up.models import N_GLOBAL_FEATURES, build_model, build_model_collator +from step_up.train import TrainConfig, build_splits + +_PERIODIC_TABLE = Chem.GetPeriodicTable() + +_COLUMNS = ( + "id", + "charge", + "spin_multiplicity", + "n_atoms", + "elements", + "xyz_true", + "xyz_pred", + "d_mae", + "d_rmse", + "rmsd", +) + + +def _xyz_block(symbols: list[str], coords: torch.Tensor, comment: str) -> str: + lines = [str(len(symbols)), comment] + for symbol, (x, y, z) in zip(symbols, coords.tolist(), strict=True): + lines.append(f"{symbol} {x:.6f} {y:.6f} {z:.6f}") + return "\n".join(lines) + + +def _aligned(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """``pred`` rigidly moved onto ``target`` — rotation and translation only.""" + p = pred - pred.mean(dim=0, keepdim=True) + q = target - target.mean(dim=0, keepdim=True) + u, _, vt = torch.linalg.svd(p.T @ q) + sign = torch.sign(torch.det(vt.T @ u.T)) + correction = torch.diag(torch.tensor([1.0, 1.0, float(sign)], dtype=p.dtype)) + rotation = vt.T @ correction @ u.T + return (rotation @ p.T).T + target.mean(dim=0, keepdim=True) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-c", "--config", required=True) + parser.add_argument("--checkpoint", help="Defaults to /best.pt") + parser.add_argument("--split", default="test", choices=["train", "val", "test"]) + parser.add_argument("--out", help="Defaults to /structures_.csv") + args = parser.parse_args(argv) + + cfg = TrainConfig.from_yaml(args.config) + device = cfg.device if torch.cuda.is_available() or cfg.device == "cpu" else "cpu" + cfg = replace(cfg, device=device) + conditioned = cfg.charge_column is not None or cfg.spin_column is not None + + dataset, train_set, val_set, test_set = build_splits(cfg) + subset = {"train": train_set, "val": val_set, "test": test_set}[args.split] + + model = build_model( + cfg.model, + n_layers=cfg.n_layers, + d_model=cfg.d_model, + d_ffn=cfg.d_ffn, + n_head=cfg.n_head, + dropout=cfg.dropout, + n_global_features=N_GLOBAL_FEATURES if conditioned else 0, + ) + checkpoint = args.checkpoint or str(Path(cfg.output_dir) / "best.pt") + model.load_state_dict(torch.load(checkpoint, map_location="cpu")) + model = model.to(device).eval() + + collator = build_model_collator(cfg.model, conditioned=conditioned) + loader = DataLoader(subset, batch_size=cfg.eval_batch_size, shuffle=False, collate_fn=collator) + + out_path = Path(args.out or Path(cfg.output_dir) / f"structures_{args.split}.csv") + out_path.parent.mkdir(parents=True, exist_ok=True) + + position = 0 + with open(out_path, "w", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(_COLUMNS) + for batch in tqdm(loader, desc=f"dumping {args.split}", dynamic_ncols=True): + device_batch = { + k: (v.to(device) if torch.is_tensor(v) else v) for k, v in batch.items() + } + with torch.no_grad(): + out = model(**device_batch) + node_mask = device_batch["node_mask"].bool() + for row in range(node_mask.shape[0]): + keep = node_mask[row] + pred = out.conformer_hat[row][keep].double().cpu() + target = out.conformer[row][keep].double().cpu() + # node_type is Z here: the collator shifts by 1 so 0 means padding. + symbols = [ + _PERIODIC_TABLE.GetElementSymbol(int(z)) + for z in device_batch["node_type"][row][keep].cpu() + ] + delta = (torch.cdist(pred, pred) - torch.cdist(target, target)).abs() + meta = dataset.metadata(subset.indices[position + row]) + writer.writerow( + [ + meta["id"], + "" if meta["charge"] is None else meta["charge"], + "" if meta["spin_multiplicity"] is None else meta["spin_multiplicity"], + len(symbols), + " ".join(symbols), + _xyz_block(symbols, target, f"{meta['id']} reference"), + _xyz_block(symbols, _aligned(pred, target), f"{meta['id']} predicted"), + f"{float(delta.mean()):.6f}", + f"{float((delta**2).mean().sqrt()):.6f}", + f"{kabsch_rmsd(pred, target):.6f}", + ] + ) + position += node_mask.shape[0] + + print(f"wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/eval_and_dump.sh b/scripts/eval_and_dump.sh new file mode 100755 index 0000000..6c78207 --- /dev/null +++ b/scripts/eval_and_dump.sh @@ -0,0 +1,44 @@ +#!/bin/bash +#SBATCH --job-name=step-up-eval +#SBATCH --gres=gpu:volta:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --output=eval-%j.out + +# Usage: sbatch scripts/eval_and_dump.sh configs/bostmc.yaml +# +# Scores the test split with ReBind's metric definitions and writes the +# per-molecule true/predicted structures beside it. Both read the same +# checkpoint and the same split, so the pooled metrics and the CSV always +# describe one state of one model. +# +# RMSD is reported twice on purpose: once hydrogen-free (comparable with the +# published C-RMSD column) and once keeping hydrogen, so a model trained with +# explicit hydrogen can be compared with its heavy-atom twin on equal terms. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 2 +fi +CONFIG="$(realpath "$1")" + +REPO_ROOT="$(git -C "${SLURM_SUBMIT_DIR:-$(dirname "$0")}" rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +SETUP_LOCK="$REPO_ROOT/.git/step-up-setup.lock" +if [[ ! -f external/ReBIND/models/rebind/modeling_rebind.py ]]; then + flock "$SETUP_LOCK" git submodule update --init --recursive +fi +flock "$SETUP_LOCK" uv sync --dev + +OUT_DIR="$(uv run python -c " +from step_up.train import TrainConfig +print(TrainConfig.from_yaml('$CONFIG').output_dir) +")" + +uv run step-up evaluate -c "$CONFIG" --split test --out "$OUT_DIR/eval_test.json" +uv run step-up evaluate -c "$CONFIG" --split test --keep-hs \ + --out "$OUT_DIR/eval_test_keephs.json" +uv run python scripts/dump_structures.py -c "$CONFIG" --split test diff --git a/src/step_up/data/csv_dataset.py b/src/step_up/data/csv_dataset.py index 04887ae..b1812c8 100644 --- a/src/step_up/data/csv_dataset.py +++ b/src/step_up/data/csv_dataset.py @@ -214,6 +214,28 @@ def __getitem__(self, idx: int) -> dict[str, Any]: self._cache[real_idx] = graph return graph + def metadata(self, idx: int) -> dict[str, Any]: + """Source-CSV facts about item ``idx``: its id, charge and spin multiplicity. + + ``charge`` and ``spin_multiplicity`` are ``None`` when the dataset was + built without those columns, which is not the same as neutral or + closed-shell — it means the CSV does not say. The conditioning collator + does default a missing value, so a ``None`` here marks a number the model + was handed rather than told. + """ + real_idx = self._valid_indices[idx] + row = self._df.iloc[real_idx] + key = ( + str(row[self.id_column]) + if self.id_column is not None + else str(self._df.index[real_idx]) + ) + return { + "id": key, + "charge": float(row[self.charge_column]) if self.charge_column else None, + "spin_multiplicity": float(row[self.spin_column]) if self.spin_column else None, + } + def split_keys(self) -> list[str]: """Stable per-row keys for :func:`step_up.data.splits.stable_split`. diff --git a/tests/test_dump_structures.py b/tests/test_dump_structures.py new file mode 100644 index 0000000..66e22d0 --- /dev/null +++ b/tests/test_dump_structures.py @@ -0,0 +1,155 @@ +"""The per-molecule structure dump: one CSV row per test molecule.""" + +from __future__ import annotations + +import csv +import importlib.util +import sys +from pathlib import Path + +import pandas as pd +import pytest +import torch +import yaml + +from step_up.models import N_GLOBAL_FEATURES, build_model + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "dump_structures.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("_dump_structures", str(_SCRIPT)) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["_dump_structures"] = module + spec.loader.exec_module(module) + return module + + +def _write_config(tmp_path: Path, dataset_path: Path, **overrides) -> Path: + config = { + "dataset_path": str(dataset_path), + "dataset_source": "mol2", + "id_column": "refcode", + "charge_column": "charge", + "spin_column": "spinmult", + "split_ratios": [0.4, 0.3, 0.3], + "split_seed": 0, + "n_layers": 1, + "d_model": 32, + "d_ffn": 64, + "n_head": 4, + "epochs": 1, + "batch_size": 2, + "eval_batch_size": 2, + "num_workers": 0, + "device": "cpu", + "output_dir": str(tmp_path / "run"), + "seed": 0, + } + config.update(overrides) + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _untrained_checkpoint(tmp_path: Path, conditioned: bool) -> Path: + torch.manual_seed(0) + model = build_model( + "rebind", + n_layers=1, + d_model=32, + d_ffn=64, + n_head=4, + n_global_features=N_GLOBAL_FEATURES if conditioned else 0, + ) + path = tmp_path / "run" / "best.pt" + path.parent.mkdir(parents=True, exist_ok=True) + torch.save(model.state_dict(), path) + return path + + +def _parse_xyz(block: str) -> tuple[list[str], list[list[float]]]: + lines = block.splitlines() + count = int(lines[0]) + symbols, coords = [], [] + for line in lines[2 : 2 + count]: + parts = line.split() + symbols.append(parts[0]) + coords.append([float(v) for v in parts[1:4]]) + assert len(symbols) == count, "count line disagrees with the number of atom lines" + return symbols, coords + + +def test_dump_writes_one_row_per_molecule_with_both_structures(bostmc_path, tmp_path) -> None: + module = _load_script() + config_path = _write_config(tmp_path, bostmc_path) + _untrained_checkpoint(tmp_path, conditioned=True) + + assert module.main(["-c", str(config_path), "--split", "test"]) == 0 + out = tmp_path / "run" / "structures_test.csv" + frame = pd.read_csv(out) + assert list(frame.columns) == list(module._COLUMNS) + assert len(frame) > 0 + + for _, row in frame.iterrows(): + true_symbols, true_coords = _parse_xyz(row["xyz_true"]) + pred_symbols, pred_coords = _parse_xyz(row["xyz_pred"]) + # Same atoms, same order, so the two blocks overlay directly. + assert true_symbols == pred_symbols + assert len(true_coords) == len(pred_coords) == row["n_atoms"] + assert row["elements"].split() == true_symbols + # Real coordinates, not padding zeros. + assert any(any(v != 0.0 for v in xyz) for xyz in true_coords) + assert all(row[key] >= 0.0 for key in ("d_mae", "d_rmse", "rmsd")) + # BOSTMC states both, so neither may be blank. + assert not pd.isna(row["charge"]) + assert not pd.isna(row["spin_multiplicity"]) + + +def test_dump_records_the_true_charge_and_spin(bostmc_path, tmp_path) -> None: + """The point of the file is downstream analysis, so these must be the data's.""" + module = _load_script() + config_path = _write_config(tmp_path, bostmc_path) + _untrained_checkpoint(tmp_path, conditioned=True) + module.main(["-c", str(config_path), "--split", "test"]) + + dumped = pd.read_csv(tmp_path / "run" / "structures_test.csv").set_index("id") + source = pd.read_csv(bostmc_path).set_index("refcode") + for key, row in dumped.iterrows(): + assert row["charge"] == pytest.approx(float(source.loc[key, "charge"])) + assert row["spin_multiplicity"] == pytest.approx(float(source.loc[key, "spinmult"])) + + +def test_dump_leaves_charge_and_spin_blank_when_the_data_is_silent(qm9_sdf_path, tmp_path) -> None: + """An unconditioned dataset must not report a charge it was never given.""" + module = _load_script() + config_path = _write_config( + tmp_path, + qm9_sdf_path, + dataset_source="sdf", + id_column="mol_id", + charge_column=None, + spin_column=None, + ) + _untrained_checkpoint(tmp_path, conditioned=False) + module.main(["-c", str(config_path), "--split", "test"]) + + with open(tmp_path / "run" / "structures_test.csv") as handle: + rows = list(csv.DictReader(handle)) + assert rows + for row in rows: + assert row["charge"] == "" + assert row["spin_multiplicity"] == "" + + +def test_predicted_block_is_rigidly_aligned_not_rebuilt(bostmc_path, tmp_path) -> None: + """Alignment may rotate and translate the prediction, never reshape it.""" + module = _load_script() + torch.manual_seed(0) + pred = torch.randn(10, 3, dtype=torch.float64) + target = torch.randn(10, 3, dtype=torch.float64) + moved = module._aligned(pred, target) + torch.testing.assert_close(torch.cdist(moved, moved), torch.cdist(pred, pred)) + # And it lands on the target's centroid rather than the origin. + torch.testing.assert_close(moved.mean(dim=0), target.mean(dim=0)) From e40c1866a5b11c565baa5c105462cab38fcce62d Mon Sep 17 00:00:00 2001 From: jwtoney Date: Thu, 1 Oct 2026 23:51:47 -0400 Subject: [PATCH 12/12] Compare all six runs, and record that ReBind's rewiring never fires scripts/compare_runs.py reads each run's metrics JSON and its structure dump, normalizes D-MAE by that test set's own mean pairwise distance -- without which a 64-atom complex and a 9-atom skeleton share a column that means nothing -- and recomputes D-MAE from the dumped geometry as a cross-check. All six runs agree with their metrics exactly. Two results worth the README space. Dropping hydrogen does not produce a better heavy-atom structure. It improves D-MAE a lot (QM9 7.5% to 1.7% relative) but that is mostly the hardest atoms leaving the average: on the identical heavy atoms under the identical metric the model trained *with* hydrogen wins, tmQMg RMSD 1.810 against 1.823 and BOSTMC 2.024 against 2.081. ReBind's LJ rewiring is dead code in the released implementation, so every number here was produced without the mechanism the paper is named for. `Collator.__call__` samples `keys` from the input graph dict while `_transform` attaches `num_near_edges` afterwards, so the padding guard never fires and the tensor stays all zeros; with k=0 the top-k retains nothing and both rewiring channels reach the decoder as zero matrices. Upstream's own mol_to_graph_dict has the same keys, so this is not our featurization. Measured on a tmQMg batch with 4d/5d metals: 13,380 candidate pairs, 0 retained, and perturbing the LJ table -- the old flat values, epsilon x1000, sigma x2 -- moves the predicted coordinates by exactly zero. That explains why the LJ-corrected tmQMg rerun scored 0.8918838762 against the pre-fix 0.8918838298, identical to 7 significant figures: the two trainings differed in nothing that reaches the loss. It also means our QM9 reproduction beats the published C-RMSD while running the paper's own ablation. Left documented rather than patched, since fixing the propagation produces a model unlike the released one. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 144 ++++++++++++++++++++++++++++---------- scripts/compare_runs.py | 150 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 scripts/compare_runs.py diff --git a/README.md b/README.md index 84c7acb..6f88603 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,36 @@ logs, per-epoch history, best checkpoint (by val D-MAE), and the test-set metrics of that checkpoint (`test_metrics.json`) to the `output_dir` specified in the config — default is `outputs/_full/`. +### Scoring a run and exporting its structures + +```bash +sbatch scripts/eval_and_dump.sh configs/bostmc.yaml +``` + +That scores the test split and exports every predicted structure from the same +checkpoint, so the metrics and the geometry can never describe different states. +It writes `eval_test.json` (heavy-atom RMSD), `eval_test_keephs.json` (RMSD over +all atoms) and `structures_test.csv` into the run's `output_dir`. + +`structures_test.csv` carries one row per test molecule: + +| Column | | +|---|---| +| `id` | the row's `id_column` value — `mol_id`, tmQMg `id`, BOSTMC `refcode` | +| `charge`, `spin_multiplicity` | from the source CSV; **blank when the data does not say**, rather than defaulted | +| `n_atoms`, `elements` | atom count and symbols, in prediction order | +| `xyz_true`, `xyz_pred` | XYZ blocks, same atoms in the same order, overlayable as-is | +| `d_mae`, `d_rmse`, `rmsd` | that molecule's own errors, for sorting and filtering | + +`xyz_pred` is Kabsch-aligned onto `xyz_true` per molecule: rotation and +translation only, so bond lengths and angles are the model's own. The alignment +is done here because both vendored models align inside their prediction head over +the *padded* batch tensor, where padding zeros drag the centroid and rotation off. + +`scripts/compare_runs.py` reads those dumps plus the metrics JSON and prints the +comparison table below, recomputing D-MAE from the geometry as a cross-check that +the two agree. + ### Reproducing ReBind's published QM9 numbers `configs/qm9_rebind.yaml` trains on the QM9 copy ReBind and GTMGC used @@ -213,44 +243,79 @@ for f in config.json pytorch_model.bin; do done ``` -### Organometallic results - -All on each dataset's published/project split, scored with `step-up evaluate`. -RMSD here is Kabsch alignment on each molecule's real atoms, without symmetry -matching, so it is comparable across rows but *not* to a GetBestRMS C-RMSD. -"Relative" is D-MAE over the mean pairwise distance of that test set, which is -what makes organic and organometallic errors comparable at all: D-MAE is an -absolute distance error and grows with molecular size. - -| Run | Test molecules | D-MAE | D-RMSE | RMSD | Relative D-MAE | -|---|---|---|---|---|---| -| QM9, ReBind | 10,661 | 0.233 | 0.439 | 0.858 | 7.2% | -| QM9, GTMGC | 10,661 | 0.264 | 0.462 | 0.931 | 8.2% | -| tmQMg, outliers removed | 1,322 | 0.890 | 1.351 | 2.316 | 15.7% | -| tmQMg, complete | 1,360 | 0.892 | 1.353 | 2.325 | 15.7% | -| BOSTMC low-spin | 12,150 | 0.964 | 1.496 | 2.532 | 16.1% | - -Hydrogens are predicted throughout: every dataset carries explicit hydrogen and -the loss covers every atom. `remove_hs: true` trains the heavy-atom variant -instead (`configs/*_noh.yaml`), dropping hydrogen from the graph so the model -neither sees nor predicts it — a different model, not a different metric. Each -heavy atom keeps its hydrogen count in the `numH` feature either way. - -In the runs above, D-MAE and D-RMSE include hydrogen, here and in ReBind's own -`evaluate.py`. The RMSD column above, however, does *not* exclude them the -way the QM9 C-RMSD does — the MOL2 rows build no RDKit molecule, so -`Chem.RemoveHs` never ran. The evaluator now drops hydrogens from that path too, -by atom type, so reruns will report lower RMSDs than this table. - -The three organometallic rows predate the full UFF Lennard-Jones table and were -trained with a flat sigma/epsilon for every element past Kr, so they are a floor -rather than a result. They want rerunning before the numbers travel anywhere. -The QM9 rows are unaffected: that table only covers Z=1..36 either way. - -Organometallic error is about twice QM9's in relative terms, not the four times -raw D-MAE suggests. Global structure degrades further than pairwise distances do: -RMSD is 27% of the mean pairwise distance on QM9 against 41-42% on the -organometallic sets. +### Benchmark results + +Every row is that dataset's published or project split, scored with +`step-up evaluate`, and reproduced independently by `scripts/compare_runs.py` +from the per-molecule structure dumps. D-MAE is an absolute distance error that +grows with molecule size, so "Rel." normalizes it by that test set's own mean +pairwise distance ("Scale"), pooled the same way. RMSD is heavy-atom in every +row — GetBestRMS on QM9, Kabsch without symmetry matching on the MOL2 sets, so +compare RMSD across the organometallic rows freely but to QM9 only loosely. + +| Run | Test mols | Atoms | D-MAE | D-RMSE | RMSD | Scale | Rel. D-MAE | RMSD/scale | +|---|---|---|---|---|---|---|---|---| +| QM9, with H | 10,661 | 18.1 | 0.233 | 0.439 | 0.265 | 3.100 | 7.5% | 8.5% | +| QM9, heavy atom | 10,661 | 8.8 | 0.042 | 0.132 | 0.187 | 2.441 | 1.7% | 7.7% | +| tmQMg, with H | 1,360 | 56.7 | 0.892 | 1.353 | 1.810 | 5.806 | 15.4% | 31.2% | +| tmQMg, heavy atom | 1,360 | 30.2 | 0.627 | 1.058 | 1.823 | 5.205 | 12.0% | 35.0% | +| BOSTMC, with H | 12,150 | 64.2 | 0.964 | 1.496 | 2.024 | 6.581 | 14.6% | 30.8% | +| BOSTMC, heavy atom | 12,150 | 35.1 | 0.696 | 1.192 | 2.081 | 5.921 | 11.8% | 35.1% | + +**Organometallic error is about twice QM9's relative to molecule size,** not the +four times raw D-MAE suggests. Global structure degrades much further than local +distances do: RMSD is 8.5% of the mean pairwise distance on QM9 against 31% on +the organometallic sets. Local geometry is largely right and the overall shape is +not. + +**Dropping hydrogen does not produce a better heavy-atom structure.** It makes +D-MAE look much better — QM9 7.5% to 1.7%, tmQMg 15.4% to 12.0% — but that is +mostly the hardest atoms leaving the average. On the identical heavy atoms under +the identical metric, the model trained *with* hydrogen wins: tmQMg RMSD 1.810 +against 1.823, BOSTMC 2.024 against 2.081. Hydrogens are useful supervision for +the heavy-atom frame, not just extra work. Only QM9 improves at all on RMSD +(0.265 to 0.187), and barely once normalized (8.5% to 7.7%). + +Hydrogens are predicted throughout unless a config sets `remove_hs: true` +(`configs/*_noh.yaml`), which drops them from the graph so the model neither sees +nor predicts them — a different model, not a different metric. Each heavy atom +keeps its hydrogen count in the `numH` feature either way. D-MAE and D-RMSE +always include hydrogen where it is present, as in ReBind's own `evaluate.py`. + +### ReBind's rewiring never fires in the released code + +ReBind's contribution over GTMGC is rewiring the decoder's attention with +Lennard-Jones forces: compute an LJ force between non-bonded atom pairs, keep the +top-k per node, and feed those as extra attraction and repulsion adjacency +channels. In the released implementation it is dead code, and the numbers above +were produced without it. + +`Collator.__call__` samples `keys = mol_sq[0].keys()` from the *input* graph dict, +then `_transform` computes `num_near_edges` onto the `Data` object afterwards. The +padding step guards on `if "num_near_edges" in keys`, which is therefore never +true, so `num_near_edges` stays the all-zero tensor it was initialized as. With +`k = 0` everywhere, `retain_top_k` keeps nothing, and both rewiring channels reach +the decoder as all-zero matrices. Upstream's own `data/utils.py:mol_to_graph_dict` +returns the same keys ours does and never includes it, so this is not an artifact +of our featurization. + +Measured rather than inferred, on a tmQMg batch containing 4d/5d metals: + +- `num_near_edges` is 0 for all 235 real atoms, against adjacency degrees of 1-6. +- 13,380 candidate non-bonded pairs, **0 retained**. +- Perturbing the LJ table changes the predicted coordinates by **exactly zero**: + not the pre-fix flat sigma/epsilon, not epsilon x1000, not sigma x2. + +This explains two things that looked wrong. The LJ retraining was a no-op — the +corrected tmQMg run scores 0.8918838762 against the pre-fix run's 0.8918838298, +identical to 7 significant figures because the two trainings differed in nothing +that reaches the loss. And our QM9 reproduction beats the published C-RMSD (0.265 +against 0.321) while running what is effectively the paper's own ablation. + +The full UFF table is still the right thing to carry: it is what the paper +describes, and it costs nothing. But no result here depends on it, and fixing the +propagation would produce a model meaningfully different from the released one, so +it is left as a deliberate choice rather than a silent patch. Training on tmQMg's 2,379 flagged-unphysical structures costs 0.7% D-MAE (0.8902 vs 0.8962 on the identical outlier-free test set) and nothing on RMSD, so @@ -311,6 +376,9 @@ configs/ # per-dataset YAML configs (smoke + full) external/ReBIND/ # git submodule, vendored upstream ReBind external/GTMGC/ # git submodule, vendored upstream GTMGC scripts/train.sh # Slurm job script (sbatch scripts/train.sh ) +scripts/eval_and_dump.sh # score a checkpoint + export its structures +scripts/dump_structures.py # per-molecule true/predicted geometry --> CSV +scripts/compare_runs.py # size-normalized comparison across finished runs scripts/prepare_qm9_rebind.py # QM9 + ReBind's published split --> CSV scripts/tokenize_molebert.py # per-atom Mole-BERT ids, needed by GTMGC tests/ # imports, dataset loading, MOL2 parsing, forward pass, metrics diff --git a/scripts/compare_runs.py b/scripts/compare_runs.py new file mode 100644 index 0000000..ffa4646 --- /dev/null +++ b/scripts/compare_runs.py @@ -0,0 +1,150 @@ +"""Compare finished runs on one table, with D-MAE normalized by molecule size. + +D-MAE is an absolute distance error, so it grows with the molecule: a 60-atom +complex and a 9-atom skeleton cannot be read off the same column. Every row here +therefore carries a *relative* D-MAE as well — the pooled error divided by that +test set's own mean pairwise distance, pooled the same way (over every ordered +pair including the zero diagonal, as ReBind's ``evaluate.py`` does). That scale +is computed from the reference structures in each run's ``structures_test.csv``, +so the normalizer comes from the same molecules that were scored. + +The script also recomputes D-MAE from the dumped structures and checks it against +the metrics JSON. They are produced by separate passes over the same checkpoint, +so a disagreement means one of them is stale. + +Usage:: + + uv run python scripts/compare_runs.py + uv run python scripts/compare_runs.py --runs outputs/bostmc_full outputs/bostmc_full_noh +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path + +import numpy as np +import pandas as pd + +# (label, output_dir). The pairs sit next to each other so the hydrogen +# comparison reads down the table. +_DEFAULT_RUNS: tuple[tuple[str, str], ...] = ( + ("QM9, with H", "outputs/qm9_rebind"), + ("QM9, heavy atom", "outputs/qm9_rebind_noh"), + ("tmQMg, with H", "outputs/tmqmg_complete_full"), + ("tmQMg, heavy atom", "outputs/tmqmg_complete_full_noh"), + ("BOSTMC, with H", "outputs/bostmc_full"), + ("BOSTMC, heavy atom", "outputs/bostmc_full_noh"), +) + + +def _coords(xyz_block: str) -> np.ndarray: + lines = xyz_block.splitlines() + count = int(lines[0]) + return np.array( + [[float(v) for v in line.split()[1:4]] for line in lines[2 : 2 + count]], + dtype=float, + ) + + +def _pooled_scale_and_dmae(frame: pd.DataFrame) -> tuple[float, float, float]: + """Mean pairwise distance, pooled D-MAE, and mean atom count over a dump. + + Both pooled quantities use the same denominator as the published protocol: + the total number of ordered atom pairs in the split, diagonal included. + """ + total_distance, total_abs_error, total_pairs, total_atoms = 0.0, 0.0, 0, 0 + for true_block, pred_block in zip(frame["xyz_true"], frame["xyz_pred"], strict=True): + target, pred = _coords(true_block), _coords(pred_block) + d_true = np.linalg.norm(target[:, None, :] - target[None, :, :], axis=-1) + d_pred = np.linalg.norm(pred[:, None, :] - pred[None, :, :], axis=-1) + total_distance += float(d_true.sum()) + total_abs_error += float(np.abs(d_pred - d_true).sum()) + total_pairs += d_true.size + total_atoms += len(target) + return ( + total_distance / max(total_pairs, 1), + total_abs_error / max(total_pairs, 1), + total_atoms / max(len(frame), 1), + ) + + +def _load(label: str, run_dir: Path) -> dict | None: + dump = run_dir / "structures_test.csv" + metrics_path = run_dir / "eval_test.json" + if not dump.exists() or not metrics_path.exists(): + missing = [str(p.name) for p in (metrics_path, dump) if not p.exists()] + print(f"skipping {label}: missing {', '.join(missing)}") + return None + + metrics = json.loads(metrics_path.read_text()) + frame = pd.read_csv(dump) + scale, dmae_from_dump, mean_atoms = _pooled_scale_and_dmae(frame) + + keephs_path = run_dir / "eval_test_keephs.json" + keephs = json.loads(keephs_path.read_text()) if keephs_path.exists() else {} + + return { + "label": label, + "n": len(frame), + "mean_atoms": mean_atoms, + "d_mae": metrics["d_mae"], + "d_rmse": metrics["d_rmse"], + "rmsd": metrics["c_rmsd"], + "rmsd_method": metrics.get("c_rmsd_method", "?"), + "rmsd_keephs": keephs.get("c_rmsd", math.nan), + "scale": scale, + "relative": metrics["d_mae"] / scale if scale else math.nan, + "rmsd_over_scale": metrics["c_rmsd"] / scale if scale else math.nan, + "dmae_from_dump": dmae_from_dump, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runs", nargs="*", help="Output directories; defaults to all six") + parser.add_argument("--out", help="Also write the table as JSON here") + args = parser.parse_args(argv) + + if args.runs: + pairs = [(Path(r).name, r) for r in args.runs] + else: + pairs = list(_DEFAULT_RUNS) + + rows = [row for label, d in pairs if (row := _load(label, Path(d))) is not None] + if not rows: + print("no finished runs found") + return 1 + + header = ( + f"| {'Run':<20} | {'Test mols':>9} | {'Atoms':>6} | {'D-MAE':>6} | {'D-RMSE':>6} " + f"| {'RMSD':>6} | {'Scale':>6} | {'Rel. D-MAE':>10} | {'RMSD/scale':>10} |" + ) + print(header) + print("|" + "|".join("-" * (len(part)) for part in header.split("|")[1:-1]) + "|") + for r in rows: + print( + f"| {r['label']:<20} | {r['n']:>9,} | {r['mean_atoms']:>6.1f} | {r['d_mae']:>6.3f} " + f"| {r['d_rmse']:>6.3f} | {r['rmsd']:>6.3f} | {r['scale']:>6.3f} " + f"| {r['relative'] * 100:>9.1f}% | {r['rmsd_over_scale'] * 100:>9.1f}% |" + ) + + print() + for r in rows: + gap = abs(r["d_mae"] - r["dmae_from_dump"]) + flag = "OK" if gap < 5e-3 else "MISMATCH" + print( + f"{flag:8s} {r['label']:<20} json={r['d_mae']:.4f} " + f"recomputed_from_dump={r['dmae_from_dump']:.4f}" + ) + + if args.out: + Path(args.out).write_text(json.dumps(rows, indent=2)) + print(f"\nwrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())